@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,108 @@
1
+ <script lang="ts">
2
+ import type { HTMLInputAttributes } from 'svelte/elements';
3
+ import { formatGrouped, parseGrouped } from '../../core/number.js';
4
+ import type { Tone } from '../../core/tones.js';
5
+ import Input, { type InputSize } from './Input.svelte';
6
+
7
+ interface Props extends Omit<
8
+ HTMLInputAttributes,
9
+ 'size' | 'prefix' | 'suffix' | 'value' | 'min' | 'max'
10
+ > {
11
+ /** Bindable amount. `null` is an empty field, which is not the same as 0. */
12
+ value?: number | null;
13
+ min?: number;
14
+ max?: number;
15
+ /** Thousands separator. */
16
+ group?: string;
17
+ /** Decimal separator. */
18
+ decimal?: string;
19
+ /** Decimal places the amount settles to when the field loses focus. */
20
+ precision?: number;
21
+ /** Leading mark — `Rp`, `$`, `€`. */
22
+ currency?: string;
23
+ /** Trailing mark instead, for the regions that write it that way. */
24
+ unit?: string;
25
+ size?: InputSize;
26
+ tone?: Tone;
27
+ invalid?: boolean;
28
+ class?: string;
29
+ }
30
+
31
+ let {
32
+ value = $bindable(null),
33
+ min,
34
+ max,
35
+ group = ',',
36
+ decimal = '.',
37
+ precision = 2,
38
+ currency,
39
+ unit,
40
+ size = 'md',
41
+ tone = 'brand',
42
+ invalid = false,
43
+ disabled = false,
44
+ class: className = '',
45
+ ...rest
46
+ }: Props = $props();
47
+
48
+ const options = $derived({ group, decimal, precision });
49
+
50
+ /**
51
+ * What the field shows. Two states on purpose: grouped and padded while the
52
+ * caret is elsewhere, and exactly what was typed while it is not — grouping
53
+ * a number as it is typed moves the caret out from under the fingers.
54
+ */
55
+ let text = $state('');
56
+ let editing = $state(false);
57
+
58
+ $effect(() => {
59
+ if (editing) return;
60
+ text = value === null || value === undefined ? '' : formatGrouped(value, options);
61
+ });
62
+
63
+ // Read off the element, not off `text`: the binding that feeds `text` has
64
+ // not flushed yet when this fires, so it still holds the previous keystroke.
65
+ function onInput(event: Event & { currentTarget: HTMLInputElement }) {
66
+ const parsed = parseGrouped(event.currentTarget.value, options);
67
+ value = parsed === null ? null : clamp(parsed);
68
+ }
69
+
70
+ function clamp(amount: number) {
71
+ if (min !== undefined && amount < min) return min;
72
+ if (max !== undefined && amount > max) return max;
73
+ return amount;
74
+ }
75
+
76
+ function settle() {
77
+ editing = false;
78
+ const parsed = parseGrouped(text, options);
79
+ value = parsed === null ? null : clamp(parsed);
80
+ text = value === null ? '' : formatGrouped(value, options);
81
+ }
82
+ </script>
83
+
84
+ <Input
85
+ bind:value={text}
86
+ {size}
87
+ {tone}
88
+ {invalid}
89
+ {disabled}
90
+ inputmode="decimal"
91
+ oninput={onInput}
92
+ onfocus={() => (editing = true)}
93
+ onblur={settle}
94
+ class={className}
95
+ {...rest}
96
+ >
97
+ {#snippet prefix()}
98
+ {#if currency}
99
+ <span class="font-mono text-xs tracking-wide">{currency}</span>
100
+ {/if}
101
+ {/snippet}
102
+
103
+ {#snippet suffix()}
104
+ {#if unit}
105
+ <span class="font-mono text-xs tracking-wide">{unit}</span>
106
+ {/if}
107
+ {/snippet}
108
+ </Input>
@@ -0,0 +1,26 @@
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' | 'value' | 'min' | 'max'> {
5
+ /** Bindable amount. `null` is an empty field, which is not the same as 0. */
6
+ value?: number | null;
7
+ min?: number;
8
+ max?: number;
9
+ /** Thousands separator. */
10
+ group?: string;
11
+ /** Decimal separator. */
12
+ decimal?: string;
13
+ /** Decimal places the amount settles to when the field loses focus. */
14
+ precision?: number;
15
+ /** Leading mark — `Rp`, `$`, `€`. */
16
+ currency?: string;
17
+ /** Trailing mark instead, for the regions that write it that way. */
18
+ unit?: string;
19
+ size?: InputSize;
20
+ tone?: Tone;
21
+ invalid?: boolean;
22
+ class?: string;
23
+ }
24
+ declare const CurrencyInput: import("svelte").Component<Props, {}, "value">;
25
+ type CurrencyInput = ReturnType<typeof CurrencyInput>;
26
+ export default CurrencyInput;
@@ -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
 
@@ -15,6 +14,8 @@
15
14
  max?: string;
16
15
  isDisabled?: (date: Date) => boolean;
17
16
  weekStart?: 0 | 1;
17
+ /** Order of the date parts the field accepts and prints. */
18
+ format?: DateFormat;
18
19
  tone?: Tone;
19
20
  invalid?: boolean;
20
21
  disabled?: boolean;
@@ -33,6 +34,7 @@
33
34
  max,
34
35
  isDisabled,
35
36
  weekStart = 1,
37
+ format = 'dmy',
36
38
  tone = 'brand',
37
39
  invalid = false,
38
40
  disabled = false,
@@ -44,8 +46,6 @@
44
46
  class: className = ''
45
47
  }: Props = $props();
46
48
 
47
- const t = useLocale();
48
-
49
49
  let open = $state(false);
50
50
  let text = $state('');
51
51
  let editing = $state(false);
@@ -53,8 +53,8 @@
53
53
  let inputEl: HTMLInputElement | null = $state(null);
54
54
 
55
55
  /** While typing the field shows the raw text; otherwise the formatted value. */
56
- const display = $derived(editing ? text : formatISO(value, t.current.dateFormat));
57
- const hint = $derived(placeholder ?? dateHint(t.current.dateFormat));
56
+ const display = $derived(editing ? text : formatISO(value, format));
57
+ const hint = $derived(placeholder ?? dateHint(format));
58
58
 
59
59
  function commit() {
60
60
  editing = false;
@@ -65,7 +65,7 @@
65
65
  }
66
66
  return;
67
67
  }
68
- const parsed = parseFormatted(text, t.current.dateFormat);
68
+ const parsed = parseFormatted(text, format);
69
69
  if (parsed && parsed !== value) {
70
70
  value = parsed;
71
71
  onchange?.(parsed);
@@ -132,7 +132,7 @@
132
132
  onchange?.('');
133
133
  inputEl?.focus();
134
134
  }}
