@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.
- package/CHANGELOG.md +64 -0
- package/CONVENTIONS.md +1499 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/components/action/button/Button.astro +95 -0
- package/components/action/button/Button.vue +92 -0
- package/components/action/button/IconButton.astro +86 -0
- package/components/action/button/IconButton.vue +85 -0
- package/components/action/button/button.css +194 -0
- package/components/action/toggle/Toggle.vue +38 -0
- package/components/action/toggle/toggle.css +77 -0
- package/components/action/toggle-group/ToggleGroup.vue +67 -0
- package/components/action/toggle-group/toggle-group.css +78 -0
- package/components/display/avatar/Avatar.astro +17 -0
- package/components/display/avatar/Avatar.vue +30 -0
- package/components/display/avatar/AvatarStack.astro +9 -0
- package/components/display/avatar/AvatarStack.vue +11 -0
- package/components/display/avatar/avatar.css +58 -0
- package/components/display/badge/Badge.astro +15 -0
- package/components/display/badge/Badge.vue +23 -0
- package/components/display/badge/badge.css +56 -0
- package/components/display/empty/Empty.astro +9 -0
- package/components/display/empty/Empty.vue +11 -0
- package/components/display/empty/empty.css +39 -0
- package/components/display/icon/Icon.astro +52 -0
- package/components/display/icon/Icon.vue +57 -0
- package/components/display/icon/icon.css +47 -0
- package/components/feedback/alert/Alert.astro +52 -0
- package/components/feedback/alert/Alert.vue +60 -0
- package/components/feedback/alert/alert.css +78 -0
- package/components/feedback/progress/Progress.astro +68 -0
- package/components/feedback/progress/Progress.vue +82 -0
- package/components/feedback/progress/progress.css +68 -0
- package/components/feedback/skeleton/Skeleton.astro +32 -0
- package/components/feedback/skeleton/Skeleton.vue +39 -0
- package/components/feedback/skeleton/skeleton.css +56 -0
- package/components/feedback/spinner/Spinner.astro +25 -0
- package/components/feedback/spinner/Spinner.vue +36 -0
- package/components/feedback/spinner/spinner.css +91 -0
- package/components/feedback/toast/Toast.astro +50 -0
- package/components/feedback/toast/Toast.vue +74 -0
- package/components/feedback/toast/toast.css +128 -0
- package/components/form/checkbox/Checkbox.astro +79 -0
- package/components/form/checkbox/Checkbox.vue +95 -0
- package/components/form/checkbox/checkbox.css +59 -0
- package/components/form/combobox/Combobox.vue +508 -0
- package/components/form/combobox/combobox.css +110 -0
- package/components/form/date-input/DateInput.astro +105 -0
- package/components/form/date-input/DateInput.vue +121 -0
- package/components/form/date-input/date-input.css +19 -0
- package/components/form/form/Form.astro +106 -0
- package/components/form/form/Form.vue +181 -0
- package/components/form/form/form.css +46 -0
- package/components/form/input-otp/InputOTP.astro +147 -0
- package/components/form/input-otp/InputOTP.vue +209 -0
- package/components/form/input-otp/input-otp.css +52 -0
- package/components/form/label/Label.astro +13 -0
- package/components/form/label/Label.vue +20 -0
- package/components/form/label/label.css +11 -0
- package/components/form/number-field/NumberField.astro +142 -0
- package/components/form/number-field/NumberField.vue +155 -0
- package/components/form/number-field/number-field.css +115 -0
- package/components/form/radio-group/RadioGroup.astro +105 -0
- package/components/form/radio-group/RadioGroup.vue +110 -0
- package/components/form/radio-group/radio-group.css +114 -0
- package/components/form/radio-group/types.ts +14 -0
- package/components/form/select/Segmented.vue +36 -0
- package/components/form/select/Select.astro +105 -0
- package/components/form/select/Select.vue +109 -0
- package/components/form/select/select.css +96 -0
- package/components/form/slider/Slider.astro +205 -0
- package/components/form/slider/Slider.vue +321 -0
- package/components/form/slider/slider.css +115 -0
- package/components/form/switch/Switch.astro +75 -0
- package/components/form/switch/Switch.vue +89 -0
- package/components/form/switch/switch.css +64 -0
- package/components/form/tags-input/TagsInput.astro +153 -0
- package/components/form/tags-input/TagsInput.vue +207 -0
- package/components/form/tags-input/tags-input.css +128 -0
- package/components/form/text-input/TextInput.astro +84 -0
- package/components/form/text-input/TextInput.vue +99 -0
- package/components/form/text-input/text-input.css +165 -0
- package/components/form/textarea/Textarea.astro +86 -0
- package/components/form/textarea/Textarea.vue +102 -0
- package/components/form/textarea/textarea.css +25 -0
- package/components/layout/accordion/Accordion.vue +59 -0
- package/components/layout/accordion/accordion.css +87 -0
- package/components/layout/card/Card.astro +13 -0
- package/components/layout/card/Card.vue +20 -0
- package/components/layout/card/card.css +55 -0
- package/components/layout/collapsible/Collapsible.vue +77 -0
- package/components/layout/collapsible/collapsible.css +76 -0
- package/components/layout/separator/Separator.astro +31 -0
- package/components/layout/separator/Separator.vue +33 -0
- package/components/layout/separator/separator.css +27 -0
- package/components/layout/table/DataTable.vue +127 -0
- package/components/layout/table/Table.astro +116 -0
- package/components/layout/table/Table.vue +146 -0
- package/components/layout/table/TableRow.vue +59 -0
- package/components/layout/table/table.css +201 -0
- package/components/layout/table/types.ts +35 -0
- package/components/layout/table/useTable.ts +7 -0
- package/components/navigation/breadcrumb/Breadcrumb.astro +36 -0
- package/components/navigation/breadcrumb/Breadcrumb.vue +36 -0
- package/components/navigation/breadcrumb/breadcrumb.css +37 -0
- package/components/navigation/navbar/Navbar.astro +62 -0
- package/components/navigation/navbar/Navbar.vue +50 -0
- package/components/navigation/navbar/navbar.css +77 -0
- package/components/navigation/pagination/Pagination.vue +107 -0
- package/components/navigation/pagination/pagination.css +53 -0
- package/components/navigation/sidebar/Sidebar.astro +132 -0
- package/components/navigation/sidebar/Sidebar.vue +174 -0
- package/components/navigation/sidebar/SidebarItemRender.astro +83 -0
- package/components/navigation/sidebar/SidebarItemRender.vue +98 -0
- package/components/navigation/sidebar/sidebar.css +303 -0
- package/components/navigation/sidebar/types.ts +72 -0
- package/components/navigation/tabs/Tabs.vue +84 -0
- package/components/navigation/tabs/tabs.css +39 -0
- package/components/overlay/alert-dialog/AlertDialog.astro +112 -0
- package/components/overlay/alert-dialog/AlertDialog.vue +117 -0
- package/components/overlay/alert-dialog/alert-dialog.css +57 -0
- package/components/overlay/command/Command.vue +356 -0
- package/components/overlay/command/command.css +179 -0
- package/components/overlay/dropdown-menu/DropdownMenu.vue +143 -0
- package/components/overlay/dropdown-menu/dropdown-menu.css +120 -0
- package/components/overlay/modal/Modal.astro +66 -0
- package/components/overlay/modal/Modal.vue +85 -0
- package/components/overlay/modal/modal.css +60 -0
- package/components/overlay/popover/Popover.vue +113 -0
- package/components/overlay/popover/popover.css +53 -0
- package/components/overlay/sheet/Sheet.vue +88 -0
- package/components/overlay/sheet/sheet.css +108 -0
- package/components/overlay/tooltip/Tooltip.vue +210 -0
- package/components/overlay/tooltip/tooltip.css +50 -0
- package/composables/useUrlSort.ts +48 -0
- package/icons/alert-triangle.ts +1 -0
- package/icons/arrow-down.ts +1 -0
- package/icons/arrow-up-down.ts +5 -0
- package/icons/arrow-up.ts +1 -0
- package/icons/bell.ts +1 -0
- package/icons/check.ts +1 -0
- package/icons/chevron-down.ts +1 -0
- package/icons/chevron-left.ts +1 -0
- package/icons/chevron-right.ts +1 -0
- package/icons/chevron-up-down.ts +5 -0
- package/icons/chevron-up.ts +1 -0
- package/icons/circle-alert.ts +1 -0
- package/icons/circle-check.ts +1 -0
- package/icons/clipboard.ts +1 -0
- package/icons/download.ts +1 -0
- package/icons/edit.ts +1 -0
- package/icons/external-link.ts +1 -0
- package/icons/eye.ts +1 -0
- package/icons/file.ts +1 -0
- package/icons/filter.ts +1 -0
- package/icons/folder.ts +1 -0
- package/icons/image.ts +1 -0
- package/icons/inbox.ts +1 -0
- package/icons/index.ts +91 -0
- package/icons/info.ts +1 -0
- package/icons/layers.ts +1 -0
- package/icons/link-2.ts +1 -0
- package/icons/list.ts +1 -0
- package/icons/loader.ts +3 -0
- package/icons/menu.ts +1 -0
- package/icons/more-horizontal.ts +1 -0
- package/icons/more-vertical.ts +1 -0
- package/icons/plus-circle.ts +1 -0
- package/icons/plus.ts +1 -0
- package/icons/save.ts +1 -0
- package/icons/search.ts +1 -0
- package/icons/send.ts +1 -0
- package/icons/settings.ts +1 -0
- package/icons/tool.ts +1 -0
- package/icons/trash-2.ts +1 -0
- package/icons/trash.ts +1 -0
- package/icons/upload-cloud.ts +1 -0
- package/icons/upload.ts +1 -0
- package/icons/x.ts +1 -0
- package/package.json +150 -0
- package/styles/0-settings/colors.css +241 -0
- package/styles/0-settings/index.css +5 -0
- package/styles/0-settings/layout.css +52 -0
- package/styles/0-settings/motion.css +11 -0
- package/styles/0-settings/spacing.css +15 -0
- package/styles/0-settings/typography.css +37 -0
- package/styles/0-utils/index.css +1 -0
- package/styles/1-reset/index.css +1 -0
- package/styles/1-reset/reset.css +26 -0
- package/styles/2-base/base.css +42 -0
- package/styles/2-base/forms.css +23 -0
- package/styles/2-base/index.css +2 -0
- package/styles/3-layout/container.css +57 -0
- package/styles/3-layout/index.css +2 -0
- package/styles/3-layout/section.css +17 -0
- package/styles/5-utilities/accessibility.css +13 -0
- package/styles/5-utilities/index.css +2 -0
- package/styles/5-utilities/text.css +5 -0
- package/styles/main.css +8 -0
- package/styles/styles.d.ts +6 -0
- package/utils/a11y/focus.ts +68 -0
- package/utils/a11y/id.ts +10 -0
- package/utils/a11y/index.ts +9 -0
- package/utils/a11y/keyboard.ts +32 -0
- package/utils/a11y/live-region.ts +36 -0
- package/utils/controllers/dialog.ts +205 -0
- package/utils/controllers/disclosure.ts +117 -0
- package/utils/controllers/form.ts +524 -0
- package/utils/controllers/index.ts +39 -0
- package/utils/controllers/menu.ts +255 -0
- package/utils/controllers/number-field.ts +103 -0
- package/utils/controllers/otp.ts +252 -0
- package/utils/controllers/popover.ts +434 -0
- package/utils/controllers/sidebar.ts +610 -0
- package/utils/controllers/slider.ts +336 -0
- package/utils/controllers/tags-input.ts +255 -0
- package/utils/controllers/toast.ts +426 -0
- package/utils/dom/index.ts +1 -0
- package/utils/dom/scroll-lock.ts +48 -0
- package/utils/index.ts +3 -0
- package/utils/sort/index.ts +3 -0
- package/utils/sort/serialize.ts +19 -0
- package/utils/sort/state.ts +11 -0
- package/utils/sort/types.ts +15 -0
- package/utils/validation/form.ts +93 -0
- package/utils/validation/index.ts +13 -0
- 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, '&')
|
|
84
|
+
.replace(/</g, '<')
|
|
85
|
+
.replace(/>/g, '>')
|
|
86
|
+
.replace(/"/g, '"')
|
|
87
|
+
.replace(/'/g, ''');
|
|
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,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,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
|
+
}
|