@nqmcreative/ui 0.4.0 → 0.4.2

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 (203) hide show
  1. package/README.md +17 -19
  2. package/dist/core/calendar.d.ts +1 -1
  3. package/dist/core/calendar.js +1 -1
  4. package/dist/core/date.d.ts +3 -3
  5. package/dist/core/date.js +1 -1
  6. package/dist/core/files.d.ts +2 -3
  7. package/dist/core/files.js +2 -3
  8. package/dist/core/index.d.ts +2 -4
  9. package/dist/core/index.js +2 -4
  10. package/dist/core/password.d.ts +1 -1
  11. package/dist/core/password.js +1 -1
  12. package/dist/core/tones.d.ts +10 -0
  13. package/dist/core/tones.js +19 -0
  14. package/dist/core/trigger.d.ts +8 -0
  15. package/dist/core/trigger.js +11 -0
  16. package/dist/styles/matte/Alert.svelte +2 -4
  17. package/dist/styles/matte/Avatar.svelte +16 -3
  18. package/dist/styles/matte/Breadcrumb.svelte +1 -4
  19. package/dist/styles/matte/Button.svelte +6 -2
  20. package/dist/styles/matte/Calendar.svelte +6 -9
  21. package/dist/styles/matte/Checkbox.svelte +49 -30
  22. package/dist/styles/matte/CheckboxGroup.svelte +15 -4
  23. package/dist/styles/matte/ColorInput.svelte +1 -4
  24. package/dist/styles/matte/Combobox.svelte +14 -8
  25. package/dist/styles/matte/CommandPalette.svelte +3 -6
  26. package/dist/styles/matte/ConfirmDialog.svelte +11 -6
  27. package/dist/styles/matte/ContextMenu.svelte +1 -4
  28. package/dist/styles/matte/CurrencyInput.svelte +1 -1
  29. package/dist/styles/matte/CurrencyInput.svelte.d.ts +1 -1
  30. package/dist/styles/matte/DatePicker.svelte +18 -11
  31. package/dist/styles/matte/DatePicker.svelte.d.ts +3 -0
  32. package/dist/styles/matte/Divider.svelte +1 -1
  33. package/dist/styles/matte/Drawer.svelte +11 -5
  34. package/dist/styles/matte/Dropdown.svelte +2 -5
  35. package/dist/styles/matte/Dropdown.svelte.d.ts +1 -1
  36. package/dist/styles/matte/Dropzone.svelte +9 -12
  37. package/dist/styles/matte/Dropzone.svelte.d.ts +1 -1
  38. package/dist/styles/matte/Field.svelte +59 -3
  39. package/dist/styles/matte/FileInput.svelte +11 -14
  40. package/dist/styles/matte/FileInput.svelte.d.ts +2 -2
  41. package/dist/styles/matte/Footer.svelte +15 -4
  42. package/dist/styles/matte/Footer.svelte.d.ts +1 -1
  43. package/dist/styles/matte/Input.svelte +9 -2
  44. package/dist/styles/matte/Label.svelte +3 -1
  45. package/dist/styles/matte/Link.svelte +13 -2
  46. package/dist/styles/matte/MenuItem.svelte +11 -4
  47. package/dist/styles/matte/Modal.svelte +11 -5
  48. package/dist/styles/matte/MultiSelect.svelte +5 -8
  49. package/dist/styles/matte/Navbar.svelte +5 -11
  50. package/dist/styles/matte/Pagination.svelte +3 -6
  51. package/dist/styles/matte/PinInput.svelte +1 -4
  52. package/dist/styles/matte/Radio.svelte +41 -22
  53. package/dist/styles/matte/RadioGroup.svelte +15 -4
  54. package/dist/styles/matte/SearchInput.svelte +1 -4
  55. package/dist/styles/matte/Select.svelte +4 -2
  56. package/dist/styles/matte/Sidebar.svelte +3 -6
  57. package/dist/styles/matte/Skeleton.svelte +2 -1
  58. package/dist/styles/matte/Spinner.svelte +2 -5
  59. package/dist/styles/matte/Spinner.svelte.d.ts +1 -1
  60. package/dist/styles/matte/Stat.svelte +27 -3
  61. package/dist/styles/matte/Switch.svelte +6 -1
  62. package/dist/styles/matte/Table.svelte +5 -8
  63. package/dist/styles/matte/Tabs.svelte +1 -1
  64. package/dist/styles/matte/TagsInput.svelte +5 -8
  65. package/dist/styles/matte/Textarea.svelte +4 -2
  66. package/dist/styles/matte/ThemeToggle.svelte +5 -8
  67. package/dist/styles/matte/TimeInput.svelte +2 -5
  68. package/dist/styles/matte/Toaster.svelte +23 -8
  69. package/dist/styles/matte/Tooltip.svelte +8 -1
  70. package/dist/styles/matte/index.d.ts +2 -3
  71. package/dist/styles/matte/index.js +1 -3
  72. package/dist/styles/matte/theme.css +21 -0
  73. package/dist/styles/paper/Alert.svelte +3 -5
  74. package/dist/styles/paper/Avatar.svelte +14 -3
  75. package/dist/styles/paper/Breadcrumb.svelte +1 -4
  76. package/dist/styles/paper/Button.svelte +5 -1
  77. package/dist/styles/paper/Calendar.svelte +6 -9
  78. package/dist/styles/paper/Card.svelte +1 -1
  79. package/dist/styles/paper/Checkbox.svelte +55 -36
  80. package/dist/styles/paper/CheckboxGroup.svelte +15 -4
  81. package/dist/styles/paper/ColorInput.svelte +1 -4
  82. package/dist/styles/paper/Combobox.svelte +14 -8
  83. package/dist/styles/paper/CommandPalette.svelte +3 -6
  84. package/dist/styles/paper/ConfirmDialog.svelte +9 -6
  85. package/dist/styles/paper/ContextMenu.svelte +1 -4
  86. package/dist/styles/paper/CurrencyInput.svelte +1 -1
  87. package/dist/styles/paper/CurrencyInput.svelte.d.ts +1 -1
  88. package/dist/styles/paper/DatePicker.svelte +18 -11
  89. package/dist/styles/paper/DatePicker.svelte.d.ts +3 -0
  90. package/dist/styles/paper/Divider.svelte +1 -1
  91. package/dist/styles/paper/Drawer.svelte +9 -5
  92. package/dist/styles/paper/Dropdown.svelte +2 -5
  93. package/dist/styles/paper/Dropdown.svelte.d.ts +1 -1
  94. package/dist/styles/paper/Dropzone.svelte +9 -12
  95. package/dist/styles/paper/Dropzone.svelte.d.ts +1 -1
  96. package/dist/styles/paper/Field.svelte +59 -3
  97. package/dist/styles/paper/FileInput.svelte +11 -14
  98. package/dist/styles/paper/FileInput.svelte.d.ts +2 -2
  99. package/dist/styles/paper/Footer.svelte +15 -4
  100. package/dist/styles/paper/Footer.svelte.d.ts +1 -1
  101. package/dist/styles/paper/Input.svelte +9 -2
  102. package/dist/styles/paper/Label.svelte +3 -1
  103. package/dist/styles/paper/Link.svelte +13 -2
  104. package/dist/styles/paper/MenuItem.svelte +11 -4
  105. package/dist/styles/paper/Modal.svelte +9 -5
  106. package/dist/styles/paper/MultiSelect.svelte +5 -8
  107. package/dist/styles/paper/Navbar.svelte +5 -11
  108. package/dist/styles/paper/Pagination.svelte +3 -6
  109. package/dist/styles/paper/PinInput.svelte +1 -4
  110. package/dist/styles/paper/Radio.svelte +41 -22
  111. package/dist/styles/paper/RadioGroup.svelte +15 -4
  112. package/dist/styles/paper/SearchInput.svelte +1 -4
  113. package/dist/styles/paper/SegmentedControl.svelte +2 -2
  114. package/dist/styles/paper/Select.svelte +4 -2
  115. package/dist/styles/paper/Sidebar.svelte +3 -6
  116. package/dist/styles/paper/Skeleton.svelte +2 -1
  117. package/dist/styles/paper/Spinner.svelte +2 -5
  118. package/dist/styles/paper/Spinner.svelte.d.ts +1 -1
  119. package/dist/styles/paper/Stat.svelte +24 -2
  120. package/dist/styles/paper/Switch.svelte +6 -1
  121. package/dist/styles/paper/Table.svelte +5 -8
  122. package/dist/styles/paper/Tabs.svelte +1 -1
  123. package/dist/styles/paper/TagsInput.svelte +5 -8
  124. package/dist/styles/paper/Textarea.svelte +4 -2
  125. package/dist/styles/paper/ThemeToggle.svelte +5 -8
  126. package/dist/styles/paper/TimeInput.svelte +2 -5
  127. package/dist/styles/paper/Toaster.svelte +22 -9
  128. package/dist/styles/paper/Tooltip.svelte +8 -1
  129. package/dist/styles/paper/index.d.ts +2 -3
  130. package/dist/styles/paper/index.js +1 -3
  131. package/dist/styles/paper/theme.css +21 -0
  132. package/dist/styles/sprout/Alert.svelte +4 -6
  133. package/dist/styles/sprout/Avatar.svelte +14 -3
  134. package/dist/styles/sprout/Breadcrumb.svelte +2 -5
  135. package/dist/styles/sprout/Button.svelte +8 -4
  136. package/dist/styles/sprout/Calendar.svelte +7 -10
  137. package/dist/styles/sprout/Card.svelte +2 -2
  138. package/dist/styles/sprout/Checkbox.svelte +55 -36
  139. package/dist/styles/sprout/CheckboxGroup.svelte +15 -4
  140. package/dist/styles/sprout/ColorInput.svelte +2 -5
  141. package/dist/styles/sprout/Combobox.svelte +15 -9
  142. package/dist/styles/sprout/CommandPalette.svelte +3 -6
  143. package/dist/styles/sprout/ConfirmDialog.svelte +9 -6
  144. package/dist/styles/sprout/ContextMenu.svelte +1 -4
  145. package/dist/styles/sprout/CurrencyInput.svelte +1 -1
  146. package/dist/styles/sprout/CurrencyInput.svelte.d.ts +1 -1
  147. package/dist/styles/sprout/DatePicker.svelte +20 -13
  148. package/dist/styles/sprout/DatePicker.svelte.d.ts +3 -0
  149. package/dist/styles/sprout/Divider.svelte +1 -1
  150. package/dist/styles/sprout/Drawer.svelte +9 -5
  151. package/dist/styles/sprout/Dropdown.svelte +2 -5
  152. package/dist/styles/sprout/Dropdown.svelte.d.ts +1 -1
  153. package/dist/styles/sprout/Dropzone.svelte +10 -13
  154. package/dist/styles/sprout/Dropzone.svelte.d.ts +1 -1
  155. package/dist/styles/sprout/Field.svelte +59 -3
  156. package/dist/styles/sprout/FileInput.svelte +14 -17
  157. package/dist/styles/sprout/FileInput.svelte.d.ts +2 -2
  158. package/dist/styles/sprout/Footer.svelte +15 -4
  159. package/dist/styles/sprout/Footer.svelte.d.ts +1 -1
  160. package/dist/styles/sprout/Input.svelte +9 -2
  161. package/dist/styles/sprout/Label.svelte +3 -1
  162. package/dist/styles/sprout/Link.svelte +13 -2
  163. package/dist/styles/sprout/LogoCloud.svelte +1 -1
  164. package/dist/styles/sprout/MenuItem.svelte +11 -4
  165. package/dist/styles/sprout/Modal.svelte +9 -5
  166. package/dist/styles/sprout/MultiSelect.svelte +8 -11
  167. package/dist/styles/sprout/Navbar.svelte +5 -11
  168. package/dist/styles/sprout/Pagination.svelte +3 -6
  169. package/dist/styles/sprout/PinInput.svelte +1 -4
  170. package/dist/styles/sprout/Radio.svelte +41 -22
  171. package/dist/styles/sprout/RadioGroup.svelte +15 -4
  172. package/dist/styles/sprout/SearchInput.svelte +2 -5
  173. package/dist/styles/sprout/SegmentedControl.svelte +2 -2
  174. package/dist/styles/sprout/Select.svelte +4 -2
  175. package/dist/styles/sprout/Sidebar.svelte +5 -7
  176. package/dist/styles/sprout/Skeleton.svelte +2 -1
  177. package/dist/styles/sprout/Spinner.svelte +2 -5
  178. package/dist/styles/sprout/Spinner.svelte.d.ts +1 -1
  179. package/dist/styles/sprout/Stat.svelte +24 -2
  180. package/dist/styles/sprout/Switch.svelte +6 -1
  181. package/dist/styles/sprout/Table.svelte +6 -9
  182. package/dist/styles/sprout/Tabs.svelte +1 -1
  183. package/dist/styles/sprout/TagsInput.svelte +6 -9
  184. package/dist/styles/sprout/Textarea.svelte +4 -2
  185. package/dist/styles/sprout/ThemeToggle.svelte +5 -8
  186. package/dist/styles/sprout/TimeInput.svelte +3 -6
  187. package/dist/styles/sprout/Toaster.svelte +25 -12
  188. package/dist/styles/sprout/Tooltip.svelte +8 -1
  189. package/dist/styles/sprout/index.d.ts +3 -4
  190. package/dist/styles/sprout/index.js +2 -4
  191. package/dist/styles/sprout/lift.d.ts +14 -0
  192. package/dist/styles/sprout/lift.js +23 -0
  193. package/dist/styles/sprout/theme.css +50 -24
  194. package/package.json +2 -19
  195. package/registry.json +7 -46
  196. package/dist/core/locale.svelte.d.ts +0 -101
  197. package/dist/core/locale.svelte.js +0 -167
  198. package/dist/styles/matte/LocaleProvider.svelte +0 -24
  199. package/dist/styles/matte/LocaleProvider.svelte.d.ts +0 -10
  200. package/dist/styles/paper/LocaleProvider.svelte +0 -24
  201. package/dist/styles/paper/LocaleProvider.svelte.d.ts +0 -10
  202. package/dist/styles/sprout/LocaleProvider.svelte +0 -24
  203. package/dist/styles/sprout/LocaleProvider.svelte.d.ts +0 -10
