@spaethtech/svelte-ui 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.claude/skills/svelte-ui/SKILL.md +13 -4
  2. package/dist/components/Avatar/Avatar.svelte +118 -0
  3. package/dist/components/Avatar/Avatar.svelte.d.ts +20 -0
  4. package/dist/components/Avatar/AvatarGroup.svelte +83 -0
  5. package/dist/components/Avatar/AvatarGroup.svelte.d.ts +24 -0
  6. package/dist/components/Avatar/index.d.ts +4 -0
  7. package/dist/components/Avatar/index.js +2 -0
  8. package/dist/components/Breadcrumbs/Breadcrumbs.svelte +131 -0
  9. package/dist/components/Breadcrumbs/Breadcrumbs.svelte.d.ts +19 -0
  10. package/dist/components/Breadcrumbs/index.d.ts +2 -0
  11. package/dist/components/Breadcrumbs/index.js +1 -0
  12. package/dist/components/ButtonGroup/ButtonGroup.svelte.d.ts +1 -1
  13. package/dist/components/Chip/Chip.svelte +139 -0
  14. package/dist/components/Chip/Chip.svelte.d.ts +22 -0
  15. package/dist/components/Chip/index.d.ts +1 -0
  16. package/dist/components/Chip/index.js +1 -0
  17. package/dist/components/Divider/Divider.svelte +48 -0
  18. package/dist/components/Divider/Divider.svelte.d.ts +11 -0
  19. package/dist/components/Divider/index.d.ts +1 -0
  20. package/dist/components/Divider/index.js +1 -0
  21. package/dist/components/Pagination/Pagination.svelte +147 -0
  22. package/dist/components/Pagination/Pagination.svelte.d.ts +20 -0
  23. package/dist/components/Pagination/index.d.ts +1 -0
  24. package/dist/components/Pagination/index.js +1 -0
  25. package/dist/components/Popup.svelte +12 -1
  26. package/dist/components/Popup.svelte.d.ts +6 -1
  27. package/dist/components/SideBarMenu/SideBarMenu.svelte +160 -76
  28. package/dist/index.d.ts +7 -0
  29. package/dist/index.js +5 -0
  30. package/dist/positioning/anchored.d.ts +4 -0
  31. package/dist/positioning/anchored.js +19 -4
  32. package/docs/components.md +64 -0
  33. package/docs/usage.md +109 -0
  34. package/package.json +1 -1
@@ -167,14 +167,18 @@ examples):
167
167
  `parse`/`format` for per-country)
168
168
  - **Date / time:** `DatePicker` `Calendar` `TimePicker` `TimeSpinner` `TimeRangeInput` `DateTimeInput`
169
169
  - **Data:** `DataTable` `Query` — driven by the headless layer at **`@spaethtech/svelte-ui/data`**
170
- (query-language parser/AST, `createGrid`, `DataGrid<T>`, `DataSet`).
170
+ (query-language parser/AST, `createGrid`, `DataGrid<T>`, `DataSet`). · **`Pagination`** (standalone
171
+ pager: numbered buttons + ellipsis, prev/next/first/last, `bind:page`/`bind:perPage`, `total`,
172
+ `perPageOptions`, `showTotal`; built from `Button`+`Select` — the reusable DataTable footer pager).
171
173
  - **Overlays:** `Dialog` (pure modal shell — BYO content; `width`/`height` take any CSS value;
172
174
  `onShow(ctx)`/`onHide(ctx)` control focus — set `ctx.autoFocus` to a selector/element/`false`,
173
175
  `ctx.restoreFocus` likewise) · `ConfirmDialog` (title + message + Confirm/Cancel, built on Dialog)
174
176
  · **`Drawer`** (edge-anchored sliding panel / "sheet"; `side` left/right/top/bottom, `size` CSS
175
177
  length, `bind:open`, `dismissible`/`backdrop`; same modal mechanics as Dialog — a pure shell, put a
176
178
  Card inside) · `Popup` `Menu` · `tooltip` (a Svelte `use:` action) · `anchored` (positioning engine)
