@nqmcreative/ui 0.3.0 → 0.4.1

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 (164) hide show
  1. package/README.md +220 -28
  2. package/cli/index.mjs +247 -70
  3. package/dist/core/calendar.d.ts +1 -1
  4. package/dist/core/calendar.js +1 -1
  5. package/dist/core/color.d.ts +24 -0
  6. package/dist/core/color.js +39 -0
  7. package/dist/core/date.d.ts +3 -3
  8. package/dist/core/date.js +1 -1
  9. package/dist/core/files.d.ts +2 -3
  10. package/dist/core/files.js +2 -3
  11. package/dist/core/index.d.ts +9 -4
  12. package/dist/core/index.js +6 -4
  13. package/dist/core/number.d.ts +22 -0
  14. package/dist/core/number.js +33 -0
  15. package/dist/core/password.d.ts +1 -1
  16. package/dist/core/password.js +1 -1
  17. package/dist/core/pin.d.ts +23 -0
  18. package/dist/core/pin.js +38 -0
  19. package/dist/core/tags.d.ts +37 -0
  20. package/dist/core/tags.js +51 -0
  21. package/dist/core/time.d.ts +30 -0
  22. package/dist/core/time.js +78 -0
  23. package/dist/styles/matte/Alert.svelte +1 -3
  24. package/dist/styles/matte/Breadcrumb.svelte +1 -4
  25. package/dist/styles/matte/Calendar.svelte +6 -9
  26. package/dist/styles/matte/ColorInput.svelte +107 -0
  27. package/dist/styles/matte/ColorInput.svelte.d.ts +16 -0
  28. package/dist/styles/matte/Combobox.svelte +3 -6
  29. package/dist/styles/matte/CommandPalette.svelte +3 -6
  30. package/dist/styles/matte/ConfirmDialog.svelte +2 -5
  31. package/dist/styles/matte/ContextMenu.svelte +1 -4
  32. package/dist/styles/matte/CurrencyInput.svelte +108 -0
  33. package/dist/styles/matte/CurrencyInput.svelte.d.ts +26 -0
  34. package/dist/styles/matte/DatePicker.svelte +9 -9
  35. package/dist/styles/matte/DatePicker.svelte.d.ts +3 -0
  36. package/dist/styles/matte/Drawer.svelte +1 -3
  37. package/dist/styles/matte/Dropdown.svelte +2 -5
  38. package/dist/styles/matte/Dropdown.svelte.d.ts +1 -1
  39. package/dist/styles/matte/Dropzone.svelte +9 -12
  40. package/dist/styles/matte/Dropzone.svelte.d.ts +1 -1
  41. package/dist/styles/matte/FileInput.svelte +191 -0
  42. package/dist/styles/matte/FileInput.svelte.d.ts +32 -0
  43. package/dist/styles/matte/Footer.svelte +1 -1
  44. package/dist/styles/matte/Footer.svelte.d.ts +1 -1
  45. package/dist/styles/matte/InputAddon.svelte +36 -0
  46. package/dist/styles/matte/InputAddon.svelte.d.ts +13 -0
  47. package/dist/styles/matte/Modal.svelte +1 -3
  48. package/dist/styles/matte/MultiSelect.svelte +5 -8
  49. package/dist/styles/matte/Navbar.svelte +3 -9
  50. package/dist/styles/matte/Pagination.svelte +3 -6
  51. package/dist/styles/matte/PinInput.svelte +134 -0
  52. package/dist/styles/matte/PinInput.svelte.d.ts +25 -0
  53. package/dist/styles/matte/SearchInput.svelte +84 -0
  54. package/dist/styles/matte/SearchInput.svelte.d.ts +24 -0
  55. package/dist/styles/matte/Sidebar.svelte +3 -6
  56. package/dist/styles/matte/Spinner.svelte +2 -5
  57. package/dist/styles/matte/Spinner.svelte.d.ts +1 -1
  58. package/dist/styles/matte/Table.svelte +5 -8
  59. package/dist/styles/matte/TagsInput.svelte +170 -0
  60. package/dist/styles/matte/TagsInput.svelte.d.ts +29 -0
  61. package/dist/styles/matte/ThemeToggle.svelte +5 -8
  62. package/dist/styles/matte/TimeInput.svelte +126 -0
  63. package/dist/styles/matte/TimeInput.svelte.d.ts +21 -0
  64. package/dist/styles/matte/Toaster.svelte +2 -5
  65. package/dist/styles/matte/index.d.ts +10 -3
  66. package/dist/styles/matte/index.js +9 -3
  67. package/dist/styles/paper/Alert.svelte +1 -3
  68. package/dist/styles/paper/Breadcrumb.svelte +1 -4
  69. package/dist/styles/paper/Calendar.svelte +6 -9
  70. package/dist/styles/paper/ColorInput.svelte +109 -0
  71. package/dist/styles/paper/ColorInput.svelte.d.ts +16 -0
  72. package/dist/styles/paper/Combobox.svelte +3 -6
  73. package/dist/styles/paper/CommandPalette.svelte +3 -6
  74. package/dist/styles/paper/ConfirmDialog.svelte +2 -5
  75. package/dist/styles/paper/ContextMenu.svelte +1 -4
  76. package/dist/styles/paper/CurrencyInput.svelte +108 -0
  77. package/dist/styles/paper/CurrencyInput.svelte.d.ts +26 -0
  78. package/dist/styles/paper/DatePicker.svelte +9 -9
  79. package/dist/styles/paper/DatePicker.svelte.d.ts +3 -0
  80. package/dist/styles/paper/Drawer.svelte +1 -3
  81. package/dist/styles/paper/Dropdown.svelte +2 -5
  82. package/dist/styles/paper/Dropdown.svelte.d.ts +1 -1
  83. package/dist/styles/paper/Dropzone.svelte +9 -12
  84. package/dist/styles/paper/Dropzone.svelte.d.ts +1 -1
  85. package/dist/styles/paper/FileInput.svelte +201 -0
  86. package/dist/styles/paper/FileInput.svelte.d.ts +32 -0
  87. package/dist/styles/paper/Footer.svelte +1 -1
  88. package/dist/styles/paper/Footer.svelte.d.ts +1 -1
  89. package/dist/styles/paper/InputAddon.svelte +36 -0
  90. package/dist/styles/paper/InputAddon.svelte.d.ts +13 -0
  91. package/dist/styles/paper/Modal.svelte +1 -3
  92. package/dist/styles/paper/MultiSelect.svelte +5 -8
  93. package/dist/styles/paper/Navbar.svelte +3 -9
  94. package/dist/styles/paper/Pagination.svelte +3 -6
  95. package/dist/styles/paper/PinInput.svelte +134 -0
  96. package/dist/styles/paper/PinInput.svelte.d.ts +25 -0
  97. package/dist/styles/paper/SearchInput.svelte +89 -0
  98. package/dist/styles/paper/SearchInput.svelte.d.ts +24 -0
  99. package/dist/styles/paper/Sidebar.svelte +3 -6
  100. package/dist/styles/paper/Spinner.svelte +2 -5
  101. package/dist/styles/paper/Spinner.svelte.d.ts +1 -1
  102. package/dist/styles/paper/Table.svelte +5 -8
  103. package/dist/styles/paper/TagsInput.svelte +175 -0
  104. package/dist/styles/paper/TagsInput.svelte.d.ts +29 -0
  105. package/dist/styles/paper/ThemeToggle.svelte +5 -8
  106. package/dist/styles/paper/TimeInput.svelte +152 -0
  107. package/dist/styles/paper/TimeInput.svelte.d.ts +21 -0
  108. package/dist/styles/paper/Toaster.svelte +3 -6
  109. package/dist/styles/paper/index.d.ts +10 -3
  110. package/dist/styles/paper/index.js +9 -3
  111. package/dist/styles/sprout/Alert.svelte +1 -3
  112. package/dist/styles/sprout/Breadcrumb.svelte +1 -4
  113. package/dist/styles/sprout/Calendar.svelte +6 -9
  114. package/dist/styles/sprout/ColorInput.svelte +109 -0
  115. package/dist/styles/sprout/ColorInput.svelte.d.ts +16 -0
  116. package/dist/styles/sprout/Combobox.svelte +3 -6
  117. package/dist/styles/sprout/CommandPalette.svelte +3 -6
  118. package/dist/styles/sprout/ConfirmDialog.svelte +2 -5
  119. package/dist/styles/sprout/ContextMenu.svelte +1 -4
  120. package/dist/styles/sprout/CurrencyInput.svelte +108 -0
  121. package/dist/styles/sprout/CurrencyInput.svelte.d.ts +26 -0
  122. package/dist/styles/sprout/DatePicker.svelte +9 -9
  123. package/dist/styles/sprout/DatePicker.svelte.d.ts +3 -0
  124. package/dist/styles/sprout/Drawer.svelte +1 -3
  125. package/dist/styles/sprout/Dropdown.svelte +2 -5
  126. package/dist/styles/sprout/Dropdown.svelte.d.ts +1 -1
  127. package/dist/styles/sprout/Dropzone.svelte +9 -12
  128. package/dist/styles/sprout/Dropzone.svelte.d.ts +1 -1
  129. package/dist/styles/sprout/FileInput.svelte +202 -0
  130. package/dist/styles/sprout/FileInput.svelte.d.ts +32 -0
  131. package/dist/styles/sprout/Footer.svelte +1 -1
  132. package/dist/styles/sprout/Footer.svelte.d.ts +1 -1
  133. package/dist/styles/sprout/InputAddon.svelte +37 -0
  134. package/dist/styles/sprout/InputAddon.svelte.d.ts +13 -0
  135. package/dist/styles/sprout/Modal.svelte +1 -3
  136. package/dist/styles/sprout/MultiSelect.svelte +5 -8
  137. package/dist/styles/sprout/Navbar.svelte +3 -9
  138. package/dist/styles/sprout/Pagination.svelte +3 -6
  139. package/dist/styles/sprout/PinInput.svelte +135 -0
  140. package/dist/styles/sprout/PinInput.svelte.d.ts +25 -0
  141. package/dist/styles/sprout/SearchInput.svelte +89 -0
  142. package/dist/styles/sprout/SearchInput.svelte.d.ts +24 -0
  143. package/dist/styles/sprout/Sidebar.svelte +3 -6
  144. package/dist/styles/sprout/Spinner.svelte +2 -5
  145. package/dist/styles/sprout/Spinner.svelte.d.ts +1 -1
  146. package/dist/styles/sprout/Table.svelte +5 -8
  147. package/dist/styles/sprout/TagsInput.svelte +176 -0
  148. package/dist/styles/sprout/TagsInput.svelte.d.ts +29 -0
  149. package/dist/styles/sprout/ThemeToggle.svelte +5 -8
  150. package/dist/styles/sprout/TimeInput.svelte +152 -0
  151. package/dist/styles/sprout/TimeInput.svelte.d.ts +21 -0
  152. package/dist/styles/sprout/Toaster.svelte +3 -6
  153. package/dist/styles/sprout/index.d.ts +10 -3
  154. package/dist/styles/sprout/index.js +9 -3
  155. package/package.json +98 -19
  156. package/registry.json +139 -38
  157. package/dist/core/locale.svelte.d.ts +0 -93
  158. package/dist/core/locale.svelte.js +0 -153
  159. package/dist/styles/matte/LocaleProvider.svelte +0 -24
  160. package/dist/styles/matte/LocaleProvider.svelte.d.ts +0 -10
  161. package/dist/styles/paper/LocaleProvider.svelte +0 -24
  162. package/dist/styles/paper/LocaleProvider.svelte.d.ts +0 -10
  163. package/dist/styles/sprout/LocaleProvider.svelte +0 -24
  164. package/dist/styles/sprout/LocaleProvider.svelte.d.ts +0 -10
