@pienter/ui 0.2.0

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 (227) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/CONVENTIONS.md +1499 -0
  3. package/LICENSE +21 -0
  4. package/README.md +71 -0
  5. package/components/action/button/Button.astro +95 -0
  6. package/components/action/button/Button.vue +92 -0
  7. package/components/action/button/IconButton.astro +86 -0
  8. package/components/action/button/IconButton.vue +85 -0
  9. package/components/action/button/button.css +194 -0
  10. package/components/action/toggle/Toggle.vue +38 -0
  11. package/components/action/toggle/toggle.css +77 -0
  12. package/components/action/toggle-group/ToggleGroup.vue +67 -0
  13. package/components/action/toggle-group/toggle-group.css +78 -0
  14. package/components/display/avatar/Avatar.astro +17 -0
  15. package/components/display/avatar/Avatar.vue +30 -0
  16. package/components/display/avatar/AvatarStack.astro +9 -0
  17. package/components/display/avatar/AvatarStack.vue +11 -0
  18. package/components/display/avatar/avatar.css +58 -0
  19. package/components/display/badge/Badge.astro +15 -0
  20. package/components/display/badge/Badge.vue +23 -0
  21. package/components/display/badge/badge.css +56 -0
  22. package/components/display/empty/Empty.astro +9 -0
  23. package/components/display/empty/Empty.vue +11 -0
  24. package/components/display/empty/empty.css +39 -0
  25. package/components/display/icon/Icon.astro +52 -0
  26. package/components/display/icon/Icon.vue +57 -0
  27. package/components/display/icon/icon.css +47 -0
  28. package/components/feedback/alert/Alert.astro +52 -0
  29. package/components/feedback/alert/Alert.vue +60 -0
  30. package/components/feedback/alert/alert.css +78 -0
  31. package/components/feedback/progress/Progress.astro +68 -0
  32. package/components/feedback/progress/Progress.vue +82 -0
  33. package/components/feedback/progress/progress.css +68 -0
  34. package/components/feedback/skeleton/Skeleton.astro +32 -0
  35. package/components/feedback/skeleton/Skeleton.vue +39 -0
  36. package/components/feedback/skeleton/skeleton.css +56 -0
  37. package/components/feedback/spinner/Spinner.astro +25 -0
  38. package/components/feedback/spinner/Spinner.vue +36 -0
  39. package/components/feedback/spinner/spinner.css +91 -0
  40. package/components/feedback/toast/Toast.astro +50 -0
  41. package/components/feedback/toast/Toast.vue +74 -0
  42. package/components/feedback/toast/toast.css +128 -0
  43. package/components/form/checkbox/Checkbox.astro +79 -0
  44. package/components/form/checkbox/Checkbox.vue +95 -0
  45. package/components/form/checkbox/checkbox.css +59 -0
  46. package/components/form/combobox/Combobox.vue +508 -0
  47. package/components/form/combobox/combobox.css +110 -0
  48. package/components/form/date-input/DateInput.astro +105 -0
  49. package/components/form/date-input/DateInput.vue +121 -0
  50. package/components/form/date-input/date-input.css +19 -0
  51. package/components/form/form/Form.astro +106 -0
  52. package/components/form/form/Form.vue +181 -0
  53. package/components/form/form/form.css +46 -0
  54. package/components/form/input-otp/InputOTP.astro +147 -0
  55. package/components/form/input-otp/InputOTP.vue +209 -0
  56. package/components/form/input-otp/input-otp.css +52 -0
  57. package/components/form/label/Label.astro +13 -0
  58. package/components/form/label/Label.vue +20 -0
  59. package/components/form/label/label.css +11 -0
  60. package/components/form/number-field/NumberField.astro +142 -0
  61. package/components/form/number-field/NumberField.vue +155 -0
  62. package/components/form/number-field/number-field.css +115 -0
  63. package/components/form/radio-group/RadioGroup.astro +105 -0
  64. package/components/form/radio-group/RadioGroup.vue +110 -0
  65. package/components/form/radio-group/radio-group.css +114 -0
  66. package/components/form/radio-group/types.ts +14 -0
  67. package/components/form/select/Segmented.vue +36 -0
  68. package/components/form/select/Select.astro +105 -0
  69. package/components/form/select/Select.vue +109 -0
  70. package/components/form/select/select.css +96 -0
  71. package/components/form/slider/Slider.astro +205 -0
  72. package/components/form/slider/Slider.vue +321 -0
  73. package/components/form/slider/slider.css +115 -0
  74. package/components/form/switch/Switch.astro +75 -0
  75. package/components/form/switch/Switch.vue +89 -0
  76. package/components/form/switch/switch.css +64 -0
  77. package/components/form/tags-input/TagsInput.astro +153 -0
  78. package/components/form/tags-input/TagsInput.vue +207 -0
  79. package/components/form/tags-input/tags-input.css +128 -0
  80. package/components/form/text-input/TextInput.astro +84 -0
  81. package/components/form/text-input/TextInput.vue +99 -0
  82. package/components/form/text-input/text-input.css +165 -0
  83. package/components/form/textarea/Textarea.astro +86 -0
  84. package/components/form/textarea/Textarea.vue +102 -0
  85. package/components/form/textarea/textarea.css +25 -0
  86. package/components/layout/accordion/Accordion.vue +59 -0
  87. package/components/layout/accordion/accordion.css +87 -0
  88. package/components/layout/card/Card.astro +13 -0
  89. package/components/layout/card/Card.vue +20 -0
  90. package/components/layout/card/card.css +55 -0
  91. package/components/layout/collapsible/Collapsible.vue +77 -0
  92. package/components/layout/collapsible/collapsible.css +76 -0
  93. package/components/layout/separator/Separator.astro +31 -0
  94. package/components/layout/separator/Separator.vue +33 -0
  95. package/components/layout/separator/separator.css +27 -0
  96. package/components/layout/table/DataTable.vue +127 -0
  97. package/components/layout/table/Table.astro +116 -0
  98. package/components/layout/table/Table.vue +146 -0
  99. package/components/layout/table/TableRow.vue +59 -0
  100. package/components/layout/table/table.css +201 -0
  101. package/components/layout/table/types.ts +35 -0
  102. package/components/layout/table/useTable.ts +7 -0
  103. package/components/navigation/breadcrumb/Breadcrumb.astro +36 -0
  104. package/components/navigation/breadcrumb/Breadcrumb.vue +36 -0
  105. package/components/navigation/breadcrumb/breadcrumb.css +37 -0
  106. package/components/navigation/navbar/Navbar.astro +62 -0
  107. package/components/navigation/navbar/Navbar.vue +50 -0
  108. package/components/navigation/navbar/navbar.css +77 -0
  109. package/components/navigation/pagination/Pagination.vue +107 -0
  110. package/components/navigation/pagination/pagination.css +53 -0
  111. package/components/navigation/sidebar/Sidebar.astro +132 -0
  112. package/components/navigation/sidebar/Sidebar.vue +174 -0
  113. package/components/navigation/sidebar/SidebarItemRender.astro +83 -0
  114. package/components/navigation/sidebar/SidebarItemRender.vue +98 -0
  115. package/components/navigation/sidebar/sidebar.css +303 -0
  116. package/components/navigation/sidebar/types.ts +72 -0
  117. package/components/navigation/tabs/Tabs.vue +84 -0
  118. package/components/navigation/tabs/tabs.css +39 -0
  119. package/components/overlay/alert-dialog/AlertDialog.astro +112 -0
  120. package/components/overlay/alert-dialog/AlertDialog.vue +117 -0
  121. package/components/overlay/alert-dialog/alert-dialog.css +57 -0
  122. package/components/overlay/command/Command.vue +356 -0
  123. package/components/overlay/command/command.css +179 -0
  124. package/components/overlay/dropdown-menu/DropdownMenu.vue +143 -0
  125. package/components/overlay/dropdown-menu/dropdown-menu.css +120 -0
  126. package/components/overlay/modal/Modal.astro +66 -0
  127. package/components/overlay/modal/Modal.vue +85 -0
  128. package/components/overlay/modal/modal.css +60 -0
  129. package/components/overlay/popover/Popover.vue +113 -0
  130. package/components/overlay/popover/popover.css +53 -0
  131. package/components/overlay/sheet/Sheet.vue +88 -0
  132. package/components/overlay/sheet/sheet.css +108 -0
  133. package/components/overlay/tooltip/Tooltip.vue +210 -0
  134. package/components/overlay/tooltip/tooltip.css +50 -0
  135. package/composables/useUrlSort.ts +48 -0
  136. package/icons/alert-triangle.ts +1 -0
  137. package/icons/arrow-down.ts +1 -0
  138. package/icons/arrow-up-down.ts +5 -0
  139. package/icons/arrow-up.ts +1 -0
  140. package/icons/bell.ts +1 -0
  141. package/icons/check.ts +1 -0
  142. package/icons/chevron-down.ts +1 -0
  143. package/icons/chevron-left.ts +1 -0
  144. package/icons/chevron-right.ts +1 -0
  145. package/icons/chevron-up-down.ts +5 -0
  146. package/icons/chevron-up.ts +1 -0
  147. package/icons/circle-alert.ts +1 -0
  148. package/icons/circle-check.ts +1 -0
  149. package/icons/clipboard.ts +1 -0
  150. package/icons/download.ts +1 -0
  151. package/icons/edit.ts +1 -0
  152. package/icons/external-link.ts +1 -0
  153. package/icons/eye.ts +1 -0
  154. package/icons/file.ts +1 -0
  155. package/icons/filter.ts +1 -0
  156. package/icons/folder.ts +1 -0
  157. package/icons/image.ts +1 -0
  158. package/icons/inbox.ts +1 -0
  159. package/icons/index.ts +91 -0
  160. package/icons/info.ts +1 -0
  161. package/icons/layers.ts +1 -0
  162. package/icons/link-2.ts +1 -0
  163. package/icons/list.ts +1 -0
  164. package/icons/loader.ts +3 -0
  165. package/icons/menu.ts +1 -0
  166. package/icons/more-horizontal.ts +1 -0
  167. package/icons/more-vertical.ts +1 -0
  168. package/icons/plus-circle.ts +1 -0
  169. package/icons/plus.ts +1 -0
  170. package/icons/save.ts +1 -0
  171. package/icons/search.ts +1 -0
  172. package/icons/send.ts +1 -0
  173. package/icons/settings.ts +1 -0
  174. package/icons/tool.ts +1 -0
  175. package/icons/trash-2.ts +1 -0
  176. package/icons/trash.ts +1 -0
  177. package/icons/upload-cloud.ts +1 -0
  178. package/icons/upload.ts +1 -0
  179. package/icons/x.ts +1 -0
  180. package/package.json +150 -0
  181. package/styles/0-settings/colors.css +241 -0
  182. package/styles/0-settings/index.css +5 -0
  183. package/styles/0-settings/layout.css +52 -0
  184. package/styles/0-settings/motion.css +11 -0
  185. package/styles/0-settings/spacing.css +15 -0
  186. package/styles/0-settings/typography.css +37 -0
  187. package/styles/0-utils/index.css +1 -0
  188. package/styles/1-reset/index.css +1 -0
  189. package/styles/1-reset/reset.css +26 -0
  190. package/styles/2-base/base.css +42 -0
  191. package/styles/2-base/forms.css +23 -0
  192. package/styles/2-base/index.css +2 -0
  193. package/styles/3-layout/container.css +57 -0
  194. package/styles/3-layout/index.css +2 -0
  195. package/styles/3-layout/section.css +17 -0
  196. package/styles/5-utilities/accessibility.css +13 -0
  197. package/styles/5-utilities/index.css +2 -0
  198. package/styles/5-utilities/text.css +5 -0
  199. package/styles/main.css +8 -0
  200. package/styles/styles.d.ts +6 -0
  201. package/utils/a11y/focus.ts +68 -0
  202. package/utils/a11y/id.ts +10 -0
  203. package/utils/a11y/index.ts +9 -0
  204. package/utils/a11y/keyboard.ts +32 -0
  205. package/utils/a11y/live-region.ts +36 -0
  206. package/utils/controllers/dialog.ts +205 -0
  207. package/utils/controllers/disclosure.ts +117 -0
  208. package/utils/controllers/form.ts +524 -0
  209. package/utils/controllers/index.ts +39 -0
  210. package/utils/controllers/menu.ts +255 -0
  211. package/utils/controllers/number-field.ts +103 -0
  212. package/utils/controllers/otp.ts +252 -0
  213. package/utils/controllers/popover.ts +434 -0
  214. package/utils/controllers/sidebar.ts +610 -0
  215. package/utils/controllers/slider.ts +336 -0
  216. package/utils/controllers/tags-input.ts +255 -0
  217. package/utils/controllers/toast.ts +426 -0
  218. package/utils/dom/index.ts +1 -0
  219. package/utils/dom/scroll-lock.ts +48 -0
  220. package/utils/index.ts +3 -0
  221. package/utils/sort/index.ts +3 -0
  222. package/utils/sort/serialize.ts +19 -0
  223. package/utils/sort/state.ts +11 -0
  224. package/utils/sort/types.ts +15 -0
  225. package/utils/validation/form.ts +93 -0
  226. package/utils/validation/index.ts +13 -0
  227. package/utils/validation/rules.ts +31 -0
