@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
@@ -2,8 +2,7 @@
2
2
  import { anchored } from '../../core/actions/anchor.js';
3
3
  import { clickOutside, portal } from '../../core/actions/dismissable.js';
4
4
  import { dateHint } from '../../core/calendar.js';
5
- import { formatISO, parseFormatted } from '../../core/date.js';
6
- import { useLocale } from '../../core/locale.svelte.js';
5
+ import { formatISO, parseFormatted, type DateFormat } from '../../core/date.js';
7
6
  import { toneFocusWithinBorder, type Tone } from '../../core/tones.js';
8
7
  import Calendar from './Calendar.svelte';
9
8
  import { edge, soft } from './lift.js';
@@ -16,6 +15,8 @@
16
15
  max?: string;
17
16
  isDisabled?: (date: Date) => boolean;
18
17
  weekStart?: 0 | 1;
18
+ /** Order of the date parts the field accepts and prints. */
19
+ format?: DateFormat;
19
20
  tone?: Tone;
20
21
  invalid?: boolean;
21
22
  disabled?: boolean;
@@ -34,6 +35,7 @@
34
35
  max,
35
36
  isDisabled,
36
37
  weekStart = 1,
38
+ format = 'dmy',
37
39
  tone = 'brand',
38
40
  invalid = false,
39
41
  disabled = false,
@@ -45,8 +47,6 @@
45
47
  class: className = ''
46
48
  }: Props = $props();
47
49
 
48
- const t = useLocale();
49
-
50
50
  let open = $state(false);
51
51
  let text = $state('');
52
52
  let editing = $state(false);
@@ -54,8 +54,8 @@
54
54
  let inputEl: HTMLInputElement | null = $state(null);
55
55
 
56
56
  /** While typing the field shows the raw text; otherwise the formatted value. */
57
- const display = $derived(editing ? text : formatISO(value, t.current.dateFormat));
58
- const hint = $derived(placeholder ?? dateHint(t.current.dateFormat));
57
+ const display = $derived(editing ? text : formatISO(value, format));
58
+ const hint = $derived(placeholder ?? dateHint(format));
59
59
 
60
60
  function commit() {
61
61
  editing = false;
@@ -66,7 +66,7 @@
66
66
  }
67
67
  return;
68
68
  }
69
- const parsed = parseFormatted(text, t.current.dateFormat);
69
+ const parsed = parseFormatted(text, format);
70
70
  if (parsed && parsed !== value) {
71
71
  value = parsed;
72
72
  onchange?.(parsed);
@@ -133,7 +133,7 @@
133
133
  onchange?.('');
134
134
  inputEl?.focus();
135
135
  }}
136
- aria-label={t.current.clear}
136
+ aria-label="Clear selection"
137
137
  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"
138
138
  >
139
139
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
@@ -151,7 +151,7 @@
151
151
  type="button"
152
152
  {disabled}
153
153
  onclick={() => (open = !open)}
154
- aria-label={t.current.selectDate}
154
+ aria-label="Select a date"
155
155
  aria-expanded={open}
156
156
  class="shrink-0 rounded p-1.5 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"
157
157
  >
@@ -1,3 +1,4 @@
1
+ import { type DateFormat } from '../../core/date.js';
1
2
  import { type Tone } from '../../core/tones.js';
