@nqmcreative/ui 0.2.1 → 0.2.3

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 (175) hide show
  1. package/README.md +7 -5
  2. package/dist/core/index.d.ts +1 -1
  3. package/dist/core/index.js +1 -1
  4. package/dist/core/trigger.d.ts +6 -0
  5. package/dist/core/trigger.js +13 -0
  6. package/dist/styles/matte/Button.svelte +4 -4
  7. package/dist/styles/matte/Card.svelte +3 -3
  8. package/dist/styles/matte/Combobox.svelte +2 -2
  9. package/dist/styles/matte/CommandPalette.svelte +32 -7
  10. package/dist/styles/matte/CommandPalette.svelte.d.ts +5 -0
  11. package/dist/styles/matte/DatePicker.svelte +2 -2
  12. package/dist/styles/matte/Drawer.svelte +2 -2
  13. package/dist/styles/matte/Dropzone.svelte +1 -1
  14. package/dist/styles/matte/FeatureCard.svelte +1 -1
  15. package/dist/styles/matte/Input.svelte +4 -4
  16. package/dist/styles/matte/MenuItem.svelte +1 -1
  17. package/dist/styles/matte/Modal.svelte +3 -3
  18. package/dist/styles/matte/NumberInput.svelte +2 -2
  19. package/dist/styles/matte/PasswordInput.svelte +2 -2
  20. package/dist/styles/matte/PricingCard.svelte +3 -3
  21. package/dist/styles/matte/SegmentedControl.svelte +1 -1
  22. package/dist/styles/matte/Select.svelte +1 -1
  23. package/dist/styles/matte/Sidebar.svelte +75 -26
  24. package/dist/styles/matte/Sidebar.svelte.d.ts +15 -1
  25. package/dist/styles/matte/StatsBand.svelte +1 -1
  26. package/dist/styles/matte/Table.svelte +3 -3
  27. package/dist/styles/matte/Testimonial.svelte +2 -2
  28. package/dist/styles/matte/Textarea.svelte +1 -1
  29. package/dist/styles/matte/index.d.ts +1 -1
  30. package/dist/styles/paper/CommandPalette.svelte +25 -2
  31. package/dist/styles/paper/CommandPalette.svelte.d.ts +5 -0
  32. package/dist/styles/paper/Modal.svelte +9 -1
  33. package/dist/styles/paper/Sidebar.svelte +75 -26
  34. package/dist/styles/paper/Sidebar.svelte.d.ts +15 -1
  35. package/dist/styles/paper/fonts.css +34 -7
  36. package/dist/styles/paper/index.d.ts +1 -1
  37. package/dist/styles/paper/theme.css +11 -8
  38. package/dist/styles/sprout/Accordion.svelte +17 -0
  39. package/dist/styles/sprout/Accordion.svelte.d.ts +8 -0
  40. package/dist/styles/sprout/AccordionItem.svelte +46 -0
  41. package/dist/styles/sprout/AccordionItem.svelte.d.ts +14 -0
  42. package/dist/styles/sprout/Alert.svelte +100 -0
  43. package/dist/styles/sprout/Alert.svelte.d.ts +18 -0
  44. package/dist/styles/sprout/Avatar.svelte +60 -0
  45. package/dist/styles/sprout/Avatar.svelte.d.ts +16 -0
  46. package/dist/styles/sprout/AvatarGroup.svelte +17 -0
  47. package/dist/styles/sprout/AvatarGroup.svelte.d.ts +8 -0
  48. package/dist/styles/sprout/Badge.svelte +58 -0
  49. package/dist/styles/sprout/Badge.svelte.d.ts +16 -0
  50. package/dist/styles/sprout/Breadcrumb.svelte +49 -0
  51. package/dist/styles/sprout/Breadcrumb.svelte.d.ts +14 -0
  52. package/dist/styles/sprout/Button.svelte +105 -0
  53. package/dist/styles/sprout/Button.svelte.d.ts +21 -0
  54. package/dist/styles/sprout/CTASection.svelte +77 -0
  55. package/dist/styles/sprout/CTASection.svelte.d.ts +18 -0
  56. package/dist/styles/sprout/Calendar.svelte +202 -0
  57. package/dist/styles/sprout/Calendar.svelte.d.ts +22 -0
  58. package/dist/styles/sprout/Card.svelte +82 -0
  59. package/dist/styles/sprout/Card.svelte.d.ts +21 -0
  60. package/dist/styles/sprout/Checkbox.svelte +76 -0
  61. package/dist/styles/sprout/Checkbox.svelte.d.ts +15 -0
  62. package/dist/styles/sprout/CheckboxGroup.svelte +112 -0
  63. package/dist/styles/sprout/CheckboxGroup.svelte.d.ts +30 -0
  64. package/dist/styles/sprout/Combobox.svelte +267 -0
  65. package/dist/styles/sprout/Combobox.svelte.d.ts +29 -0
  66. package/dist/styles/sprout/CommandPalette.svelte +233 -0
  67. package/dist/styles/sprout/CommandPalette.svelte.d.ts +37 -0
  68. package/dist/styles/sprout/ConfirmDialog.svelte +97 -0
  69. package/dist/styles/sprout/ConfirmDialog.svelte.d.ts +22 -0
  70. package/dist/styles/sprout/ContextMenu.svelte +89 -0
  71. package/dist/styles/sprout/ContextMenu.svelte.d.ts +15 -0
  72. package/dist/styles/sprout/DatePicker.svelte +193 -0
  73. package/dist/styles/sprout/DatePicker.svelte.d.ts +23 -0
  74. package/dist/styles/sprout/Divider.svelte +30 -0
  75. package/dist/styles/sprout/Divider.svelte.d.ts +10 -0
  76. package/dist/styles/sprout/Drawer.svelte +166 -0
  77. package/dist/styles/sprout/Drawer.svelte.d.ts +20 -0
  78. package/dist/styles/sprout/Dropdown.svelte +95 -0
  79. package/dist/styles/sprout/Dropdown.svelte.d.ts +23 -0
  80. package/dist/styles/sprout/Dropzone.svelte +211 -0
  81. package/dist/styles/sprout/Dropzone.svelte.d.ts +29 -0
  82. package/dist/styles/sprout/EmptyState.svelte +71 -0
  83. package/dist/styles/sprout/EmptyState.svelte.d.ts +18 -0
  84. package/dist/styles/sprout/FeatureCard.svelte +61 -0
  85. package/dist/styles/sprout/FeatureCard.svelte.d.ts +16 -0
  86. package/dist/styles/sprout/FeatureGrid.svelte +62 -0
  87. package/dist/styles/sprout/FeatureGrid.svelte.d.ts +16 -0
  88. package/dist/styles/sprout/Field.svelte +42 -0
  89. package/dist/styles/sprout/Field.svelte.d.ts +16 -0
  90. package/dist/styles/sprout/Footer.svelte +91 -0
  91. package/dist/styles/sprout/Footer.svelte.d.ts +25 -0
  92. package/dist/styles/sprout/HeroSection.svelte +86 -0
  93. package/dist/styles/sprout/HeroSection.svelte.d.ts +21 -0
  94. package/dist/styles/sprout/Input.svelte +61 -0
  95. package/dist/styles/sprout/Input.svelte.d.ts +18 -0
  96. package/dist/styles/sprout/InputGroup.svelte +20 -0
  97. package/dist/styles/sprout/InputGroup.svelte.d.ts +11 -0
  98. package/dist/styles/sprout/Kbd.svelte +18 -0
  99. package/dist/styles/sprout/Kbd.svelte.d.ts +8 -0
  100. package/dist/styles/sprout/Label.svelte +16 -0
  101. package/dist/styles/sprout/Label.svelte.d.ts +9 -0
  102. package/dist/styles/sprout/Link.svelte +44 -0
  103. package/dist/styles/sprout/Link.svelte.d.ts +16 -0
  104. package/dist/styles/sprout/LocaleProvider.svelte +24 -0
  105. package/dist/styles/sprout/LocaleProvider.svelte.d.ts +10 -0
  106. package/dist/styles/sprout/LogoCloud.svelte +61 -0
  107. package/dist/styles/sprout/LogoCloud.svelte.d.ts +19 -0
  108. package/dist/styles/sprout/MenuItem.svelte +64 -0
  109. package/dist/styles/sprout/MenuItem.svelte.d.ts +18 -0
  110. package/dist/styles/sprout/MenuSeparator.svelte +22 -0
  111. package/dist/styles/sprout/MenuSeparator.svelte.d.ts +8 -0
  112. package/dist/styles/sprout/Modal.svelte +127 -0
  113. package/dist/styles/sprout/Modal.svelte.d.ts +21 -0
  114. package/dist/styles/sprout/MultiSelect.svelte +288 -0
  115. package/dist/styles/sprout/MultiSelect.svelte.d.ts +31 -0
  116. package/dist/styles/sprout/Navbar.svelte +201 -0
  117. package/dist/styles/sprout/Navbar.svelte.d.ts +33 -0
  118. package/dist/styles/sprout/NumberInput.svelte +97 -0
  119. package/dist/styles/sprout/NumberInput.svelte.d.ts +15 -0
  120. package/dist/styles/sprout/Pagination.svelte +84 -0
  121. package/dist/styles/sprout/Pagination.svelte.d.ts +15 -0
  122. package/dist/styles/sprout/PasswordInput.svelte +101 -0
  123. package/dist/styles/sprout/PasswordInput.svelte.d.ts +12 -0
  124. package/dist/styles/sprout/Popover.svelte +90 -0
  125. package/dist/styles/sprout/Popover.svelte.d.ts +19 -0
  126. package/dist/styles/sprout/PricingCard.svelte +109 -0
  127. package/dist/styles/sprout/PricingCard.svelte.d.ts +25 -0
  128. package/dist/styles/sprout/Progress.svelte +59 -0
  129. package/dist/styles/sprout/Progress.svelte.d.ts +17 -0
  130. package/dist/styles/sprout/Radio.svelte +56 -0
  131. package/dist/styles/sprout/Radio.svelte.d.ts +15 -0
  132. package/dist/styles/sprout/RadioGroup.svelte +96 -0
  133. package/dist/styles/sprout/RadioGroup.svelte.d.ts +27 -0
  134. package/dist/styles/sprout/SegmentedControl.svelte +89 -0
  135. package/dist/styles/sprout/SegmentedControl.svelte.d.ts +25 -0
  136. package/dist/styles/sprout/Select.svelte +62 -0
  137. package/dist/styles/sprout/Select.svelte.d.ts +20 -0
  138. package/dist/styles/sprout/Sidebar.svelte +255 -0
  139. package/dist/styles/sprout/Sidebar.svelte.d.ts +50 -0
  140. package/dist/styles/sprout/Skeleton.svelte +46 -0
  141. package/dist/styles/sprout/Skeleton.svelte.d.ts +13 -0
  142. package/dist/styles/sprout/Slider.svelte +80 -0
  143. package/dist/styles/sprout/Slider.svelte.d.ts +19 -0
  144. package/dist/styles/sprout/Spinner.svelte +34 -0
  145. package/dist/styles/sprout/Spinner.svelte.d.ts +13 -0
  146. package/dist/styles/sprout/Stat.svelte +51 -0
  147. package/dist/styles/sprout/Stat.svelte.d.ts +16 -0
  148. package/dist/styles/sprout/StatsBand.svelte +64 -0
  149. package/dist/styles/sprout/StatsBand.svelte.d.ts +20 -0
  150. package/dist/styles/sprout/Steps.svelte +143 -0
  151. package/dist/styles/sprout/Steps.svelte.d.ts +24 -0
  152. package/dist/styles/sprout/Switch.svelte +65 -0
  153. package/dist/styles/sprout/Switch.svelte.d.ts +14 -0
  154. package/dist/styles/sprout/Table.svelte +218 -0
  155. package/dist/styles/sprout/Table.svelte.d.ts +66 -0
  156. package/dist/styles/sprout/Tabs.svelte +103 -0
  157. package/dist/styles/sprout/Tabs.svelte.d.ts +23 -0
  158. package/dist/styles/sprout/Testimonial.svelte +65 -0
  159. package/dist/styles/sprout/Testimonial.svelte.d.ts +19 -0
  160. package/dist/styles/sprout/Textarea.svelte +55 -0
  161. package/dist/styles/sprout/Textarea.svelte.d.ts +12 -0
  162. package/dist/styles/sprout/ThemeToggle.svelte +131 -0
  163. package/dist/styles/sprout/ThemeToggle.svelte.d.ts +16 -0
  164. package/dist/styles/sprout/Toaster.svelte +133 -0
  165. package/dist/styles/sprout/Toaster.svelte.d.ts +8 -0
  166. package/dist/styles/sprout/Tooltip.svelte +34 -0
  167. package/dist/styles/sprout/Tooltip.svelte.d.ts +12 -0
  168. package/dist/styles/sprout/fonts.css +77 -0
  169. package/dist/styles/sprout/index.d.ts +109 -0
  170. package/dist/styles/sprout/index.js +83 -0
  171. package/dist/styles/sprout/lift.d.ts +40 -0
  172. package/dist/styles/sprout/lift.js +36 -0
  173. package/dist/styles/sprout/theme.css +255 -0
  174. package/package.json +269 -3
  175. package/registry.json +139 -66
