Add TagInput chip input

This commit is contained in:
2026-09-25 02:17:53 +02:00
parent 2b2f4093fb
commit 153c33bf51
2 changed files with 101 additions and 2 deletions
+2 -2
View File
@@ -49,7 +49,7 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail
- `primitives/` for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on). - `primitives/` for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on).
- `overlay/` for anything that floats above the content (Modal, ContextMenu, GlassMenu, ConfirmDialog, and so on). - `overlay/` for anything that floats above the content (Modal, ContextMenu, GlassMenu, ConfirmDialog, and so on).
- `media/` for stateless music display components that receive props (Artwork, MediaCard, ListRow, PlayButton, and so on). - `media/` for stateless music display components that receive props (Artwork, MediaCard, ListRow, PlayButton, and so on).
- `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone). - `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone, TagInput).
- `layout/` for page level scaffolding (Page, PageHeader, SectionHeading). - `layout/` for page level scaffolding (Page, PageHeader, SectionHeading).
- The root `+layout.svelte` only holds the globals (fonts, theme, `DialogHost`). Everything the user sees after signing in lives under `src/routes/(app)/`, including Home (`(app)/+page.svelte`); `(app)/+layout.server.ts` guards the group and loads the shell data, and `(app)/+error.svelte` renders inside the shell. Unknown URLs hit the catch-all `(app)/[...rest]/` (it throws a 404) so they also get the shell (unknown `/api/...` URLs hit `api/[...rest]/`, a JSON 404); the root `+error.svelte` only covers errors outside the group or in the group layout load. Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`). - The root `+layout.svelte` only holds the globals (fonts, theme, `DialogHost`). Everything the user sees after signing in lives under `src/routes/(app)/`, including Home (`(app)/+page.svelte`); `(app)/+layout.server.ts` guards the group and loads the shell data, and `(app)/+error.svelte` renders inside the shell. Unknown URLs hit the catch-all `(app)/[...rest]/` (it throws a 404) so they also get the shell (unknown `/api/...` URLs hit `api/[...rest]/`, a JSON 404); the root `+error.svelte` only covers errors outside the group or in the group layout load. Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`).
@@ -70,7 +70,7 @@ The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utili
- **Spacing:** stick to the Tailwind scale in `.5` steps and avoid quarters like `.25`/`.75`, unless the semantic token calls for it (for example `mb-4.5` in `SectionHeading`). - **Spacing:** stick to the Tailwind scale in `.5` steps and avoid quarters like `.25`/`.75`, unless the semantic token calls for it (for example `mb-4.5` in `SectionHeading`).
- **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth. - **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth.
- **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token. - **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token.
- Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `ChipGroup`, `SegmentedControl`, `Tabs`, `SearchInput`, `StatusBadge`, `SelectionBar`, `DataGrid`, `FileDropzone`, `CoverForm`. - Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `ChipGroup`, `SegmentedControl`, `Tabs`, `SearchInput`, `StatusBadge`, `SelectionBar`, `DataGrid`, `FileDropzone`, `TagInput`, `CoverForm`.
- **Icon stroke:** one global rule (`svg.lucide { stroke-width: 1.5 }` in `layout.css`) sets the stroke for every icon. Never pass `strokeWidth` to an icon, since any CSS rule overrides the SVG attribute and it would do nothing. For a different stroke on one icon, use a Tailwind `stroke-*` utility. - **Icon stroke:** one global rule (`svg.lucide { stroke-width: 1.5 }` in `layout.css`) sets the stroke for every icon. Never pass `strokeWidth` to an icon, since any CSS rule overrides the SVG attribute and it would do nothing. For a different stroke on one icon, use a Tailwind `stroke-*` utility.
## App shell ## App shell
@@ -0,0 +1,99 @@
<script lang="ts" module>
export interface Tag {
id: string;
label: string;
}
</script>
<script lang="ts">
import X from '@lucide/svelte/icons/x';
interface Props {
tags: Tag[];
value?: string;
label: string;
id?: string;
placeholder?: string;
disabled?: boolean;
controls?: string;
onadd?: (text: string) => void;
onremove: (id: string) => void;
oninput?: () => void;
class?: string;
}
let {
tags,
value = $bindable(''),
label,
id,
placeholder,
disabled = false,
controls,
onadd,
onremove,
oninput,
class: klass = ''
}: Props = $props();
let input: HTMLInputElement | undefined = $state();
function commit() {
const text = value.trim();
if (!text) return;
onadd?.(text);
}
function onkeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault();
commit();
} else if (event.key === 'Backspace' && value === '' && tags.length > 0) {
onremove(tags[tags.length - 1].id);
}
}
function remove(tagId: string) {
onremove(tagId);
input?.focus();
}
</script>
<div
role="group"
aria-label={label}
class="flex min-h-11 flex-wrap items-center gap-2 rounded-control bg-surface-2 px-3 py-2 transition focus-within:bg-surface-hover {disabled
? 'opacity-40'
: ''} {klass}"
>
<ul class="contents">
{#each tags as tag (tag.id)}
<li class="flex items-center gap-1 rounded-full bg-btn py-1 pr-1 pl-3 text-xs text-fg">
<span class="max-w-40 truncate">{tag.label}</span>
<button
type="button"
{disabled}
aria-label="Quitar {tag.label}"
onclick={() => remove(tag.id)}
class="grid size-5 shrink-0 place-items-center rounded-full text-fg-2 focus-ring transition-colors hover:bg-btn-hover hover:text-fg"
>
<X class="size-icon-xs" />
</button>
</li>
{/each}
</ul>
<input
bind:this={input}
bind:value
{id}
{placeholder}
{disabled}
{oninput}
{onkeydown}
type="text"
autocomplete="off"
aria-label={label}
aria-controls={controls}
class="min-w-32 flex-1 bg-transparent text-sm text-fg placeholder:text-fg-2 focus:outline-none"
/>
</div>