@@ -2,9 +2,13 @@
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';
7
- import { toneFocusWithinBorder, type Tone } from '../../core/tones.js';
5
+ import { formatISO, parseFormatted, type DateFormat } from '../../core/date.js';
6
+ import {
7
+ focusWithinRing,
8
+ toneFocusWithinBorder,
9
+ toneFocusWithinRing,
10
+ type Tone
11
+ } from '../../core/tones.js';
8
12
  import Calendar from './Calendar.svelte';
9
13
  import { edge, soft } from './lift.js';
10
14
 
@@ -16,6 +20,8 @@
16
20
  max?: string;
17
21
  isDisabled?: (date: Date) => boolean;
18
22
  weekStart?: 0 | 1;
23
+ /** Order of the date parts the field accepts and prints. */
24
+ format?: DateFormat;
19
25
  tone?: Tone;
20
26
  invalid?: boolean;
21
27
  disabled?: boolean;
@@ -34,6 +40,7 @@
34
40
  max,
35
41
  isDisabled,
36
42
  weekStart = 1,
43
+ format = 'dmy',
37
44
  tone = 'brand',
38
45
  invalid = false,
39
46
  disabled = false,
@@ -45,8 +52,6 @@
45
52
  class: className = ''
46
53
  }: Props = $props();
