feat(fend): skeleton components

This commit is contained in:
Lewis Wynne 2026-03-23 18:51:48 +00:00
parent 3c6a31f86b
commit 608d863c88
18 changed files with 395 additions and 77 deletions

View file

@ -1,8 +1,14 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import type { CheckboxField } from '$lib/types';
let { field, value = [], onChange }: { field: CheckboxField; value: string[]; onChange: (v: string[]) => void } = $props();
let customInput = $state('');
let knownValues = $derived(new Set(field.options.map((o) => o.value)));
let customValues = $derived(value.filter((v) => !knownValues.has(v)));
function toggle(optValue: string) {
if (value.includes(optValue)) {
onChange(value.filter((v) => v !== optValue));
@ -10,10 +16,22 @@
onChange([...value, optValue]);
}
}
function addCustom() {
const trimmed = customInput.trim();
if (trimmed && !value.includes(trimmed)) {
onChange([...value, trimmed]);
}
customInput = '';
}
function removeCustom(val: string) {
onChange(value.filter((v) => v !== val));
}
</script>
<fieldset>
<legend class="text-sm font-medium">{field.label}</legend>
<legend class="text-sm font-medium">{field.label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</legend>
<div class="mt-1 flex flex-col gap-1">
{#each field.options as opt}
<label class="flex items-center gap-2 text-sm cursor-pointer">
@ -25,5 +43,31 @@
{opt.label}
</label>
{/each}
{#each customValues as cv}
<span class="flex items-center gap-2 text-sm">
<input type="checkbox" checked={true} onchange={() => removeCustom(cv)} />
{cv}
<button onclick={() => removeCustom(cv)} class="hover:opacity-60">
<X size={12} />
</button>
</span>
{/each}
</div>
<div class="flex items-center gap-2 mt-2">
<input
type="text"
bind:value={customInput}
placeholder="Other..."
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustom(); } }}
class="rounded px-3 py-1.5 text-sm"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
/>
<button
onclick={addCustom}
class="text-sm hover:opacity-80"
style="color: var(--text-muted);"
>
Add
</button>
</div>
</fieldset>

View file