2
3
  interface Props {
3
4
  /** Bindable `YYYY-MM-DD` — the same shape as `<input type="date">`. */
@@ -7,6 +8,8 @@ interface Props {
7
8
  max?: string;
8
9
  isDisabled?: (date: Date) => boolean;
9
10
  weekStart?: 0 | 1;
11
+ /** Order of the date parts the field accepts and prints. */
12
+ format?: DateFormat;
10
13
  tone?: Tone;
11
14
  invalid?: boolean;
12
15
  disabled?: boolean;
@@ -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
  import { float } from './lift.js';
8
7
 
@@ -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 horizontal = $derived(side === 'left' || side === 'right');
@@ -110,7 +108,7 @@
110
108
  <button
111
109
  type="button"
112
110
  onclick={close}
113
- aria-label={t.current.close}
111
+ aria-label="Close"
114
112
  class="-m-1 shrink-0 rounded-xl p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
115
113
  >
116
114
  <svg class="size-4" viewBox="0 0 14 14" fill="none" aria-hidden="true">
@@ -3,7 +3,6 @@
3
3
  import { anchored, type Placement } from '../../core/actions/anchor.js';
4
4
  import { clickOutside, focusTrap, navigateList, portal } from '../../core/actions/dismissable.js';
5
5
  import { describeTrigger, menuItems, popupTrigger } from '../../core/trigger.js';
6
- import { useLocale } from '../../core/locale.svelte.js';
7
6
  import { soft } from './lift.js';
8
7
 
9
8
  interface Props {
@@ -18,7 +17,7 @@
18
17
  matchWidth?: boolean;
19
18
  /** Close as soon as an item inside is activated. Default true. */
20
19
  closeOnSelect?: boolean;
21
- /** Accessible name for the menu. Defaults to the locale's `menu`. */
20
+ /** Accessible name for the menu. Defaults to `Menu`. */
22
21
  label?: string;
23
22
  class?: string;
24
23
  /** Classes for the wrapper around the trigger. */
@@ -38,8 +37,6 @@
38
37
  triggerClass = ''
39
38
  }: Props = $props();
40
39
 
41
- const t = useLocale();
42
-
43
40
  let anchorEl: HTMLElement | null = $state(null);
44
41
  let menuEl: HTMLElement | null = $state(null);
45
42
 
@@ -86,7 +83,7 @@
86
83
  onkeydown={onMenuKeydown}
87
84
  onclick={onMenuClick}
88
85
  role="menu"
89
- aria-label={label ?? t.current.menu}
86
+ aria-label={label ?? 'Menu'}
90
87
  tabindex="-1"
91
88
  class="z-50 flex max-h-[min(24rem,80vh)] min-w-48 flex-col overflow-y-auto rounded-2xl border border-hairline bg-bg py-1 font-sans {soft} {className}"
92
89
  >
@@ -12,7 +12,7 @@ interface Props {
12
12
  matchWidth?: boolean;
13
13
  /** Close as soon as an item inside is activated. Default true. */
14
14
  closeOnSelect?: boolean;
15
- /** Accessible name for the menu. Defaults to the locale's `menu`. */
15
+ /** Accessible name for the menu. Defaults to `Menu`. */
16
16
  label?: string;
17
17
  class?: string;
18
18
  /** Classes for the wrapper around the trigger. */
@@ -1,7 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import { toneBorder, toneSoft, toneSurface, toneText, type Tone } from '../../core/tones.js';
4
- import { useLocale } from '../../core/locale.svelte.js';
5
4
  import { formatSize as toReadableSize, sortFiles, type RejectedFile } from '../../core/files.js';
6
5
  import { edge } from './lift.js';
7
6
 
@@ -17,7 +16,7 @@
17
16
  maxFiles?: number;
18
17
  disabled?: boolean;
19
18
  tone?: Tone;
20
- /** Headline inside the zone. Defaults to the locale's `dropFiles`. */
19
+ /** Headline inside the zone. Defaults to `Drop files here`. */
21
20
  label?: string;
22
21
  hint?: string;
23
22
  /** Hide the built-in file list to render your own. */
@@ -46,15 +45,13 @@
46
45
  class: className = ''
47
46
  }: Props = $props();
48
47
 
49
- const t = useLocale();
50
-
51
48
  let input: HTMLInputElement | null = $state(null);
52
49
  let dragging = $state(false);
53
50
  let rejected = $state<RejectedFile[]>([]);
54
51
  /** Nested elements fire dragleave too, so count depth instead of toggling. */
55
52
  let depth = 0;
56
53
 
57
- const formatSize = (bytes: number) => toReadableSize(bytes, t.current.byteUnits);
54
+ const formatSize = (bytes: number) => toReadableSize(bytes);
58
55
 
59
56
  const defaultHint = $derived.by(() => {
60
57
  if (hint) return hint;
@@ -97,9 +94,9 @@
97
94
  }
98
95
 
99
96
  const reasons = $derived({
100
- type: t.current.rejectedType,
101
- size: t.current.rejectedSize,
102
- count: t.current.rejectedCount
97
+ type: 'wrong file type',
98
+ size: 'too large',
99
+ count: 'over the file limit'
103
100
  });
104
101
  </script>
105
102
 
@@ -147,10 +144,10 @@
147
144
  {/if}
148
145
  </span>
149
146
  <span class="flex flex-col gap-1">
150
- <span class="text-sm font-semibold text-text">{label ?? t.current.dropFiles}</span>
147
+ <span class="text-sm font-semibold text-text">{label ?? 'Drop files here'}</span>
151
148
  <span class="text-[13px] text-text-secondary">
152
- {t.current.or}
153
- <span class="underline underline-offset-2">{t.current.browse}</span>
149
+ or
150
+ <span class="underline underline-offset-2">browse</span>
154
151
  </span>
155
152
  {#if defaultHint}
156
153
  <span class="text-xs text-text-muted">{defaultHint}</span>
@@ -192,7 +189,7 @@
192
189
  <button
193
190
  type="button"
194
191
  onclick={() => remove(i)}
195
- aria-label="{t.current.remove} {file.name}"
192
+ aria-label="Remove {file.name}"
196
193
  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"
197
194
  >
198
195
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
@@ -13,7 +13,7 @@ interface Props {
13
13
  maxFiles?: number;
14
14
  disabled?: boolean;
15
15
  tone?: Tone;
16
- /** Headline inside the zone. Defaults to the locale's `dropFiles`. */
16
+ /** Headline inside the zone. Defaults to `Drop files here`. */
17
17
  label?: string;
18
18
  hint?: string;
19
19
  /** Hide the built-in file list to render your own. */
@@ -0,0 +1,202 @@
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 { edge } from './lift.js';
5
+ import type { InputSize } from './Input.svelte';
6
+
7
+ interface Props {
8
+ /** Bindable list of accepted files. */
9
+ files?: File[];
10
+ /** Same syntax as the input's `accept` — `'image/*,.pdf'`. */
11
+ accept?: string;
12
+ multiple?: boolean;
13
+ /** Per-file ceiling in bytes. `0` means no limit. */
14
+ maxSize?: number;
15
+ /** Cap on how many files may be held at once. `0` means no limit. */
16
+ maxFiles?: number;
17
+ disabled?: boolean;
18
+ size?: InputSize;
19
+ tone?: Tone;
20
+ invalid?: boolean;
21
+ /** Text on the button. Defaults to `Choose file`. */
22
+ label?: string;
23
+ /** Shown while nothing is chosen. Defaults to `No file chosen`. */
24
+ placeholder?: string;
25
+ /** Show the list of held files below the row. */
26
+ showList?: boolean;
27
+ /** The `name` the hidden input posts under, for a plain form submit. */
28
+ name?: string;
29
+ onaccept?: (files: File[]) => void;
30
+ onreject?: (rejected: RejectedFile[]) => void;
31
+ class?: string;
32
+ }
33
+
34
+ let {
35
+ files = $bindable([]),
36
+ accept,
37
+ multiple = false,
38
+ maxSize = 0,
39
+ maxFiles = 0,
40
+ disabled = false,
41
+ size = 'md',
42
+ tone = 'brand',
43
+ invalid = false,
44
+ label,
45
+ placeholder,
46
+ showList = true,
47
+ name,
48
+ onaccept,
49
+ onreject,
50
+ class: className = ''
51
+ }: Props = $props();
52
+
53
+ let input: HTMLInputElement | null = $state(null);
54
+ let rejected = $state<RejectedFile[]>([]);
55
+
56
+ const formatSize = (bytes: number) => toReadableSize(bytes);
57
+
58
+ /** One file reads as its name and weight; several read as a count. */
59
+ const summary = $derived.by(() => {
60
+ if (files.length === 0) return placeholder ?? 'No file chosen';
61
+ if (files.length === 1) return `${files[0].name} · ${formatSize(files[0].size)}`;
62
+ return `${files.length} $selected`;
63
+ });
64
+
65
+ function add(incoming: FileList | File[] | null) {
66
+ if (!incoming || disabled) return;
67
+
68
+ const { accepted, rejected: bad } = sortFiles(incoming, {
69
+ accept,
70
+ maxSize,
71
+ maxFiles,
72
+ multiple,
73
+ held: files.length
74
+ });
75
+
76
+ rejected = bad;
77
+ if (bad.length) onreject?.(bad);
78
+ if (accepted.length === 0) return;
79
+
80
+ files = multiple ? [...files, ...accepted] : accepted.slice(0, 1);
81
+ onaccept?.(accepted);
82
+ }
83
+
84
+ function clear() {
85
+ files = [];
86
+ rejected = [];
87
+ if (input) input.value = '';
88
+ }
89
+
90
+ const reasons: Record<RejectedFile['reason'], string> = $derived({
91
+ type: 'wrong file type',
92
+ size: 'too large',
93
+ count: 'over the file limit'
94
+ });
95
+
96
+ const sizes: Record<InputSize, string> = {
97
+ sm: 'h-8 text-[13px]',
98
+ md: 'h-10 text-sm',
99
+ lg: 'h-11 text-[15px]'
100
+ };
101
+
102
+ const buttons: Record<InputSize, string> = {
103
+ sm: 'h-6 px-2 text-[11px]',
104
+ md: 'h-7 px-2.5 text-xs',
105
+ lg: 'h-8 px-3 text-xs'
106
+ };
107
+ </script>
108
+
109
+ <div class="flex w-full flex-col gap-3 {className}">
110
+ <span
111
+ class="inline-flex w-full items-center gap-2 rounded-xl border bg-bg px-1 {edge} transition-colors duration-150 ease-brand-out
112
+ {sizes[size]}
113
+ {invalid
114
+ ? 'border-danger focus-within:border-danger'
115
+ : `border-hairline-strong ${toneFocusWithinBorder[tone]}`}
116
+ {disabled ? 'pointer-events-none opacity-50' : ''}"
117
+ >
118
+ <button
119
+ type="button"
120
+ {disabled}
121
+ onclick={() => input?.click()}
122
+ class="shrink-0 rounded-lg 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
123
+ {buttons[size]}"
124
+ >
125
+ {label ?? 'Choose file'}
126
+ </button>
127
+
128
+ <span
129
+ class="min-w-0 flex-1 truncate font-sans {files.length ? 'text-text' : 'text-text-muted'}"
130
+ >
131
+ {summary}
132
+ </span>
133
+
134
+ {#if files.length}
135
+ <button
136
+ type="button"
137
+ onclick={clear}
138
+ aria-label="Remove"
139
+ 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"
140
+ >
141
+ <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
142
+ <path
143
+ d="m2 2 10 10M12 2 2 12"
144
+ stroke="currentColor"
145
+ stroke-width="1.6"
146
+ stroke-linecap="round"
147
+ />
148
+ </svg>
149
+ </button>
150
+ {/if}
151
+ </span>
152
+
153
+ <input
154
+ bind:this={input}
155
+ type="file"
156
+ {name}
157
+ {accept}
158
+ {multiple}
159
+ {disabled}
160
+ aria-invalid={invalid ? 'true' : undefined}
161
+ class="sr-only"
162
+ onchange={(event) => add(event.currentTarget.files)}
163
+ />
164
+
165
+ {#if rejected.length}
166
+ <ul class="flex flex-col gap-1">
167
+ {#each rejected as item (item.file.name + item.reason)}
168
+ <li class="font-sans text-sm text-danger">
169
+ {item.file.name} — {reasons[item.reason]}
170
+ </li>
171
+ {/each}
172
+ </ul>
173
+ {/if}
174
+
175
+ {#if showList && multiple && files.length > 1}
176
+ <ul class="flex flex-col rounded-[20px] border border-hairline bg-bg {edge}">
177
+ {#each files as file, i (file.name + file.size + i)}
178
+ <li
179
+ class="flex items-center gap-3 border-b border-hairline px-4 py-2.5 font-sans last:border-b-0"
180
+ >
181
+ <span class="min-w-0 flex-1 truncate text-sm text-text">{file.name}</span>
182
+ <span class="shrink-0 text-xs text-text-muted tabular-nums">{formatSize(file.size)}</span>
183
+ <button
184
+ type="button"
185
+ onclick={() => (files = files.filter((_, at) => at !== i))}
186
+ aria-label="Remove {file.name}"
187
+ 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"
188
+ >
189
+ <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
190
+ <path
191
+ d="m2 2 10 10M12 2 2 12"
192
+ stroke="currentColor"
193
+ stroke-width="1.6"
194
+ stroke-linecap="round"
195
+ />
196
+ </svg>
197
+ </button>
198
+ </li>
199
+ {/each}
200
+ </ul>
201
+ {/if}
202
+ </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,37 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import { edge } from './lift.js';
5
+ import type { InputSize } from './Input.svelte';
6
+
7
+ interface Props extends HTMLAttributes<HTMLSpanElement> {
8
+ /** Match the Input it sits beside — the heights have to agree. */
9
+ size?: InputSize;
10
+ /** Paint it like a field instead of a fixed label. */
11
+ variant?: 'muted' | 'plain';
12
+ children: Snippet;
13
+ }
14
+
15
+ let {
16
+ size = 'md',
17
+ variant = 'muted',
18
+ class: className = '',
19
+ children,
20
+ ...rest
21
+ }: Props = $props();
22
+
23
+ const sizes: Record<InputSize, string> = {
24
+ sm: 'h-8 px-2.5 text-[13px]',
25
+ md: 'h-10 px-3 text-sm',
26
+ lg: 'h-11 px-3 text-[15px]'
27
+ };
28
+ </script>
29
+
30
+ <span
31
+ class="inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-hairline-strong font-sans whitespace-nowrap text-text-secondary {edge}
32
+ {sizes[size]}
33
+ {variant === 'muted' ? 'bg-bg-alt' : 'bg-bg'} {className}"
34
+ {...rest}
35
+ >
36
+ {@render children()}
37
+ </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
  import { float } from './lift.js';
8
7
 
@@ -41,7 +40,6 @@
41
40
  ...rest
42
41
  }: Props = $props();
43
42
 
44
- const t = useLocale();
45
43
  let dialog: HTMLDialogElement | null = $state(null);
46
44
 
47
45
  const closable = $derived(showClose ?? dismissible);
@@ -93,7 +91,7 @@
93
91
  <button
94
92
  type="button"
95
93
  onclick={close}
96
- aria-label={t.current.close}
94
+ aria-label="Close"
97
95
  class="-m-1 shrink-0 rounded-xl p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
98
96
  >
99
97
  <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
  import { edge, soft } from './lift.js';
@@ -59,8 +58,6 @@
59
58
  class: className = ''
60
59
  }: Props = $props();
61
60
 
62
- const t = useLocale();
63
-
64
61
  let open = $state(false);
65
62
  let query = $state('');
66
63
  let wrapper: HTMLElement | null = $state(null);
@@ -150,7 +147,7 @@
150
147
  class="inline-flex items-center rounded-xl px-2 py-0.5 text-xs font-medium {toneSoft[tone]}"
151
148
  >
152
149
  {chosen.length}
153
- {t.current.selected}
150
+ selected
154
151
  </span>
155
152
  {:else}
156
153
  {#each chosen as option (option.value)}
@@ -163,7 +160,7 @@
163
160
  <button
164
161
  type="button"
165
162
  onclick={() => remove(option)}
166
- aria-label="{t.current.removeItem} {option.label}"
163
+ aria-label="Remove {option.label}"
167
164
  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"
168
165
  >
169
166
  <svg class="size-2.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
@@ -190,7 +187,7 @@
190
187
  aria-expanded={open}
191
188
  aria-controls="{id ?? 'multiselect'}-listbox"
192
189
  aria-invalid={invalid ? 'true' : undefined}
193
- placeholder={chosen.length ? '' : (placeholder ?? t.current.comboboxPlaceholder)}
190
+ placeholder={chosen.length ? '' : (placeholder ?? 'Search…')}
194
191
  oninput={() => {
195
192
  open = true;
196
193
  cursor.index = 0;
@@ -212,7 +209,7 @@
212
209
  onchange?.([]);
213
210
  inputEl?.focus();
214
211
  }}
215
- aria-label={t.current.clear}
212
+ aria-label="Clear selection"
216
213
  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"
217
214
  >
218
215
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
@@ -240,7 +237,7 @@
240
237
  >
241
238
  {#if flat.length === 0}
242
239
  <p class="px-3 py-6 text-center text-sm text-text-muted">
243
- {emptyText ?? t.current.noMatches}
240
+ {emptyText ?? 'No matches'}
244
241
  </p>
245
242
  {:else}
246
243
  {#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
  // sprout gives the active link a filled pill rather than a colour change, so
59
56
  // the 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-xl 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>