47
54
 
48
- const t = useLocale();
49
-
50
55
  let open = $state(false);
51
56
  let text = $state('');
52
57
  let editing = $state(false);
@@ -54,8 +59,8 @@
54
59
  let inputEl: HTMLInputElement | null = $state(null);
55
60
 
56
61
  /** 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));
62
+ const display = $derived(editing ? text : formatISO(value, format));
63
+ const hint = $derived(placeholder ?? dateHint(format));
59
64
 
60
65
  function commit() {
61
66
  editing = false;
@@ -66,7 +71,7 @@
66
71
  }
67
72
  return;
68
73
  }
69
- const parsed = parseFormatted(text, t.current.dateFormat);
74
+ const parsed = parseFormatted(text, format);
70
75
  if (parsed && parsed !== value) {
71
76
  value = parsed;
72
77
  onchange?.(parsed);
@@ -95,7 +100,9 @@
95
100
 
96
101
  <div
97
102
  bind:this={wrapper}
98
- class="inline-flex w-full items-center gap-1 rounded-xl border bg-bg pr-1 pl-3 {edge} transition-colors duration-150 ease-brand-out
103
+ class="inline-flex w-full items-center gap-1 rounded-xl border bg-bg pr-1 pl-3 {edge} transition-colors duration-150 ease-brand-out {focusWithinRing} {toneFocusWithinRing[
104
+ tone
105
+ ]}
99
106
  {invalid ? 'border-danger' : `border-hairline-strong ${toneFocusWithinBorder[tone]}`}
100
107
  {disabled ? 'pointer-events-none opacity-50' : ''} {className}"
101
108
  >
@@ -133,8 +140,8 @@
133
140
  onchange?.('');
134
141
  inputEl?.focus();
135
142
  }}
136
- aria-label={t.current.clear}
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"
143
+ aria-label="Clear selection"
144
+ class="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-current"
138
145
  >
139
146
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
140
147
  <path
@@ -151,9 +158,9 @@
151
158
  type="button"
152
159
  {disabled}
153
160
  onclick={() => (open = !open)}
154
- aria-label={t.current.selectDate}
161
+ aria-label="Select a date"
155
162
  aria-expanded={open}
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"
163
+ class="shrink-0 rounded-xl 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
164
  >
158
165
  <svg class="size-4" viewBox="0 0 20 20" fill="none" aria-hidden="true">
159
166
  <rect x="3" y="4.5" width="14" height="12" rx="2" stroke="currentColor" stroke-width="1.4" />
@@ -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;
@@ -20,7 +20,7 @@
20
20
  {...rest}
21
21
  ></div>
22
22
  {:else if label}
23
- <div class="flex items-center gap-3 {className}" {...rest}>
23
+ <div role="separator" class="flex items-center gap-3 {className}" {...rest}>
24
24
  <span class="h-px flex-1 bg-hairline"></span>
25
25
  <span class="font-sans text-xs font-medium text-text-muted">{label}</span>
26
26
  <span class="h-px flex-1 bg-hairline"></span>
@@ -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,8 @@
40
39
  ...rest
41
40
  }: Props = $props();
42
41
 
43
- const t = useLocale();
42
+ const uid = $props.id();
43
+
44
44
  let dialog: HTMLDialogElement | null = $state(null);
45
45
 
46
46
  const horizontal = $derived(side === 'left' || side === 'right');
@@ -83,6 +83,8 @@
83
83
 
84
84
  <dialog
85
85
  bind:this={dialog}
86
+ aria-labelledby={title ? `${uid}-title` : undefined}
87
+ aria-describedby={description ? `${uid}-description` : undefined}
86
88
  onclose={close}
87
89
  onclick={(event) => {
88
90
  if (dismissible && isBackdropClick(event, dialog)) close();
@@ -100,17 +102,19 @@
100
102
  {#if icon}<span class={iconLg}>{@render icon()}</span>{/if}
101
103
  <div class="flex min-w-0 flex-1 flex-col gap-1">
102
104
  {#if title}
103
- <h2 class="font-heading text-base font-semibold text-text">{title}</h2>
105
+ <h2 id="{uid}-title" class="font-heading text-base font-semibold text-text">{title}</h2>
104
106
  {/if}
105
107
  {#if description}
106
- <p class="font-sans text-[13px] text-text-muted">{description}</p>
108
+ <p id="{uid}-description" class="font-sans text-[13px] text-text-muted">
109
+ {description}
110
+ </p>
107
111
  {/if}
108
112
  </div>
109
113
  {#if dismissible}
110
114
  <button
111
115
  type="button"
112
116
  onclick={close}
113
- aria-label={t.current.close}
117
+ aria-label="Close"
114
118
  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
119
  >
116
120
  <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,8 +189,8 @@
192
189
  <button
193
190
  type="button"
194
191
  onclick={() => remove(i)}
195
- aria-label="{t.current.remove} {file.name}"
196
- 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"
192
+ aria-label="Remove {file.name}"
193
+ class="-m-1 shrink-0 rounded-xl 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">
199
196
  <path
@@ -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. */
@@ -24,9 +24,65 @@
24
24
  children,
