@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,201 @@
1
+ <script lang="ts">
2
+ import { formatSize as toReadableSize, sortFiles, type RejectedFile } from '../../core/files.js';
3
+ import { toneFocusWithinBorder, type Tone } from '../../core/tones.js';
4
+ import type { InputSize } from './Input.svelte';
5
+
6
+ interface Props {
7
+ /** Bindable list of accepted files. */
8
+ files?: File[];
9
+ /** Same syntax as the input's `accept` — `'image/*,.pdf'`. */
10
+ accept?: string;
11
+ multiple?: boolean;
12
+ /** Per-file ceiling in bytes. `0` means no limit. */
13
+ maxSize?: number;
14
+ /** Cap on how many files may be held at once. `0` means no limit. */
15
+ maxFiles?: number;
16
+ disabled?: boolean;
17
+ size?: InputSize;
18
+ tone?: Tone;
19
+ invalid?: boolean;
20
+ /** Text on the button. Defaults to `Choose file`. */
21
+ label?: string;
22
+ /** Shown while nothing is chosen. Defaults to `No file chosen`. */
23
+ placeholder?: string;
24
+ /** Show the list of held files below the row. */
25
+ showList?: boolean;
26
+ /** The `name` the hidden input posts under, for a plain form submit. */
27
+ name?: string;
28
+ onaccept?: (files: File[]) => void;
29
+ onreject?: (rejected: RejectedFile[]) => void;
30
+ class?: string;
31
+ }
32
+
33
+ let {
34
+ files = $bindable([]),
35
+ accept,
36
+ multiple = false,
37
+ maxSize = 0,
38
+ maxFiles = 0,
39
+ disabled = false,
40
+ size = 'md',
41
+ tone = 'brand',
42
+ invalid = false,
43
+ label,
44
+ placeholder,
45
+ showList = true,
46
+ name,
47
+ onaccept,
48
+ onreject,
49
+ class: className = ''
50
+ }: Props = $props();
51
+
52
+ let input: HTMLInputElement | null = $state(null);
53
+ let rejected = $state<RejectedFile[]>([]);
54
+
55
+ const formatSize = (bytes: number) => toReadableSize(bytes);
56
+
57
+ /** One file reads as its name and weight; several read as a count. */
58
+ const summary = $derived.by(() => {
59
+ if (files.length === 0) return placeholder ?? 'No file chosen';
60
+ if (files.length === 1) return `${files[0].name} · ${formatSize(files[0].size)}`;
61
+ return `${files.length} $selected`;
62
+ });
63
+
64
+ function add(incoming: FileList | File[] | null) {
65
+ if (!incoming || disabled) return;
66
+
67
+ const { accepted, rejected: bad } = sortFiles(incoming, {
68
+ accept,
69
+ maxSize,
70
+ maxFiles,
71
+ multiple,
72
+ held: files.length
73
+ });
74
+
75
+ rejected = bad;
76
+ if (bad.length) onreject?.(bad);
77
+ if (accepted.length === 0) return;
78
+
79
+ files = multiple ? [...files, ...accepted] : accepted.slice(0, 1);
80
+ onaccept?.(accepted);
81
+ }
82
+
83
+ function clear() {
84
+ files = [];
85
+ rejected = [];
86
+ if (input) input.value = '';
87
+ }
88
+
89
+ const reasons: Record<RejectedFile['reason'], string> = $derived({
90
+ type: 'wrong file type',
91
+ size: 'too large',
92
+ count: 'over the file limit'
93
+ });
94
+
95
+ const sizes: Record<InputSize, string> = {
96
+ sm: 'h-8 text-[13px]',
97
+ md: 'h-10 text-sm',
98
+ lg: 'h-11 text-[15px]'
99
+ };
100
+
101
+ const buttons: Record<InputSize, string> = {
102
+ sm: 'h-6 px-2 text-[11px]',
103
+ md: 'h-7 px-2.5 text-xs',
104
+ lg: 'h-8 px-3 text-xs'
105
+ };
106
+ </script>
107
+
108
+ <div class="flex w-full flex-col gap-3 {className}">
109
+ <span
110
+ class="inline-flex w-full items-center gap-2 rounded-md border bg-bg px-1 shadow-xs transition-colors duration-150 ease-brand-out
111
+ {sizes[size]}
112
+ {invalid
113
+ ? 'border-danger focus-within:border-danger'
114
+ : `border-hairline-strong ${toneFocusWithinBorder[tone]}`}
115
+ {disabled ? 'pointer-events-none opacity-50' : ''}"
116
+ >
117
+ <button
118
+ type="button"
119
+ {disabled}
120
+ onclick={() => input?.click()}
121
+ class="shrink-0 rounded border border-hairline-strong bg-bg-alt font-sans font-medium text-text-secondary transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current
122
+ {buttons[size]}"
123
+ >
124
+ {label ?? 'Choose file'}
125
+ </button>
126
+
127
+ <span
128
+ class="min-w-0 flex-1 truncate font-sans {files.length ? 'text-text' : 'text-text-muted'}"
129
+ >
130
+ {summary}
131
+ </span>
132
+
133
+ {#if files.length}
134
+ <button
135
+ type="button"
136
+ onclick={clear}
137
+ aria-label="Remove"
138
+ class="mr-1 shrink-0 rounded p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-danger focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger"
139
+ >
140
+ <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
141
+ <path
142
+ d="m2 2 10 10M12 2 2 12"
143
+ stroke="currentColor"
144
+ stroke-width="1.6"
145
+ stroke-linecap="round"
146
+ />
147
+ </svg>
148
+ </button>
149
+ {/if}
150
+ </span>
151
+
152
+ <input
153
+ bind:this={input}
154
+ type="file"
155
+ {name}
156
+ {accept}
157
+ {multiple}
158
+ {disabled}
159
+ aria-invalid={invalid ? 'true' : undefined}
160
+ class="sr-only"
161
+ onchange={(event) => add(event.currentTarget.files)}
162
+ />
163
+
164
+ {#if rejected.length}
165
+ <ul class="flex flex-col gap-1">
166
+ {#each rejected as item (item.file.name + item.reason)}
167
+ <li class="font-sans text-sm text-danger">
168
+ {item.file.name} — {reasons[item.reason]}
169
+ </li>
170
+ {/each}
171
+ </ul>
172
+ {/if}
173
+
174
+ {#if showList && multiple && files.length > 1}
175
+ <ul class="flex flex-col rounded-lg border border-hairline bg-bg shadow-xs">
176
+ {#each files as file, i (file.name + file.size + i)}
177
+ <li
178
+ class="flex items-center gap-3 border-b border-hairline px-4 py-2.5 font-sans last:border-b-0"
179
+ >
180
+ <span class="min-w-0 flex-1 truncate text-sm text-text">{file.name}</span>
181
+ <span class="shrink-0 text-xs text-text-muted tabular-nums">{formatSize(file.size)}</span>
182
+ <button
183
+ type="button"
184
+ onclick={() => (files = files.filter((_, at) => at !== i))}
185
+ aria-label="Remove {file.name}"
186
+ class="-m-1 shrink-0 rounded p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-danger focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger"
187
+ >
188
+ <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
189
+ <path
190
+ d="m2 2 10 10M12 2 2 12"
191
+ stroke="currentColor"
192
+ stroke-width="1.6"
193
+ stroke-linecap="round"
194
+ />
195
+ </svg>
196
+ </button>
197
+ </li>
198
+ {/each}
199
+ </ul>
200
+ {/if}
201
+ </div>
@@ -0,0 +1,32 @@
1
+ import { type RejectedFile } from '../../core/files.js';
2
+ import { type Tone } from '../../core/tones.js';
3
+ import type { InputSize } from './Input.svelte';
4
+ interface Props {
5
+ /** Bindable list of accepted files. */
6
+ files?: File[];
7
+ /** Same syntax as the input's `accept` — `'image/*,.pdf'`. */
8
+ accept?: string;
9
+ multiple?: boolean;
10
+ /** Per-file ceiling in bytes. `0` means no limit. */
11
+ maxSize?: number;
12
+ /** Cap on how many files may be held at once. `0` means no limit. */
13
+ maxFiles?: number;
14
+ disabled?: boolean;
15
+ size?: InputSize;
16
+ tone?: Tone;
17
+ invalid?: boolean;
18
+ /** Text on the button. Defaults to `Choose file`. */
19
+ label?: string;
20
+ /** Shown while nothing is chosen. Defaults to `No file chosen`. */
21
+ placeholder?: string;
22
+ /** Show the list of held files below the row. */
23
+ showList?: boolean;
24
+ /** The `name` the hidden input posts under, for a plain form submit. */
25
+ name?: string;
26
+ onaccept?: (files: File[]) => void;
27
+ onreject?: (rejected: RejectedFile[]) => void;
28
+ class?: string;
29
+ }
30
+ declare const FileInput: import("svelte").Component<Props, {}, "files">;
31
+ type FileInput = ReturnType<typeof FileInput>;
32
+ export default FileInput;
@@ -22,7 +22,7 @@
22
22
  columns?: FooterColumn[];
23
23
  /** Logo, wordmark and a line of copy on the left. */
24
24
  brand?: Snippet;
25
- /** Right side of the bottom bar — social links, a locale picker. */
25
+ /** Right side of the bottom bar — social links, a language picker. */
26
26
  bottom?: Snippet;
27
27
  /** Left side of the bottom bar. Ignored when `bottom` covers both. */
28
28
  copyright?: string;
@@ -15,7 +15,7 @@ interface Props {
15
15
  columns?: FooterColumn[];
16
16
  /** Logo, wordmark and a line of copy on the left. */
17
17
  brand?: Snippet;
18
- /** Right side of the bottom bar — social links, a locale picker. */
18
+ /** Right side of the bottom bar — social links, a language picker. */
19
19
  bottom?: Snippet;
20
20
  /** Left side of the bottom bar. Ignored when `bottom` covers both. */
21
21
  copyright?: string;
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import type { InputSize } from './Input.svelte';
5
+
6
+ interface Props extends HTMLAttributes<HTMLSpanElement> {
7
+ /** Match the Input it sits beside — the heights have to agree. */
8
+ size?: InputSize;
9
+ /** Paint it like a field instead of a fixed label. */
10
+ variant?: 'muted' | 'plain';
11
+ children: Snippet;
12
+ }
13
+
14
+ let {
15
+ size = 'md',
16
+ variant = 'muted',
17
+ class: className = '',
18
+ children,
19
+ ...rest
20
+ }: Props = $props();
21
+
22
+ const sizes: Record<InputSize, string> = {
23
+ sm: 'h-8 px-2.5 text-[13px]',
24
+ md: 'h-10 px-3 text-sm',
25
+ lg: 'h-11 px-3 text-[15px]'
26
+ };
27
+ </script>
28
+
29
+ <span
30
+ class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-hairline-strong font-sans whitespace-nowrap text-text-secondary shadow-xs
31
+ {sizes[size]}
32
+ {variant === 'muted' ? 'bg-bg-alt' : 'bg-bg'} {className}"
33
+ {...rest}
34
+ >
35
+ {@render children()}
36
+ </span>
@@ -0,0 +1,13 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { InputSize } from './Input.svelte';
4
+ interface Props extends HTMLAttributes<HTMLSpanElement> {
5
+ /** Match the Input it sits beside — the heights have to agree. */
6
+ size?: InputSize;
7
+ /** Paint it like a field instead of a fixed label. */
8
+ variant?: 'muted' | 'plain';
9
+ children: Snippet;
10
+ }
11
+ declare const InputAddon: import("svelte").Component<Props, {}, "">;
12
+ type InputAddon = ReturnType<typeof InputAddon>;
13
+ export default InputAddon;
@@ -2,7 +2,6 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import { iconLg } from './icon.js';
4
4
  import type { HTMLDialogAttributes } from 'svelte/elements';
5
- import { useLocale } from '../../core/locale.svelte.js';
6
5
  import { isBackdropClick, syncDialog } from '../../core/dialog.svelte.js';
7
6
 
8
7
  export type ModalSize = 'sm' | 'md' | 'lg';
@@ -40,7 +39,6 @@
40
39
  ...rest
41
40
  }: Props = $props();
42
41
 
43
- const t = useLocale();
44
42
  let dialog: HTMLDialogElement | null = $state(null);
45
43
 
46
44
  const closable = $derived(showClose ?? dismissible);
@@ -92,7 +90,7 @@
92
90
  <button
93
91
  type="button"
94
92
  onclick={close}
95
- aria-label={t.current.close}
93
+ aria-label="Close"
96
94
  class="-m-1 shrink-0 rounded-md 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"
97
95
  >
98
96
  <svg class="size-4" viewBox="0 0 14 14" fill="none" aria-hidden="true">
@@ -16,7 +16,6 @@
16
16
  import { anchored } from '../../core/actions/anchor.js';
17
17
  import { clickOutside, portal } from '../../core/actions/dismissable.js';
18
18
  import { ListCursor, groupItems, matchQuery, revealIndex } from '../../core/list.svelte.js';
19
- import { useLocale } from '../../core/locale.svelte.js';
20
19
  import { iconMd, iconSm } from './icon.js';
21
20
  import { toneFocusWithinBorder, toneSoft, toneText, type Tone } from '../../core/tones.js';
22
21
 
@@ -58,8 +57,6 @@
58
57
  class: className = ''
59
58
  }: Props = $props();
60
59
 
61
- const t = useLocale();
62
-
63
60
  let open = $state(false);
64
61
  let query = $state('');
65
62
  let wrapper: HTMLElement | null = $state(null);
@@ -149,7 +146,7 @@
149
146
  class="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium {toneSoft[tone]}"
150
147
  >
151
148
  {chosen.length}
152
- {t.current.selected}
149
+ selected
153
150
  </span>
154
151
  {:else}
155
152
  {#each chosen as option (option.value)}
@@ -162,7 +159,7 @@
162
159
  <button
163
160
  type="button"
164
161
  onclick={() => remove(option)}
165
- aria-label="{t.current.removeItem} {option.label}"
162
+ aria-label="Remove {option.label}"
166
163
  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"
167
164
  >
168
165
  <svg class="size-2.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
@@ -189,7 +186,7 @@
189
186
  aria-expanded={open}
190
187
  aria-controls="{id ?? 'multiselect'}-listbox"
191
188
  aria-invalid={invalid ? 'true' : undefined}
192
- placeholder={chosen.length ? '' : (placeholder ?? t.current.comboboxPlaceholder)}
189
+ placeholder={chosen.length ? '' : (placeholder ?? 'Search…')}
193
190
  oninput={() => {
194
191
  open = true;
195
192
  cursor.index = 0;
@@ -211,7 +208,7 @@
211
208
  onchange?.([]);
212
209
  inputEl?.focus();
213
210
  }}
214
- aria-label={t.current.clear}
211
+ aria-label="Clear selection"
215
212
  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"
216
213
  >
217
214
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
@@ -239,7 +236,7 @@
239
236
  >
240
237
  {#if flat.length === 0}
241
238
  <p class="px-3 py-6 text-center text-sm text-text-muted">
242
- {emptyText ?? t.current.noMatches}
239
+ {emptyText ?? 'No matches'}
243
240
  </p>
244
241
  {:else}
245
242
  {#each groups as [group, items] (group)}
@@ -16,7 +16,6 @@
16
16
 
17
17
  <script lang="ts">
18
18
  import type { Snippet } from 'svelte';
19
- import { useLocale } from '../../core/locale.svelte.js';
20
19
  import { iconMd } from './icon.js';
21
20
  import { focusRing, toneRing, toneSoft, toneText, type Tone } from '../../core/tones.js';
22
21
  import Drawer from './Drawer.svelte';
@@ -53,8 +52,6 @@
53
52
  class: className = ''
54
53
  }: Props = $props();
55
54
 
56
- const t = useLocale();
57
-
58
55
  // paper gives the active link a pill rather than a colour change, so the
59
56
  // current page is legible without relying on hue alone.
60
57
  const link =
@@ -66,10 +63,7 @@
66
63
  ? 'border-b border-hairline'
67
64
  : ''} {className}"
68
65
  >
69
- <nav
70
- aria-label={t.current.navigation}
71
- class="mx-auto flex h-16 w-full max-w-6xl items-center gap-6 px-6"
72
- >
66
+ <nav aria-label="Navigation" class="mx-auto flex h-16 w-full max-w-6xl items-center gap-6 px-6">
73
67
  {#if brand}
74
68
  <div class="flex shrink-0 items-center">{@render brand()}</div>
75
69
  {/if}
@@ -140,7 +134,7 @@
140
134
  <button
141
135
  type="button"
142
136
  onclick={() => (menuOpen = true)}
143
- aria-label={t.current.openMenu}
137
+ aria-label="Open menu"
144
138
  aria-expanded={menuOpen}
145
139
  class="inline-flex size-9 items-center justify-center rounded-md text-text-secondary transition-colors duration-150 hover:bg-bg-inset hover:text-text md:hidden {focusRing} {toneRing[
146
140
  tone
@@ -159,7 +153,7 @@
159
153
  </nav>
160
154
  </header>
161
155
 
162
- <Drawer bind:open={menuOpen} side="right" size="sm" title={t.current.navigation}>
156
+ <Drawer bind:open={menuOpen} side="right" size="sm" title="Navigation">
163
157
  <ul class="flex flex-col gap-0.5">
164
158
  {#each items as item (item.label)}
165
159
  <li>
@@ -1,7 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
3
  import { focusRing, toneRing, toneSolid, type Tone } from '../../core/tones.js';
4
- import { useLocale } from '../../core/locale.svelte.js';
5
4
  import { pageRange } from '../../core/pagination.js';
6
5
 
7
6
  interface Props extends Omit<HTMLAttributes<HTMLElement>, 'onchange'> {
@@ -25,8 +24,6 @@
25
24
  ...rest
26
25
  }: Props = $props();
27
26
 
28
- const t = useLocale();
29
-
30
27
  const pages = $derived(pageRange(page, total, siblings));
31
28
 
32
29
  function go(next: number) {
@@ -39,12 +36,12 @@
39
36
  const cell = `inline-flex h-9 min-w-9 items-center justify-center rounded-md px-2 font-sans text-sm font-medium tabular-nums transition-colors duration-150 ease-brand-out disabled:opacity-40 disabled:pointer-events-none ${focusRing}`;
40
37
  </script>
41
38
 
42
- <nav aria-label={t.current.pagination} class="flex items-center gap-1 {className}" {...rest}>
39
+ <nav aria-label="Pagination" class="flex items-center gap-1 {className}" {...rest}>
43
40
  <button
44
41
  type="button"
45
42
  class="{cell} {toneRing[tone]} text-text-secondary hover:bg-bg-inset"
46
43
  disabled={page <= 1}
47
- aria-label={t.current.previousPage}
44
+ aria-label="Previous page"
48
45
  onclick={() => go(page - 1)}
49
46
  >
50
47
  <svg class="size-4" viewBox="0 0 16 16" fill="none" aria-hidden="true">
@@ -73,7 +70,7 @@
73
70
  type="button"
74
71
  class="{cell} {toneRing[tone]} text-text-secondary hover:bg-bg-inset"
75
72
  disabled={page >= total}
76
- aria-label={t.current.nextPage}
73
+ aria-label="Next page"
77
74
  onclick={() => go(page + 1)}
78
75
  >
79
76
  <svg class="size-4" viewBox="0 0 16 16" fill="none" aria-hidden="true">
@@ -0,0 +1,134 @@
1
+ <script lang="ts">
2
+ import { caretIndex, clearFrom, fillFrom, sanitisePin, type PinMode } from '../../core/pin.js';
3
+ import { toneFocusBorder, type Tone } from '../../core/tones.js';
4
+
5
+ interface Props {
6
+ /** Bindable code. Shorter than `length` means still incomplete. */
7
+ value?: string;
8
+ /** How many boxes. */
9
+ length?: number;
10
+ /** What a box accepts. */
11
+ mode?: PinMode;
12
+ /** Render the characters as dots. */
13
+ mask?: boolean;
14
+ /** Draw a gap after this many boxes — `3` gives `123 456`. */
15
+ groupAfter?: number;
16
+ disabled?: boolean;
17
+ invalid?: boolean;
18
+ tone?: Tone;
19
+ /** `name` for a plain form submit — one hidden field holding the code. */
20
+ name?: string;
21
+ /** Fires once the last box is filled. */
22
+ oncomplete?: (value: string) => void;
23
+ class?: string;
24
+ }
25
+
26
+ let {
27
+ value = $bindable(''),
28
+ length = 6,
29
+ mode = 'numeric',
30
+ mask = false,
31
+ groupAfter = 0,
32
+ disabled = false,
33
+ invalid = false,
34
+ tone = 'brand',
35
+ name,
36
+ oncomplete,
37
+ class: className = ''
38
+ }: Props = $props();
39
+
40
+ let boxes: HTMLInputElement[] = $state([]);
41
+
42
+ const chars = $derived(Array.from({ length }, (_, i) => value[i] ?? ''));
43
+
44
+ function focusAt(index: number) {
45
+ boxes[Math.max(0, Math.min(length - 1, index))]?.focus();
46
+ }
47
+
48
+ /**
49
+ * The DOM is written back on every keystroke, not only when the value
50
+ * changed: a refused character leaves the box holding text the state never
51
+ * accepted, and nothing else would clear it.
52
+ */
53
+ function sync(box: HTMLInputElement, index: number) {
54
+ box.value = value[index] ?? '';
55
+ }
56
+
57
+ function commit(next: string, from: number) {
58
+ const was = value;
59
+ value = next;
60
+ if (value.length === length && was.length < length) oncomplete?.(value);
61
+ // Land on the first empty box, or the one after whatever was just typed.
62
+ focusAt(Math.min(Math.max(from, caretIndex(value, length)), length - 1));
63
+ }
64
+
65
+ function onInput(event: Event & { currentTarget: HTMLInputElement }, index: number) {
66
+ const typed = sanitisePin(event.currentTarget.value, mode);
67
+ if (!typed) {
68
+ sync(event.currentTarget, index);
69
+ return;
70
+ }
71
+ const next = fillFrom(value, index, typed, length, mode);
72
+ commit(next, index + typed.length);
73
+ for (const [at, box] of boxes.entries()) sync(box, at);
74
+ }
75
+
76
+ function onKeydown(event: KeyboardEvent, index: number) {
77
+ if (event.key === 'Backspace') {
78
+ event.preventDefault();
79
+ // An empty box deletes the one before it, the way a caret would.
80
+ const target = value[index] ? index : index - 1;
81
+ if (target < 0) return;
82
+ value = clearFrom(value, target);
83
+ for (const [at, box] of boxes.entries()) sync(box, at);
84
+ focusAt(target);
85
+ } else if (event.key === 'ArrowLeft') {
86
+ event.preventDefault();
87
+ focusAt(index - 1);
88
+ } else if (event.key === 'ArrowRight') {
89
+ event.preventDefault();
90
+ focusAt(index + 1);
91
+ }
92
+ }
93
+
94
+ function onPaste(event: ClipboardEvent, index: number) {
95
+ event.preventDefault();
96
+ const text = event.clipboardData?.getData('text') ?? '';
97
+ const clean = sanitisePin(text, mode);
98
+ if (!clean) return;
99
+ commit(fillFrom(value, index, clean, length, mode), index + clean.length);
100
+ for (const [at, box] of boxes.entries()) sync(box, at);
101
+ }
102
+ </script>
103
+
104
+ <div class="flex items-center gap-2 {className}">
105
+ {#each chars as char, index (index)}
106
+ {#if groupAfter && index > 0 && index % groupAfter === 0}
107
+ <span class="h-px w-2 bg-hairline-strong" aria-hidden="true"></span>
108
+ {/if}
109
+ <input
110
+ bind:this={boxes[index]}
111
+ value={char}
112
+ {disabled}
113
+ type={mask ? 'password' : 'text'}
114
+ inputmode={mode === 'numeric' ? 'numeric' : 'text'}
115
+ autocomplete={index === 0 ? 'one-time-code' : 'off'}
116
+ autocapitalize="characters"
117
+ spellcheck="false"
118
+ maxlength={length}
119
+ aria-label="Digit {index + 1}"
120
+ aria-invalid={invalid ? 'true' : undefined}
121
+ oninput={(event) => onInput(event, index)}
122
+ onkeydown={(event) => onKeydown(event, index)}
123
+ onpaste={(event) => onPaste(event, index)}
124
+ onfocus={(event) => event.currentTarget.select()}
125
+ class="size-11 rounded-md border bg-bg text-center font-sans text-base font-medium text-text tabular-nums shadow-xs transition-colors duration-150 ease-brand-out focus:outline-none
126
+ {invalid ? 'border-danger' : `border-hairline-strong ${toneFocusBorder[tone]}`}
127
+ {disabled ? 'pointer-events-none opacity-50' : ''}"
128
+ />
129
+ {/each}
130
+
131
+ {#if name}
132
+ <input type="hidden" {name} {value} />
133
+ {/if}
134
+ </div>
@@ -0,0 +1,25 @@
1
+ import { type PinMode } from '../../core/pin.js';
2
+ import { type Tone } from '../../core/tones.js';
3
+ interface Props {
4
+ /** Bindable code. Shorter than `length` means still incomplete. */
5
+ value?: string;
6
+ /** How many boxes. */
7
+ length?: number;
8
+ /** What a box accepts. */
9
+ mode?: PinMode;
10
+ /** Render the characters as dots. */
11
+ mask?: boolean;
12
+ /** Draw a gap after this many boxes — `3` gives `123 456`. */
13
+ groupAfter?: number;
14
+ disabled?: boolean;
15
+ invalid?: boolean;
16
+ tone?: Tone;
17
+ /** `name` for a plain form submit — one hidden field holding the code. */
18
+ name?: string;
19
+ /** Fires once the last box is filled. */
20
+ oncomplete?: (value: string) => void;
21
+ class?: string;
22
+ }
23
+ declare const PinInput: import("svelte").Component<Props, {}, "value">;
24
+ type PinInput = ReturnType<typeof PinInput>;
25
+ export default PinInput;