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

@ -3,10 +3,15 @@
<description>The standard record format used by a majority of players.</description> <description>The standard record format used by a majority of players.</description>
<record type="public" expanded="true"> <record type="public" expanded="true">
<field label="Name" type="text" /> <field label="Name" type="text" required="true" />
<field label="Species" type="species" /> <field label="Species" type="species" required="true" />
<field label="Subspecies" type="subspecies" /> <field label="Subspecies" type="subspecies" />
<field label="Pronouns" type="text" /> <field label="Pronouns" type="multi-select">
<option value="he/him" label="he/him" />
<option value="she/her" label="she/her" />
<option value="they/them" label="they/them" />
<option value="it/its" label="it/its" />
</field>
<field label="Date of Birth" type="date" placeholder="March 15th, 2438" /> <field label="Date of Birth" type="date" placeholder="March 15th, 2438" />
<field label="Citizenship" type="citizenship" /> <field label="Citizenship" type="citizenship" />
<field label="Spoken Languages" type="languages" /> <field label="Spoken Languages" type="languages" />
@ -29,7 +34,7 @@
<record type="medical" expanded="false"> <record type="medical" expanded="false">
<preamble>The following information is protected by doctor-patient confidentiality laws. Do not release without patient's consent.</preamble> <preamble>The following information is protected by doctor-patient confidentiality laws. Do not release without patient's consent.</preamble>
<field label="Opt-Outs" type="checkbox"> <field label="Opt-Outs" type="multi-select">
<option value="no-borg" label="Do Not Borgify" /> <option value="no-borg" label="Do Not Borgify" />
<option value="no-revive" label="Do Not Revive" /> <option value="no-revive" label="Do Not Revive" />
<option value="no-prosthetic" label="Do Not Prostheticize" /> <option value="no-prosthetic" label="Do Not Prostheticize" />

View file