@@ -0,0 +1,127 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLDialogAttributes } from 'svelte/elements';
4
+ import { useLocale } from '../../core/locale.svelte.js';
5
+ import { isBackdropClick, syncDialog } from '../../core/dialog.svelte.js';
6
+ import { float } from './lift.js';
7
+
8
+ export type ModalSize = 'sm' | 'md' | 'lg';
9
+
10
+ interface Props extends HTMLDialogAttributes {
11
+ /** Bindable. Uses the native `<dialog>` top layer + backdrop. */
12
+ open?: boolean;
13
+ title?: string;
14
+ description?: string;
15
+ size?: ModalSize;
16
+ /** Clicking the backdrop or pressing Escape closes the dialog. */
17
+ dismissible?: boolean;
18
+ /** Show the header's close button. Defaults to `dismissible` — turn it off
19
+ * when the footer already carries the way out. */
20
+ showClose?: boolean;
21
+ footer?: Snippet;
22
+ onclose?: () => void;
23
+ children: Snippet;
24
+ }
25
+
26
+ let {
27
+ open = $bindable(false),
28
+ title,
29
+ description,
30
+ size = 'md',
31
+ dismissible = true,
32
+ showClose,
33
+ footer,
34
+ onclose,
35
+ class: className = '',
36
+ children,
37
+ ...rest
38
+ }: Props = $props();
39
+
40
+ const t = useLocale();
41
+ let dialog: HTMLDialogElement | null = $state(null);
42
+
43
+ const closable = $derived(showClose ?? dismissible);
44
+
45
+ const sizes: Record<ModalSize, string> = {
46
+ sm: 'max-w-sm',
47
+ md: 'max-w-lg',
48
+ lg: 'max-w-2xl'
49
+ };
50
+
51
+ $effect(() => syncDialog(dialog, open));
52
+
53
+ function close() {
54
+ open = false;
55
+ onclose?.();
56
+ }
57
+
58
+ function onBackdrop(event: MouseEvent) {
59
+ if (!dismissible) return;
60
+ if (isBackdropClick(event, dialog)) close();
61
+ }
62
+ </script>
63
+
64
+ <dialog
65
+ bind:this={dialog}
66
+ onclose={close}
67
+ onclick={onBackdrop}
68
+ oncancel={(event) => {
69
+ if (!dismissible) event.preventDefault();
70
+ }}
71
+ class="m-auto w-[calc(100%-2rem)] rounded-[28px] border border-hairline bg-bg p-0 text-text {float} backdrop:bg-text/30 backdrop:backdrop-blur-[1px] {sizes[
72
+ size
73
+ ]} {className}"
74
+ {...rest}
75
+ >
76
+ <div class="flex flex-col">
77
+ {#if title || closable}
78
+ <div class="flex items-start gap-4 px-6 pt-5 pb-3">
79
+ <div class="flex min-w-0 flex-1 flex-col gap-1">
80
+ {#if title}
81
+ <h2 class="font-heading text-base font-semibold text-text">{title}</h2>
82
+ {/if}
83
+ {#if description}
84
+ <p class="font-sans text-[13px] text-text-muted">{description}</p>
85
+ {/if}
86
+ </div>
87
+ {#if closable}
88
+ <button
89
+ type="button"
90
+ onclick={close}
91
+ aria-label={t.current.close}
92
+ class="-m-1 shrink-0 rounded-xl p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
93
+ >
94
+ <svg class="size-4" viewBox="0 0 14 14" fill="none" aria-hidden="true">
95
+ <path
96
+ d="m2 2 10 10M12 2 2 12"
97
+ stroke="currentColor"
98
+ stroke-width="1.6"
99
+ stroke-linecap="round"
100
+ />
101
+ </svg>
102
+ </button>
103
+ {/if}
104
+ </div>
105
+ {/if}
106
+
107
+ <!--
108
+ The body's top padding depends on what sits above it: 8px is the right
109
+ gap under a header, and far too tight when there is none — a titleless
110
+ modal, ConfirmDialog among them, would otherwise start against the edge.
111
+ -->
112
+ <div
113
+ class="px-6 pb-5 font-sans text-sm leading-relaxed text-text-secondary
114
+ {title || closable ? 'pt-2' : 'pt-5'}"
115
+ >
116
+ {@render children()}
117
+ </div>
118
+
119
+ {#if footer}
120
+ <div
121
+ class="flex items-center justify-end gap-2 rounded-b-[28px] border-t border-hairline bg-bg-alt px-6 py-4"
122
+ >
123
+ {@render footer()}
124
+ </div>
125
+ {/if}
126
+ </div>
127
+ </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,288 @@
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 '../../core/actions/anchor.js';
13
+ import { clickOutside, portal } from '../../core/actions/dismissable.js';
14
+ import { ListCursor, groupItems, matchQuery, revealIndex } from '../../core/list.svelte.js';
15
+ import { useLocale } from '../../core/locale.svelte.js';
16
+ import { toneFocusWithinBorder, toneSoft, toneText, type Tone } from '../../core/tones.js';
17
+ import { edge, soft } from './lift.js';
18
+
19
+ interface Props {
20
+ /** Bindable — the `value` of every chosen option. */
21
+ value?: string[];
22
+ options: MultiSelectOption[];
23
+ placeholder?: string;
24
+ emptyText?: string;
25
+ tone?: Tone;
26
+ invalid?: boolean;
27
+ disabled?: boolean;
28
+ clearable?: boolean;
29
+ /** Cap on how many may be chosen. `0` means no limit. */
30
+ max?: number;
31
+ /** Collapse to "n selected" past this many chips. `0` shows them all. */
32
+ maxChips?: number;
33
+ id?: string;
34
+ /** Submits one hidden input per value. */
35
+ name?: string;
36
+ onchange?: (value: string[]) => void;
37
+ class?: string;
38
+ }
39
+
40
+ let {
41
+ value = $bindable([]),
42
+ options,
43
+ placeholder,
44
+ emptyText,
45
+ tone = 'brand',
46
+ invalid = false,
47
+ disabled = false,
48
+ clearable = true,
49
+ max = 0,
50
+ maxChips = 0,
51
+ id,
52
+ name,
53
+ onchange,
54
+ class: className = ''
55
+ }: Props = $props();
56
+
57
+ const t = useLocale();
58
+
59
+ let open = $state(false);
60
+ let query = $state('');
61
+ let wrapper: HTMLElement | null = $state(null);
62
+ let inputEl: HTMLInputElement | null = $state(null);
63
+ let listEl: HTMLElement | null = $state(null);
64
+
65
+ const chosen = $derived(
66
+ value.map((v) => options.find((o) => o.value === v)).filter(Boolean) as MultiSelectOption[]
67
+ );
68
+ const full = $derived(max > 0 && value.length >= max);
69
+ const collapsed = $derived(maxChips > 0 && chosen.length > maxChips);
70
+
71
+ const matches = $derived(
72
+ matchQuery(options, query, (option) => `${option.label} ${option.description ?? ''}`)
73
+ );
74
+
75
+ const groups = $derived(groupItems(matches));
76
+ const flat = $derived(groups.flatMap(([, items]) => items));
77
+
78
+ const cursor = new ListCursor(() => flat);
79
+ const active = $derived(cursor.index);
80
+
81
+ function toggle(option: MultiSelectOption) {
82
+ const has = value.includes(option.value);
83
+ if (option.disabled || (!has && full)) return;
84
+ value = has ? value.filter((v) => v !== option.value) : [...value, option.value];
85
+ onchange?.(value);
86
+ }
87
+
88
+ function remove(option: MultiSelectOption) {
89
+ value = value.filter((v) => v !== option.value);
90
+ onchange?.(value);
91
+ }
92
+
93
+ function move(step: number) {
94
+ cursor.move(step);
95
+ revealIndex(listEl, cursor.index);
96
+ }
97
+
98
+ function onKeydown(event: KeyboardEvent) {
99
+ switch (event.key) {
100
+ case 'ArrowDown':
101
+ event.preventDefault();
102
+ if (!open) open = true;
103
+ else move(1);
104
+ break;
105
+ case 'ArrowUp':
106
+ event.preventDefault();
107
+ if (open) move(-1);
108
+ break;
109
+ case 'Enter':
110
+ if (open && cursor.current) {
111
+ event.preventDefault();
112
+ toggle(cursor.current);
113
+ }
114
+ break;
115
+ case 'Backspace':
116
+ // Empty query: peel the last chip off, the way tag inputs behave.
117
+ if (!query && chosen.length) {
118
+ event.preventDefault();
119
+ remove(chosen[chosen.length - 1]);
120
+ }
121
+ break;
122
+ case 'Escape':
123
+ if (open) {
124
+ event.preventDefault();
125
+ event.stopPropagation();
126
+ open = false;
127
+ query = '';
128
+ }
129
+ break;
130
+ case 'Tab':
131
+ open = false;
132
+ break;
133
+ }
134
+ }
135
+ </script>
136
+
137
+ <div
138
+ bind:this={wrapper}
139
+ class="flex w-full flex-wrap items-center gap-1.5 rounded-xl border bg-bg py-1.5 pr-1 pl-2 {edge} transition-colors duration-150 ease-brand-out
140
+ {invalid ? 'border-danger' : `border-hairline-strong ${toneFocusWithinBorder[tone]}`}
141
+ {disabled ? 'pointer-events-none opacity-50' : ''} {className}"
142
+ >
143
+ {#if collapsed}
144
+ <span
145
+ class="inline-flex items-center rounded-xl px-2 py-0.5 text-xs font-medium {toneSoft[tone]}"
146
+ >
147
+ {chosen.length}
148
+ {t.current.selected}
149
+ </span>
150
+ {:else}
151
+ {#each chosen as option (option.value)}
152
+ <span
153
+ class="inline-flex items-center gap-1 rounded-xl py-0.5 pr-0.5 pl-2 text-xs font-medium {toneSoft[
154
+ tone
155
+ ]}"
156
+ >
157
+ {option.label}
158
+ <button
159
+ type="button"
160
+ onclick={() => remove(option)}
161
+ aria-label="{t.current.removeItem} {option.label}"
162
+ class="inline-flex size-4 items-center justify-center rounded transition-colors duration-150 hover:bg-bg/70 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-current"
163
+ >
164
+ <svg class="size-2.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
165
+ <path
166
+ d="m2 2 10 10M12 2 2 12"
167
+ stroke="currentColor"
168
+ stroke-width="2.2"
169
+ stroke-linecap="round"
170
+ />
171
+ </svg>
172
+ </button>
173
+ </span>
174
+ {/each}
175
+ {/if}
176
+
177
+ <input
178
+ bind:this={inputEl}
179
+ bind:value={query}
180
+ {id}
181
+ {disabled}
182
+ type="text"
183
+ role="combobox"
184
+ autocomplete="off"
185
+ aria-expanded={open}
186
+ aria-controls="{id ?? 'multiselect'}-listbox"
187
+ aria-invalid={invalid ? 'true' : undefined}
188
+ placeholder={chosen.length ? '' : (placeholder ?? t.current.comboboxPlaceholder)}
189
+ oninput={() => {
190
+ open = true;
191
+ cursor.index = 0;
192
+ }}
193
+ onfocus={() => (open = true)}
194
+ onkeydown={onKeydown}
195
+ class="h-7 min-w-24 flex-1 bg-transparent px-1 font-sans text-sm text-text placeholder:text-text-muted focus:outline-none"
196
+ />
197
+
198
+ {#if name}
199
+ {#each value as v (v)}<input type="hidden" {name} value={v} />{/each}
200
+ {/if}
201
+
202
+ {#if clearable && chosen.length && !disabled}
203
+ <button
204
+ type="button"
205
+ onclick={() => {
206
+ value = [];
207
+ onchange?.([]);
208
+ inputEl?.focus();
209
+ }}
210
+ aria-label={t.current.clear}
211
+ class="shrink-0 rounded p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
212
+ >
213
+ <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
214
+ <path
215
+ d="m2 2 10 10M12 2 2 12"
216
+ stroke="currentColor"
217
+ stroke-width="1.6"
218
+ stroke-linecap="round"
219
+ />
220
+ </svg>
221
+ </button>
222
+ {/if}
223
+ </div>
224
+
225
+ {#if open}
226
+ <div
227
+ bind:this={listEl}
228
+ use:portal
229
+ use:anchored={{ anchor: wrapper, placement: 'bottom-start', offset: 4, matchWidth: true }}
230
+ use:clickOutside={{ onoutside: () => (open = false), ignore: [wrapper] }}
231
+ id="{id ?? 'multiselect'}-listbox"
232
+ role="listbox"
233
+ aria-multiselectable="true"
234
+ class="z-50 max-h-64 overflow-y-auto rounded-2xl border border-hairline bg-bg py-1 font-sans {soft}"
235
+ >
236
+ {#if flat.length === 0}
237
+ <p class="px-3 py-6 text-center text-sm text-text-muted">
238
+ {emptyText ?? t.current.noMatches}
239
+ </p>
240
+ {:else}
241
+ {#each groups as [group, items] (group)}
242
+ {#if group}
243
+ <p class="px-3 pt-2 pb-1 text-[11px] font-semibold text-text-muted">{group}</p>
244
+ {/if}
245
+ {#each items as option (option.value)}
246
+ {@const index = flat.indexOf(option)}
247
+ {@const checked = value.includes(option.value)}
248
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
249
+ <div
250
+ data-index={index}
251
+ role="option"
252
+ tabindex="-1"
253
+ aria-selected={checked}
254
+ aria-disabled={option.disabled || (full && !checked)}
255
+ onclick={() => toggle(option)}
256
+ onpointermove={() => (cursor.index = index)}
257
+ class="mx-1 flex cursor-pointer items-start gap-2.5 rounded-xl px-2.5 py-2 text-sm transition-colors duration-100
258
+ {option.disabled || (full && !checked) ? 'pointer-events-none opacity-40' : ''}
259
+ {index === active ? toneSoft[tone] : 'text-text-secondary'}"
260
+ >
261
+ <span
262
+ class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border transition-colors duration-100
263
+ {checked ? `border-transparent ${toneText[tone]}` : 'border-hairline-strong'}"
264
+ >
265
+ {#if checked}
266
+ <svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
267
+ <path
268
+ d="m2 6 2.6 2.6L10 3.2"
269
+ stroke="currentColor"
270
+ stroke-width="2"
271
+ stroke-linecap="round"
272
+ stroke-linejoin="round"
273
+ />
274
+ </svg>
275
+ {/if}
276
+ </span>
277
+ <span class="flex min-w-0 flex-1 flex-col gap-0.5">
278
+ <span class="truncate">{option.label}</span>
279
+ {#if option.description}
280
+ <span class="truncate text-xs text-text-muted">{option.description}</span>
281
+ {/if}
282
+ </span>
283
+ </div>
284
+ {/each}
285
+ {/each}
286
+ {/if}
287
+ </div>
288
+ {/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 '../../core/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;
@@ -0,0 +1,201 @@
1
+ <script module lang="ts">
2
+ export interface NavItem {
3
+ label: string;
4
+ href?: string;
5
+ /** Marks the current page — also sets `aria-current`. */
6
+ active?: boolean;
7
+ disabled?: boolean;
8
+ /** Renders a dropdown instead of a plain link. */
9
+ items?: NavItem[];
10
+ /** Small count or tag after the label. */
11
+ badge?: string | number;
12
+ }
13
+ </script>
14
+
15
+ <script lang="ts">
16
+ import type { Snippet } from 'svelte';
17
+ import { useLocale } from '../../core/locale.svelte.js';
18
+ import { focusRing, toneRing, toneSoft, toneText, type Tone } from '../../core/tones.js';
19
+ import Drawer from './Drawer.svelte';
20
+ import Dropdown from './Dropdown.svelte';
21
+ import MenuItem from './MenuItem.svelte';
22
+
23
+ interface Props {
24
+ items?: NavItem[];
25
+ /** Logo or wordmark on the left. */
26
+ brand?: Snippet;
27
+ /** Buttons on the right — sign in, theme toggle, avatar. */
28
+ actions?: Snippet;
29
+ /** Extra content inside the mobile drawer, under the links. */
30
+ mobileExtra?: Snippet;
31
+ tone?: Tone;
32
+ /** Sticks to the top of the viewport. */
33
+ sticky?: boolean;
34
+ /** Hairline along the bottom edge. Default true. */
35
+ bordered?: boolean;
36
+ /** Bindable — the mobile drawer's state. */
37
+ menuOpen?: boolean;
38
+ class?: string;
39
+ }
40
+
41
+ let {
42
+ items = [],
43
+ brand,
44
+ actions,
45
+ mobileExtra,
46
+ tone = 'brand',
47
+ sticky = false,
48
+ bordered = true,
49
+ menuOpen = $bindable(false),
50
+ class: className = ''
51
+ }: Props = $props();
52
+
53
+ const t = useLocale();
54
+
55
+ // sprout gives the active link a filled pill rather than a colour change, so
56
+ // the current page is legible without relying on hue alone.
57
+ const link =
58
+ 'inline-flex items-center gap-2 rounded-full px-3.5 py-2 font-heading text-sm font-medium transition-colors duration-150 ease-brand-out';
59
+ </script>
60
+
61
+ <header
62
+ class="w-full bg-bg {sticky ? 'sticky top-0 z-40' : ''} {bordered
63
+ ? 'border-b border-hairline'
64
+ : ''} {className}"
65
+ >
66
+ <nav
67
+ aria-label={t.current.navigation}
68
+ class="mx-auto flex h-16 w-full max-w-6xl items-center gap-6 px-6"
69
+ >
70
+ {#if brand}
71
+ <div class="flex shrink-0 items-center">{@render brand()}</div>
72
+ {/if}
73
+
74
+ <!-- desktop links -->
75
+ <ul class="hidden flex-1 items-center gap-1 md:flex">
76
+ {#each items as item (item.label)}
77
+ <li>
78
+ {#if item.items?.length}
79
+ <Dropdown label={item.label}>
80
+ {#snippet trigger()}
81
+ <button
82
+ type="button"
83
+ class="{link} {focusRing} {toneRing[tone]} {item.active
84
+ ? `${toneSoft[tone]}`
85
+ : 'text-text-secondary hover:bg-bg-inset hover:text-text'}"
86
+ >
87
+ {item.label}
88
+ <svg class="size-3" viewBox="0 0 16 16" fill="none" aria-hidden="true">
89
+ <path
90
+ d="m4 6 4 4 4-4"
91
+ stroke="currentColor"
92
+ stroke-width="1.8"
93
+ stroke-linecap="round"
94
+ />
95
+ </svg>
96
+ </button>
97
+ {/snippet}
98
+ {#each item.items as child (child.label)}
99
+ <MenuItem href={child.href} disabled={child.disabled} selected={child.active}>
100
+ {child.label}
101
+ </MenuItem>
102
+ {/each}
103
+ </Dropdown>
104
+ {:else}
105
+ <a
106
+ href={item.href}
107
+ aria-current={item.active ? 'page' : undefined}
108
+ class="{link} {focusRing} {toneRing[tone]} {item.active
109
+ ? toneSoft[tone]
110
+ : 'text-text-secondary hover:bg-bg-inset hover:text-text'} {item.disabled
111
+ ? 'pointer-events-none opacity-50'
112
+ : ''}"
113
+ >
114
+ {item.label}
115
+ {#if item.badge !== undefined}
116
+ <span class="text-[11px] text-text-muted tabular-nums">{item.badge}</span>
117
+ {/if}
118
+ </a>
119
+ {/if}
120
+ </li>
121
+ {/each}
122
+ </ul>
123
+
124
+ <div class="ml-auto flex items-center gap-3">
125
+ {#if actions}
126
+ <div class="hidden items-center gap-2 md:flex">{@render actions()}</div>
127
+ {/if}
128
+
129
+ <!-- mobile trigger -->
130
+ <button
131
+ type="button"
132
+ onclick={() => (menuOpen = true)}
133
+ aria-label={t.current.openMenu}
134
+ aria-expanded={menuOpen}
135
+ class="inline-flex size-9 items-center justify-center rounded-xl text-text-secondary transition-colors duration-150 hover:bg-bg-inset hover:text-text md:hidden {focusRing} {toneRing[
136
+ tone
137
+ ]}"
138
+ >
139
+ <svg class="size-5" viewBox="0 0 20 20" fill="none" aria-hidden="true">
140
+ <path
141
+ d="M3 6h14M3 10h14M3 14h14"
142
+ stroke="currentColor"
143
+ stroke-width="1.5"
144
+ stroke-linecap="round"
145
+ />
146
+ </svg>
147
+ </button>
148
+ </div>
149
+ </nav>
150
+ </header>
151
+
152
+ <Drawer bind:open={menuOpen} side="right" size="sm" title={t.current.navigation}>
153
+ <ul class="flex flex-col gap-0.5">
154
+ {#each items as item (item.label)}
155
+ <li>
156
+ <a
157
+ href={item.href}
158
+ onclick={() => (menuOpen = false)}
159
+ aria-current={item.active ? 'page' : undefined}
160
+ class="flex items-center justify-between gap-3 rounded-xl px-3 py-2.5 font-sans text-sm font-medium {item.active
161
+ ? `${toneSoft[tone]}`
162
+ : 'text-text-secondary hover:bg-bg-inset'} {item.disabled
163
+ ? 'pointer-events-none opacity-50'
164
+ : ''}"
165
+ >
166
+ {item.label}
167
+ {#if item.badge !== undefined}
168
+ <span class="text-[11px] text-text-muted tabular-nums">{item.badge}</span>
169
+ {/if}
170
+ </a>
171
+ {#if item.items?.length}
172
+ <ul class="flex flex-col pb-1 pl-3">
173
+ {#each item.items as child (child.label)}
174
+ <li>
175
+ <a
176
+ href={child.href}
177
+ onclick={() => (menuOpen = false)}
178
+ class="block rounded-xl px-3 py-2 font-sans text-[13px] text-text-muted hover:bg-bg-inset {toneText[
179
+ tone
180
+ ]}"
181
+ >
182
+ {child.label}
183
+ </a>
184
+ </li>
185
+ {/each}
186
+ </ul>
187
+ {/if}
188
+ </li>
189
+ {/each}
190
+ </ul>
191
+
192
+ {#if actions || mobileExtra}
193
+ <div class="mt-6 flex flex-col gap-3 border-t border-hairline pt-6">
194
+ {#if mobileExtra}
195
+ {@render mobileExtra()}
196
+ {:else if actions}
197
+ {@render actions()}
198
+ {/if}
199
+ </div>
200
+ {/if}
201
+ </Drawer>
@@ -0,0 +1,33 @@
1
+ export interface NavItem {
2
+ label: string;
3
+ href?: string;
4
+ /** Marks the current page — also sets `aria-current`. */
5
+ active?: boolean;
6
+ disabled?: boolean;
7
+ /** Renders a dropdown instead of a plain link. */
8
+ items?: NavItem[];
9
+ /** Small count or tag after the label. */
10
+ badge?: string | number;
11
+ }
12
+ import type { Snippet } from 'svelte';
13
+ import { type Tone } from '../../core/tones.js';
14
+ interface Props {
15
+ items?: NavItem[];
16
+ /** Logo or wordmark on the left. */
17
+ brand?: Snippet;
18
+ /** Buttons on the right — sign in, theme toggle, avatar. */
19
+ actions?: Snippet;
20
+ /** Extra content inside the mobile drawer, under the links. */
21
+ mobileExtra?: Snippet;
22
+ tone?: Tone;
23
+ /** Sticks to the top of the viewport. */
24
+ sticky?: boolean;
25
+ /** Hairline along the bottom edge. Default true. */
26
+ bordered?: boolean;
27
+ /** Bindable — the mobile drawer's state. */
28
+ menuOpen?: boolean;
29
+ class?: string;
30
+ }
31
+ declare const Navbar: import("svelte").Component<Props, {}, "menuOpen">;
32
+ type Navbar = ReturnType<typeof Navbar>;
33
+ export default Navbar;