25
25
  ...rest
26
26
  }: Props = $props();
27
+
28
+ const uid = $props.id();
29
+ const messageId = $derived(error ? `${uid}-error` : hint ? `${uid}-hint` : undefined);
30
+
31
+ interface Described {
32
+ for?: string;
33
+ messageId?: string;
34
+ required: boolean;
35
+ invalid: boolean;
36
+ }
37
+
38
+ /**
39
+ * A hint nobody can hear is not a hint. `aria-describedby` has to sit on the
40
+ * control, and the control arrives through `children` — out of reach of this
41
+ * template — so the field finds it once the DOM exists and keeps the three
42
+ * attributes in step itself. It touches only what it set: an `aria-invalid`
43
+ * the control already carries from its own `invalid` prop stays that
44
+ * control's business.
45
+ */
46
+ function describes(node: HTMLElement, state: Described) {
47
+ let ourId: string | undefined;
48
+ let ownsInvalid = false;
49
+
50
+ const apply = (next: Described) => {
51
+ const control =
52
+ (next.for ? node.querySelector<HTMLElement>(`#${CSS.escape(next.for)}`) : null) ??
53
+ node.querySelector<HTMLElement>('input, select, textarea');
54
+ if (!control) return;
55
+
56
+ const ids = (control.getAttribute('aria-describedby') ?? '')
57
+ .split(/\s+/)
58
+ .filter((id) => id && id !== ourId);
59
+ if (next.messageId) ids.push(next.messageId);
60
+ if (ids.length) control.setAttribute('aria-describedby', ids.join(' '));
61
+ else control.removeAttribute('aria-describedby');
62
+ ourId = next.messageId;
63
+
64
+ if (next.required) control.setAttribute('aria-required', 'true');
65
+ else control.removeAttribute('aria-required');
66
+
67
+ if (next.invalid) {
68
+ ownsInvalid ||= !control.hasAttribute('aria-invalid');
69
+ control.setAttribute('aria-invalid', 'true');
70
+ } else if (ownsInvalid) {
71
+ control.removeAttribute('aria-invalid');
72
+ ownsInvalid = false;
73
+ }
74
+ };
75
+
76
+ apply(state);
77
+ return { update: apply };
78
+ }
27
79
  </script>
