@pienter/ui 0.3.0 → 0.5.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 (86) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/CONVENTIONS.md +297 -385
  3. package/README.md +3 -18
  4. package/components/feedback/toast/toast.css +1 -1
  5. package/components/form/combobox/Combobox.vue +21 -38
  6. package/components/form/combobox/combobox.css +1 -1
  7. package/components/form/form/Form.vue +1 -2
  8. package/components/form/label/label.css +1 -1
  9. package/components/form/number-field/NumberField.vue +1 -1
  10. package/components/form/number-field/number-field.css +1 -1
  11. package/components/form/radio-group/RadioGroup.vue +2 -5
  12. package/components/form/slider/slider.css +2 -3
  13. package/components/form/tags-input/tags-input.css +1 -2
  14. package/components/form/textarea/textarea.css +1 -1
  15. package/components/layout/accordion/Accordion.vue +6 -27
  16. package/components/layout/collapsible/Collapsible.vue +9 -19
  17. package/components/layout/table/table.css +3 -3
  18. package/components/navigation/sidebar/Sidebar.vue +326 -84
  19. package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
  20. package/components/navigation/sidebar/context.ts +21 -0
  21. package/components/navigation/sidebar/sidebar.css +346 -78
  22. package/components/navigation/sidebar/types.ts +13 -1
  23. package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
  24. package/components/overlay/command/Command.vue +10 -38
  25. package/components/overlay/command/command.css +1 -1
  26. package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
  27. package/components/overlay/modal/Modal.vue +7 -28
  28. package/components/overlay/popover/Popover.vue +9 -31
  29. package/components/overlay/sheet/Sheet.vue +7 -28
  30. package/components/overlay/tooltip/Tooltip.vue +14 -19
  31. package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
  32. package/composables/useDisclosure.ts +113 -0
  33. package/composables/useEventListener.ts +16 -0
  34. package/{utils/controllers/menu.ts → composables/useMenu.ts} +66 -102
  35. package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
  36. package/package.json +5 -39
  37. package/styles/0-settings/layout.css +18 -0
  38. package/styles/0-settings/motion.css +2 -2
  39. package/styles/0-settings/spacing.css +3 -1
  40. package/utils/navigation/sidebar.ts +97 -0
  41. package/utils/validation/form.ts +6 -9
  42. package/components/action/button/Button.astro +0 -95
  43. package/components/action/button/IconButton.astro +0 -86
  44. package/components/display/avatar/Avatar.astro +0 -17
  45. package/components/display/avatar/AvatarStack.astro +0 -9
  46. package/components/display/badge/Badge.astro +0 -15
  47. package/components/display/empty/Empty.astro +0 -9
  48. package/components/display/icon/Icon.astro +0 -52
  49. package/components/feedback/alert/Alert.astro +0 -52
  50. package/components/feedback/progress/Progress.astro +0 -68
  51. package/components/feedback/skeleton/Skeleton.astro +0 -32
  52. package/components/feedback/spinner/Spinner.astro +0 -25
  53. package/components/feedback/toast/Toast.astro +0 -50
  54. package/components/form/checkbox/Checkbox.astro +0 -79
  55. package/components/form/date-input/DateInput.astro +0 -105
  56. package/components/form/form/Form.astro +0 -106
  57. package/components/form/input-otp/InputOTP.astro +0 -147
  58. package/components/form/label/Label.astro +0 -13
  59. package/components/form/number-field/NumberField.astro +0 -142
  60. package/components/form/radio-group/RadioGroup.astro +0 -105
  61. package/components/form/select/Select.astro +0 -105
  62. package/components/form/slider/Slider.astro +0 -205
  63. package/components/form/switch/Switch.astro +0 -75
  64. package/components/form/tags-input/TagsInput.astro +0 -153
  65. package/components/form/text-input/TextInput.astro +0 -84
  66. package/components/form/textarea/Textarea.astro +0 -86
  67. package/components/layout/card/Card.astro +0 -13
  68. package/components/layout/separator/Separator.astro +0 -31
  69. package/components/layout/table/Table.astro +0 -116
  70. package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
  71. package/components/navigation/navbar/Navbar.astro +0 -62
  72. package/components/navigation/pagination/PaginationFooter.astro +0 -24
  73. package/components/navigation/sidebar/Sidebar.astro +0 -132
  74. package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
  75. package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
  76. package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
  77. package/components/overlay/modal/Modal.astro +0 -66
  78. package/utils/controllers/disclosure.ts +0 -117
  79. package/utils/controllers/form.ts +0 -524
  80. package/utils/controllers/index.ts +0 -39
  81. package/utils/controllers/number-field.ts +0 -103
  82. package/utils/controllers/otp.ts +0 -252
  83. package/utils/controllers/sidebar.ts +0 -610
  84. package/utils/controllers/slider.ts +0 -336
  85. package/utils/controllers/tags-input.ts +0 -255
  86. package/utils/controllers/toast.ts +0 -426
@@ -1,426 +0,0 @@
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
- }