@@ -0,0 +1,426 @@
1
+ import { announce } from '../a11y/live-region.js';
2
+ import { icons, type IconName } from '../../icons/index.js';
3
+
4
+ /**
5
+ * Toast tones (parity with Toast.vue / Toast.astro). Drives the
6
+ * `data-tone` attribute on the rendered toast and the default icon
7
+ * selection inside `renderToastEl`.
8
+ */
9
+ export type ToastTone = 'brand' | 'success' | 'warning' | 'danger';
10
+
11
+ export type ToastPosition =
12
+ | 'bottom-right'
13
+ | 'bottom-center'
14
+ | 'bottom-left'
15
+ | 'top-right'
16
+ | 'top-center'
17
+ | 'top-left';
18
+
19
+ /**
20
+ * Options accepted by `toast.show(opts)` and `toast.queue` items.
21
+ *
22
+ * `duration` is in milliseconds; `0` disables auto-dismiss (the
23
+ * toast stays until dismissed manually or via `toast.dismiss(id)`).
24
+ * `action` adds a single action button alongside the close button;
25
+ * clicking it invokes `onClick` and then dismisses the toast.
26
+ */
27
+ export interface ToastOptions {
28
+ title: string;
29
+ message?: string;
30
+ tone?: ToastTone;
31
+ /** Milliseconds. `0` disables auto-dismiss. Default `defaultDuration`. */
32
+ duration?: number;
33
+ action?: { label: string; onClick: () => void };
34
+ /** Optional icon name override; defaults to the tone-mapped icon. */
35
+ icon?: IconName;
36
+ /** Optional ID for manual dismiss; auto-generated if omitted. */
37
+ id?: string;
38
+ }
39
+
40
+ export interface ToastConfig {
41
+ /** Maximum visible toasts; queued toasts wait until a slot opens. Default `3`. */
42
+ maxVisible?: number;
43
+ /** Default auto-dismiss duration in ms. Default `5000`. */
44
+ defaultDuration?: number;
45
+ /** Container position. v1 ships `bottom-right` only; others reserved. */
46
+ position?: ToastPosition;
47
+ }
48
+
49
+ interface ActiveToast {
50
+ id: string;
51
+ el: HTMLElement;
52
+ duration: number;
53
+ timerId: number | null;
54
+ remaining: number;
55
+ startedAt: number;
56
+ }
57
+
58
+ const toneIconMap: Record<ToastTone, IconName> = {
59
+ brand: 'info',
60
+ success: 'circle-check',
61
+ warning: 'alert-triangle',
62
+ danger: 'circle-alert',
63
+ };
64
+
65
+ const config: Required<ToastConfig> = {
66
+ maxVisible: 3,
67
+ defaultDuration: 5000,
68
+ position: 'bottom-right',
69
+ };
70
+
71
+ const queued: ToastOptions[] = [];
72
+ const active: ActiveToast[] = [];
73
+ let regionEl: HTMLElement | null = null;
74
+ let nextIdCounter = 0;
75
+
76
+ function generateToastId(): string {
77
+ nextIdCounter += 1;
78
+ return `v-toast-${Date.now().toString(36)}-${nextIdCounter}`;
79
+ }
80
+
81
+ function escapeHtml(value: string): string {
82
+ return value
83
+ .replace(/&/g, '&amp;')
84
+ .replace(/</g, '&lt;')
85
+ .replace(/>/g, '&gt;')
86
+ .replace(/"/g, '&quot;')
87
+ .replace(/'/g, '&#39;');
88
+ }
89
+
90
+ /**
91
+ * Lazily create the global region container on first `show()`. The
92
+ * region is `role="region" aria-label="Notifications"` per the
93
+ * locked CONVENTIONS rule — distinct from the global ARIA live
94
+ * region (which is a separate visually-hidden node managed by
95
+ * `utils/a11y/live-region.ts:announce()`). The two responsibilities
96
+ * are intentionally separate: the region is the visual container,
97
+ * the live region is the SR announcement channel.
98
+ */
99
+ function ensureRegion(): HTMLElement {
100
+ if (regionEl && document.body.contains(regionEl)) return regionEl;
101
+ const el = document.createElement('div');
102
+ el.id = 'v-toast-region';
103
+ el.className = 'v-toast-region';
104
+ el.setAttribute('role', 'region');
105
+ el.setAttribute('aria-label', 'Notifications');
106
+ el.setAttribute('data-position', config.position);
107
+ document.body.appendChild(el);
108
+ regionEl = el;
109
+ return el;
110
+ }
111
+
112
+ function buildToastInnerHTML(opts: ToastOptions): string {
113
+ const iconName: IconName =
114
+ opts.icon ?? (opts.tone ? toneIconMap[opts.tone] : 'info');
115
+ const iconSvg = icons[iconName];
116
+ const xSvg = icons['x'];
117
+ return [
118
+ `<span class="v-toast__icon" aria-hidden="true">${iconSvg}</span>`,
119
+ '<div class="v-toast__body">',
120
+ `<p class="v-toast__title">${escapeHtml(opts.title)}</p>`,
121
+ opts.message
122
+ ? `<p class="v-toast__msg">${escapeHtml(opts.message)}</p>`
123
+ : '',
124
+ '</div>',
125
+ opts.action
126
+ ? `<button class="v-toast__action" type="button">${escapeHtml(opts.action.label)}</button>`
127
+ : '',
128
+ `<button class="v-toast__close" type="button" aria-label="Close">${xSvg}</button>`,
129
+ ].join('');
130
+ }
131
+
132
+ /**
133
+ * Render the toast wrapper element matching Toast.vue / Toast.astro
134
+ * markup so the same `toast.css` styles apply. Wires close + action
135
+ * + hover/focus pause/resume listeners. The element is returned
136
+ * un-mounted; callers append into the region.
137
+ */
138
+ function renderToastEl(opts: ToastOptions, id: string): HTMLElement {
139
+ const el = document.createElement('div');
140
+ el.className = 'v-toast';
141
+ if (opts.tone) el.setAttribute('data-tone', opts.tone);
142
+ el.setAttribute('data-toast-id', id);
143
+ // No `role` here — `announce()` already covers SR announcement, and
144
+ // double-announcing (visual `role="status"` + live region) is the
145
+ // common Toast a11y bug. See components/feedback/toast/AUDIT.md.
146
+ el.innerHTML = buildToastInnerHTML(opts);
147
+
148
+ el.querySelector<HTMLButtonElement>('.v-toast__close')?.addEventListener(
149
+ 'click',
150
+ () => {
151
+ dismissByEl(el);
152
+ },
153
+ );
154
+ if (opts.action) {
155
+ el.querySelector<HTMLButtonElement>(
156
+ '.v-toast__action',
157
+ )?.addEventListener('click', () => {
158
+ opts.action!.onClick();
159
+ dismissByEl(el);
160
+ });
161
+ }
162
+ el.addEventListener('mouseenter', () => pauseEl(el));
163
+ el.addEventListener('mouseleave', () => resumeEl(el));
164
+ el.addEventListener('focusin', () => pauseEl(el));
165
+ el.addEventListener('focusout', () => resumeEl(el));
166
+ el.addEventListener('keydown', (event) => {
167
+ if (event.key === 'Escape') {
168
+ event.preventDefault();
169
+ dismissByEl(el);
170
+ }
171
+ });
172
+
173
+ return el;
174
+ }
175
+
176
+ function findActiveByEl(el: HTMLElement): ActiveToast | undefined {
177
+ return active.find((a) => a.el === el);
178
+ }
179
+
180
+ function findActiveById(id: string): ActiveToast | undefined {
181
+ return active.find((a) => a.id === id);
182
+ }
183
+
184
+ function startTimer(entry: ActiveToast): void {
185
+ if (entry.duration <= 0) return; // persistent
186
+ if (entry.timerId !== null) return; // already running
187
+ entry.startedAt = Date.now();
188
+ entry.timerId = window.setTimeout(() => {
189
+ entry.timerId = null;
190
+ dismissEntry(entry);
191
+ }, entry.remaining);
192
+ }
193
+
194
+ function pauseTimer(entry: ActiveToast): void {
195
+ if (entry.timerId === null) return;
196
+ window.clearTimeout(entry.timerId);
197
+ entry.timerId = null;
198
+ const elapsed = Date.now() - entry.startedAt;
199
+ entry.remaining = Math.max(0, entry.remaining - elapsed);
200
+ }
201
+
202
+ function pauseEl(el: HTMLElement): void {
203
+ const entry = findActiveByEl(el);
204
+ if (entry) pauseTimer(entry);
205
+ }
206
+
207
+ function resumeEl(el: HTMLElement): void {
208
+ const entry = findActiveByEl(el);
209
+ if (entry) startTimer(entry);
210
+ }
211
+
212
+ function dismissEntry(entry: ActiveToast): void {
213
+ if (entry.timerId !== null) {
214
+ window.clearTimeout(entry.timerId);
215
+ entry.timerId = null;
216
+ }
217
+ if (entry.el.parentNode) {
218
+ entry.el.parentNode.removeChild(entry.el);
219
+ }
220
+ const idx = active.indexOf(entry);
221
+ if (idx !== -1) active.splice(idx, 1);
222
+ drainQueue();
223
+ }
224
+
225
+ function dismissByEl(el: HTMLElement): void {
226
+ const entry = findActiveByEl(el);
227
+ if (entry) dismissEntry(entry);
228
+ }
229
+
230
+ function drainQueue(): void {
231
+ while (active.length < config.maxVisible && queued.length > 0) {
232
+ const next = queued.shift()!;
233
+ showNow(next);
234
+ }
235
+ }
236
+
237
+ function showNow(opts: ToastOptions): string {
238
+ const region = ensureRegion();
239
+ const id = opts.id ?? generateToastId();
240
+ const duration = opts.duration ?? config.defaultDuration;
241
+ const el = renderToastEl(opts, id);
242
+ region.appendChild(el);
243
+
244
+ const entry: ActiveToast = {
245
+ id,
246
+ el,
247
+ duration,
248
+ timerId: null,
249
+ remaining: duration,
250
+ startedAt: Date.now(),
251
+ };
252
+ active.push(entry);
253
+
254
+ // Announce via the global SR live region. Danger tone is assertive;
255
+ // everything else polite. The announcement uses title (+ message).
256
+ const announceText = opts.message
257
+ ? `${opts.title}. ${opts.message}`
258
+ : opts.title;
259
+ announce(announceText, opts.tone === 'danger' ? 'assertive' : 'polite');
260
+
261
+ startTimer(entry);
262
+ return id;
263
+ }
264
+
265
+ /**
266
+ * Module-level singleton API per the singleton-controller exception
267
+ * documented in CONVENTIONS § Controller mount-on-element pattern.
268
+ *
269
+ * Toast manages a single global queue + region; consumers call
270
+ * `toast.show({ tone, title, message })` rather than rendering the
271
+ * `<Toast>` component themselves. Returns the assigned id so callers
272
+ * can dismiss programmatically via `toast.dismiss(id)`.
273
+ *
274
+ * `toast.configure({ maxVisible, defaultDuration, position })` mutates
275
+ * the module-level config; call once on app init if the defaults
276
+ * (`maxVisible: 3`, `defaultDuration: 5000`, `position: 'bottom-right'`)
277
+ * need overriding. v1 only ships `bottom-right`; other positions are
278
+ * accepted by the type but not yet styled.
279
+ */
280
+ export const toast = {
281
+ show(opts: ToastOptions): string {
282
+ if (active.length >= config.maxVisible) {
283
+ const queuedOpts = { ...opts, id: opts.id ?? generateToastId() };
284
+ queued.push(queuedOpts);
285
+ return queuedOpts.id;
286
+ }
287
+ return showNow(opts);
288
+ },
289
+
290
+ dismiss(id: string): void {
291
+ const entry = findActiveById(id);
292
+ if (entry) {
293
+ dismissEntry(entry);
294
+ return;
295
+ }
296
+ const queueIdx = queued.findIndex((q) => q.id === id);
297
+ if (queueIdx !== -1) queued.splice(queueIdx, 1);
298
+ },
299
+
300
+ dismissAll(): void {
301
+ queued.length = 0;
302
+ // Snapshot active because dismissEntry mutates the array.
303
+ const snapshot = [...active];
304
+ for (const entry of snapshot) dismissEntry(entry);
305
+ },
306
+
307
+ configure(c: ToastConfig): void {
308
+ if (typeof c.maxVisible === 'number' && c.maxVisible > 0) {
309
+ config.maxVisible = Math.floor(c.maxVisible);
310
+ }
311
+ if (typeof c.defaultDuration === 'number' && c.defaultDuration >= 0) {
312
+ config.defaultDuration = c.defaultDuration;
313
+ }
314
+ if (c.position) {
315
+ config.position = c.position;
316
+ if (regionEl) regionEl.setAttribute('data-position', c.position);
317
+ }
318
+ drainQueue();
319
+ },
320
+ };
321
+
322
+ /**
323
+ * Per-instance escape hatch for consumers who render a `<Toast>`
324
+ * declaratively (Astro `<Toast>` or `<Toast>` Vue component) and
325
+ * want the controller to manage close-button wiring, action wiring,
326
+ * hover/focus pause-resume, and auto-dismiss timing. Returns a
327
+ * teardown function per the canonical mount signature.
328
+ *
329
+ * Most consumers should use the module-level `toast.show()` API
330
+ * instead. This is a fallback for cases where the toast DOM is
331
+ * authored declaratively and the controller layers behavior on top.
332
+ *
333
+ * Note: `mountToast(el)` does NOT add `el` to the global `active`
334
+ * queue, because the consumer owns the DOM placement (it isn't in
335
+ * the controller's region container). The lifecycle is local to
336
+ * `el` only — close button, action button, hover/focus pause-resume,
337
+ * auto-dismiss timer (if duration provided), Escape-to-dismiss on
338
+ * the rendered element. Calling `dismiss()` removes `el` from its
339
+ * parent.
340
+ */
341
+ export function mountToast(
342
+ el: HTMLElement,
343
+ opts: { duration?: number; onDismiss?: () => void } = {},
344
+ ): () => void {
345
+ const duration = opts.duration ?? 0; // default to no auto-dismiss for declarative usage
346
+ let timerId: number | null = null;
347
+ let remaining = duration;
348
+ let startedAt = Date.now();
349
+ let dismissed = false;
350
+
351
+ function dismiss(): void {
352
+ if (dismissed) return;
353
+ dismissed = true;
354
+ if (timerId !== null) {
355
+ window.clearTimeout(timerId);
356
+ timerId = null;
357
+ }
358
+ el.removeEventListener('mouseenter', pause);
359
+ el.removeEventListener('mouseleave', resume);
360
+ el.removeEventListener('focusin', pause);
361
+ el.removeEventListener('focusout', resume);
362
+ closeBtn?.removeEventListener('click', onClose);
363
+ actionBtn?.removeEventListener('click', onAction);
364
+ el.removeEventListener('keydown', onKeydown);
365
+ if (el.parentNode) el.parentNode.removeChild(el);
366
+ opts.onDismiss?.();
367
+ }
368
+
369
+ function start(): void {
370
+ if (duration <= 0 || timerId !== null) return;
371
+ startedAt = Date.now();
372
+ timerId = window.setTimeout(() => {
373
+ timerId = null;
374
+ dismiss();
375
+ }, remaining);
376
+ }
377
+ function pause(): void {
378
+ if (timerId === null) return;
379
+ window.clearTimeout(timerId);
380
+ timerId = null;
381
+ remaining = Math.max(0, remaining - (Date.now() - startedAt));
382
+ }
383
+ function resume(): void {
384
+ start();
385
+ }
386
+ function onClose(): void {
387
+ dismiss();
388
+ }
389
+ function onAction(): void {
390
+ dismiss();
391
+ }
392
+ function onKeydown(event: KeyboardEvent): void {
393
+ if (event.key === 'Escape') {
394
+ event.preventDefault();
395
+ dismiss();
396
+ }
397
+ }
398
+
399
+ const closeBtn = el.querySelector<HTMLButtonElement>('.v-toast__close');
400
+ const actionBtn = el.querySelector<HTMLButtonElement>('.v-toast__action');
401
+ closeBtn?.addEventListener('click', onClose);
402
+ actionBtn?.addEventListener('click', onAction);
403
+ el.addEventListener('mouseenter', pause);
404
+ el.addEventListener('mouseleave', resume);
405
+ el.addEventListener('focusin', pause);
406
+ el.addEventListener('focusout', resume);
407
+ el.addEventListener('keydown', onKeydown);
408
+
409
+ start();
410
+
411
+ return () => {
412
+ if (dismissed) return;
413
+ if (timerId !== null) {
414
+ window.clearTimeout(timerId);
415
+ timerId = null;
416
+ }
417
+ el.removeEventListener('mouseenter', pause);
418
+ el.removeEventListener('mouseleave', resume);
419
+ el.removeEventListener('focusin', pause);
420
+ el.removeEventListener('focusout', resume);
421
+ closeBtn?.removeEventListener('click', onClose);
422
+ actionBtn?.removeEventListener('click', onAction);
423
+ el.removeEventListener('keydown', onKeydown);
424
+ dismissed = true;
425
+ };
426
+ }
@@ -0,0 +1 @@
1
+ export { lockScroll } from './scroll-lock.js';
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Reference-counted body scroll lock with scrollbar-width compensation.
3
+ *
4
+ * The lock is applied on the first call and released only when the matching
5
+ * unlock fn is the last outstanding caller. This handles nested overlays
6
+ * (e.g., a Modal opening an AlertDialog) without one releasing the other's
7
+ * lock prematurely.
8
+ *
9
+ * Scrollbar compensation: when locking, we add the scrollbar's width as
10
+ * extra `padding-right` on `<body>` so layout doesn't shift when the
11
+ * scrollbar disappears.
12
+ */
13
+
14
+ let lockCount = 0;
15
+ let originalOverflow: string | null = null;
16
+ let originalPaddingRight: string | null = null;
17
+
18
+ export function lockScroll(): () => void {
19
+ if (lockCount === 0) {
20
+ const scrollbarWidth =
21
+ window.innerWidth - document.documentElement.clientWidth;
22
+ originalOverflow = document.body.style.overflow;
23
+ originalPaddingRight = document.body.style.paddingRight;
24
+ document.body.style.overflow = 'hidden';
25
+ if (scrollbarWidth > 0) {
26
+ const computedPaddingRight = parseFloat(
27
+ getComputedStyle(document.body).paddingRight,
28
+ );
29
+ document.body.style.paddingRight = `${
30
+ computedPaddingRight + scrollbarWidth
31
+ }px`;
32
+ }
33
+ }
34
+ lockCount += 1;
35
+
36
+ let released = false;
37
+ return function unlock(): void {
38
+ if (released) return;
39
+ released = true;
40
+ lockCount = Math.max(0, lockCount - 1);
41
+ if (lockCount === 0) {
42
+ document.body.style.overflow = originalOverflow ?? '';
43
+ document.body.style.paddingRight = originalPaddingRight ?? '';
44
+ originalOverflow = null;
45
+ originalPaddingRight = null;
46
+ }
47
+ };
48
+ }
package/utils/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './a11y/index.js';
2
+ export * from './sort/index.js';
3
+ export * from './validation/index.js';
@@ -0,0 +1,3 @@
1
+ export { toggleSort } from './state.js';
2
+ export { parseSort, formatSort } from './serialize.js';
3
+ export type { Sort, SortDirection, SortState } from './types.js';
@@ -0,0 +1,19 @@
1
+ // Wire form of a sort state: the column key, `-`-prefixed for descending, absent when unsorted.
2
+ // A column key must not itself start with `-`, since that prefix is how direction is encoded.
3
+ import type { SortState } from './types.js';
4
+
5
+ /** Read the wire form. Anything unparseable is treated as unsorted. */
6
+ export function parseSort(raw: string | null | undefined): SortState {
7
+ if (!raw) return null;
8
+ const descending = raw.startsWith('-');
9
+ const key = descending ? raw.slice(1) : raw;
10
+ // a bare '-' carries no column, so it is unsorted rather than a key of ''
11
+ if (!key) return null;
12
+ return { key, direction: descending ? 'desc' : 'asc' };
13
+ }
14
+
15
+ /** Write the wire form. `null` when unsorted — the cue to drop the parameter entirely. */
16
+ export function formatSort(state: SortState): string | null {
17
+ if (!state) return null;
18
+ return (state.direction === 'desc' ? '-' : '') + state.key;
19
+ }
@@ -0,0 +1,11 @@
1
+ import type { SortState } from './types.js';
2
+
3
+ /**
4
+ * The sort state activating `key` should produce: ascending → descending → unsorted,
5
+ * restarting ascending on a different column. Pure; swap it out for another cycle.
6
+ */
7
+ export function toggleSort(state: SortState, key: string): SortState {
8
+ if (state?.key !== key) return { key, direction: 'asc' };
9
+ if (state.direction === 'asc') return { key, direction: 'desc' };
10
+ return null;
11
+ }
@@ -0,0 +1,15 @@
1
+ /** Direction of the sorted column. */
2
+ export type SortDirection = 'asc' | 'desc';
3
+
4
+ /** One column, sorted one way. */
5
+ export interface Sort {
6
+ /** The sorted column — matches a `Column.key` on a table. */
7
+ key: string;
8
+ direction: SortDirection;
9
+ }
10
+
11
+ /**
12
+ * Sort state of a list. `null` is unsorted — a real state meaning "original order",
13
+ * reached on a column's third activation. One column at a time (ADR 0005).
14
+ */
15
+ export type SortState = Sort | null;
@@ -0,0 +1,93 @@
1
+ import { type Validator, validate } from './rules.js';
2
+
3
+ /**
4
+ * Placeholder shape for form-submit responses, consumed identically by both
5
+ * `Form.vue` (reactive orchestration) and `controllers/form.ts:mountForm`
6
+ * (Astro imperative orchestration). The symmetry requirement is locked in
7
+ * design D13 / spec "Unified Form Submit Response Handling"; the exact
8
+ * shape is **subject to change when ts-sdk publishes the canonical
9
+ * response shape**, at which point both implementations are updated
10
+ * together.
11
+ */
12
+ export interface FormSubmitResponse {
13
+ /** Whether the submit succeeded. */
14
+ ok: boolean;
15
+ /** Per-field errors keyed by field `name`. Single string or array of strings. */
16
+ fieldErrors?: Record<string, string | string[]>;
17
+ /** Top-level errors not tied to a specific field (e.g., "Something went wrong"). */
18
+ errors?: string[];
19
+ /** Success/status message to announce via aria-live. */
20
+ statusMessage?: string;
21
+ /**
22
+ * Optional post-submit redirect URL — orchestration does NOT auto-navigate
23
+ * (consumer's call); the value is just stored on the response for the
24
+ * consumer to act on.
25
+ */
26
+ redirect?: string;
27
+ }
28
+
29
+ /**
30
+ * Run a per-field validator map against a `FormData` instance and return
31
+ * a `Record<string, string[]>` of field errors. Pure function; framework
32
+ * agnostic. Used by both Vue and Astro Form orchestration.
33
+ *
34
+ * Field values are read via `formData.get(name)` and coerced to strings.
35
+ * Fields without an entry in `validators` are not checked. Fields whose
36
+ * validators all return `null` are omitted from the result entirely
37
+ * (rather than mapped to an empty array) so consumers can tell errors
38
+ * apart from "validated, passed".
39
+ *
40
+ * Note: validators receive `formData.get(name)` (single value). For
41
+ * multi-value fields (`<select multiple>`, repeated `<input name="x">`
42
+ * checkboxes, future TagsInput), the controller will need a multi-value
43
+ * path — out of scope for v1; revisit when the first multi-value
44
+ * primitive ships.
45
+ */
46
+ export function validateFormData(
47
+ formData: FormData,
48
+ validators?: Record<string, Validator[]>,
49
+ ): Record<string, string[]> {
50
+ const result: Record<string, string[]> = {};
51
+ if (!validators) return result;
52
+
53
+ for (const [name, fieldValidators] of Object.entries(validators)) {
54
+ const raw = formData.get(name);
55
+ let value: string;
56
+ if (typeof raw === 'string') {
57
+ value = raw;
58
+ } else if (raw == null) {
59
+ value = '';
60
+ } else {
61
+ // v1 validators are string-only; a `File` entry (multi-value support
62
+ // is out of scope, see the function doc) has no meaningful string
63
+ // form here — coerce and let the string validators run.
64
+ value = String(raw);
65
+ }
66
+ const fieldErrors = validate(value, fieldValidators);
67
+ if (fieldErrors.length > 0) {
68
+ result[name] = fieldErrors;
69
+ }
70
+ }
71
+
72
+ return result;
73
+ }
74
+
75
+ /**
76
+ * Normalize the per-field error map carried on a `FormSubmitResponse`
77
+ * (where each entry can be a string or array of strings) into the
78
+ * `Record<string, string[]>` shape used internally by orchestration.
79
+ */
80
+ export function normalizeFieldErrors(
81
+ fieldErrors: Record<string, string | string[]> | undefined,
82
+ ): Record<string, string[]> {
83
+ if (!fieldErrors) return {};
84
+ const result: Record<string, string[]> = {};
85
+ for (const [name, value] of Object.entries(fieldErrors)) {
86
+ if (Array.isArray(value)) {
87
+ if (value.length > 0) result[name] = value;
88
+ } else if (value) {
89
+ result[name] = [value];
90
+ }
91
+ }
92
+ return result;
93
+ }
@@ -0,0 +1,13 @@
1
+ export {
2
+ type ValidationResult,
3
+ type Validator,
4
+ isRequired,
5
+ isEmail,
6
+ minLength,
7
+ validate,
8
+ } from './rules.js';
9
+ export {
10
+ type FormSubmitResponse,
11
+ validateFormData,
12
+ normalizeFieldErrors,
13
+ } from './form.js';
@@ -0,0 +1,31 @@
1
+ export type ValidationResult = string | null;
2
+ export type Validator = (value: string) => ValidationResult;
3
+
4
+ export function isRequired(message = 'This field is required'): Validator {
5
+ return (value: string) => (value.trim() === '' ? message : null);
6
+ }
7
+
8
+ export function isEmail(
9
+ message = 'Please enter a valid email address',
10
+ ): Validator {
11
+ return (value: string) =>
12
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : message;
13
+ }
14
+
15
+ export function minLength(min: number, message?: string): Validator {
16
+ return (value: string) =>
17
+ value.length >= min
18
+ ? null
19
+ : (message ?? `Must be at least ${min} characters`);
20
+ }
21
+
22
+ export function validate(value: string, validators: Validator[]): string[] {
23
+ const errors: string[] = [];
24
+ for (const validator of validators) {
25
+ const result = validator(value);
26
+ if (result !== null) {
27
+ errors.push(result);
28
+ }
29
+ }
30
+ return errors;
31
+ }