@nqmcreative/ui 0.1.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 (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +507 -0
  3. package/cli/index.mjs +410 -0
  4. package/dist/actions/anchor.d.ts +29 -0
  5. package/dist/actions/anchor.js +122 -0
  6. package/dist/actions/dismissable.d.ts +44 -0
  7. package/dist/actions/dismissable.js +147 -0
  8. package/dist/components/Accordion.svelte +14 -0
  9. package/dist/components/Accordion.svelte.d.ts +8 -0
  10. package/dist/components/AccordionItem.svelte +46 -0
  11. package/dist/components/AccordionItem.svelte.d.ts +14 -0
  12. package/dist/components/Alert.svelte +88 -0
  13. package/dist/components/Alert.svelte.d.ts +18 -0
  14. package/dist/components/Avatar.svelte +58 -0
  15. package/dist/components/Avatar.svelte.d.ts +16 -0
  16. package/dist/components/AvatarGroup.svelte +17 -0
  17. package/dist/components/AvatarGroup.svelte.d.ts +8 -0
  18. package/dist/components/Badge.svelte +53 -0
  19. package/dist/components/Badge.svelte.d.ts +18 -0
  20. package/dist/components/Breadcrumb.svelte +46 -0
  21. package/dist/components/Breadcrumb.svelte.d.ts +14 -0
  22. package/dist/components/Button.svelte +94 -0
  23. package/dist/components/Button.svelte.d.ts +21 -0
  24. package/dist/components/Calendar.svelte +206 -0
  25. package/dist/components/Calendar.svelte.d.ts +22 -0
  26. package/dist/components/Card.svelte +76 -0
  27. package/dist/components/Card.svelte.d.ts +21 -0
  28. package/dist/components/Checkbox.svelte +69 -0
  29. package/dist/components/Checkbox.svelte.d.ts +15 -0
  30. package/dist/components/CheckboxGroup.svelte +109 -0
  31. package/dist/components/CheckboxGroup.svelte.d.ts +30 -0
  32. package/dist/components/Combobox.svelte +268 -0
  33. package/dist/components/Combobox.svelte.d.ts +29 -0
  34. package/dist/components/CommandPalette.svelte +226 -0
  35. package/dist/components/CommandPalette.svelte.d.ts +32 -0
  36. package/dist/components/ConfirmDialog.svelte +97 -0
  37. package/dist/components/ConfirmDialog.svelte.d.ts +22 -0
  38. package/dist/components/ContextMenu.svelte +90 -0
  39. package/dist/components/ContextMenu.svelte.d.ts +15 -0
  40. package/dist/components/DatePicker.svelte +186 -0
  41. package/dist/components/DatePicker.svelte.d.ts +23 -0
  42. package/dist/components/Divider.svelte +30 -0
  43. package/dist/components/Divider.svelte.d.ts +10 -0
  44. package/dist/components/Drawer.svelte +161 -0
  45. package/dist/components/Drawer.svelte.d.ts +20 -0
  46. package/dist/components/Dropdown.svelte +112 -0
  47. package/dist/components/Dropdown.svelte.d.ts +23 -0
  48. package/dist/components/Dropzone.svelte +226 -0
  49. package/dist/components/Dropzone.svelte.d.ts +32 -0
  50. package/dist/components/EmptyState.svelte +57 -0
  51. package/dist/components/EmptyState.svelte.d.ts +18 -0
  52. package/dist/components/Field.svelte +42 -0
  53. package/dist/components/Field.svelte.d.ts +16 -0
  54. package/dist/components/Footer.svelte +93 -0
  55. package/dist/components/Footer.svelte.d.ts +25 -0
  56. package/dist/components/Input.svelte +60 -0
  57. package/dist/components/Input.svelte.d.ts +18 -0
  58. package/dist/components/InputGroup.svelte +19 -0
  59. package/dist/components/InputGroup.svelte.d.ts +10 -0
  60. package/dist/components/Kbd.svelte +17 -0
  61. package/dist/components/Kbd.svelte.d.ts +8 -0
  62. package/dist/components/Label.svelte +16 -0
  63. package/dist/components/Label.svelte.d.ts +9 -0
  64. package/dist/components/Link.svelte +44 -0
  65. package/dist/components/Link.svelte.d.ts +16 -0
  66. package/dist/components/LocaleProvider.svelte +24 -0
  67. package/dist/components/LocaleProvider.svelte.d.ts +10 -0
  68. package/dist/components/MenuItem.svelte +58 -0
  69. package/dist/components/MenuItem.svelte.d.ts +18 -0
  70. package/dist/components/MenuSeparator.svelte +22 -0
  71. package/dist/components/MenuSeparator.svelte.d.ts +8 -0
  72. package/dist/components/Modal.svelte +114 -0
  73. package/dist/components/Modal.svelte.d.ts +21 -0
  74. package/dist/components/MultiSelect.svelte +292 -0
  75. package/dist/components/MultiSelect.svelte.d.ts +31 -0
  76. package/dist/components/Navbar.svelte +190 -0
  77. package/dist/components/Navbar.svelte.d.ts +33 -0
  78. package/dist/components/NumberInput.svelte +100 -0
  79. package/dist/components/NumberInput.svelte.d.ts +15 -0
  80. package/dist/components/Pagination.svelte +90 -0
  81. package/dist/components/Pagination.svelte.d.ts +15 -0
  82. package/dist/components/PasswordInput.svelte +104 -0
  83. package/dist/components/PasswordInput.svelte.d.ts +12 -0
  84. package/dist/components/Popover.svelte +114 -0
  85. package/dist/components/Popover.svelte.d.ts +19 -0
  86. package/dist/components/Progress.svelte +56 -0
  87. package/dist/components/Progress.svelte.d.ts +17 -0
  88. package/dist/components/Radio.svelte +55 -0
  89. package/dist/components/Radio.svelte.d.ts +15 -0
  90. package/dist/components/RadioGroup.svelte +93 -0
  91. package/dist/components/RadioGroup.svelte.d.ts +27 -0
  92. package/dist/components/SegmentedControl.svelte +88 -0
  93. package/dist/components/SegmentedControl.svelte.d.ts +25 -0
  94. package/dist/components/Select.svelte +61 -0
  95. package/dist/components/Select.svelte.d.ts +20 -0
  96. package/dist/components/Sidebar.svelte +206 -0
  97. package/dist/components/Sidebar.svelte.d.ts +36 -0
  98. package/dist/components/Skeleton.svelte +46 -0
  99. package/dist/components/Skeleton.svelte.d.ts +13 -0
  100. package/dist/components/Slider.svelte +79 -0
  101. package/dist/components/Slider.svelte.d.ts +19 -0
  102. package/dist/components/Spinner.svelte +34 -0
  103. package/dist/components/Spinner.svelte.d.ts +13 -0
  104. package/dist/components/Stat.svelte +49 -0
  105. package/dist/components/Stat.svelte.d.ts +16 -0
  106. package/dist/components/Steps.svelte +136 -0
  107. package/dist/components/Steps.svelte.d.ts +24 -0
  108. package/dist/components/Switch.svelte +64 -0
  109. package/dist/components/Switch.svelte.d.ts +14 -0
  110. package/dist/components/Table.svelte +236 -0
  111. package/dist/components/Table.svelte.d.ts +70 -0
  112. package/dist/components/Tabs.svelte +91 -0
  113. package/dist/components/Tabs.svelte.d.ts +23 -0
  114. package/dist/components/Textarea.svelte +54 -0
  115. package/dist/components/Textarea.svelte.d.ts +12 -0
  116. package/dist/components/ThemeToggle.svelte +133 -0
  117. package/dist/components/ThemeToggle.svelte.d.ts +20 -0
  118. package/dist/components/Toaster.svelte +110 -0
  119. package/dist/components/Toaster.svelte.d.ts +8 -0
  120. package/dist/components/Tooltip.svelte +33 -0
  121. package/dist/components/Tooltip.svelte.d.ts +12 -0
  122. package/dist/date.d.ts +35 -0
  123. package/dist/date.js +108 -0
  124. package/dist/fonts.css +193 -0
  125. package/dist/index.d.ts +105 -0
  126. package/dist/index.js +77 -0
  127. package/dist/locale.svelte.d.ts +93 -0
  128. package/dist/locale.svelte.js +153 -0
  129. package/dist/theme.css +210 -0
  130. package/dist/toast.svelte.d.ts +47 -0
  131. package/dist/toast.svelte.js +60 -0
  132. package/dist/tones.d.ts +48 -0
  133. package/dist/tones.js +209 -0
  134. package/package.json +349 -0
  135. package/registry.json +669 -0
@@ -0,0 +1,44 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAnchorAttributes } from 'svelte/elements';
4
+ import { focusRing, toneHoverText, toneRing, toneText, type Tone } from '../tones.js';
5
+
6
+ interface Props extends HTMLAnchorAttributes {
7
+ tone?: Tone;
8
+ /** `'hover'` shows the rule only on hover. */
9
+ underline?: 'always' | 'hover' | 'none';
10
+ /** Adds `target="_blank" rel="noreferrer"` and an arrow glyph. */
11
+ external?: boolean;
12
+ /** Inherit the surrounding text colour instead of the tone. */
13
+ muted?: boolean;
14
+ children: Snippet;
15
+ }
16
+
17
+ let {
18
+ tone = 'brand',
19
+ underline = 'hover',
20
+ external = false,
21
+ muted = false,
22
+ class: className = '',
23
+ children,
24
+ ...rest
25
+ }: Props = $props();
26
+
27
+ const underlines = {
28
+ always: 'underline underline-offset-4',
29
+ hover: 'hover:underline underline-offset-4',
30
+ none: ''
31
+ };
32
+ </script>
33
+
34
+ <a
35
+ target={external ? '_blank' : undefined}
36
+ rel={external ? 'noreferrer' : undefined}
37
+ class="inline-flex items-center gap-1 font-sans transition-colors duration-150 ease-brand-out
38
+ {muted ? `text-text-secondary ${toneHoverText[tone]}` : toneText[tone]}
39
+ {underlines[underline]} {focusRing} {toneRing[tone]} {className}"
40
+ {...rest}
41
+ >
42
+ {@render children()}
43
+ {#if external}<span aria-hidden="true" class="text-[0.85em]">↗</span>{/if}
44
+ </a>
@@ -0,0 +1,16 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAnchorAttributes } from 'svelte/elements';
3
+ import { type Tone } from '../tones.js';
4
+ interface Props extends HTMLAnchorAttributes {
5
+ tone?: Tone;
6
+ /** `'hover'` shows the rule only on hover. */
7
+ underline?: 'always' | 'hover' | 'none';
8
+ /** Adds `target="_blank" rel="noreferrer"` and an arrow glyph. */
9
+ external?: boolean;
10
+ /** Inherit the surrounding text colour instead of the tone. */
11
+ muted?: boolean;
12
+ children: Snippet;
13
+ }
14
+ declare const Link: import("svelte").Component<Props, {}, "">;
15
+ type Link = ReturnType<typeof Link>;
16
+ export default Link;
@@ -0,0 +1,24 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { provideLocale, type Locale } from '../locale.svelte.js';
4
+
5
+ interface Props {
6
+ /** Full locale or just the keys you want to change. */
7
+ locale: Partial<Locale>;
8
+ children: Snippet;
9
+ }
10
+
11
+ let { locale, children }: Props = $props();
12
+
13
+ // Seeded during init so the first render already has the right strings —
14
+ // an effect would leave one frame (and the SSR output) in the default
15
+ // language. The effect below only handles later changes, e.g. a switcher.
16
+ // svelte-ignore state_referenced_locally
17
+ const store = provideLocale(locale);
18
+
19
+ $effect(() => {
20
+ store.replace(locale);
21
+ });
22
+ </script>
23
+
24
+ {@render children()}
@@ -0,0 +1,10 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type Locale } from '../locale.svelte.js';
3
+ interface Props {
4
+ /** Full locale or just the keys you want to change. */
5
+ locale: Partial<Locale>;
6
+ children: Snippet;
7
+ }
8
+ declare const LocaleProvider: import("svelte").Component<Props, {}, "">;
9
+ type LocaleProvider = ReturnType<typeof LocaleProvider>;
10
+ export default LocaleProvider;
@@ -0,0 +1,58 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLButtonAttributes } from 'svelte/elements';
4
+ import { toneSoftHover, toneText, type Tone } from '../tones.js';
5
+
6
+ interface Props extends HTMLButtonAttributes {
7
+ /** Renders an `<a>` instead of a `<button>`. */
8
+ href?: string;
9
+ tone?: Tone;
10
+ /** Leading glyph. */
11
+ icon?: Snippet;
12
+ /** Right-aligned hint — a keyboard shortcut, a count. */
13
+ shortcut?: string;
14
+ /** Shows a check on the left and marks the item as chosen. */
15
+ selected?: boolean;
16
+ children: Snippet;
17
+ }
18
+
19
+ let {
20
+ href,
21
+ tone = 'neutral',
22
+ icon,
23
+ shortcut,
24
+ selected = false,
25
+ disabled = false,
26
+ class: className = '',
27
+ children,
28
+ ...rest
29
+ }: Props = $props();
30
+ </script>
31
+
32
+ <svelte:element
33
+ this={href ? 'a' : 'button'}
34
+ {href}
35
+ type={href ? undefined : 'button'}
36
+ role="menuitem"
37
+ disabled={href ? undefined : disabled}
38
+ aria-disabled={disabled ? 'true' : undefined}
39
+ tabindex={disabled ? undefined : -1}
40
+ class="flex w-full items-center gap-2.5 px-3 py-2 text-left text-[14px] transition-colors duration-100 ease-brand-out focus:outline-none disabled:pointer-events-none disabled:opacity-40
41
+ {tone === 'neutral'
42
+ ? 'text-text-secondary hover:bg-bg-inset focus-visible:bg-bg-inset'
43
+ : `${toneText[tone]} ${toneSoftHover[tone]}`}
44
+ {className}"
45
+ {...rest}
46
+ >
47
+ {#if selected}
48
+ <svg class="size-3.5 shrink-0" viewBox="0 0 14 14" fill="none" aria-hidden="true">
49
+ <path d="m2 7 3.2 3.2L12 3.6" stroke="currentColor" stroke-width="1.6" />
50
+ </svg>
51
+ {:else if icon}
52
+ <span class="flex size-3.5 shrink-0 items-center justify-center">{@render icon()}</span>
53
+ {/if}
54
+ <span class="min-w-0 flex-1 truncate">{@render children()}</span>
55
+ {#if shortcut}
56
+ <span class="shrink-0 font-mono text-[11px] text-text-muted">{shortcut}</span>
57
+ {/if}
58
+ </svelte:element>
@@ -0,0 +1,18 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLButtonAttributes } from 'svelte/elements';
3
+ import { type Tone } from '../tones.js';
4
+ interface Props extends HTMLButtonAttributes {
5
+ /** Renders an `<a>` instead of a `<button>`. */
6
+ href?: string;
7
+ tone?: Tone;
8
+ /** Leading glyph. */
9
+ icon?: Snippet;
10
+ /** Right-aligned hint — a keyboard shortcut, a count. */
11
+ shortcut?: string;
12
+ /** Shows a check on the left and marks the item as chosen. */
13
+ selected?: boolean;
14
+ children: Snippet;
15
+ }
16
+ declare const MenuItem: import("svelte").Component<Props, {}, "">;
17
+ type MenuItem = ReturnType<typeof MenuItem>;
18
+ export default MenuItem;
@@ -0,0 +1,22 @@
1
+ <script lang="ts">
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+
4
+ interface Props extends HTMLAttributes<HTMLDivElement> {
5
+ /** Optional caption, rendered as a section heading above the rule. */
6
+ label?: string;
7
+ }
8
+
9
+ let { label, class: className = '', ...rest }: Props = $props();
10
+ </script>
11
+
12
+ {#if label}
13
+ <div
14
+ class="mt-1 px-3 pt-2 pb-1 font-mono text-[10px] tracking-wide text-text-muted uppercase {className}"
15
+ role="presentation"
16
+ {...rest}
17
+ >
18
+ {label}
19
+ </div>
20
+ {:else}
21
+ <div role="separator" class="my-1 h-px bg-hairline {className}" {...rest}></div>
22
+ {/if}
@@ -0,0 +1,8 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ interface Props extends HTMLAttributes<HTMLDivElement> {
3
+ /** Optional caption, rendered as a section heading above the rule. */
4
+ label?: string;
5
+ }
6
+ declare const MenuSeparator: import("svelte").Component<Props, {}, "">;
7
+ type MenuSeparator = ReturnType<typeof MenuSeparator>;
8
+ export default MenuSeparator;
@@ -0,0 +1,114 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLDialogAttributes } from 'svelte/elements';
4
+ import { useLocale } from '../locale.svelte.js';
5
+
6
+ export type ModalSize = 'sm' | 'md' | 'lg';
7
+
8
+ interface Props extends HTMLDialogAttributes {
9
+ /** Bindable. Uses the native `<dialog>` top layer + backdrop. */
10
+ open?: boolean;
11
+ title?: string;
12
+ description?: string;
13
+ size?: ModalSize;
14
+ /** Clicking the backdrop or pressing Escape closes the dialog. */
15
+ dismissible?: boolean;
16
+ /** Show the header's close button. Defaults to `dismissible` — turn it off
17
+ * when the footer already carries the way out. */
18
+ showClose?: boolean;
19
+ footer?: Snippet;
20
+ onclose?: () => void;
21
+ children: Snippet;
22
+ }
23
+
24
+ let {
25
+ open = $bindable(false),
26
+ title,
27
+ description,
28
+ size = 'md',
29
+ dismissible = true,
30
+ showClose,
31
+ footer,
32
+ onclose,
33
+ class: className = '',
34
+ children,
35
+ ...rest
36
+ }: Props = $props();
37
+
38
+ const t = useLocale();
39
+ let dialog: HTMLDialogElement | null = $state(null);
40
+
41
+ const closable = $derived(showClose ?? dismissible);
42
+
43
+ const sizes: Record<ModalSize, string> = {
44
+ sm: 'max-w-sm',
45
+ md: 'max-w-lg',
46
+ lg: 'max-w-2xl'
47
+ };
48
+
49
+ $effect(() => {
50
+ if (!dialog) return;
51
+ if (open && !dialog.open) dialog.showModal();
52
+ else if (!open && dialog.open) dialog.close();
53
+ });
54
+
55
+ function close() {
56
+ open = false;
57
+ onclose?.();
58
+ }
59
+
60
+ function onBackdrop(event: MouseEvent) {
61
+ if (!dismissible) return;
62
+ if (event.target === dialog) close();
63
+ }
64
+ </script>
65
+
66
+ <dialog
67
+ bind:this={dialog}
68
+ onclose={close}
69
+ onclick={onBackdrop}
70
+ oncancel={(event) => {
71
+ if (!dismissible) event.preventDefault();
72
+ }}
73
+ class="m-auto w-[calc(100%-2rem)] border border-hairline bg-bg p-0 text-text backdrop:bg-text/40 {sizes[
74
+ size
75
+ ]} {className}"
76
+ {...rest}
77
+ >
78
+ <div class="flex flex-col">
79
+ {#if title || closable}
80
+ <div class="flex items-start gap-4 border-b border-hairline px-6 py-5">
81
+ <div class="flex min-w-0 flex-1 flex-col gap-1">
82
+ {#if title}
83
+ <h2 class="font-heading text-lg font-medium tracking-tight">{title}</h2>
84
+ {/if}
85
+ {#if description}
86
+ <p class="font-sans text-sm text-text-secondary">{description}</p>
87
+ {/if}
88
+ </div>
89
+ {#if closable}
90
+ <button
91
+ type="button"
92
+ onclick={close}
93
+ aria-label={t.current.close}
94
+ class="-m-1 shrink-0 p-1 text-text-muted transition-colors duration-150 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
95
+ >
96
+ <svg class="size-4" viewBox="0 0 14 14" fill="none" aria-hidden="true">
97
+ <path d="m2 2 10 10M12 2 2 12" stroke="currentColor" stroke-width="1.5" />
98
+ </svg>
99
+ </button>
100
+ {/if}
101
+ </div>
102
+ {/if}
103
+
104
+ <div class="px-6 py-5 font-sans text-[15px] leading-relaxed text-text-secondary">
105
+ {@render children()}
106
+ </div>
107
+
108
+ {#if footer}
109
+ <div class="flex items-center justify-end gap-3 border-t border-hairline px-6 py-4">
110
+ {@render footer()}
111
+ </div>
112
+ {/if}
113
+ </div>
114
+ </dialog>
@@ -0,0 +1,21 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLDialogAttributes } from 'svelte/elements';
3
+ export type ModalSize = 'sm' | 'md' | 'lg';
4
+ interface Props extends HTMLDialogAttributes {
5
+ /** Bindable. Uses the native `<dialog>` top layer + backdrop. */
6
+ open?: boolean;
7
+ title?: string;
8
+ description?: string;
9
+ size?: ModalSize;
10
+ /** Clicking the backdrop or pressing Escape closes the dialog. */
11
+ dismissible?: boolean;
12
+ /** Show the header's close button. Defaults to `dismissible` — turn it off
13
+ * when the footer already carries the way out. */
14
+ showClose?: boolean;
15
+ footer?: Snippet;
16
+ onclose?: () => void;
17
+ children: Snippet;
18
+ }
19
+ declare const Modal: import("svelte").Component<Props, {}, "open">;
20
+ type Modal = ReturnType<typeof Modal>;
21
+ export default Modal;
@@ -0,0 +1,292 @@
1
+ <script module lang="ts">
2
+ export interface MultiSelectOption {
3
+ value: string;
4
+ label: string;
5
+ description?: string;
6
+ disabled?: boolean;
7
+ group?: string;
8
+ }
9
+ </script>
10
+
11
+ <script lang="ts">
12
+ import { anchored } from '../actions/anchor.js';
13
+ import { clickOutside, portal } from '../actions/dismissable.js';
14
+ import { useLocale } from '../locale.svelte.js';
15
+ import { toneFocusWithinBorder, toneSoft, toneText, type Tone } from '../tones.js';
16
+
17
+ interface Props {
18
+ /** Bindable — the `value` of every chosen option. */
19
+ value?: string[];
20
+ options: MultiSelectOption[];
21
+ placeholder?: string;
22
+ emptyText?: string;
23
+ tone?: Tone;
24
+ invalid?: boolean;
25
+ disabled?: boolean;
26
+ clearable?: boolean;
27
+ /** Cap on how many may be chosen. `0` means no limit. */
28
+ max?: number;
29
+ /** Collapse to "n selected" past this many chips. `0` shows them all. */
30
+ maxChips?: number;
31
+ id?: string;
32
+ /** Submits one hidden input per value. */
33
+ name?: string;
34
+ onchange?: (value: string[]) => void;
35
+ class?: string;
36
+ }
37
+
38
+ let {
39
+ value = $bindable([]),
40
+ options,
41
+ placeholder,
42
+ emptyText,
43
+ tone = 'brand',
44
+ invalid = false,
45
+ disabled = false,
46
+ clearable = true,
47
+ max = 0,
48
+ maxChips = 0,
49
+ id,
50
+ name,
51
+ onchange,
52
+ class: className = ''
53
+ }: Props = $props();
54
+
55
+ const t = useLocale();
56
+
57
+ let open = $state(false);
58
+ let query = $state('');
59
+ let active = $state(0);
60
+ let wrapper: HTMLElement | null = $state(null);
61
+ let inputEl: HTMLInputElement | null = $state(null);
62
+ let listEl: HTMLElement | null = $state(null);
63
+
64
+ const chosen = $derived(
65
+ value.map((v) => options.find((o) => o.value === v)).filter(Boolean) as MultiSelectOption[]
66
+ );
67
+ const full = $derived(max > 0 && value.length >= max);
68
+ const collapsed = $derived(maxChips > 0 && chosen.length > maxChips);
69
+
70
+ const matches = $derived.by(() => {
71
+ const needle = query.trim().toLowerCase();
72
+ if (!needle) return options;
73
+ return options.filter(
74
+ (option) =>
75
+ option.label.toLowerCase().includes(needle) ||
76
+ option.description?.toLowerCase().includes(needle)
77
+ );
78
+ });
79
+
80
+ const groups = $derived.by(() => {
81
+ const out: [string, MultiSelectOption[]][] = [];
82
+ for (const option of matches) {
83
+ const key = option.group ?? '';
84
+ const bucket = out.find(([n]) => n === key);
85
+ if (bucket) bucket[1].push(option);
86
+ else out.push([key, [option]]);
87
+ }
88
+ return out;
89
+ });
90
+
91
+ const flat = $derived(groups.flatMap(([, items]) => items));
92
+
93
+ function toggle(option: MultiSelectOption) {
94
+ const has = value.includes(option.value);
95
+ if (option.disabled || (!has && full)) return;
96
+ value = has ? value.filter((v) => v !== option.value) : [...value, option.value];
97
+ onchange?.(value);
98
+ }
99
+
100
+ function remove(option: MultiSelectOption) {
101
+ value = value.filter((v) => v !== option.value);
102
+ onchange?.(value);
103
+ }
104
+
105
+ function move(step: number) {
106
+ if (flat.length === 0) return;
107
+ let next = active;
108
+ for (let i = 0; i < flat.length; i++) {
109
+ next = (next + step + flat.length) % flat.length;
110
+ if (!flat[next].disabled) break;
111
+ }
112
+ active = next;
113
+ listEl?.querySelector(`[data-index="${next}"]`)?.scrollIntoView({ block: 'nearest' });
114
+ }
115
+
116
+ function onKeydown(event: KeyboardEvent) {
117
+ switch (event.key) {
118
+ case 'ArrowDown':
119
+ event.preventDefault();
120
+ if (!open) open = true;
121
+ else move(1);
122
+ break;
123
+ case 'ArrowUp':
124
+ event.preventDefault();
125
+ if (open) move(-1);
126
+ break;
127
+ case 'Enter':
128
+ if (open && flat[active]) {
129
+ event.preventDefault();
130
+ toggle(flat[active]);
131
+ }
132
+ break;
133
+ case 'Backspace':
134
+ // Empty query: peel the last chip off, the way tag inputs behave.
135
+ if (!query && chosen.length) {
136
+ event.preventDefault();
137
+ remove(chosen[chosen.length - 1]);
138
+ }
139
+ break;
140
+ case 'Escape':
141
+ if (open) {
142
+ event.preventDefault();
143
+ event.stopPropagation();
144
+ open = false;
145
+ query = '';
146
+ }
147
+ break;
148
+ case 'Tab':
149
+ open = false;
150
+ break;
151
+ }
152
+ }
153
+ </script>
154
+
155
+ <div
156
+ bind:this={wrapper}
157
+ class="flex w-full flex-wrap items-center gap-1.5 border bg-bg py-1.5 pr-1 pl-2 transition-colors duration-150 ease-brand-out
158
+ {invalid ? 'border-danger' : `border-hairline ${toneFocusWithinBorder[tone]}`}
159
+ {disabled ? 'pointer-events-none opacity-50' : ''} {className}"
160
+ >
161
+ {#if collapsed}
162
+ <span
163
+ class="inline-flex items-center rounded-full px-2.5 py-1 font-mono text-xs {toneSoft[tone]}"
164
+ >
165
+ {chosen.length}
166
+ {t.current.selected}
167
+ </span>
168
+ {:else}
169
+ {#each chosen as option (option.value)}
170
+ <span
171
+ class="inline-flex items-center gap-1.5 rounded-full py-1 pr-1 pl-2.5 font-mono text-xs {toneSoft[
172
+ tone
173
+ ]}"
174
+ >
175
+ {option.label}
176
+ <button
177
+ type="button"
178
+ onclick={() => remove(option)}
179
+ aria-label="{t.current.removeItem} {option.label}"
180
+ class="inline-flex size-4 items-center justify-center rounded-full transition-colors duration-150 hover:bg-bg focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-current"
181
+ >
182
+ <svg class="size-2.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
183
+ <path d="m2 2 10 10M12 2 2 12" stroke="currentColor" stroke-width="2" />
184
+ </svg>
185
+ </button>
186
+ </span>
187
+ {/each}
188
+ {/if}
189
+
190
+ <input
191
+ bind:this={inputEl}
192
+ bind:value={query}
193
+ {id}
194
+ {disabled}
195
+ type="text"
196
+ role="combobox"
197
+ autocomplete="off"
198
+ aria-expanded={open}
199
+ aria-controls="{id ?? 'multiselect'}-listbox"
200
+ aria-invalid={invalid ? 'true' : undefined}
201
+ placeholder={chosen.length ? '' : (placeholder ?? t.current.comboboxPlaceholder)}
202
+ oninput={() => {
203
+ open = true;
204
+ active = 0;
205
+ }}
206
+ onfocus={() => (open = true)}
207
+ onkeydown={onKeydown}
208
+ class="h-8 min-w-24 flex-1 bg-transparent px-1 font-sans text-[15px] text-text placeholder:text-text-muted focus:outline-none"
209
+ />
210
+
211
+ {#if name}
212
+ {#each value as v (v)}<input type="hidden" {name} value={v} />{/each}
213
+ {/if}
214
+
215
+ {#if clearable && chosen.length && !disabled}
216
+ <button
217
+ type="button"
218
+ onclick={() => {
219
+ value = [];
220
+ onchange?.([]);
221
+ inputEl?.focus();
222
+ }}
223
+ aria-label={t.current.clear}
224
+ class="shrink-0 p-1.5 text-text-muted transition-colors duration-150 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
225
+ >
226
+ <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
227
+ <path d="m2 2 10 10M12 2 2 12" stroke="currentColor" stroke-width="1.5" />
228
+ </svg>
229
+ </button>
230
+ {/if}
231
+ </div>
232
+
233
+ {#if open}
234
+ <div
235
+ bind:this={listEl}
236
+ use:portal
237
+ use:anchored={{ anchor: wrapper, placement: 'bottom-start', offset: 4, matchWidth: true }}
238
+ use:clickOutside={{ onoutside: () => (open = false), ignore: [wrapper] }}
239
+ id="{id ?? 'multiselect'}-listbox"
240
+ role="listbox"
241
+ aria-multiselectable="true"
242
+ class="z-50 max-h-64 overflow-y-auto border border-hairline bg-bg py-1 font-sans"
243
+ >
244
+ {#if flat.length === 0}
245
+ <p class="px-3 py-6 text-center text-sm text-text-muted">
246
+ {emptyText ?? t.current.noMatches}
247
+ </p>
248
+ {:else}
249
+ {#each groups as [group, items] (group)}
250
+ {#if group}
251
+ <p class="px-3 pt-2 pb-1 font-mono text-[10px] tracking-wide text-text-muted uppercase">
252
+ {group}
253
+ </p>
254
+ {/if}
255
+ {#each items as option (option.value)}
256
+ {@const index = flat.indexOf(option)}
257
+ {@const checked = value.includes(option.value)}
258
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
259
+ <div
260
+ data-index={index}
261
+ role="option"
262
+ tabindex="-1"
263
+ aria-selected={checked}
264
+ aria-disabled={option.disabled || (full && !checked)}
265
+ onclick={() => toggle(option)}
266
+ onpointermove={() => (active = index)}
267
+ class="flex cursor-pointer items-start gap-2.5 px-3 py-2 text-[14px] transition-colors duration-100
268
+ {option.disabled || (full && !checked) ? 'pointer-events-none opacity-40' : ''}
269
+ {index === active ? toneSoft[tone] : 'text-text-secondary'}"
270
+ >
271
+ <span
272
+ class="mt-0.5 flex size-4 shrink-0 items-center justify-center border transition-colors duration-100
273
+ {checked ? `border-transparent ${toneText[tone]}` : 'border-hairline-strong'}"
274
+ >
275
+ {#if checked}
276
+ <svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
277
+ <path d="m2 6 2.6 2.6L10 3.2" stroke="currentColor" stroke-width="1.8" />
278
+ </svg>
279
+ {/if}
280
+ </span>
281
+ <span class="flex min-w-0 flex-1 flex-col gap-0.5">
282
+ <span class="truncate">{option.label}</span>
283
+ {#if option.description}
284
+ <span class="truncate text-xs text-text-muted">{option.description}</span>
285
+ {/if}
286
+ </span>
287
+ </div>
288
+ {/each}
289
+ {/each}
290
+ {/if}
291
+ </div>
292
+ {/if}
@@ -0,0 +1,31 @@
1
+ export interface MultiSelectOption {
2
+ value: string;
3
+ label: string;
4
+ description?: string;
5
+ disabled?: boolean;
6
+ group?: string;
7
+ }
8
+ import { type Tone } from '../tones.js';
9
+ interface Props {
10
+ /** Bindable — the `value` of every chosen option. */
11
+ value?: string[];
12
+ options: MultiSelectOption[];
13
+ placeholder?: string;
14
+ emptyText?: string;
15
+ tone?: Tone;
16
+ invalid?: boolean;
17
+ disabled?: boolean;
18
+ clearable?: boolean;
19
+ /** Cap on how many may be chosen. `0` means no limit. */
20
+ max?: number;
21
+ /** Collapse to "n selected" past this many chips. `0` shows them all. */
22
+ maxChips?: number;
23
+ id?: string;
24
+ /** Submits one hidden input per value. */
25
+ name?: string;
26
+ onchange?: (value: string[]) => void;
27
+ class?: string;
28
+ }
29
+ declare const MultiSelect: import("svelte").Component<Props, {}, "value">;
30
+ type MultiSelect = ReturnType<typeof MultiSelect>;
31
+ export default MultiSelect;