177
- - **Navigation:** `TabStrip` (tabs; `size`/`variant`, `gap`/`pad` bordered spacer cells, `height` for a
179
+ - **Navigation:** **`Breadcrumbs`** (trail of `BreadcrumbItem` {`label`,`href`,`onclick`,`icon`}; last =
180
+ current page; `maxItems` collapses the middle to a `⋯` Menu; `separator` slot) · `TabStrip` (tabs;
181
+ `size`/`variant`, `gap`/`pad` bordered spacer cells, `height` for a
178
182
  fixed-height bar with bottom-aligned tabs, `activeSurface` to flow the active tab into the content
179
183
  below) · **`SideBarMenu`** (data-driven vertical nav: `items`/`bottomItems` of `SideBarMenuItem`
180
184
  {`icon`,`label`,`link`,`badge`,`children`,`onclick`,`popout`,`reload`,`trailing` snippet,`activeMatch`};
@@ -189,11 +193,16 @@ examples):
189
193
  · **`Skeleton`** (neutral loading placeholder; `shape` text/rect/circle, `lines`, `width`/`height`,
190
194
  `animate` shimmer. No `variant` — it's absent-content, not a themed surface. Compose to sketch a card.)
191
195
  - **Layout:** `Card` `CardHeader` `CardBody` `CardFooter` · **`Grid`** (auto-placed equal-cell grid;
192
- `columns` + `gap`)
196
+ `columns` + `gap`) · **`Divider`** (thin `role="separator"` rule; `orientation` h/v, optional inline
197
+ `label`+`labelAlign`; `--ui-border-color`)
193
198
  - **Disclosure:** **`Disclosure`** (one expand/collapse section; `title`/`header`, `bind:open`, panel
194
199
  stays mounted so state is preserved) · **`Accordion`** (coordinated group — `multiple` single/multi,
195
200
  `bind:value`; children are `<Disclosure value="…">`, no per-item `bind:open`)
196
- - **Display / theme:** `NotesEditor` · `ThemeSelector` (Auto/Light/Dark Select) · `ThemeToggle`
201
+ - **Display / theme:** **`Chip`** (interactive pill — `selected` toggle fill, `removable` ×/`onremove`,
202
+ `onclick`/`href`, `icon`; the interactive counterpart to display-only `Badge`) · **`Avatar`** (image →
203
+ `name` initials → `icon` → glyph fallback; `size`, `variant` fill, `shape`, `status` dot) +
204
+ **`AvatarGroup`** (overlapped stack; `items`+`max` → `+N`) ·
205
+ `NotesEditor` · `ThemeSelector` (Auto/Light/Dark Select) · `ThemeToggle`
197
206
  (compact ☀/☾ icon button, same persisted state)
198
207
  - **Types / utils:** `Variant` `Size` `TabDefinition` `MenuItem` `ButtonGroupItem` `ButtonGroupSelect`
199
208
  (types), `responsiveClasses` / `resolveScalar`, `BREAKPOINT_PX`.
@@ -0,0 +1,118 @@
1
+ <!--
2
+ /**
3
+ * Avatar — a person/entity likeness: image, else initials (from `name`), else icon, else a default
4
+ * person glyph. Shared `size` axis; the fallback fill uses the `variant` semantic tokens. Optional
5
+ * status dot. See Avatar.spec.md.
6
+ */
7
+ -->
8
+ <script lang="ts" module>
9
+ export type AvatarShape = "circle" | "square";
10
+ export type AvatarStatus = "online" | "offline" | "busy" | "away";
11
+ </script>
12
+
13
+ <script lang="ts">
14
+ import type { Snippet } from "svelte";
15
+ import type { Variant } from "../../types/variants.js";
16
+ import { variantToken } from "../../types/variants.js";
17
+ import type { Size } from "../../types/sizes.js";
18
+ import { responsiveClasses, type Responsive } from "../../types/responsive.js";
19
+ import IconAccount from "~icons/mdi/account";
20
+
21
+ let {
22
+ src,
23
+ name,
24
+ alt,
25
+ icon,
26
+ size = "md",
27
+ variant = "secondary",
28
+ shape = "circle",
29
+ status,
30
+ class: cls = "",
31
+ }: {
32
+ src?: string;
33
+ name?: string;
34
+ alt?: string;
35
+ icon?: Snippet;
36
+ size?: Responsive<Size>;
37
+ variant?: Variant;
38
+ shape?: AvatarShape;
39
+ status?: AvatarStatus;
40
+ class?: string;
41
+ } = $props();
42
+
43
+ let imgFailed = $state(false);
44
+ // Reset the failed flag whenever the source changes.
45
+ $effect(() => {
46
+ void src;
47
+ imgFailed = false;
48
+ });
49
+
50
+ const initials = $derived(
51
+ (name ?? "")
52
+ .trim()
53
+ .split(/\s+/)
54
+ .filter(Boolean)
55
+ .slice(0, 2)
56
+ .map((w) => w[0]!.toUpperCase())
57
+ .join(""),
58
+ );
59
+ const showImg = $derived(!!src && !imgFailed);
60
+ const token = $derived(variantToken[variant]);
61
+
62
+ const boxMap: Record<Size, string> = {
63
+ sm: "w-8 h-8 text-xs",
64
+ md: "w-10 h-10 text-sm",
65
+ lg: "w-12 h-12 text-base",
66
+ };
67
+ const iconBox: Record<Size, string> = {
68
+ sm: "[&_svg]:w-4 [&_svg]:h-4",
69
+ md: "[&_svg]:w-5 [&_svg]:h-5",
70
+ lg: "[&_svg]:w-6 [&_svg]:h-6",
71
+ };
72
+ const statusColor: Record<AvatarStatus, string> = {
73
+ online: "--ui-color-success",
74
+ busy: "--ui-color-error",
75
+ away: "--ui-color-warning",
76
+ offline: "--ui-color-secondary",
77
+ };
78
+ const statusSize: Record<Size, string> = { sm: "w-2 h-2", md: "w-2.5 h-2.5", lg: "w-3 h-3" };
79
+
80
+ const shapeClass = $derived(shape === "circle" ? "rounded-full" : "rounded-[var(--ui-border-radius)]");
81
+ </script>
82
+
83
+ <span class="relative inline-flex shrink-0 {cls}">
84
+ <span
85
+ class="ui-avatar inline-flex items-center justify-center overflow-hidden font-medium select-none {shapeClass} {responsiveClasses(
86
+ size,
87
+ boxMap,
88
+ )} {responsiveClasses(size, iconBox)}"
89
+ style={showImg
90
+ ? ""
91
+ : `background-color: color-mix(in srgb, var(${token}) 20%, transparent); color: color-mix(in srgb, var(${token}) var(--ui-tint-strong), var(--ui-color-text));`}
92
+ >
93
+ {#if showImg}
94
+ <img
95
+ {src}
96
+ alt={alt ?? name ?? ""}
97
+ class="w-full h-full object-cover"
98
+ onerror={() => (imgFailed = true)}
99
+ />
100
+ {:else if initials}
101
+ {initials}
102
+ {:else if icon}
103
+ {@render icon()}
104
+ {:else}
105
+ <IconAccount />
106
+ {/if}
107
+ </span>
108
+ {#if status}
109
+ <span
110
+ title={status}
111
+ class="absolute bottom-0 right-0 rounded-full border-2 [border-color:var(--ui-color-background)] {responsiveClasses(
112
+ size,
113
+ statusSize,
114
+ )}"
115
+ style="background-color: var({statusColor[status]});"
116
+ ></span>
117
+ {/if}
118
+ </span>
@@ -0,0 +1,20 @@
1
+ export type AvatarShape = "circle" | "square";
2
+ export type AvatarStatus = "online" | "offline" | "busy" | "away";
3
+ import type { Snippet } from "svelte";
4
+ import type { Variant } from "../../types/variants.js";
5
+ import type { Size } from "../../types/sizes.js";
6
+ import { type Responsive } from "../../types/responsive.js";
7
+ type $$ComponentProps = {
8
+ src?: string;
9
+ name?: string;
10
+ alt?: string;
11
+ icon?: Snippet;
12
+ size?: Responsive<Size>;
13
+ variant?: Variant;
14
+ shape?: AvatarShape;
15
+ status?: AvatarStatus;
16
+ class?: string;
17
+ };
18
+ declare const Avatar: import("svelte").Component<$$ComponentProps, {}, "">;
19
+ type Avatar = ReturnType<typeof Avatar>;
20
+ export default Avatar;
@@ -0,0 +1,83 @@
1
+ <!--
2
+ /**
3
+ * AvatarGroup — stacks Avatars with an overlap + a background-coloured ring around each. Data-driven
4
+ * via `items` (with an optional `+N` overflow chip when `max` is set), or hand-authored via children
5
+ * (overlapped, no overflow chip). See Avatar.spec.md.
6
+ */
7
+ -->
8
+ <script lang="ts" module>
9
+ import type { Variant } from "../../types/variants.js";
10
+ import type { Snippet } from "svelte";
11
+ import type { AvatarShape } from "./Avatar.svelte";
12
+
13
+ export type AvatarItem = {
14
+ src?: string;
15
+ name?: string;
16
+ alt?: string;
17
+ variant?: Variant;
18
+ icon?: Snippet;
19
+ };
20
+ </script>
21
+
22
+ <script lang="ts">
23
+ import Avatar from "./Avatar.svelte";
24
+ import { variantToken } from "../../types/variants.js";
25
+ import type { Size } from "../../types/sizes.js";
26
+ import { responsiveClasses, type Responsive } from "../../types/responsive.js";
27
+
28
+ let {
29
+ items,
30
+ max,
31
+ size = "md",
32
+ shape = "circle",
33
+ variant = "secondary",
34
+ class: cls = "",
35
+ children,
36
+ }: {
37
+ items?: AvatarItem[];
38
+ max?: number;
39
+ size?: Responsive<Size>;
40
+ shape?: AvatarShape;
41
+ variant?: Variant;
42
+ class?: string;
43
+ children?: Snippet;
44
+ } = $props();
45
+
46
+ const shown = $derived(
47
+ items ? (max != null && items.length > max ? items.slice(0, max) : items) : [],
48
+ );
49
+ const overflow = $derived(items && max != null && items.length > max ? items.length - max : 0);
50
+
51
+ const boxMap: Record<Size, string> = {
52
+ sm: "w-8 h-8 text-xs",
53
+ md: "w-10 h-10 text-sm",
54
+ lg: "w-12 h-12 text-base",
55
+ };
56
+ const shapeClass = $derived(shape === "circle" ? "rounded-full" : "rounded-[var(--ui-border-radius)]");
57
+ const token = $derived(variantToken[variant]);
58
+ </script>
59
+
60
+ <!-- Overlap + a background-coloured ring around each avatar's shape (`.ui-avatar`) so stacked
61
+ avatars stay visually separated. -->
62
+ <div
63
+ class="flex items-center [&_.ui-avatar]:border-2 [&_.ui-avatar]:[border-color:var(--ui-color-background)] [&>*:not(:first-child)]:-ml-2 {cls}"
64
+ >
65
+ {#if items}
66
+ {#each shown as it, i (i)}
67
+ <Avatar {...it} {size} {shape} variant={it.variant ?? variant} />
68
+ {/each}
69
+ {#if overflow > 0}
70
+ <span
71
+ class="ui-avatar relative inline-flex shrink-0 items-center justify-center overflow-hidden font-medium select-none {shapeClass} {responsiveClasses(
72
+ size,
73
+ boxMap,
74
+ )}"
75
+ style="background-color: color-mix(in srgb, var({token}) 20%, transparent); color: color-mix(in srgb, var({token}) var(--ui-tint-strong), var(--ui-color-text));"
76
+ >
77
+ +{overflow}
78
+ </span>
79
+ {/if}
80
+ {:else}
81
+ {@render children?.()}
82
+ {/if}
83
+ </div>
@@ -0,0 +1,24 @@
1
+ import type { Variant } from "../../types/variants.js";
2
+ import type { Snippet } from "svelte";
3
+ import type { AvatarShape } from "./Avatar.svelte";
4
+ export type AvatarItem = {
5
+ src?: string;
6
+ name?: string;
7
+ alt?: string;
8
+ variant?: Variant;
9
+ icon?: Snippet;
10
+ };
11
+ import type { Size } from "../../types/sizes.js";
12
+ import { type Responsive } from "../../types/responsive.js";
13
+ type $$ComponentProps = {
14
+ items?: AvatarItem[];
15
+ max?: number;
16
+ size?: Responsive<Size>;
17
+ shape?: AvatarShape;
18
+ variant?: Variant;
19
+ class?: string;
20
+ children?: Snippet;
21
+ };
22
+ declare const AvatarGroup: import("svelte").Component<$$ComponentProps, {}, "">;
23
+ type AvatarGroup = ReturnType<typeof AvatarGroup>;
24
+ export default AvatarGroup;
@@ -0,0 +1,4 @@
1
+ export { default as Avatar } from "./Avatar.svelte";
2
+ export type { AvatarShape, AvatarStatus } from "./Avatar.svelte";
3
+ export { default as AvatarGroup } from "./AvatarGroup.svelte";
4
+ export type { AvatarItem } from "./AvatarGroup.svelte";
@@ -0,0 +1,2 @@
1
+ export { default as Avatar } from "./Avatar.svelte";
2
+ export { default as AvatarGroup } from "./AvatarGroup.svelte";
@@ -0,0 +1,131 @@
1
+ <!--
2
+ /**
3
+ * Breadcrumbs — a navigation trail (root → current). Link crumbs are `Button variant="ghost"`; the
4
+ * current (last) crumb is non-interactive text with aria-current. A long trail collapses its middle
5
+ * into an ellipsis that opens a `Menu` of the hidden crumbs. Composes library components only.
6
+ * See Breadcrumbs.spec.md.
7
+ */
8
+ -->
9
+ <script lang="ts" module>
10
+ import type { Snippet } from "svelte";
11
+ export type BreadcrumbItem = {
12
+ label: string;
13
+ href?: string;
14
+ onclick?: () => void;
15
+ icon?: Snippet;
16
+ };
17
+ </script>
18
+
19
+ <script lang="ts">
20
+ import Button from "../Button.svelte";
21
+ import Menu from "../Menu.svelte";
22
+ import type { MenuItem } from "../../data/table/types.js";
23
+ import type { Size } from "../../types/sizes.js";
24
+ import { responsiveClasses, type Responsive } from "../../types/responsive.js";
25
+ import IconChevronRight from "~icons/mdi/chevron-right";
26
+ import IconDots from "~icons/mdi/dots-horizontal";
27
+
28
+ let {
29
+ items,
30
+ separator,
31
+ maxItems,
32
+ size = "md",
33
+ class: cls = "",
34
+ }: {
35
+ items: BreadcrumbItem[];
36
+ separator?: Snippet | string;
37
+ maxItems?: number;
38
+ size?: Responsive<Size>;
39
+ class?: string;
40
+ } = $props();
41
+
42
+ type Node =
43
+ | { kind: "crumb"; item: BreadcrumbItem; last: boolean }
44
+ | { kind: "ellipsis"; hidden: BreadcrumbItem[] };
45
+
46
+ const nodes = $derived.by<Node[]>(() => {
47
+ const n = items.length;
48
+ const lastItem = items[n - 1];
49
+ const crumb = (item: BreadcrumbItem): Node => ({ kind: "crumb", item, last: item === lastItem });
50
+ if (!maxItems || n <= maxItems || maxItems < 3) return items.map(crumb);
51
+ const tailCount = Math.max(1, maxItems - 2);
52
+ return [
53
+ crumb(items[0]),
54
+ { kind: "ellipsis", hidden: items.slice(1, n - tailCount) },
55
+ ...items.slice(n - tailCount).map(crumb),
56
+ ];
57
+ });
58
+
59
+ const textSize: Record<Size, string> = { sm: "text-xs", md: "text-sm", lg: "text-base" };
60
+ const sepSize: Record<Size, string> = {
61
+ sm: "[&_svg]:w-3.5 [&_svg]:h-3.5",
62
+ md: "[&_svg]:w-4 [&_svg]:h-4",
63
+ lg: "[&_svg]:w-5 [&_svg]:h-5",
64
+ };
65
+
66
+ // Ellipsis → Menu of the hidden crumbs.
67
+ let menuOpen = $state(false);
68
+ let menuAnchor = $state<HTMLButtonElement | HTMLAnchorElement | undefined>();
69
+ function hiddenMenu(hidden: BreadcrumbItem[]): MenuItem[] {
70
+ return hidden.map((h) => ({ label: h.label, href: h.href, onclick: h.onclick }));
71
+ }
72
+ </script>
73
+
74
+ <nav aria-label="Breadcrumb" class={cls}>
75
+ <ol
76
+ class="flex flex-wrap items-center gap-1 [color:color-mix(in_srgb,var(--ui-color-text)_60%,transparent)]"
77
+ >
78
+ {#each nodes as node, i (i)}
79
+ {#if i > 0}
80
+ <li aria-hidden="true" class="inline-flex items-center {responsiveClasses(size, sepSize)}">
81
+ {#if typeof separator === "string"}
82
+ <span>{separator}</span>
83
+ {:else if separator}
84
+ {@render separator()}
85
+ {:else}
86
+ <IconChevronRight />
87
+ {/if}
88
+ </li>
89
+ {/if}
90
+
91
+ <li class="inline-flex items-center">
92
+ {#if node.kind === "ellipsis"}
93
+ <Button
94
+ title="Show more"
95
+ variant="ghost"
96
+ {size}
97
+ aria-haspopup="menu"
98
+ aria-expanded={menuOpen}
99
+ bind:element={menuAnchor}
100
+ onclick={() => (menuOpen = !menuOpen)}
101
+ >
102
+ {#snippet icon()}<IconDots />{/snippet}
103
+ </Button>
104
+ <Menu anchor={menuAnchor} bind:open={menuOpen} items={hiddenMenu(node.hidden)} {size} />
105
+ {:else if node.last}
106
+ <span
107
+ aria-current="page"
108
+ class="inline-flex items-center gap-1 px-1 font-medium [color:var(--ui-color-text)] {responsiveClasses(
109
+ size,
110
+ textSize,
111
+ )}"
112
+ >
113
+ {#if node.item.icon}<span class="inline-flex {responsiveClasses(size, sepSize)}"
114
+ >{@render node.item.icon()}</span
115
+ >{/if}
116
+ <span class="truncate">{node.item.label}</span>
117
+ </span>
118
+ {:else}
119
+ <Button
120
+ text={node.item.label}
121
+ href={node.item.href}
122
+ variant="ghost"
123
+ {size}
124
+ icon={node.item.icon}
125
+ onclick={node.item.onclick}
126
+ />
127
+ {/if}
128
+ </li>
129
+ {/each}
130
+ </ol>
131
+ </nav>
@@ -0,0 +1,19 @@
1
+ import type { Snippet } from "svelte";
2
+ export type BreadcrumbItem = {
3
+ label: string;
4
+ href?: string;
5
+ onclick?: () => void;
6
+ icon?: Snippet;
7
+ };
8
+ import type { Size } from "../../types/sizes.js";
9
+ import { type Responsive } from "../../types/responsive.js";
10
+ type $$ComponentProps = {
11
+ items: BreadcrumbItem[];
12
+ separator?: Snippet | string;
13
+ maxItems?: number;
14
+ size?: Responsive<Size>;
15
+ class?: string;
16
+ };
17
+ declare const Breadcrumbs: import("svelte").Component<$$ComponentProps, {}, "">;
18
+ type Breadcrumbs = ReturnType<typeof Breadcrumbs>;
19
+ export default Breadcrumbs;
@@ -0,0 +1,2 @@
1
+ export { default as Breadcrumbs } from "./Breadcrumbs.svelte";
2
+ export type { BreadcrumbItem } from "./Breadcrumbs.svelte";
@@ -0,0 +1 @@
1
+ export { default as Breadcrumbs } from "./Breadcrumbs.svelte";
@@ -37,6 +37,6 @@ type $$ComponentProps = {
37
37
  class?: string;
38
38
  children?: Snippet;
39
39
  };
40
- declare const ButtonGroup: import("svelte").Component<$$ComponentProps, {}, "value" | "values">;
40
+ declare const ButtonGroup: import("svelte").Component<$$ComponentProps, {}, "values" | "value">;
41
41
  type ButtonGroup = ReturnType<typeof ButtonGroup>;
42
42
  export default ButtonGroup;
@@ -0,0 +1,139 @@
1
+ <!--
2
+ /**
3
+ * Chip — an interactive pill: a filter/choice token, a removable tag, or a clickable label. The
4
+ * interactive counterpart to `Badge` (display-only). Shared `variant`/`size` axes, `--ui-*` tokens.
5
+ *
6
+ * The root is a `<span>` (or `<a href>`) with `role="button"` when clickable — NOT a `<button>` — so a
7
+ * removable chip's × can be a real nested `<button>` without invalid button-in-button nesting.
8
+ * See Chip.spec.md.
9
+ */
10
+ -->
11
+ <script lang="ts">
12
+ import type { Snippet } from "svelte";
13
+ import IconClose from "~icons/mdi/close";
14
+ import type { Variant } from "../../types/variants.js";
15
+ import { variantToken } from "../../types/variants.js";
16
+ import type { Size } from "../../types/sizes.js";
17
+ import { responsiveClasses, type Responsive } from "../../types/responsive.js";
18
+
19
+ let {
20
+ text,
21
+ icon,
22
+ variant = "secondary",
23
+ size = "md",
24
+ selected = false,
25
+ removable = false,
26
+ onremove,
27
+ onclick,
28
+ disabled = false,
29
+ href,
30
+ rounded = true,
31
+ class: cls = "",
32
+ children,
33
+ }: {
34
+ text?: string;
35
+ icon?: Snippet;
36
+ variant?: Variant;
37
+ size?: Responsive<Size>;
38
+ selected?: boolean;
39
+ removable?: boolean;
40
+ onremove?: () => void;
41
+ onclick?: (e: MouseEvent | KeyboardEvent) => void;
42
+ disabled?: boolean;
43
+ href?: string;
44
+ rounded?: boolean;
45
+ class?: string;
46
+ children?: Snippet;
47
+ } = $props();
48
+
49
+ const interactive = $derived(!!onclick || !!href);
50
+ const isButton = $derived(!!onclick && !href); // role=button (href navigates instead)
51
+
52
+ const token = $derived(variantToken[variant]);
53
+
54
+ const sizeClass: Record<Size, string> = {
55
+ sm: "h-6 text-xs px-2.5 gap-1",
56
+ md: "h-8 text-sm px-3 gap-1.5",
57
+ lg: "h-10 text-base px-4 gap-2",
58
+ };
59
+ const iconClass: Record<Size, string> = {
60
+ sm: "[&_svg]:w-3.5 [&_svg]:h-3.5",
61
+ md: "[&_svg]:w-4 [&_svg]:h-4",
62
+ lg: "[&_svg]:w-5 [&_svg]:h-5",
63
+ };
64
+
65
+ // Selected = filled with the variant (white text); unselected = soft tint + tint border (same token
66
+ // vocabulary as Badge). `ghost` is the neutral, near-transparent chip.
67
+ const chipStyle = $derived(
68
+ variant === "ghost"
69
+ ? selected
70
+ ? "background-color: color-mix(in srgb, var(--ui-color-text) 12%, transparent); border-color: transparent; color: var(--ui-color-text);"
71
+ : "background-color: transparent; border-color: color-mix(in srgb, var(--ui-color-text) 20%, transparent); color: color-mix(in srgb, var(--ui-color-text) 70%, transparent);"
72
+ : selected
73
+ ? `background-color: var(${token}); border-color: var(${token}); color: white;`
74
+ : `background-color: color-mix(in srgb, var(${token}) var(--ui-tint-soft), transparent); border-color: color-mix(in srgb, var(${token}) var(--ui-tint-border), transparent); color: color-mix(in srgb, var(${token}) var(--ui-tint-strong), var(--ui-color-text));`,
75
+ );
76
+
77
+ const chipClass = $derived(
78
+ [
79
+ "inline-flex items-center border select-none no-underline align-middle",
80
+ responsiveClasses(size, sizeClass),
81
+ responsiveClasses(size, iconClass),
82
+ rounded ? "rounded-full" : "rounded-[var(--ui-border-radius)]",
83
+ interactive
84
+ ? "cursor-pointer hover:brightness-95 active:brightness-90 transition-[filter,background-color,border-color]"
85
+ : "transition-colors",
86
+ isButton
87
+ ? "focus-visible:[outline:2px_solid_color-mix(in_srgb,var(--ui-color-text)_70%,transparent)] focus-visible:[outline-offset:1px]"
88
+ : "",
89
+ disabled ? "opacity-50 pointer-events-none" : "",
90
+ cls,
91
+ ]
92
+ .filter(Boolean)
93
+ .join(" "),
94
+ );
95
+
96
+ function onKeydown(e: KeyboardEvent) {
97
+ if (disabled) return;
98
+ if (isButton && (e.key === "Enter" || e.key === " ")) {
99
+ e.preventDefault();
100
+ onclick?.(e);
101
+ } else if (removable && (e.key === "Backspace" || e.key === "Delete")) {
102
+ e.preventDefault();
103
+ onremove?.();
104
+ }
105
+ }
106
+ </script>
107
+
108
+ <svelte:element
109
+ this={href ? "a" : "span"}
110
+ {href}
111
+ role={isButton ? "button" : undefined}
112
+ tabindex={isButton ? (disabled ? -1 : 0) : undefined}
113
+ aria-pressed={isButton ? selected : undefined}
114
+ aria-disabled={disabled || undefined}
115
+ class={chipClass}
116
+ style={chipStyle}
117
+ onclick={interactive && !disabled ? onclick : undefined}
118
+ onkeydown={isButton || removable ? onKeydown : undefined}
119
+ >
120
+ {#if icon}
121
+ <span class="inline-flex shrink-0" aria-hidden="true">{@render icon()}</span>
122
+ {/if}
123
+ {#if children}{@render children()}{:else if text}<span class="truncate">{text}</span>{/if}
124
+ {#if removable}
125
+ <button
126
+ type="button"
127
+ aria-label={text ? `Remove ${text}` : "Remove"}
128
+ tabindex={disabled ? -1 : 0}
129
+ {disabled}
130
+ onclick={(e) => {
131
+ e.stopPropagation();
132
+ if (!disabled) onremove?.();
133
+ }}
134
+ class="-mr-1 ml-0.5 inline-flex shrink-0 items-center justify-center rounded-full hover:[background-color:color-mix(in_srgb,currentColor_20%,transparent)] focus:outline-none transition-colors"
135
+ >
136
+ <IconClose />
137
+ </button>
138
+ {/if}
139
+ </svelte:element>
@@ -0,0 +1,22 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { Variant } from "../../types/variants.js";
3
+ import type { Size } from "../../types/sizes.js";
4
+ import { type Responsive } from "../../types/responsive.js";
5
+ type $$ComponentProps = {
6
+ text?: string;
7
+ icon?: Snippet;
8
+ variant?: Variant;
9
+ size?: Responsive<Size>;
10
+ selected?: boolean;
11
+ removable?: boolean;
12
+ onremove?: () => void;
13
+ onclick?: (e: MouseEvent | KeyboardEvent) => void;
14
+ disabled?: boolean;
15
+ href?: string;
16
+ rounded?: boolean;
17
+ class?: string;
18
+ children?: Snippet;
19
+ };
20
+ declare const Chip: import("svelte").Component<$$ComponentProps, {}, "">;
21
+ type Chip = ReturnType<typeof Chip>;
22
+ export default Chip;
@@ -0,0 +1 @@
1
+ export { default as Chip } from "./Chip.svelte";
@@ -0,0 +1 @@
1
+ export { default as Chip } from "./Chip.svelte";