135
- aria-label={t.current.clear}
135
+ aria-label="Clear selection"
136
136
  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"
137
137
  >
138
138
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
@@ -145,7 +145,7 @@
145
145
  type="button"
146
146
  {disabled}
147
147
  onclick={() => (open = !open)}
148
- aria-label={t.current.selectDate}
148
+ aria-label="Select a date"
149
149
  aria-expanded={open}
150
150
  class="shrink-0 p-2 text-text-muted transition-colors duration-150 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
151
151
  >
@@ -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
 
8
7
  export type DrawerSide = 'left' | 'right' | 'top' | 'bottom';
@@ -39,7 +38,6 @@
39
38
  ...rest
40
39
  }: Props = $props();
41
40
 
42
- const t = useLocale();
43
41
  let dialog: HTMLDialogElement | null = $state(null);
44
42
 
45
43
  const horizontal = $derived(side === 'left' || side === 'right');
@@ -109,7 +107,7 @@
109
107
  <button
110
108
  type="button"
111
109
  onclick={close}
112
- aria-label={t.current.close}
110
+ aria-label="Close"
113
111
  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-brand"
114
112
  >
115
113
  <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
 
8
7
  interface Props {
9
8
  /** The control that opens the menu — put a `Button` in here. */
@@ -17,7 +16,7 @@
17
16
  matchWidth?: boolean;
18
17
  /** Close as soon as an item inside is activated. Default true. */
19
18
  closeOnSelect?: boolean;
20
- /** Accessible name for the menu. Defaults to the locale's `menu`. */
19
+ /** Accessible name for the menu. Defaults to `Menu`. */
21
20
  label?: string;
22
21
  class?: string;
23
22
  /** Classes for the wrapper around the trigger. */
@@ -37,8 +36,6 @@
37
36
  triggerClass = ''
38
37
  }: Props = $props();
39
38
 
40
- const t = useLocale();
41
-
42
39
  let anchorEl: HTMLElement | null = $state(null);
43
40
  let menuEl: HTMLElement | null = $state(null);
44
41
 
@@ -85,7 +82,7 @@
85
82
  onkeydown={onMenuKeydown}
86
83
  onclick={onMenuClick}
87
84
  role="menu"
88
- aria-label={label ?? t.current.menu}
85
+ aria-label={label ?? 'Menu'}
89
86
  tabindex="-1"
90
87
  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 {className}"
91
88
  >
@@ -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
 
7
6
  interface Props {
@@ -16,7 +15,7 @@
16
15
  maxFiles?: number;
17
16
  disabled?: boolean;
18
17
  tone?: Tone;
19
- /** Headline inside the zone. Defaults to the locale's `dropFiles`. */
18
+ /** Headline inside the zone. Defaults to `Drop files here`. */
20
19
  label?: string;
21
20
  hint?: string;
22
21
  /** Hide the built-in file list to render your own. */
@@ -45,8 +44,6 @@
45
44
  class: className = ''
46
45
  }: Props = $props();
47
46
 
