Add TagInput chip input
This commit is contained in:
@@ -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>
|
||||||
Reference in New Issue
Block a user