@ -3,19 +3,54 @@
import { citizenships } from '$lib/data';
let { field, value = '', onChange }: { field: CitizenshipField; value: string; onChange: (v: string) => void } = $props();
let custom = $state(false);
function handleSelect(v: string) {
if (v === '__custom') {
custom = true;
onChange('');
} else {
custom = false;
onChange(v);
}
}
let isCustom = $derived(custom || (value !== '' && !citizenships.some((c) => c.name === value)));
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<select
{value}
onchange={(e) => onChange((e.target as HTMLSelectElement).value)}
class="mt-1 block w-full rounded px-3 py-2 text-sm"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
>
<option value=""></option>
{#each citizenships as c}
<option value={c.name}>{c.name}</option>
{/each}
</select>
<span class="text-sm font-medium">{field.label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</span>
{#if isCustom}
<div class="flex items-center gap-2 mt-1">
<input
type="text"
{value}
placeholder="Enter citizenship"
oninput={(e) => onChange((e.target as HTMLInputElement).value)}
class="block w-full rounded px-3 py-2 text-sm"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
/>
<button
onclick={() => { custom = false; onChange(''); }}
class="text-sm whitespace-nowrap hover:opacity-80"
style="color: var(--text-muted);"
>
Cancel
</button>
</div>
{:else}
<select
{value}
onchange={(e) => handleSelect((e.target as HTMLSelectElement).value)}
class="mt-1 block w-full rounded px-3 py-2 text-sm"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
>
<option value=""></option>
{#each citizenships as c}
<option value={c.name}>{c.name}</option>
{/each}
<option value="__custom">Other...</option>
</select>
{/if}
</label>

View file

@ -5,7 +5,7 @@
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<span class="text-sm font-medium">{field.label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</span>
<input
type="text"
{value}

View file

@ -6,6 +6,7 @@
import NumberField from './NumberField.svelte';
import SelectField from './SelectField.svelte';
import CheckboxField from './CheckboxField.svelte';
import MultiSelectField from './MultiSelectField.svelte';
import DateField from './DateField.svelte';
import HeightField from './HeightField.svelte';
import WeightField from './WeightField.svelte';
@ -33,7 +34,7 @@
{:else if field.type === 'select'}
<SelectField {field} {value} {onChange} />
{:else if field.type === 'multi-select'}
<CheckboxField field={{ ...field, type: 'checkbox' }} {value} {onChange} />
<MultiSelectField {field} {value} {onChange} />
{:else if field.type === 'checkbox'}
<CheckboxField {field} {value} {onChange} />
{:else if field.type === 'date'}

View file

@ -8,18 +8,18 @@
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<span class="text-sm font-medium">{field.label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</span>
<div class="flex items-center gap-2 mt-1">
<input
type="number"
value={value ?? ''}
min={0}
oninput={(e) => onChange(Number((e.target as HTMLInputElement).value))}
class="block w-full rounded px-3 py-2 text-sm"
class="w-24 rounded px-3 py-2 text-sm"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
/>
<span class="text-sm whitespace-nowrap" style="color: var(--text-muted);">
cm {converted ? `(${converted})` : ''}
<span class="text-sm" style="color: var(--text-muted);">
cm{#if converted} ({converted}){/if}
</span>
</div>
</label>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import type { LanguagesField } from '$lib/types';
import { languages } from '$lib/data';
@ -8,27 +9,89 @@
onChange: (v: string[]) => void;
} = $props();
function toggle(name: string) {
if (value.includes(name)) {
onChange(value.filter((v) => v !== name));
} else {
onChange([...value, name]);
let input = $state('');
let open = $state(false);
let available = $derived(
languages
.filter((l) => !value.includes(l.name))
.filter((l) => !input || l.name.toLowerCase().includes(input.toLowerCase()))
);
function add(name: string) {
onChange([...value, name]);
input = '';
}
function addCustom() {
const trimmed = input.trim();
if (trimmed && !value.includes(trimmed)) {
onChange([...value, trimmed]);
}
input = '';
}
function remove(name: string) {
onChange(value.filter((v) => v !== name));
}
</script>
<fieldset>
<legend class="text-sm font-medium">{field.label}</legend>
<div class="mt-1 flex flex-col gap-1">
{#each languages as lang}
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={value.includes(lang.name)}
onchange={() => toggle(lang.name)}
/>
{lang.name}
</label>
<div class="block">
<span class="text-sm font-medium">{field.label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</span>
<div class="flex flex-wrap gap-1 mt-1">
{#each value as lang}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-sm" style="background: var(--border); color: var(--text);">
{lang}
<button onclick={() => remove(lang)} class="hover:opacity-60">
<X size={12} />
</button>
</span>
{/each}
</div>
</fieldset>
<div class="relative mt-1">
<input
type="text"
bind:value={input}
placeholder="Add language..."
onfocus={() => { open = true; }}
onblur={() => { setTimeout(() => { open = false; }, 150); }}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (available.length) add(available[0].name);
else addCustom();
}
}}
class="block w-full rounded px-3 py-2 text-sm"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
/>
{#if open && (available.length || input.trim())}
<ul class="absolute z-10 w-full mt-1 rounded shadow-lg max-h-48 overflow-y-auto" style="background: var(--bg-card); border: 1px solid var(--border);">
{#each available as lang}
<li>
<button
onmousedown={(e) => { e.preventDefault(); add(lang.name); }}
class="block w-full text-left px-3 py-1.5 text-sm hover:opacity-80"
style="color: var(--text);"
>
{lang.name}
</button>
</li>
{/each}
{#if input.trim() && !languages.some((l) => l.name.toLowerCase() === input.trim().toLowerCase())}
<li>
<button
onmousedown={(e) => { e.preventDefault(); addCustom(); }}
class="block w-full text-left px-3 py-1.5 text-sm"
style="color: var(--text-muted);"
>
Add "{input.trim()}"
</button>
</li>
{/if}
</ul>
{/if}
</div>
</div>

View file

@ -5,7 +5,7 @@
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<span class="text-sm font-medium">{field.label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</span>
<textarea
{value}
placeholder="One entry per line"

View file

@ -0,0 +1,100 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import type { MultiSelectField } from '$lib/types';
let { field, value = [], onChange }: {
field: MultiSelectField;
value: string[];
onChange: (v: string[]) => void;
} = $props();
let input = $state('');
let open = $state(false);
let available = $derived(
field.options
.filter((o) => !value.includes(o.value))
.filter((o) => !input || o.label.toLowerCase().includes(input.toLowerCase()))
);
function add(val: string) {
onChange([...value, val]);
input = '';
}
function addCustom() {
const trimmed = input.trim();
if (trimmed && !value.includes(trimmed)) {
onChange([...value, trimmed]);
}
input = '';
}
function remove(val: string) {
onChange(value.filter((v) => v !== val));
}
function displayLabel(val: string): string {
return field.options.find((o) => o.value === val)?.label ?? val;
}
</script>
<div class="block">
<span class="text-sm font-medium">{field.label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</span>
<div class="flex flex-wrap gap-1 mt-1">
{#each value as val}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-sm" style="background: var(--border); color: var(--text);">
{displayLabel(val)}
<button onclick={() => remove(val)} class="hover:opacity-60">
<X size={12} />
</button>
</span>
{/each}
</div>
<div class="relative mt-1">
<input
type="text"
bind:value={input}
placeholder="Add..."
onfocus={() => { open = true; }}
onblur={() => { setTimeout(() => { open = false; }, 150); }}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (available.length) add(available[0].value);
else addCustom();
}
}}
class="block w-full rounded px-3 py-2 text-sm"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
/>
{#if open && (available.length || input.trim())}
<ul class="absolute z-10 w-full mt-1 rounded shadow-lg max-h-48 overflow-y-auto" style="background: var(--bg-card); border: 1px solid var(--border);">
{#each available as opt}
<li>
<button
onmousedown={(e) => { e.preventDefault(); add(opt.value); }}
class="block w-full text-left px-3 py-1.5 text-sm hover:opacity-80"
style="color: var(--text);"
>
{opt.label}
</button>
</li>
{/each}
{#if input.trim() && !field.options.some((o) => o.label.toLowerCase() === input.trim().toLowerCase())}
<li>
<button
onmousedown={(e) => { e.preventDefault(); addCustom(); }}
class="block w-full text-left px-3 py-1.5 text-sm"
style="color: var(--text-muted);"
>
Add "{input.trim()}"
</button>
</li>
{/if}
</ul>
{/if}
</div>
</div>

View file

@ -5,7 +5,7 @@
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<span class="text-sm font-medium">{field.label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</span>
<div class="flex items-center gap-2 mt-1">
<input
type="number"

View file

@ -2,19 +2,53 @@
import type { SelectField } from '$lib/types';
let { field, value = '', onChange }: { field: SelectField; value: string; onChange: (v: string) => void } = $props();
let custom = $state(false);
function handleSelect(v: string) {
if (v === '__custom') {
custom = true;
onChange('');
} else {
custom = false;
onChange(v);
}
}
let isCustom = $derived(custom || (value !== '' && !field.options.some((o) => o.value === value)));
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<select
{value}
onchange={(e) => onChange((e.target as HTMLSelectElement).value)}
class="mt-1 block w-full rounded px-3 py-2 text-sm"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
>
<option value=""></option>
{#each field.options as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<span class="text-sm font-medium">{field.label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</span>
{#if isCustom}
<div class="flex items-center gap-2 mt-1">
<input
type="text"
{value}
oninput={(e) => onChange((e.target as HTMLInputElement).value)}
class="block w-full rounded px-3 py-2 text-sm"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
/>
<button
onclick={() => { custom = false; onChange(''); }}
class="text-sm whitespace-nowrap hover:opacity-80"
style="color: var(--text-muted);"
>
Cancel
</button>
</div>
{:else}
<select
{value}
onchange={(e) => handleSelect((e.target as HTMLSelectElement).value)}
class="mt-1 block w-full rounded px-3 py-2 text-sm"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
>
<option value=""></option>
{#each field.options as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
<option value="__custom">Other...</option>
</select>
{/if}
</label>

View file

@ -9,7 +9,7 @@
<div class="block">
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<span class="text-sm font-medium">{field.label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</span>
<select
{value}
onchange={(e) => onChange((e.target as HTMLSelectElement).value)}

View file

@ -14,23 +14,57 @@
let subs = $derived(currentSpecies?.subspecies ?? []);
let label = $derived(currentSpecies?.subspeciesLabel ?? field.label);
let selected = $derived(subs.find((s) => s.id === value));
let custom = $state(false);
function handleSelect(v: string) {
if (v === '__custom') {
custom = true;
onChange('');
} else {
custom = false;
onChange(v);
}
}
let isCustom = $derived(custom || (value !== '' && !subs.some((s) => s.id === value)));
</script>
{#if subs.length > 0}
{#if subs.length > 0 || isCustom}
<div class="block">
<label class="block">
<span class="text-sm font-medium">{label}</span>
<select
{value}
onchange={(e) => onChange((e.target as HTMLSelectElement).value)}
class="mt-1 block w-full rounded px-3 py-2 text-sm"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
>
<option value=""></option>
{#each subs as sub}
<option value={sub.id}>{sub.name}</option>
{/each}
</select>
<span class="text-sm font-medium">{label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</span>
{#if isCustom}
<div class="flex items-center gap-2 mt-1">
<input
type="text"
{value}
oninput={(e) => onChange((e.target as HTMLInputElement).value)}
class="block w-full rounded px-3 py-2 text-sm"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
/>
<button
onclick={() => { custom = false; onChange(''); }}
class="text-sm whitespace-nowrap hover:opacity-80"
style="color: var(--text-muted);"
>
Cancel
</button>
</div>
{:else}
<select
{value}
onchange={(e) => handleSelect((e.target as HTMLSelectElement).value)}
class="mt-1 block w-full rounded px-3 py-2 text-sm"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
>
<option value=""></option>
{#each subs as sub}
<option value={sub.id}>{sub.name}</option>
{/each}
<option value="__custom">Other...</option>
</select>
{/if}
</label>
{#if selected?.description}
<p class="mt-1 text-sm italic" style="color: var(--text-muted);">{selected.description}</p>

View file

@ -5,7 +5,7 @@
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<span class="text-sm font-medium">{field.label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</span>
<input
type="text"
{value}

View file

@ -5,7 +5,7 @@
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<span class="text-sm font-medium">{field.label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</span>
<textarea
{value}
placeholder={field.placeholder}

View file

@ -4,22 +4,22 @@
let { field, value, onChange }: { field: WeightField; value: number | undefined; onChange: (v: number) => void } = $props();
let converted = $derived(value ? Math.round(kgToLb(value)) : '');
let converted = $derived(value ? Math.round(kgToLb(value)) : 0);
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<span class="text-sm font-medium">{field.label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</span>
<div class="flex items-center gap-2 mt-1">
<input
type="number"
value={value ?? ''}
min={0}
oninput={(e) => onChange(Number((e.target as HTMLInputElement).value))}
class="block w-full rounded px-3 py-2 text-sm"
class="w-24 rounded px-3 py-2 text-sm"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
/>
<span class="text-sm whitespace-nowrap" style="color: var(--text-muted);">
kg {converted ? `(${converted} lb)` : ''}
<span class="text-sm" style="color: var(--text-muted);">
kg{#if converted} ({converted} lb){/if}
</span>
</div>
</label>