@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,23 @@
1
+ /**
2
+ * One-time codes: what a character box accepts, and where a paste lands.
3
+ *
4
+ * The value is a plain compact string — `'0421'` means the first four boxes
5
+ * are filled and the rest are empty. Holes are impossible by construction,
6
+ * which is what lets the component always know which box to focus next.
7
+ */
8
+ export type PinMode = 'numeric' | 'alphanumeric';
9
+ /** Strips whatever the mode refuses, then trims to `length` characters. */
10
+ export declare function sanitisePin(text: string, mode?: PinMode, length?: number): string;
11
+ /**
12
+ * Writes `text` into the boxes from `index` on.
13
+ *
14
+ * One keystroke replaces one box and leaves the tail alone; a six-character
15
+ * paste into the first box fills all six. Anything past `length` is dropped
16
+ * rather than wrapped, because the extra characters were never part of a code
17
+ * this long.
18
+ */
19
+ export declare function fillFrom(value: string, index: number, text: string, length: number, mode?: PinMode): string;
20
+ /** Clears one box and everything after it — what Backspace on a filled box does. */
21
+ export declare function clearFrom(value: string, index: number): string;
22
+ /** The box the caret belongs in: the first empty one, or the last. */
23
+ export declare function caretIndex(value: string, length: number): number;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * One-time codes: what a character box accepts, and where a paste lands.
3
+ *
4
+ * The value is a plain compact string — `'0421'` means the first four boxes
5
+ * are filled and the rest are empty. Holes are impossible by construction,
6
+ * which is what lets the component always know which box to focus next.
7
+ */
8
+ const ALLOWED = {
9
+ numeric: /[^0-9]/g,
10
+ alphanumeric: /[^0-9a-zA-Z]/g
11
+ };
12
+ /** Strips whatever the mode refuses, then trims to `length` characters. */
13
+ export function sanitisePin(text, mode = 'numeric', length = Infinity) {
14
+ return text.replace(ALLOWED[mode], '').slice(0, length);
15
+ }
16
+ /**
17
+ * Writes `text` into the boxes from `index` on.
18
+ *
19
+ * One keystroke replaces one box and leaves the tail alone; a six-character
20
+ * paste into the first box fills all six. Anything past `length` is dropped
21
+ * rather than wrapped, because the extra characters were never part of a code
22
+ * this long.
23
+ */
24
+ export function fillFrom(value, index, text, length, mode = 'numeric') {
25
+ const head = value.slice(0, index);
26
+ const clean = sanitisePin(text, mode, length - head.length);
27
+ if (!clean)
28
+ return value;
29
+ return (head + clean + value.slice(index + clean.length)).slice(0, length);
30
+ }
31
+ /** Clears one box and everything after it — what Backspace on a filled box does. */
32
+ export function clearFrom(value, index) {
33
+ return value.slice(0, index);
34
+ }
35
+ /** The box the caret belongs in: the first empty one, or the last. */
36
+ export function caretIndex(value, length) {
37
+ return Math.min(value.length, length - 1);
38
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Free-text tokens: what counts as a new tag, and why one was turned away.
3
+ *
4
+ * The rejection is returned rather than thrown or swallowed, so the component
5
+ * can say *why* nothing happened — a silent no-op on a duplicate reads like a
6
+ * broken field.
7
+ */
8
+ export type TagRejection = 'empty' | 'short' | 'duplicate' | 'full';
9
+ export interface TagRules {
10
+ /** Trim the ends and collapse inner runs of whitespace. Default `true`. */
11
+ trim?: boolean;
12
+ /** Ignore case when refusing duplicates. Default `true`. */
13
+ caseInsensitive?: boolean;
14
+ /** Cap on how many tags may be held. `0` means no limit. */
15
+ max?: number;
16
+ /** Shortest a tag may be, after trimming. */
17
+ minLength?: number;
18
+ }
19
+ /**
20
+ * Sorts one commit into the next list, or the reason there isn't one.
21
+ *
22
+ * Order matters, the way it does in `sortFiles`: a tag is judged on being
23
+ * present, then long enough, then unique, then whether there is room — so the
24
+ * message a user sees is the first rule they actually broke.
25
+ */
26
+ export declare function addTag(tags: string[], raw: string, rules?: TagRules): {
27
+ tags: string[];
28
+ rejected?: TagRejection;
29
+ };
30
+ /**
31
+ * Splits pasted text so one paste can commit several tags.
32
+ *
33
+ * Scanned character by character rather than with a RegExp: the separators are
34
+ * the app's to choose, and building a character class out of them means
35
+ * escaping every one that happens to be a metacharacter.
36
+ */
37
+ export declare function splitTags(text: string, separators?: readonly string[]): string[];
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Free-text tokens: what counts as a new tag, and why one was turned away.
3
+ *
4
+ * The rejection is returned rather than thrown or swallowed, so the component
5
+ * can say *why* nothing happened — a silent no-op on a duplicate reads like a
6
+ * broken field.
7
+ */
8
+ /**
9
+ * Sorts one commit into the next list, or the reason there isn't one.
10
+ *
11
+ * Order matters, the way it does in `sortFiles`: a tag is judged on being
12
+ * present, then long enough, then unique, then whether there is room — so the
13
+ * message a user sees is the first rule they actually broke.
14
+ */
15
+ export function addTag(tags, raw, rules = {}) {
16
+ const { trim = true, caseInsensitive = true, max = 0, minLength = 1 } = rules;
17
+ const value = trim ? raw.trim().replace(/\s+/g, ' ') : raw;
18
+ if (!value)
19
+ return { tags, rejected: 'empty' };
20
+ if (value.length < minLength)
21
+ return { tags, rejected: 'short' };
22
+ const fold = (text) => (caseInsensitive ? text.toLowerCase() : text);
23
+ if (tags.some((tag) => fold(tag) === fold(value)))
24
+ return { tags, rejected: 'duplicate' };
25
+ if (max && tags.length >= max)
26
+ return { tags, rejected: 'full' };
27
+ return { tags: [...tags, value] };
28
+ }
29
+ /**
30
+ * Splits pasted text so one paste can commit several tags.
31
+ *
32
+ * Scanned character by character rather than with a RegExp: the separators are
33
+ * the app's to choose, and building a character class out of them means
34
+ * escaping every one that happens to be a metacharacter.
35
+ */
36
+ export function splitTags(text, separators = [',']) {
37
+ const marks = new Set([...separators, '\n', '\r', '\t']);
38
+ const found = [];
39
+ let current = '';
40
+ for (const char of text) {
41
+ if (marks.has(char)) {
42
+ found.push(current);
43
+ current = '';
44
+ }
45
+ else {
46
+ current += char;
47
+ }
48
+ }
49
+ found.push(current);
50
+ return found.map((tag) => tag.trim()).filter(Boolean);
51
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Clock times as `HH:MM` strings — the shape `<input type="time">` uses, so a
3
+ * form posts exactly what the browser would have posted.
4
+ *
5
+ * Everything here works in minutes since midnight. A day is 1440 of them, and
6
+ * nothing in this module crosses that boundary: stepping past 23:59 stops
7
+ * there rather than wrapping into a day this module cannot name.
8
+ */
9
+ export declare const MINUTES_IN_DAY = 1440;
10
+ /**
11
+ * Reads `9:5`, `09:05`, `0905` or `09:05:30` into minutes since midnight.
12
+ * Returns `null` for anything that is not a time, including 24:00 and 12:60.
13
+ */
14
+ export declare function parseTime(text: string): number | null;
15
+ /** Minutes since midnight back to `HH:MM`, always four digits and a colon. */
16
+ export declare function formatTime(minutes: number): string;
17
+ /** Clamps into `min…max` and snaps to the nearest multiple of `step` minutes. */
18
+ export declare function clampTime(minutes: number, min?: string, max?: string, step?: number): number;
19
+ /**
20
+ * Moves by whole steps. Stops at the bounds instead of wrapping — a stepper
21
+ * that rolls 23:55 round to 00:00 turns a late meeting into an early one.
22
+ */
23
+ export declare function stepTime(value: string, direction: 1 | -1, step?: number, min?: string, max?: string): string;
24
+ /**
25
+ * `13:05` as the locale writes it — `1:05 PM` in en-US, `13.05` in id-ID.
26
+ *
27
+ * Display only. The bound value stays `HH:MM` whatever this returns, because a
28
+ * localised string is not something a form can post.
29
+ */
30
+ export declare function formatClock(value: string, locale: string, hour12?: boolean): string;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Clock times as `HH:MM` strings — the shape `<input type="time">` uses, so a
3
+ * form posts exactly what the browser would have posted.
4
+ *
5
+ * Everything here works in minutes since midnight. A day is 1440 of them, and
6
+ * nothing in this module crosses that boundary: stepping past 23:59 stops
7
+ * there rather than wrapping into a day this module cannot name.
8
+ */
9
+ export const MINUTES_IN_DAY = 1440;
10
+ /**
11
+ * Reads `9:5`, `09:05`, `0905` or `09:05:30` into minutes since midnight.
12
+ * Returns `null` for anything that is not a time, including 24:00 and 12:60.
13
+ */
14
+ export function parseTime(text) {
15
+ const trimmed = text.trim();
16
+ // Digits and colons only: `9a` and `later` are refusals, not a hint that
17
+ // someone meant 09:00. Without this a bare `Number('')` reads as midnight.
18
+ if (!trimmed || !/^[0-9:]+$/.test(trimmed))
19
+ return null;
20
+ const digits = trimmed.replace(/[^0-9]/g, '');
21
+ if (!digits)
22
+ return null;
23
+ let hours;
24
+ let minutes;
25
+ if (trimmed.includes(':')) {
26
+ const [rawHours, rawMinutes = '0'] = trimmed.split(':');
27
+ hours = Number(rawHours);
28
+ minutes = Number(rawMinutes);
29
+ }
30
+ else if (digits.length <= 2) {
31
+ // A bare number is an hour: `9` means 09:00.
32
+ hours = Number(digits);
33
+ minutes = 0;
34
+ }
35
+ else {
36
+ hours = Number(digits.slice(0, digits.length - 2));
37
+ minutes = Number(digits.slice(-2));
38
+ }
39
+ if (!Number.isInteger(hours) || !Number.isInteger(minutes))
40
+ return null;
41
+ if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59)
42
+ return null;
43
+ return hours * 60 + minutes;
44
+ }
45
+ /** Minutes since midnight back to `HH:MM`, always four digits and a colon. */
46
+ export function formatTime(minutes) {
47
+ const total = Math.max(0, Math.min(MINUTES_IN_DAY - 1, Math.round(minutes)));
48
+ const hours = Math.floor(total / 60);
49
+ return `${String(hours).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
50
+ }
51
+ /** Clamps into `min…max` and snaps to the nearest multiple of `step` minutes. */
52
+ export function clampTime(minutes, min, max, step = 1) {
53
+ const floor = min ? (parseTime(min) ?? 0) : 0;
54
+ const ceiling = max ? (parseTime(max) ?? MINUTES_IN_DAY - 1) : MINUTES_IN_DAY - 1;
55
+ const snapped = step > 1 ? Math.round(minutes / step) * step : minutes;
56
+ return Math.max(floor, Math.min(ceiling, snapped));
57
+ }
58
+ /**
59
+ * Moves by whole steps. Stops at the bounds instead of wrapping — a stepper
60
+ * that rolls 23:55 round to 00:00 turns a late meeting into an early one.
61
+ */
62
+ export function stepTime(value, direction, step = 1, min, max) {
63
+ const current = parseTime(value) ?? clampTime(0, min, max, step);
64
+ return formatTime(clampTime(current + direction * step, min, max, step));
65
+ }
66
+ /**
67
+ * `13:05` as the locale writes it — `1:05 PM` in en-US, `13.05` in id-ID.
68
+ *
69
+ * Display only. The bound value stays `HH:MM` whatever this returns, because a
70
+ * localised string is not something a form can post.
71
+ */
72
+ export function formatClock(value, locale, hour12) {
73
+ const minutes = parseTime(value);
74
+ if (minutes === null)
75
+ return '';
76
+ const date = new Date(2000, 0, 1, Math.floor(minutes / 60), minutes % 60);
77
+ return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', hour12 }).format(date);
78
+ }
@@ -8,7 +8,6 @@
8
8
  toneText,
9
9
  type Tone
10
10
  } from '../../core/tones.js';
11
- import { useLocale } from '../../core/locale.svelte.js';
12
11
 
13
12
  export type AlertVariant = 'soft' | 'outline' | 'accent';
14
13
 
@@ -36,7 +35,6 @@
36
35
  ...rest
37
36
  }: Props = $props();
38
37
 
39
- const t = useLocale();
40
38
  let open = $state(true);
41
39
 
42
40
  const variants = $derived({
@@ -82,7 +80,7 @@
82
80
  <button
83
81
  type="button"
84
82
  onclick={dismiss}
85
- aria-label={t.current.dismiss}
83
+ aria-label="Dismiss"
86
84
  class="-m-1 shrink-0 p-1 text-text-muted transition-colors duration-150 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
87
85
  >
88
86
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
@@ -2,7 +2,6 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLAttributes } from 'svelte/elements';
4
4
  import { toneHoverText, type Tone } from '../../core/tones.js';
5
- import { useLocale } from '../../core/locale.svelte.js';
6
5
  import { iconMd } from './icon.js';
7
6
 
8
7
  export interface BreadcrumbItem {
@@ -19,11 +18,9 @@
19
18
  }
20
19
 
21
20
  let { items, separator = '/', tone = 'brand', class: className = '', ...rest }: Props = $props();
22
-
23
- const t = useLocale();
24
21
  </script>
25
22
 
26
- <nav aria-label={t.current.breadcrumb} class="font-sans text-sm {className}" {...rest}>
23
+ <nav aria-label="Breadcrumb" class="font-sans text-sm {className}" {...rest}>
27
24
  <ol class="flex flex-wrap items-center gap-2">
28
25
  {#each items as item, i (item.label)}
29
26
  <li class="flex items-center gap-2">
@@ -11,7 +11,6 @@
11
11
  weekdayNames
12
12
  } from '../../core/date.js';
13
13
  import { calendarKeyMove } from '../../core/calendar.js';
14
- import { useLocale } from '../../core/locale.svelte.js';
15
14
  import { focusRing, toneFill, toneRing, toneText, type Tone } from '../../core/tones.js';
16
15
 
17
16
  interface Props {
@@ -46,8 +45,6 @@
46
45
  class: className = ''
47
46
  }: Props = $props();
48
47
 
49
- const t = useLocale();
50
-
51
48
  const selected = $derived(fromISO(value));
52
49
  const now = today();
53
50
 
@@ -64,7 +61,7 @@
64
61
  });
65
62
 
66
63
  const days = $derived(monthGrid(view, weekStart));
67
- const weekdays = $derived(weekdayNames(t.current.dateLocale, weekStart));
64
+ const weekdays = $derived(weekdayNames(undefined, weekStart));
68
65
 
69
66
  function blocked(date: Date) {
70
67
  return isOutOfRange(date, min, max) || (isDisabled?.(date) ?? false);
@@ -109,7 +106,7 @@
109
106
  <button
110
107
  type="button"
111
108
  onclick={() => show(addMonths(view, -1))}
112
- aria-label={t.current.previousMonth}
109
+ aria-label="Previous month"
113
110
  class="inline-flex size-8 items-center justify-center text-text-secondary transition-colors duration-150 hover:bg-bg-inset hover:text-text {focusRing} {toneRing[
114
111
  tone
115
112
  ]}"
@@ -120,13 +117,13 @@
120
117
  </button>
121
118
 
122
119
  <span aria-live="polite" class="text-sm font-medium text-text">
123
- {monthLabel(view, t.current.dateLocale)}
120
+ {monthLabel(view)}
124
121
  </span>
125
122
 
126
123
  <button
127
124
  type="button"
128
125
  onclick={() => show(addMonths(view, 1))}
129
- aria-label={t.current.nextMonth}
126
+ aria-label="Next month"
130
127
  class="inline-flex size-8 items-center justify-center text-text-secondary transition-colors duration-150 hover:bg-bg-inset hover:text-text {focusRing} {toneRing[
131
128
  tone
132
129
  ]}"
@@ -148,7 +145,7 @@
148
145
  bind:this={grid}
149
146
  role="grid"
150
147
  tabindex="-1"
151
- aria-label={t.current.selectDate}
148
+ aria-label="Select a date"
152
149
  onkeydown={onKeydown}
153
150
  class="grid grid-cols-7 gap-px focus:outline-none"
154
151
  >
@@ -190,7 +187,7 @@
190
187
  tone
191
188
  ]} hover:underline hover:underline-offset-4 {focusRing} {toneRing[tone]}"
192
189
  >
193
- {t.current.today}
190
+ Today
194
191
  </button>
195
192
  {/if}
196
193
  </div>
@@ -0,0 +1,107 @@
1
+ <script lang="ts">
2
+ import type { HTMLInputAttributes } from 'svelte/elements';
3
+ import { normaliseHex } from '../../core/color.js';
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' | 'suffix' | 'type'> {
8
+ /** Bindable `#rrggbb`. Shorthand and alpha are normalised away on blur. */
9
+ value?: string;
10
+ size?: InputSize;
11
+ tone?: Tone;
12
+ invalid?: boolean;
13
+ /** Fixed choices under the field. Empty hides the row. */
14
+ swatches?: string[];
15
+ class?: string;
16
+ }
17
+
18
+ let {
19
+ value = $bindable('#000000'),
20
+ size = 'md',
21
+ tone = 'brand',
22
+ invalid = false,
23
+ disabled = false,
24
+ swatches = [],
25
+ class: className = '',
26
+ ...rest
27
+ }: Props = $props();
28
+
29
+ // The text can be mid-typing (`#a2`) while the swatch needs a real colour,
30
+ // so the swatch follows the last value that parsed rather than the field.
31
+ let text = $state('');
32
+ let editing = $state(false);
33
+
34
+ $effect(() => {
35
+ if (!editing) text = value;
36
+ });
37
+
38
+ const resolved = $derived(normaliseHex(text) ?? normaliseHex(value) ?? '#000000');
39
+
40
+ // Same reason as CurrencyInput: at input time the bound `text` is one
41
+ // keystroke behind, so the element is the only current source.
42
+ function onText(event: Event & { currentTarget: HTMLInputElement }) {
43
+ const hex = normaliseHex(event.currentTarget.value);
44
+ if (hex) value = hex;
45
+ }
46
+
47
+ function settle() {
48
+ editing = false;
49
+ value = normaliseHex(text) ?? value;
50
+ text = value;
51
+ }
52
+
53
+ function choose(hex: string) {
54
+ value = normaliseHex(hex) ?? value;
55
+ text = value;
56
+ }
57
+ </script>
58
+
59
+ <div class="flex w-full flex-col gap-2 {className}">
60
+ <Input
61
+ bind:value={text}
62
+ {size}
63
+ {tone}
64
+ {invalid}
65
+ {disabled}
66
+ spellcheck="false"
67
+ autocapitalize="none"
68
+ placeholder="#000000"
69
+ oninput={onText}
70
+ onfocus={() => (editing = true)}
71
+ onblur={settle}
72
+ class="[&_input]:font-mono [&_input]:uppercase"
73
+ {...rest}
74
+ >
75
+ {#snippet prefix()}
76
+ <span class="relative grid size-5 place-items-center border border-hairline">
77
+ <span class="absolute inset-0" style="background-color: {resolved}"></span>
78
+ <input
79
+ type="color"
80
+ value={resolved}
81
+ {disabled}
82
+ aria-label="Pick a colour"
83
+ oninput={(event) => choose(event.currentTarget.value)}
84
+ class="absolute inset-0 cursor-pointer opacity-0 disabled:cursor-not-allowed"
85
+ />
86
+ </span>
87
+ {/snippet}
88
+ </Input>
89
+
90
+ {#if swatches.length}
91
+ <div class="flex flex-wrap gap-1.5">
92
+ {#each swatches as swatch (swatch)}
93
+ <button
94
+ type="button"
95
+ {disabled}
96
+ onclick={() => choose(swatch)}
97
+ title={swatch}
98
+ aria-label={swatch}
99
+ aria-pressed={resolved === normaliseHex(swatch)}
100
+ class="size-6 border transition-transform duration-150 hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current disabled:pointer-events-none disabled:opacity-50
101
+ {resolved === normaliseHex(swatch) ? 'border-text' : 'border-hairline'}"
102
+ style="background-color: {swatch}"
103
+ ></button>
104
+ {/each}
105
+ </div>
106
+ {/if}
107
+ </div>
@@ -0,0 +1,16 @@
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' | 'type'> {
5
+ /** Bindable `#rrggbb`. Shorthand and alpha are normalised away on blur. */
6
+ value?: string;
7
+ size?: InputSize;
8
+ tone?: Tone;
9
+ invalid?: boolean;
10
+ /** Fixed choices under the field. Empty hides the row. */
11
+ swatches?: string[];
12
+ class?: string;
13
+ }
14
+ declare const ColorInput: import("svelte").Component<Props, {}, "value">;
15
+ type ColorInput = ReturnType<typeof ColorInput>;
16
+ export default ColorInput;
@@ -4,7 +4,6 @@
4
4
  import { clickOutside, portal } from '../../core/actions/dismissable.js';
5
5
  import { ListCursor, groupItems, matchQuery, revealIndex } from '../../core/list.svelte.js';
6
6
  import { toneFocusWithinBorder, toneSoft, toneText, type Tone } from '../../core/tones.js';
7
- import { useLocale } from '../../core/locale.svelte.js';
8
7
  import { iconMd } from './icon.js';
9
8
 
10
9
  export interface ComboboxOption {
@@ -51,8 +50,6 @@
51
50
  class: className = ''
52
51
  }: Props = $props();
53
52
 
54
- const t = useLocale();
55
-
56
53
  let open = $state(false);
57
54
  let query = $state('');
58
55
  let wrapper: HTMLElement | null = $state(null);
@@ -161,7 +158,7 @@
161
158
  aria-controls="{id ?? 'combobox'}-listbox"
162
159
  aria-activedescendant={open && flat[active] ? `${id ?? 'combobox'}-opt-${active}` : undefined}
163
160
  aria-invalid={invalid ? 'true' : undefined}
164
- placeholder={selected ? selected.label : (placeholder ?? t.current.comboboxPlaceholder)}
161
+ placeholder={selected ? selected.label : (placeholder ?? 'Search…')}
165
162
  value={open ? query : (selected?.label ?? '')}
166
163
  oninput={(event) => {
167
164
  query = event.currentTarget.value;
@@ -179,7 +176,7 @@
179
176
  <button
180
177
  type="button"
181
178
  onclick={clear}
182
- aria-label={t.current.clear}
179
+ aria-label="Clear selection"
183
180
  class="shrink-0 p-1.5 text-text-muted transition-colors duration-150 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
184
181
  >
185
182
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
@@ -212,7 +209,7 @@
212
209
  >
213
210
  {#if flat.length === 0}
214
211
  <p class="px-3 py-6 text-center text-sm text-text-muted">
215
- {emptyText ?? t.current.noMatches}
212
+ {emptyText ?? 'No matches'}
216
213
  </p>
217
214
  {:else}
218
215
  {#each groups as [group, items] (group)}
@@ -23,7 +23,6 @@
23
23
  import { focusTrap } from '../../core/actions/dismissable.js';
24
24
  import { syncDialog } from '../../core/dialog.svelte.js';
25
25
  import { ListCursor, groupItems, matchQuery, revealIndex } from '../../core/list.svelte.js';
26
- import { useLocale } from '../../core/locale.svelte.js';
27
26
  import { toneSoft, type Tone } from '../../core/tones.js';
28
27
  import { isTypingTarget } from '../../core/trigger.js';
29
28
  import { iconMd } from './icon.js';
@@ -61,8 +60,6 @@
61
60
  class: className = ''
62
61
  }: Props = $props();
63
62
 
64
- const t = useLocale();
65
-
66
63
  let dialog: HTMLDialogElement | null = $state(null);
67
64
  let inputEl: HTMLInputElement | null = $state(null);
68
65
  let listEl: HTMLElement | null = $state(null);
@@ -182,8 +179,8 @@
182
179
  oninput={() => cursor.reset()}
183
180
  type="text"
184
181
  autocomplete="off"
185
- aria-label={placeholder ?? t.current.commandPlaceholder}
186
- placeholder={placeholder ?? t.current.commandPlaceholder}
182
+ aria-label={placeholder ?? 'Type a command or search…'}
183
+ placeholder={placeholder ?? 'Type a command or search…'}
187
184
  class="h-11 w-full min-w-0 bg-transparent font-sans text-[15px] text-text placeholder:text-text-muted focus:outline-none"
188
185
  />
189
186
  <Kbd>esc</Kbd>
@@ -197,7 +194,7 @@
197
194
  >
198
195
  {#if flat.length === 0}
199
196
  <p class="px-3.5 py-10 text-center font-sans text-sm text-text-muted">
200
- {emptyText ?? t.current.noResults}
197
+ {emptyText ?? 'No results'}
201
198
  </p>
202
199
  {:else}
203
200
  {#each groups as [group, groupItems] (group)}
@@ -2,7 +2,6 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import Button from './Button.svelte';
4
4
  import Modal from './Modal.svelte';
5
- import { useLocale } from '../../core/locale.svelte.js';
6
5
  import { toneSoft, type Tone } from '../../core/tones.js';
7
6
 
8
7
  interface Props {
@@ -37,8 +36,6 @@
37
36
  children
38
37
  }: Props = $props();
39
38
 
40
- const t = useLocale();
41
-
42
39
  async function confirm() {
43
40
  try {
44
41
  loading = true;
@@ -88,10 +85,10 @@
88
85
 
89
86
  {#snippet footer()}
90
87
  <Button variant="ghost" tone="neutral" onclick={cancel} disabled={loading}>
91
- {cancelLabel ?? t.current.cancel}
88
+ {cancelLabel ?? 'Cancel'}
92
89
  </Button>
93
90
  <Button {tone} onclick={confirm} {loading}>
94
- {confirmLabel ?? t.current.confirm}
91
+ {confirmLabel ?? 'Confirm'}
95
92
  </Button>
96
93
  {/snippet}
97
94
  </Modal>
@@ -3,7 +3,6 @@
3
3
  import { anchored } from '../../core/actions/anchor.js';
4
4
  import { clickOutside, focusTrap, navigateList, portal } from '../../core/actions/dismissable.js';
5
5
  import { menuItems } from '../../core/trigger.js';
6
- import { useLocale } from '../../core/locale.svelte.js';
7
6
 
8
7
  interface Props {
9
8
  /** The region that responds to a right-click. */
@@ -27,8 +26,6 @@
27
26
  menuClass = ''
28
27
  }: Props = $props();
29
28
 
30
- const t = useLocale();
31
-
32
29
  let menuEl: HTMLElement | null = $state(null);
33
30
  /**
34
31
  * A 0×0 element parked at the pointer, so the same `anchored` action that
@@ -79,7 +76,7 @@
79
76
  if ((event.target as HTMLElement).closest('[role="menuitem"]:not([disabled])')) open = false;
80
77
  }}
81
78
  role="menu"
82
- aria-label={label ?? t.current.menu}
79
+ aria-label={label ?? 'Menu'}
83
80
  tabindex="-1"
84
81
  class="z-50 flex max-h-[min(24rem,80vh)] min-w-44 flex-col overflow-y-auto border border-hairline bg-bg py-1 font-sans {menuClass}"
85
82
  >