28
80
 
29
- <div class="flex w-full flex-col gap-1.5 {className}" {...rest}>
81
+ <div
82
+ use:describes={{ for: htmlFor, messageId, required, invalid: Boolean(error) }}
83
+ class="flex w-full flex-col gap-1.5 {className}"
84
+ {...rest}
85
+ >
30
86
  {#if label}
31
87
  <label for={htmlFor} class="font-sans text-sm font-medium text-text">
32
88
  {label}
@@ -35,8 +91,8 @@
35
91
  {/if}
36
92
  {@render children()}
37
93
  {#if error}
38
- <p class="font-sans text-[13px] text-danger">{error}</p>
94
+ <p id="{uid}-error" role="alert" class="font-sans text-[13px] text-danger">{error}</p>
39
95
  {:else if hint}
40
- <p class="font-sans text-[13px] text-text-muted">{hint}</p>
96
+ <p id="{uid}-hint" class="font-sans text-[13px] text-text-muted">{hint}</p>
41
97
  {/if}
42
98
  </div>
@@ -1,6 +1,5 @@
1
1
  <script lang="ts">
2
2
  import { formatSize as toReadableSize, sortFiles, type RejectedFile } from '../../core/files.js';
3
- import { useLocale } from '../../core/locale.svelte.js';
4
3
  import { toneFocusWithinBorder, type Tone } from '../../core/tones.js';
5
4
  import { edge } from './lift.js';
6
5
  import type { InputSize } from './Input.svelte';
@@ -19,9 +18,9 @@
19
18
  size?: InputSize;
20
19
  tone?: Tone;
21
20
  invalid?: boolean;
22
- /** Text on the button. Defaults to the locale's `chooseFile`. */
21
+ /** Text on the button. Defaults to `Choose file`. */
23
22
  label?: string;
24
- /** Shown while nothing is chosen. Defaults to the locale's `noFile`. */
23
+ /** Shown while nothing is chosen. Defaults to `No file chosen`. */
25
24
  placeholder?: string;
26
25
  /** Show the list of held files below the row. */
27
26
  showList?: boolean;
@@ -51,18 +50,16 @@
51
50
  class: className = ''
52
51
  }: Props = $props();
53
52
 
54
- const t = useLocale();
55
-
56
53
  let input: HTMLInputElement | null = $state(null);
57
54
  let rejected = $state<RejectedFile[]>([]);
58
55
 
59
- const formatSize = (bytes: number) => toReadableSize(bytes, t.current.byteUnits);
56
+ const formatSize = (bytes: number) => toReadableSize(bytes);
60
57
 
61
58
  /** One file reads as its name and weight; several read as a count. */
62
59
  const summary = $derived.by(() => {
63
- if (files.length === 0) return placeholder ?? t.current.noFile;
60
+ if (files.length === 0) return placeholder ?? 'No file chosen';
64
61
  if (files.length === 1) return `${files[0].name} · ${formatSize(files[0].size)}`;
65
- return `${files.length} ${t.current.selected}`;
62
+ return `${files.length} $selected`;
66
63
  });
67
64
 
68
65
  function add(incoming: FileList | File[] | null) {
@@ -91,9 +88,9 @@
91
88
  }
92
89
 
93
90
  const reasons: Record<RejectedFile['reason'], string> = $derived({
94
- type: t.current.rejectedType,
95
- size: t.current.rejectedSize,
96
- count: t.current.rejectedCount
91
+ type: 'wrong file type',
92
+ size: 'too large',
93
+ count: 'over the file limit'
97
94
  });
98
95
 
99
96
  const sizes: Record<InputSize, string> = {
@@ -122,10 +119,10 @@
122
119
  type="button"
123
120
  {disabled}
124
121
  onclick={() => input?.click()}
125
- 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
122
+ class="shrink-0 rounded-xl 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
126
123
  {buttons[size]}"
127
124
  >
128
- {label ?? t.current.chooseFile}
125
+ {label ?? 'Choose file'}
129
126
  </button>
130
127
 
131
128
  <span
@@ -138,8 +135,8 @@
138
135
  <button
139
136
  type="button"
140
137
  onclick={clear}
141
- aria-label={t.current.remove}
142
- 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"
138
+ aria-label="Remove"
139
+ class="mr-1 shrink-0 rounded-xl 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"
143
140
  >
144
141
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
145
142
  <path
@@ -186,8 +183,8 @@
186
183
  <button
187
184
  type="button"
188
185
  onclick={() => (files = files.filter((_, at) => at !== i))}
189
- aria-label="{t.current.remove} {file.name}"
190
- 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"
186
+ aria-label="Remove {file.name}"
187
+ class="-m-1 shrink-0 rounded-xl 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"
191
188
  >
192
189
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
193
190
  <path
@@ -15,9 +15,9 @@ interface Props {
15
15
  size?: InputSize;
16
16
  tone?: Tone;
17
17
  invalid?: boolean;
18
- /** Text on the button. Defaults to the locale's `chooseFile`. */
18
+ /** Text on the button. Defaults to `Choose file`. */
19
19
  label?: string;
20
- /** Shown while nothing is chosen. Defaults to the locale's `noFile`. */
20
+ /** Shown while nothing is chosen. Defaults to `No file chosen`. */
21
21
  placeholder?: string;
22
22
  /** Show the list of held files below the row. */
23
23
  showList?: boolean;
@@ -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;
@@ -59,15 +59,26 @@
59
59
  <a
60
60
  href={item.href}
61
61
  target={item.external ? '_blank' : undefined}
62
- rel={item.external ? 'noreferrer' : undefined}
62
+ rel={item.external ? 'noopener noreferrer' : undefined}
63
63
  class="inline-flex items-center gap-2 font-sans text-[13px] text-text-muted transition-colors duration-150 ease-brand-out {toneHoverText[
64
64
  tone
65
65
  ]}"
66
66
  >
67
67
  {#if item.icon}<span class={iconSm}>{@render item.icon()}</span
68
68
  >{/if}{item.label}
69
- {#if item.external}<span aria-hidden="true" class="text-[0.85em]">↗</span
70
- >{/if}
69
+ {#if item.external}<svg
70
+ class="inline-block size-[0.85em] align-[-0.05em]"
71
+ viewBox="0 0 14 14"
72
+ fill="none"
73
+ aria-hidden="true"
74
+ ><path
75
+ d="M5.5 2.5h6v6M11.5 2.5 3.5 10.5"
76
+ stroke="currentColor"
77
+ stroke-width="1.5"
78
+ stroke-linecap="round"
79
+ stroke-linejoin="round"
80
+ /></svg
81
+ ><span class="sr-only">(opens in a new tab)</span>{/if}
71
82
  </a>
72
83
  </li>
73
84
  {/each}
@@ -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;
@@ -1,7 +1,12 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLInputAttributes } from 'svelte/elements';
4
- import { toneFocusWithinBorder, type Tone } from '../../core/tones.js';
4
+ import {
5
+ focusWithinRing,
6
+ toneFocusWithinBorder,
7
+ toneFocusWithinRing,
8
+ type Tone
9
+ } from '../../core/tones.js';
5
10
  import { edge } from './lift.js';
6
11
 
7
12
  export type InputSize = 'sm' | 'md' | 'lg';
@@ -38,7 +43,9 @@
38
43
  </script>
39
44
 
40
45
  <span
41
- class="inline-flex w-full items-center gap-2 rounded-xl border bg-bg px-3 {edge} transition-colors duration-150 ease-brand-out
46
+ class="inline-flex w-full items-center gap-2 rounded-xl border bg-bg px-3 {edge} transition-colors duration-150 ease-brand-out {focusWithinRing} {toneFocusWithinRing[
47
+ tone
48
+ ]}
42
49
  {sizes[size]}
43
50
  {invalid
44
51
  ? 'border-danger focus-within:border-danger'
@@ -12,5 +12,7 @@
12
12
 
13
13
  <label class="font-sans text-sm font-medium text-text {className}" {...rest}>
14
14
  {@render children()}
15
- {#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
15
+ {#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span><span class="sr-only"
16
+ >(required)</span
17
+ >{/if}
16
18
  </label>
@@ -40,7 +40,7 @@
40
40
 
41
41
  <a
42
42
  target={external ? '_blank' : undefined}
43
- rel={external ? 'noreferrer' : undefined}
43
+ rel={external ? 'noopener noreferrer' : undefined}
44
44
  class="inline-flex items-center gap-1 rounded-md font-sans transition-colors duration-150 ease-brand-out
45
45
  {muted ? `text-text-secondary ${toneHoverText[tone]}` : toneText[tone]}
46
46
  {underlines[underline]} {focusRing} {toneRing[tone]} {className}"
@@ -49,5 +49,16 @@
49
49
  {#if icon}<span class={iconSm}>{@render icon()}</span>{/if}
50
50
  {@render children()}
51
51
  {#if iconEnd}<span class={iconSm}>{@render iconEnd()}</span>{/if}
52
- {#if external}<span aria-hidden="true" class="text-[0.85em]">↗</span>{/if}
52
+ {#if external}
53
+ <svg class="size-[0.85em] shrink-0" viewBox="0 0 14 14" fill="none" aria-hidden="true">
54
+ <path
55
+ d="M5.5 2.5h6v6M11.5 2.5 3.5 10.5"
56
+ stroke="currentColor"
57
+ stroke-width="1.5"
58
+ stroke-linecap="round"
59
+ stroke-linejoin="round"
60
+ />
61
+ </svg>
62
+ <span class="sr-only">(opens in a new tab)</span>
63
+ {/if}
53
64
  </a>
@@ -42,7 +42,7 @@
42
42
  <svelte:element
43
43
  this={logo.href ? 'a' : 'div'}
44
44
  href={logo.href}
45
- class="flex items-center rounded transition-all duration-200 ease-brand-out {muted
45
+ class="flex items-center rounded-md transition-all duration-200 ease-brand-out {muted
46
46
  ? 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0'
47
47
  : ''}"
48
48
  >
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLButtonAttributes } from 'svelte/elements';
4
- import { toneSoftHover, toneText, type Tone } from '../../core/tones.js';
4
+ import { toneSoft, toneSoftHover, toneText, type Tone } from '../../core/tones.js';
5
5
  import { iconMd } from './icon.js';
6
6
 
7
7
  interface Props extends HTMLButtonAttributes {
@@ -28,6 +28,11 @@
28
28
  children,
29
29
  ...rest
30
30
  }: Props = $props();
31
+
32
+ // A chosen row speaks in the brand, the same as an active nav link — a tick
33
+ // alone leaves it the same grey as every row above it. An item that already
34
+ // carries a tone (a red `Delete`) keeps its own.
35
+ const chosen = $derived(toneSoft[tone === 'neutral' ? 'brand' : tone]);
31
36
  </script>
32
37
 
33
38
  <svelte:element
@@ -39,9 +44,11 @@
39
44
  aria-disabled={disabled ? 'true' : undefined}
40
45
  tabindex={disabled ? undefined : -1}
41
46
  class="mx-1 flex items-center gap-2.5 rounded-xl px-2.5 py-1.5 text-left text-sm transition-colors duration-100 ease-brand-out focus:outline-none disabled:pointer-events-none disabled:opacity-40
42
- {tone === 'neutral'
43
- ? 'text-text-secondary hover:bg-bg-inset focus-visible:bg-bg-inset'
44
- : `${toneText[tone]} ${toneSoftHover[tone]}`}
47
+ {selected
48
+ ? `${chosen} font-medium`
49
+ : tone === 'neutral'
50
+ ? 'text-text-secondary hover:bg-bg-inset focus-visible:bg-bg-inset'
51
+ : `${toneText[tone]} ${toneSoftHover[tone]}`}
45
52
  {className}"
46
53
  {...rest}
47
54
  >