48
- const t = useLocale();
49
-
50
47
  let input: HTMLInputElement | null = $state(null);
51
48
  let dragging = $state(false);
52
49
  let rejected = $state<RejectedFile[]>([]);
@@ -63,7 +60,7 @@
63
60
  return parts.join(' · ');
64
61
  });
65
62
 
66
- const formatSize = (bytes: number) => toReadableSize(bytes, t.current.byteUnits);
63
+ const formatSize = (bytes: number) => toReadableSize(bytes);
67
64
 
68
65
  function add(incoming: FileList | File[] | null) {
69
66
  if (!incoming || disabled) return;
@@ -96,9 +93,9 @@
96
93
  }
97
94
 
98
95
  const reasons: Record<RejectedFile['reason'], string> = $derived({
99
- type: t.current.rejectedType,
100
- size: t.current.rejectedSize,
101
- count: t.current.rejectedCount
96
+ type: 'wrong file type',
97
+ size: 'too large',
98
+ count: 'over the file limit'
102
99
  });
103
100
  </script>
104
101
 
@@ -135,10 +132,10 @@
135
132
  {/if}
136
133
  </span>
137
134
  <span class="flex flex-col gap-1">
138
- <span class="text-[15px] font-medium text-text">{label ?? t.current.dropFiles}</span>
135
+ <span class="text-[15px] font-medium text-text">{label ?? 'Drop files here'}</span>
139
136
  <span class="text-sm text-text-secondary">
140
- {t.current.or}
141
- <span class="underline underline-offset-4">{t.current.browse}</span>
137
+ or
138
+ <span class="underline underline-offset-4">browse</span>
142
139
  </span>
143
140
  {#if defaultHint}
144
141
  <span class="font-mono text-xs text-text-muted">{defaultHint}</span>
@@ -180,7 +177,7 @@
180
177
  <button
181
178
  type="button"
182
179
  onclick={() => remove(i)}
183
- aria-label="{t.current.remove} {file.name}"
180
+ aria-label="Remove {file.name}"
184
181
  class="-m-1 shrink-0 p-1 text-text-muted transition-colors duration-150 hover:text-danger focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger"
185
182
  >
186
183
  <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,191 @@
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-[15px]',
98
+ lg: 'h-11 text-base'
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 border bg-bg pr-1 pl-1 transition-colors duration-150 ease-brand-out
111
+ {sizes[size]}
112
+ {invalid
113
+ ? 'border-danger focus-within:border-danger'
114
+ : `border-hairline ${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 border border-hairline bg-bg-alt font-mono tracking-wide 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 p-1 text-text-muted transition-colors duration-150 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 d="m2 2 10 10M12 2 2 12" stroke="currentColor" stroke-width="1.5" />
142
+ </svg>
143
+ </button>
144
+ {/if}
145
+ </span>
146
+
147
+ <input
148
+ bind:this={input}
149
+ type="file"
150
+ {name}
151
+ {accept}
152
+ {multiple}
153
+ {disabled}
154
+ aria-invalid={invalid ? 'true' : undefined}
155
+ class="sr-only"
156
+ onchange={(event) => add(event.currentTarget.files)}
157
+ />
158
+
159
+ {#if rejected.length}
160
+ <ul class="flex flex-col gap-1">
161
+ {#each rejected as item (item.file.name + item.reason)}
162
+ <li class="font-sans text-sm text-danger">
163
+ {item.file.name} — {reasons[item.reason]}
164
+ </li>
165
+ {/each}
166
+ </ul>
167
+ {/if}
168
+
169
+ {#if showList && multiple && files.length > 1}
170
+ <ul class="flex flex-col border border-hairline">
171
+ {#each files as file, i (file.name + file.size + i)}
172
+ <li
173
+ class="flex items-center gap-3 border-b border-hairline px-3.5 py-2.5 font-sans last:border-b-0"
174
+ >
175
+ <span class="min-w-0 flex-1 truncate text-sm text-text">{file.name}</span>
176
+ <span class="shrink-0 font-mono text-xs text-text-muted">{formatSize(file.size)}</span>
177
+ <button
178
+ type="button"
179
+ onclick={() => (files = files.filter((_, at) => at !== i))}
180
+ aria-label="Remove {file.name}"
181
+ class="-m-1 shrink-0 p-1 text-text-muted transition-colors duration-150 hover:text-danger focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger"
182
+ >
183
+ <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
184
+ <path d="m2 2 10 10M12 2 2 12" stroke="currentColor" stroke-width="1.5" />
185
+ </svg>
186
+ </button>
187
+ </li>
188
+ {/each}
189
+ </ul>
190
+ {/if}
191
+ </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.5 text-[15px]',
25
+ lg: 'h-11 px-3.5 text-base'
26
+ };
27
+ </script>
28
+
29
+ <span
30
+ class="inline-flex shrink-0 items-center gap-1.5 border border-hairline font-mono whitespace-nowrap text-text-secondary
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;