@@ -0,0 +1,89 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLInputAttributes } from 'svelte/elements';
4
+ import type { Tone } from '../../core/tones.js';
5
+ import Input, { type InputSize } from './Input.svelte';
6
+
7
+ interface Props extends Omit<HTMLInputAttributes, 'size' | 'prefix' | 'type'> {
8
+ value?: string;
9
+ size?: InputSize;
10
+ tone?: Tone;
11
+ invalid?: boolean;
12
+ /**
13
+ * Milliseconds to wait after the last keystroke before `onsearch` fires.
14
+ * `0` fires on every one.
15
+ */
16
+ debounce?: number;
17
+ /** The debounced value. The bound `value` still updates immediately. */
18
+ onsearch?: (value: string) => void;
19
+ onclear?: () => void;
20
+ /** Replaces the magnifier. */
21
+ icon?: Snippet;
22
+ class?: string;
23
+ }
24
+
25
+ let {
26
+ value = $bindable(''),
27
+ size = 'md',
28
+ tone = 'brand',
29
+ invalid = false,
30
+ disabled = false,
31
+ debounce = 0,
32
+ onsearch,
33
+ onclear,
34
+ icon,
35
+ class: className = '',
36
+ ...rest
37
+ }: Props = $props();
38
+
39
+ // The timer is cleared by the effect's own teardown, so a keystroke that
40
+ // lands mid-wait restarts the clock rather than queueing a second call.
41
+ $effect(() => {
42
+ const next = value;
43
+ if (!onsearch) return;
44
+ if (debounce <= 0) {
45
+ onsearch(next);
46
+ return;
47
+ }
48
+ const timer = setTimeout(() => onsearch(next), debounce);
49
+ return () => clearTimeout(timer);
50
+ });
51
+
52
+ function clear() {
53
+ value = '';
54
+ onclear?.();
55
+ }
56
+ </script>
57
+
58
+ <Input bind:value type="search" {size} {tone} {invalid} {disabled} class={className} {...rest}>
59
+ {#snippet prefix()}
60
+ {#if icon}
61
+ {@render icon()}
62
+ {:else}
63
+ <svg class="size-4" viewBox="0 0 20 20" fill="none" aria-hidden="true">
64
+ <circle cx="9" cy="9" r="5.5" stroke="currentColor" stroke-width="1.6" />
65
+ <path d="m13 13 3.5 3.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
66
+ </svg>
67
+ {/if}
68
+ {/snippet}
69
+
70
+ {#snippet suffix()}
71
+ {#if value}
72
+ <button
73
+ type="button"
74
+ onclick={clear}
75
+ aria-label="Clear search"
76
+ class="-mr-1 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"
77
+ >
78
+ <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
79
+ <path
80
+ d="m2 2 10 10M12 2 2 12"
81
+ stroke="currentColor"
82
+ stroke-width="1.6"
83
+ stroke-linecap="round"
84
+ />
85
+ </svg>
86
+ </button>
87
+ {/if}
88
+ {/snippet}
89
+ </Input>
@@ -0,0 +1,24 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLInputAttributes } from 'svelte/elements';
3
+ import type { Tone } from '../../core/tones.js';
4
+ import { type InputSize } from './Input.svelte';
5
+ interface Props extends Omit<HTMLInputAttributes, 'size' | 'prefix' | 'type'> {
6
+ value?: string;
7
+ size?: InputSize;
8
+ tone?: Tone;
9
+ invalid?: boolean;
10
+ /**
11
+ * Milliseconds to wait after the last keystroke before `onsearch` fires.
12
+ * `0` fires on every one.
13
+ */
14
+ debounce?: number;
15
+ /** The debounced value. The bound `value` still updates immediately. */
16
+ onsearch?: (value: string) => void;
17
+ onclear?: () => void;
18
+ /** Replaces the magnifier. */
19
+ icon?: Snippet;
20
+ class?: string;
21
+ }
22
+ declare const SearchInput: import("svelte").Component<Props, {}, "value">;
23
+ type SearchInput = ReturnType<typeof SearchInput>;
24
+ export default SearchInput;
@@ -35,7 +35,6 @@
35
35
  </script>
36
36
 
37
37
  <script lang="ts">
38
- import { useLocale } from '../../core/locale.svelte.js';
39
38
  import { iconMd } from './icon.js';
40
39
  import { focusRing, toneRing, toneSoft, type Tone } from '../../core/tones.js';
41
40
 
@@ -69,8 +68,6 @@
69
68
  class: className = ''
70
69
  }: Props = $props();
71
70
 
72
- const t = useLocale();
73
-
74
71
  const keyOf = (item: SidebarItem) => item.id ?? item.href ?? item.label;
75
72
 
76
73
  /** Groups start open when asked, or when they contain the active item. */
@@ -141,7 +138,7 @@
141
138
  </div>
142
139
  {/if}
143
140
 
144
- <nav aria-label={t.current.navigation} class="flex-1 overflow-y-auto p-2">
141
+ <nav aria-label="Navigation" class="flex-1 overflow-y-auto p-2">
145
142
  {#each sections as section, i (section.label ?? i)}
146
143
  <div class="flex flex-col gap-0.5 {i > 0 ? 'mt-4' : ''}">
147
144
  {#if section.label && !collapsed}
@@ -230,7 +227,7 @@
230
227
  <button
231
228
  type="button"
232
229
  onclick={() => (collapsed = !collapsed)}
233
- aria-label={collapsed ? t.current.expandSidebar : t.current.collapseSidebar}
230
+ aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
234
231
  class="inline-flex h-8 items-center justify-center gap-2 rounded-md font-sans text-xs text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text {focusRing} {toneRing[
235
232
  tone
236
233
  ]}"
@@ -248,7 +245,7 @@
248
245
  stroke-linecap="round"
249
246
  />
250
247
  </svg>
251
- {#if !collapsed}<span>{t.current.collapseSidebar}</span>{/if}
248
+ {#if !collapsed}<span>Collapse sidebar</span>{/if}
252
249
  </button>
253
250
  {/if}
254
251
  </div>
@@ -1,7 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
3
  import { toneText, type Tone } from '../../core/tones.js';
4
- import { useLocale } from '../../core/locale.svelte.js';
5
4
 
6
5
  export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg';
7
6
 
@@ -9,12 +8,10 @@
9
8
  size?: SpinnerSize;
10
9
  /** Omit to inherit the parent's `currentColor` (e.g. inside a Button). */
11
10
  tone?: Tone;
12
- /** Defaults to the locale's loading string; pass `''` to hide it. */
11
+ /** Defaults to `Loading`; pass `''` to hide it. */
13
12
  label?: string;
14
13
  }
15
14
 
16
- const t = useLocale();
17
-
18
15
  let { size = 'md', tone, label, class: className = '', ...rest }: Props = $props();
19
16
 
20
17
  const sizes: Record<SpinnerSize, string> = {
@@ -27,7 +24,7 @@
27
24
 
28
25
  <span
29
26
  role="status"
30
- aria-label={label ?? t.current.loading}
27
+ aria-label={label ?? 'Loading'}
31
28
  class="inline-block shrink-0 animate-spin rounded-full border-current/25 border-t-current
32
29
  {sizes[size]} {tone ? toneText[tone] : ''} {className}"
33
30
  {...rest}
@@ -5,7 +5,7 @@ interface Props extends HTMLAttributes<HTMLSpanElement> {
5
5
  size?: SpinnerSize;
6
6
  /** Omit to inherit the parent's `currentColor` (e.g. inside a Button). */
7
7
  tone?: Tone;
8
- /** Defaults to the locale's loading string; pass `''` to hide it. */
8
+ /** Defaults to `Loading`; pass `''` to hide it. */
9
9
  label?: string;
10
10
  }
11
11
  declare const Spinner: import("svelte").Component<Props, {}, "">;
@@ -16,7 +16,6 @@
16
16
  import Checkbox from './Checkbox.svelte';
17
17
  import type { Tone } from '../../core/tones.js';
18
18
  import { nextSort, sortRows, toggleKey, type TableSort } from '../../core/table.js';
19
- import { useLocale } from '../../core/locale.svelte.js';
20
19
 
21
20
  interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'onselect'> {
22
21
  columns: TableColumn[];
@@ -82,8 +81,6 @@
82
81
  ...rest
83
82
  }: Props = $props();
84
83
 
85
- const t = useLocale();
86
-
87
84
  const aligns = {
88
85
  left: 'text-left',
89
86
  center: 'text-center',
@@ -139,14 +136,14 @@
139
136
  >
140
137
  <span class="font-sans text-[13px] font-semibold text-text">
141
138
  {selected.length}
142
- {t.current.selected}
139
+ selected
143
140
  </span>
144
141
  <button
145
142
  type="button"
146
143
  onclick={clearSelection}
147
144
  class="rounded font-sans text-[13px] font-medium text-text-muted transition-colors duration-150 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
148
145
  >
149
- {t.current.clear}
146
+ Clear selection
150
147
  </button>
151
148
  <div class="ml-auto flex flex-wrap items-center gap-2">
152
149
  {@render bulkActions?.({ selected, clear: clearSelection })}
@@ -176,7 +173,7 @@
176
173
  checked={allSelected}
177
174
  indeterminate={someSelected}
178
175
  {tone}
179
- aria-label={t.current.selectAllRows}
176
+ aria-label="Select all rows"
180
177
  onchange={toggleAll}
181
178
  />
182
179
  </th>
@@ -223,7 +220,7 @@
223
220
  colspan={columns.length + (selectable ? 1 : 0)}
224
221
  class="px-4 py-10 text-center text-sm text-text-muted"
225
222
  >
226
- {#if empty}{@render empty()}{:else}{t.current.noData}{/if}
223
+ {#if empty}{@render empty()}{:else}No data{/if}
227
224
  </td>
228
225
  </tr>
229
226
  {:else}
@@ -240,7 +237,7 @@
240
237
  <Checkbox
241
238
  checked={isSelected}
242
239
  {tone}
243
- aria-label={t.current.selectRow}
240
+ aria-label="Select row"
244
241
  onchange={() => toggleRow(row)}
245
242
  />
246
243
  </td>
@@ -0,0 +1,175 @@
1
+ <script lang="ts">
2
+ import { addTag, splitTags, type TagRejection } from '../../core/tags.js';
3
+ import { toneFocusWithinBorder, toneSoft, type Tone } from '../../core/tones.js';
4
+
5
+ interface Props {
6
+ /** Bindable list of tags, in the order they were added. */
7
+ tags?: string[];
8
+ placeholder?: string;
9
+ /** Cap on how many may be held. `0` means no limit. */
10
+ max?: number;
11
+ /** Shortest a tag may be, after trimming. */
12
+ minLength?: number;
13
+ /** Keys that commit the tag under the caret, besides Enter. */
14
+ separators?: string[];
15
+ /** Refuse a tag that differs from an existing one only in case. */
16
+ caseInsensitive?: boolean;
17
+ /** Commit whatever is half-typed when the field loses focus. */
18
+ commitOnBlur?: boolean;
19
+ disabled?: boolean;
20
+ invalid?: boolean;
21
+ tone?: Tone;
22
+ /** `name` for a plain form submit — one hidden field, comma-joined. */
23
+ name?: string;
24
+ onadd?: (tag: string) => void;
25
+ onremove?: (tag: string) => void;
26
+ onreject?: (reason: TagRejection, raw: string) => void;
27
+ class?: string;
28
+ }
29
+
30
+ let {
31
+ tags = $bindable([]),
32
+ placeholder,
33
+ max = 0,
34
+ minLength = 1,
35
+ separators = [','],
36
+ caseInsensitive = true,
37
+ commitOnBlur = true,
38
+ disabled = false,
39
+ invalid = false,
40
+ tone = 'brand',
41
+ name,
42
+ onadd,
43
+ onremove,
44
+ onreject,
45
+ class: className = ''
46
+ }: Props = $props();
47
+
48
+ let draft = $state('');
49
+ let field: HTMLInputElement | null = $state(null);
50
+ /** The last refusal, cleared as soon as the next keystroke lands. */
51
+ let refused = $state<TagRejection | null>(null);
52
+
53
+ const full = $derived(max > 0 && tags.length >= max);
54
+ const rules = $derived({ max, minLength, caseInsensitive });
55
+
56
+ function commit(raw: string) {
57
+ const { tags: next, rejected } = addTag(tags, raw, rules);
58
+ if (rejected) {
59
+ // An empty commit is the Enter key on an empty field, not a mistake.
60
+ if (rejected !== 'empty') {
61
+ refused = rejected;
62
+ onreject?.(rejected, raw);
63
+ }
64
+ return false;
65
+ }
66
+ tags = next;
67
+ onadd?.(next[next.length - 1]);
68
+ return true;
69
+ }
70
+
71
+ function remove(index: number) {
72
+ const [gone] = tags.slice(index, index + 1);
73
+ tags = tags.filter((_, at) => at !== index);
74
+ if (gone !== undefined) onremove?.(gone);
75
+ }
76
+
77
+ function onKeydown(event: KeyboardEvent) {
78
+ refused = null;
79
+
80
+ if (event.key === 'Enter' || separators.includes(event.key)) {
81
+ event.preventDefault();
82
+ if (commit(draft)) draft = '';
83
+ return;
84
+ }
85
+
86
+ // Empty field: Backspace peels the last chip off, the way chips behave
87
+ // everywhere else in this library.
88
+ if (event.key === 'Backspace' && draft === '' && tags.length) {
89
+ event.preventDefault();
90
+ remove(tags.length - 1);
91
+ }
92
+ }
93
+
94
+ function onPaste(event: ClipboardEvent) {
95
+ const text = event.clipboardData?.getData('text') ?? '';
96
+ const parts = splitTags(text, separators);
97
+ if (parts.length <= 1) return;
98
+
99
+ event.preventDefault();
100
+ for (const part of parts) commit(part);
101
+ draft = '';
102
+ }
103
+
104
+ function onBlur() {
105
+ if (commitOnBlur && draft.trim() && commit(draft)) draft = '';
106
+ }
107
+
108
+ const messages: Record<TagRejection, string> = $derived({
109
+ empty: '',
110
+ short: `${minLength}+`,
111
+ duplicate: 'Already added',
112
+ full: `$selected ${tags.length}/${max}`
113
+ });
114
+ </script>
115
+
116
+ <div class="flex w-full flex-col gap-1.5 {className}">
117
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
118
+ <div
119
+ onclick={() => field?.focus()}
120
+ class="flex w-full flex-wrap items-center gap-1.5 rounded-md border bg-bg py-1.5 pr-1.5 pl-2 shadow-xs transition-colors duration-150 ease-brand-out
121
+ {invalid
122
+ ? 'border-danger focus-within:border-danger'
123
+ : `border-hairline-strong ${toneFocusWithinBorder[tone]}`}
124
+ {disabled ? 'pointer-events-none opacity-50' : ''}"
125
+ >
126
+ {#each tags as tag, index (tag)}
127
+ <span
128
+ class="inline-flex items-center gap-1 rounded-md py-0.5 pr-0.5 pl-2 text-xs font-medium {toneSoft[
129
+ tone
130
+ ]}"
131
+ >
132
+ {tag}
133
+ <button
134
+ type="button"
135
+ onclick={() => remove(index)}
136
+ aria-label="Remove {tag}"
137
+ 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"
138
+ >
139
+ <svg class="size-2.5" viewBox="0 0 10 10" fill="none" aria-hidden="true">
140
+ <path
141
+ d="m1.5 1.5 7 7M8.5 1.5l-7 7"
142
+ stroke="currentColor"
143
+ stroke-width="1.6"
144
+ stroke-linecap="round"
145
+ />
146
+ </svg>
147
+ </button>
148
+ </span>
149
+ {/each}
150
+
151
+ <input
152
+ bind:this={field}
153
+ bind:value={draft}
154
+ {disabled}
155
+ readonly={full}
156
+ placeholder={tags.length === 0 ? (placeholder ?? 'Add a tag') : ''}
157
+ aria-label={placeholder ?? 'Add a tag'}
158
+ aria-invalid={invalid ? 'true' : undefined}
159
+ onkeydown={onKeydown}
160
+ onpaste={onPaste}
161
+ onblur={onBlur}
162
+ class="h-7 min-w-24 flex-1 bg-transparent font-sans text-sm text-text placeholder:text-text-muted focus:outline-none"
163
+ />
164
+
165
+ {#if name}
166
+ <input type="hidden" {name} value={tags.join(',')} />
167
+ {/if}
168
+ </div>
169
+
170
+ {#if refused && messages[refused]}
171
+ <span class="text-xs font-medium text-danger">{messages[refused]}</span>
172
+ {:else if max}
173
+ <span class="text-xs text-text-muted tabular-nums">{tags.length}/{max}</span>
174
+ {/if}
175
+ </div>
@@ -0,0 +1,29 @@
1
+ import { type TagRejection } from '../../core/tags.js';
2
+ import { type Tone } from '../../core/tones.js';
3
+ interface Props {
4
+ /** Bindable list of tags, in the order they were added. */
5
+ tags?: string[];
6
+ placeholder?: string;
7
+ /** Cap on how many may be held. `0` means no limit. */
8
+ max?: number;
9
+ /** Shortest a tag may be, after trimming. */
10
+ minLength?: number;
11
+ /** Keys that commit the tag under the caret, besides Enter. */
12
+ separators?: string[];
13
+ /** Refuse a tag that differs from an existing one only in case. */
14
+ caseInsensitive?: boolean;
15
+ /** Commit whatever is half-typed when the field loses focus. */
16
+ commitOnBlur?: boolean;
17
+ disabled?: boolean;
18
+ invalid?: boolean;
19
+ tone?: Tone;
20
+ /** `name` for a plain form submit — one hidden field, comma-joined. */
21
+ name?: string;
22
+ onadd?: (tag: string) => void;
23
+ onremove?: (tag: string) => void;
24
+ onreject?: (reason: TagRejection, raw: string) => void;
25
+ class?: string;
26
+ }
27
+ declare const TagsInput: import("svelte").Component<Props, {}, "tags">;
28
+ type TagsInput = ReturnType<typeof TagsInput>;
29
+ export default TagsInput;
@@ -10,7 +10,6 @@
10
10
  storedTheme,
11
11
  type Theme
12
12
  } from '../../core/theme.svelte.js';
13
- import { useLocale } from '../../core/locale.svelte.js';
14
13
 
15
14
  interface Props {
16
15
  /** Bindable. `'system'` follows the OS until the visitor chooses. */
@@ -33,8 +32,6 @@
33
32
  class: className = ''
34
33
  }: Props = $props();
35
34
 
36
- const t = useLocale();
37
-
38
35
  $effect(() => {
39
36
  applyTheme(theme);
40
37
  if (persist) persistTheme(theme);
@@ -50,8 +47,8 @@
50
47
  }
51
48
 
52
49
  const options = $derived([
53
- { value: 'light' as Theme, label: t.current.lightMode },
54
- { value: 'dark' as Theme, label: t.current.darkMode },
50
+ { value: 'light' as Theme, label: 'Light' },
51
+ { value: 'dark' as Theme, label: 'Dark' },
55
52
  { value: 'system' as Theme, label: 'Auto' }
56
53
  ]);
57
54
  </script>
@@ -95,7 +92,7 @@
95
92
  {#if variant === 'segmented'}
96
93
  <div
97
94
  role="radiogroup"
98
- aria-label={t.current.toggleTheme}
95
+ aria-label="Toggle theme"
99
96
  class="inline-flex items-center gap-1 rounded-lg border border-hairline bg-bg-inset p-1 {className}"
100
97
  >
101
98
  {#each options as option (option.value)}
@@ -119,8 +116,8 @@
119
116
  <button
120
117
  type="button"
121
118
  onclick={cycle}
122
- aria-label="{t.current.toggleTheme} ({theme})"
123
- title={t.current.toggleTheme}
119
+ aria-label="Toggle theme ({theme})"
120
+ title="Toggle theme"
124
121
  class="inline-flex size-9 items-center justify-center rounded-md border border-hairline bg-bg text-text-secondary shadow-xs transition-colors duration-150 ease-brand-out hover:bg-bg-alt hover:text-text {focusRing} {toneRing[
125
122
  tone
126
123
  ]} {className}"
@@ -0,0 +1,152 @@
1
+ <script lang="ts">
2
+ import type { HTMLInputAttributes } from 'svelte/elements';
3
+ import { clampTime, formatTime, parseTime, stepTime } from '../../core/time.js';
4
+ import type { Tone } from '../../core/tones.js';
5
+ import Input, { type InputSize } from './Input.svelte';
6
+
7
+ interface Props extends Omit<
8
+ HTMLInputAttributes,
9
+ 'size' | 'prefix' | 'suffix' | 'min' | 'max' | 'step'
10
+ > {
11
+ /** Bindable `HH:MM`, or `''` for empty. */
12
+ value?: string;
13
+ /** Inclusive bounds, `HH:MM`. */
14
+ min?: string;
15
+ max?: string;
16
+ /** Minutes one press of a stepper moves, and what typing snaps to. */
17
+ step?: number;
18
+ size?: InputSize;
19
+ tone?: Tone;
20
+ invalid?: boolean;
21
+ /** Hide the steppers and let the field be typed into only. */
22
+ steppers?: boolean;
23
+ class?: string;
24
+ }
25
+
26
+ let {
27
+ value = $bindable(''),
28
+ min,
29
+ max,
30
+ step = 15,
31
+ size = 'md',
32
+ tone = 'brand',
33
+ invalid = false,
34
+ disabled = false,
35
+ steppers = true,
36
+ class: className = '',
37
+ ...rest
38
+ }: Props = $props();
39
+
40
+ // Typed text and the bound value are separate until the field settles:
41
+ // `9` is a valid thing to be halfway through typing and an invalid time.
42
+ let text = $state('');
43
+ let editing = $state(false);
44
+
45
+ $effect(() => {
46
+ if (!editing) text = value;
47
+ });
48
+
49
+ function settle() {
50
+ editing = false;
51
+ const minutes = parseTime(text);
52
+ value = minutes === null ? '' : formatTime(clampTime(minutes, min, max, step));
53
+ text = value;
54
+ }
55
+
56
+ function nudge(direction: 1 | -1) {
57
+ value = stepTime(value, direction, step, min, max);
58
+ text = value;
59
+ }
60
+
61
+ function onKeydown(event: KeyboardEvent) {
62
+ if (event.key === 'ArrowUp') {
63
+ event.preventDefault();
64
+ nudge(1);
65
+ } else if (event.key === 'ArrowDown') {
66
+ event.preventDefault();
67
+ nudge(-1);
68
+ } else if (event.key === 'Enter') {
69
+ settle();
70
+ }
71
+ }
72
+
73
+ const stepper =
74
+ 'grid size-6 place-items-center rounded text-text-secondary transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-current';
75
+ </script>
76
+
77
+ <Input
78
+ bind:value={text}
79
+ {size}
80
+ {tone}
81
+ {invalid}
82
+ {disabled}
83
+ inputmode="numeric"
84
+ placeholder="--:--"
85
+ onfocus={() => (editing = true)}
86
+ onblur={settle}
87
+ onkeydown={onKeydown}
88
+ class="[&_input]:tabular-nums {className}"
89
+ {...rest}
90
+ >
91
+ {#snippet prefix()}
92
+ <svg class="size-4" viewBox="0 0 20 20" fill="none" aria-hidden="true">
93
+ <circle
94
+ cx="10"
95
+ cy="10"
96
+ r="6.5"
97
+ stroke="currentColor"
98
+ stroke-width="1.6"
99
+ stroke-linecap="round"
100
+ stroke-linejoin="round"
101
+ />
102
+ <path
103
+ d="M10 6.5V10l2.5 1.5"
104
+ stroke="currentColor"
105
+ stroke-width="1.6"
106
+ stroke-linecap="round"
107
+ stroke-linejoin="round"
108
+ />
109
+ </svg>
110
+ {/snippet}
111
+
112
+ {#snippet suffix()}
113
+ {#if steppers}
114
+ <span class="-mr-2 flex flex-col">
115
+ <button
116
+ type="button"
117
+ class={stepper}
118
+ {disabled}
119
+ onclick={() => nudge(1)}
120
+ aria-label="Increase"
121
+ >
122
+ <svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
123
+ <path
124
+ d="m3 7.5 3-3 3 3"
125
+ stroke="currentColor"
126
+ stroke-width="1.6"
127
+ stroke-linecap="round"
128
+ stroke-linejoin="round"
129
+ />
130
+ </svg>
131
+ </button>
132
+ <button
133
+ type="button"
134
+ class={stepper}
135
+ {disabled}
136
+ onclick={() => nudge(-1)}
137
+ aria-label="Decrease"
138
+ >
139
+ <svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
140
+ <path
141
+ d="m3 4.5 3 3 3-3"
142
+ stroke="currentColor"
143
+ stroke-width="1.6"
144
+ stroke-linecap="round"
145
+ stroke-linejoin="round"
146
+ />
147
+ </svg>
148
+ </button>
149
+ </span>
150
+ {/if}
151
+ {/snippet}
152
+ </Input>
@@ -0,0 +1,21 @@
1
+ import type { HTMLInputAttributes } from 'svelte/elements';
2
+ import type { Tone } from '../../core/tones.js';
3
+ import { type InputSize } from './Input.svelte';
4
+ interface Props extends Omit<HTMLInputAttributes, 'size' | 'prefix' | 'suffix' | 'min' | 'max' | 'step'> {
5
+ /** Bindable `HH:MM`, or `''` for empty. */
6
+ value?: string;
7
+ /** Inclusive bounds, `HH:MM`. */
8
+ min?: string;
9
+ max?: string;
10
+ /** Minutes one press of a stepper moves, and what typing snaps to. */
11
+ step?: number;
12
+ size?: InputSize;
13
+ tone?: Tone;
14
+ invalid?: boolean;
15
+ /** Hide the steppers and let the field be typed into only. */
16
+ steppers?: boolean;
17
+ class?: string;
18
+ }
19
+ declare const TimeInput: import("svelte").Component<Props, {}, "value">;
20
+ type TimeInput = ReturnType<typeof TimeInput>;
21
+ export default TimeInput;