@ -1,8 +1,14 @@
<script lang="ts"> <script lang="ts">
import { X } from 'lucide-svelte';
import type { CheckboxField } from '$lib/types'; import type { CheckboxField } from '$lib/types';
let { field, value = [], onChange }: { field: CheckboxField; value: string[]; onChange: (v: string[]) => void } = $props(); 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) { function toggle(optValue: string) {
if (value.includes(optValue)) { if (value.includes(optValue)) {
onChange(value.filter((v) => v !== optValue)); onChange(value.filter((v) => v !== optValue));
@ -10,10 +16,22 @@
onChange([...value, optValue]); 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> </script>
<fieldset> <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"> <div class="mt-1 flex flex-col gap-1">
{#each field.options as opt} {#each field.options as opt}
<label class="flex items-center gap-2 text-sm cursor-pointer"> <label class="flex items-center gap-2 text-sm cursor-pointer">
@ -25,5 +43,31 @@
{opt.label} {opt.label}
</label> </label>
{/each} {/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> </div>
</fieldset> </fieldset>

View file

@ -3,19 +3,54 @@
import { citizenships } from '$lib/data'; import { citizenships } from '$lib/data';
let { field, value = '', onChange }: { field: CitizenshipField; value: string; onChange: (v: string) => void } = $props(); 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> </script>
<label 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 {#if isCustom}
{value} <div class="flex items-center gap-2 mt-1">
onchange={(e) => onChange((e.target as HTMLSelectElement).value)} <input
class="mt-1 block w-full rounded px-3 py-2 text-sm" type="text"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);" {value}
> placeholder="Enter citizenship"
<option value=""></option> oninput={(e) => onChange((e.target as HTMLInputElement).value)}
{#each citizenships as c} class="block w-full rounded px-3 py-2 text-sm"
<option value={c.name}>{c.name}</option> style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
{/each} />
</select> <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> </label>

View file

@ -5,7 +5,7 @@
</script> </script>
<label 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>
<input <input
type="text" type="text"
{value} {value}

View file

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

View file

@ -8,18 +8,18 @@
</script> </script>
<label 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>
<div class="flex items-center gap-2 mt-1"> <div class="flex items-center gap-2 mt-1">
<input <input
type="number" type="number"
value={value ?? ''} value={value ?? ''}
min={0} min={0}
oninput={(e) => onChange(Number((e.target as HTMLInputElement).value))} 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);" style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
/> />
<span class="text-sm whitespace-nowrap" style="color: var(--text-muted);"> <span class="text-sm" style="color: var(--text-muted);">
cm {converted ? `(${converted})` : ''} cm{#if converted} ({converted}){/if}
</span> </span>
</div> </div>
</label> </label>

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { X } from 'lucide-svelte';
import type { LanguagesField } from '$lib/types'; import type { LanguagesField } from '$lib/types';
import { languages } from '$lib/data'; import { languages } from '$lib/data';
@ -8,27 +9,89 @@
onChange: (v: string[]) => void; onChange: (v: string[]) => void;
} = $props(); } = $props();
function toggle(name: string) { let input = $state('');
if (value.includes(name)) { let open = $state(false);
onChange(value.filter((v) => v !== name));
} else { let available = $derived(
onChange([...value, name]); 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> </script>
<fieldset> <div class="block">
<legend class="text-sm font-medium">{field.label}</legend> <span class="text-sm font-medium">{field.label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</span>
<div class="mt-1 flex flex-col gap-1">
{#each languages as lang} <div class="flex flex-wrap gap-1 mt-1">
<label class="flex items-center gap-2 text-sm cursor-pointer"> {#each value as lang}
<input <span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-sm" style="background: var(--border); color: var(--text);">
type="checkbox" {lang}
checked={value.includes(lang.name)} <button onclick={() => remove(lang)} class="hover:opacity-60">
onchange={() => toggle(lang.name)} <X size={12} />
/> </button>
{lang.name} </span>
</label>
{/each} {/each}
</div> </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> </script>
<label 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>
<textarea <textarea
{value} {value}
placeholder="One entry per line" 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> </script>
<label 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>
<div class="flex items-center gap-2 mt-1"> <div class="flex items-center gap-2 mt-1">
<input <input
type="number" type="number"

View file

@ -2,19 +2,53 @@
import type { SelectField } from '$lib/types'; import type { SelectField } from '$lib/types';
let { field, value = '', onChange }: { field: SelectField; value: string; onChange: (v: string) => void } = $props(); 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> </script>
<label 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 {#if isCustom}
{value} <div class="flex items-center gap-2 mt-1">
onchange={(e) => onChange((e.target as HTMLSelectElement).value)} <input
class="mt-1 block w-full rounded px-3 py-2 text-sm" type="text"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);" {value}
> oninput={(e) => onChange((e.target as HTMLInputElement).value)}
<option value=""></option> class="block w-full rounded px-3 py-2 text-sm"
{#each field.options as opt} style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
<option value={opt.value}>{opt.label}</option> />
{/each} <button
</select> 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> </label>

View file

@ -9,7 +9,7 @@
<div class="block"> <div class="block">
<label 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 <select
{value} {value}
onchange={(e) => onChange((e.target as HTMLSelectElement).value)} onchange={(e) => onChange((e.target as HTMLSelectElement).value)}

View file

@ -14,23 +14,57 @@
let subs = $derived(currentSpecies?.subspecies ?? []); let subs = $derived(currentSpecies?.subspecies ?? []);
let label = $derived(currentSpecies?.subspeciesLabel ?? field.label); let label = $derived(currentSpecies?.subspeciesLabel ?? field.label);
let selected = $derived(subs.find((s) => s.id === value)); 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> </script>
{#if subs.length > 0} {#if subs.length > 0 || isCustom}
<div class="block"> <div class="block">
<label class="block"> <label class="block">
<span class="text-sm font-medium">{label}</span> <span class="text-sm font-medium">{label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</span>
<select {#if isCustom}
{value} <div class="flex items-center gap-2 mt-1">
onchange={(e) => onChange((e.target as HTMLSelectElement).value)} <input
class="mt-1 block w-full rounded px-3 py-2 text-sm" type="text"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);" {value}
> oninput={(e) => onChange((e.target as HTMLInputElement).value)}
<option value=""></option> class="block w-full rounded px-3 py-2 text-sm"
{#each subs as sub} style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
<option value={sub.id}>{sub.name}</option> />
{/each} <button
</select> 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> </label>
{#if selected?.description} {#if selected?.description}
<p class="mt-1 text-sm italic" style="color: var(--text-muted);">{selected.description}</p> <p class="mt-1 text-sm italic" style="color: var(--text-muted);">{selected.description}</p>

View file

@ -5,7 +5,7 @@
</script> </script>
<label 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>
<input <input
type="text" type="text"
{value} {value}

View file

@ -5,7 +5,7 @@
</script> </script>
<label 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>
<textarea <textarea
{value} {value}
placeholder={field.placeholder} placeholder={field.placeholder}

View file

@ -4,22 +4,22 @@
let { field, value, onChange }: { field: WeightField; value: number | undefined; onChange: (v: number) => void } = $props(); 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> </script>
<label 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>
<div class="flex items-center gap-2 mt-1"> <div class="flex items-center gap-2 mt-1">
<input <input
type="number" type="number"
value={value ?? ''} value={value ?? ''}
min={0} min={0}
oninput={(e) => onChange(Number((e.target as HTMLInputElement).value))} 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);" style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
/> />
<span class="text-sm whitespace-nowrap" style="color: var(--text-muted);"> <span class="text-sm" style="color: var(--text-muted);">
kg {converted ? `(${converted} lb)` : ''} kg{#if converted} ({converted} lb){/if}
</span> </span>
</div> </div>
</label> </label>

View file

@ -62,7 +62,8 @@ function parseOptions(field: any): SelectOption[] {
function parseField(raw: any): FieldDef { function parseField(raw: any): FieldDef {
const base = { const base = {
label: raw['@_label'] label: raw['@_label'],
...(raw['@_required'] === 'true' && { required: true })
}; };
const type = raw['@_type']; const type = raw['@_type'];

View file

@ -5,6 +5,7 @@ export interface SelectOption {
export interface BaseFieldDef { export interface BaseFieldDef {
label: string; label: string;
required?: boolean;
} }
export interface TextField extends BaseFieldDef { export interface TextField extends BaseFieldDef {