@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,610 @@
|
|
|
1
|
+
import { createFocusTrap, type FocusTrap } from '../a11y/focus.js';
|
|
2
|
+
import { lockScroll } from '../dom/scroll-lock.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Imperative control surface returned by `mountSidebar`.
|
|
6
|
+
*
|
|
7
|
+
* Three independent state-machines composed inside one controller:
|
|
8
|
+
* 1. Desktop collapse/expand — narrows the panel to icon-only width.
|
|
9
|
+
* 2. Mobile drawer open/closed — overlay slide-in with focus trap +
|
|
10
|
+
* body-scroll lock; only meaningful below `drawerBreakpoint`.
|
|
11
|
+
* 3. Per-group expand/collapse — each group identified by a stable
|
|
12
|
+
* string key; multiple groups may be expanded at once.
|
|
13
|
+
*
|
|
14
|
+
* Plus: roving-tabindex keyboard navigation across menu items.
|
|
15
|
+
*
|
|
16
|
+
* The controller is the source of truth for runtime side-effects
|
|
17
|
+
* (DOM attributes, focus, scroll lock, listeners). The framework
|
|
18
|
+
* (Vue prop / Astro consumer script) is the trigger source — it
|
|
19
|
+
* calls `collapse()` / `openDrawer()` / `expandGroup()` and reflects
|
|
20
|
+
* controller-emitted callbacks back into reactive state.
|
|
21
|
+
*/
|
|
22
|
+
export interface SidebarControl {
|
|
23
|
+
// Desktop collapse state.
|
|
24
|
+
collapse(): void;
|
|
25
|
+
expand(): void;
|
|
26
|
+
isCollapsed(): boolean;
|
|
27
|
+
|
|
28
|
+
// Mobile drawer state.
|
|
29
|
+
openDrawer(): void;
|
|
30
|
+
closeDrawer(): void;
|
|
31
|
+
isDrawerOpen(): boolean;
|
|
32
|
+
|
|
33
|
+
// Per-group expand/collapse.
|
|
34
|
+
expandGroup(groupKey: string): void;
|
|
35
|
+
collapseGroup(groupKey: string): void;
|
|
36
|
+
isGroupExpanded(groupKey: string): boolean;
|
|
37
|
+
|
|
38
|
+
/** Re-derive `aria-current="page"` for every link from the new active href. */
|
|
39
|
+
setActiveHref(href: string | null | undefined): void;
|
|
40
|
+
|
|
41
|
+
/** Release listeners, focus trap, scroll lock, and stored references. */
|
|
42
|
+
teardown(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SidebarConfig {
|
|
46
|
+
/** Initial desktop collapsed state. Default `false`. */
|
|
47
|
+
collapsed?: boolean;
|
|
48
|
+
/** Initial mobile drawer state. Default `false`. */
|
|
49
|
+
drawerOpen?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Pixel breakpoint below which drawer mode applies. Above this
|
|
52
|
+
* width, the sidebar is a persistent column and drawer state has no
|
|
53
|
+
* visual effect (the controller still tracks it but
|
|
54
|
+
* `[data-drawer-state]` is meaningful only with the mobile-drawer
|
|
55
|
+
* CSS media query). Default `768`.
|
|
56
|
+
*/
|
|
57
|
+
drawerBreakpoint?: number;
|
|
58
|
+
/** Initial set of expanded group keys. */
|
|
59
|
+
expandedGroups?: string[];
|
|
60
|
+
/** Initial active href used to derive `aria-current="page"`. */
|
|
61
|
+
activeHref?: string | null;
|
|
62
|
+
/**
|
|
63
|
+
* Called after the user toggles the desktop collapse via internal
|
|
64
|
+
* affordances (none in v1 — collapse is consumer-driven only).
|
|
65
|
+
* Reserved for symmetry with other change callbacks.
|
|
66
|
+
*/
|
|
67
|
+
onCollapsedChange?: (collapsed: boolean) => void;
|
|
68
|
+
/**
|
|
69
|
+
* Called when the user requests drawer close (Escape, backdrop
|
|
70
|
+
* click). Mirrors `mountDialog`'s `onClose` — controller does NOT
|
|
71
|
+
* auto-close; the caller flips its reactive state and calls
|
|
72
|
+
* `closeDrawer()`.
|
|
73
|
+
*/
|
|
74
|
+
onDrawerOpenChange?: (open: boolean) => void;
|
|
75
|
+
/** Called when a group's expanded state changes via internal affordance (toggle click, keyboard). */
|
|
76
|
+
onGroupExpandedChange?: (groupKey: string, expanded: boolean) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// DOM hooks — locked markup contract between controller and component templates.
|
|
80
|
+
const PANEL_SELECTOR = '[data-sidebar-panel]';
|
|
81
|
+
const BACKDROP_SELECTOR = '[data-sidebar-backdrop]';
|
|
82
|
+
const MENU_ITEM_SELECTOR =
|
|
83
|
+
'[data-sidebar-menu-item]:not([data-disabled="true"])';
|
|
84
|
+
const GROUP_SELECTOR = '[data-sidebar-group]';
|
|
85
|
+
const GROUP_TOGGLE_SELECTOR = '[data-sidebar-group-toggle]';
|
|
86
|
+
const GROUP_ITEMS_SELECTOR = '[data-sidebar-group-items]';
|
|
87
|
+
const LINK_SELECTOR = '[data-sidebar-link]';
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Hierarchical-href match — locked algorithm. Returns true when
|
|
91
|
+
* `itemHref` is the prefix of `activeHref` honoring path boundaries.
|
|
92
|
+
*
|
|
93
|
+
* - `/` (root) is special-cased to strict equality so it doesn't
|
|
94
|
+
* greedily match every URL.
|
|
95
|
+
* - Substring-only matches are rejected (`/admin/members-old` does
|
|
96
|
+
* NOT match `/admin/members`).
|
|
97
|
+
*
|
|
98
|
+
* See CONVENTIONS.md § Active-href hierarchical-match algorithm.
|
|
99
|
+
*/
|
|
100
|
+
export function isHrefActive(
|
|
101
|
+
activeHref: string | null | undefined,
|
|
102
|
+
itemHref: string,
|
|
103
|
+
): boolean {
|
|
104
|
+
if (!activeHref) return false;
|
|
105
|
+
if (itemHref === '/') return activeHref === '/';
|
|
106
|
+
if (activeHref === itemHref) return true;
|
|
107
|
+
return activeHref.startsWith(itemHref + '/');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function mountSidebar(
|
|
111
|
+
el: HTMLElement,
|
|
112
|
+
config: SidebarConfig = {},
|
|
113
|
+
): SidebarControl {
|
|
114
|
+
const onClose = config.onDrawerOpenChange;
|
|
115
|
+
const onGroup = config.onGroupExpandedChange;
|
|
116
|
+
|
|
117
|
+
// Internal state.
|
|
118
|
+
let collapsedState = config.collapsed ?? false;
|
|
119
|
+
let drawerOpenState = false; // start closed; if config.drawerOpen, open() is called below
|
|
120
|
+
const expandedGroups = new Set<string>(config.expandedGroups ?? []);
|
|
121
|
+
let activeHref = config.activeHref ?? null;
|
|
122
|
+
|
|
123
|
+
// Drawer-only transient state.
|
|
124
|
+
let trap: FocusTrap | null = null;
|
|
125
|
+
let unlockScrollFn: (() => void) | null = null;
|
|
126
|
+
let triggerEl: HTMLElement | null = null;
|
|
127
|
+
// Whether the controller installed `aria-label` on the panel during
|
|
128
|
+
// openDrawer(). Used in closeDrawer() so we only strip the attribute
|
|
129
|
+
// we added — a consumer-supplied `aria-label` on the panel must not
|
|
130
|
+
// be clobbered.
|
|
131
|
+
let panelAriaLabelSetByController = false;
|
|
132
|
+
// Tracks whether THIS controller added the `tabindex="-1"` fallback to
|
|
133
|
+
// the panel (when no focusable menu item existed at open time). If we
|
|
134
|
+
// set it, we own removing it on close so the attribute doesn't persist
|
|
135
|
+
// across open/close cycles. Mirrors `dialog.ts`'s `tabindexWasSetByUs`.
|
|
136
|
+
let panelTabindexWasSetByController = false;
|
|
137
|
+
const panel = el.querySelector<HTMLElement>(PANEL_SELECTOR) ?? el;
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Initial DOM sync.
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
el.setAttribute('data-state', collapsedState ? 'collapsed' : 'expanded');
|
|
144
|
+
el.setAttribute('data-drawer-state', 'closed');
|
|
145
|
+
|
|
146
|
+
// Sync per-group state from config.expandedGroups + the markup-level
|
|
147
|
+
// `data-default-open` hint that templates emit for groups whose item
|
|
148
|
+
// declared `defaultOpen: true`. The Set wins when both are present.
|
|
149
|
+
el.querySelectorAll<HTMLElement>(GROUP_SELECTOR).forEach((groupEl) => {
|
|
150
|
+
const key = groupEl.dataset.groupKey ?? '';
|
|
151
|
+
if (!key) return;
|
|
152
|
+
const defaultOpen = groupEl.dataset.defaultOpen === 'true';
|
|
153
|
+
if (defaultOpen && !config.expandedGroups) {
|
|
154
|
+
// Only honor markup defaults when the consumer didn't pass an
|
|
155
|
+
// explicit expandedGroups override.
|
|
156
|
+
expandedGroups.add(key);
|
|
157
|
+
}
|
|
158
|
+
syncGroupAttrs(groupEl, key, expandedGroups.has(key));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Wire active-href.
|
|
162
|
+
applyActiveHref();
|
|
163
|
+
|
|
164
|
+
// Wire roving-tabindex initial state.
|
|
165
|
+
syncRovingTabindex();
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Listeners.
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
el.addEventListener('click', handleClick);
|
|
172
|
+
el.addEventListener('keydown', handleKeydown);
|
|
173
|
+
el.addEventListener('focusin', handleFocusIn);
|
|
174
|
+
|
|
175
|
+
function handleClick(event: MouseEvent): void {
|
|
176
|
+
const target = event.target;
|
|
177
|
+
if (!(target instanceof Element)) return;
|
|
178
|
+
|
|
179
|
+
// Backdrop click → close drawer.
|
|
180
|
+
const backdrop = target.closest(BACKDROP_SELECTOR);
|
|
181
|
+
if (backdrop && drawerOpenState) {
|
|
182
|
+
onClose?.(false);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Group toggle click → toggle expand state.
|
|
187
|
+
const toggle = target.closest<HTMLElement>(GROUP_TOGGLE_SELECTOR);
|
|
188
|
+
if (toggle) {
|
|
189
|
+
const group = toggle.closest<HTMLElement>(GROUP_SELECTOR);
|
|
190
|
+
if (!group) return;
|
|
191
|
+
const key = group.dataset.groupKey ?? '';
|
|
192
|
+
if (!key) return;
|
|
193
|
+
const next = !expandedGroups.has(key);
|
|
194
|
+
if (next) {
|
|
195
|
+
expandedGroups.add(key);
|
|
196
|
+
} else {
|
|
197
|
+
expandedGroups.delete(key);
|
|
198
|
+
}
|
|
199
|
+
syncGroupAttrs(group, key, next);
|
|
200
|
+
// After expand/collapse, the visible-item set changed; refresh
|
|
201
|
+
// the roving tabindex so keyboard nav skips collapsed children.
|
|
202
|
+
syncRovingTabindex();
|
|
203
|
+
onGroup?.(key, next);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function handleKeydown(event: KeyboardEvent): void {
|
|
208
|
+
// Escape closes the drawer when open (only meaningful on mobile).
|
|
209
|
+
if (event.key === 'Escape' && drawerOpenState) {
|
|
210
|
+
event.preventDefault();
|
|
211
|
+
onClose?.(false);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const target = event.target;
|
|
216
|
+
if (!(target instanceof HTMLElement)) return;
|
|
217
|
+
const item = target.closest<HTMLElement>(MENU_ITEM_SELECTOR);
|
|
218
|
+
if (!item) return;
|
|
219
|
+
|
|
220
|
+
switch (event.key) {
|
|
221
|
+
case 'ArrowDown': {
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
const items = visibleMenuItems();
|
|
224
|
+
const idx = items.indexOf(item);
|
|
225
|
+
const next = items[Math.min(idx + 1, items.length - 1)];
|
|
226
|
+
if (next) focusItem(next);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
case 'ArrowUp': {
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
const items = visibleMenuItems();
|
|
232
|
+
const idx = items.indexOf(item);
|
|
233
|
+
const next = items[Math.max(idx - 1, 0)];
|
|
234
|
+
if (next) focusItem(next);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
case 'Home': {
|
|
238
|
+
event.preventDefault();
|
|
239
|
+
const items = visibleMenuItems();
|
|
240
|
+
if (items[0]) focusItem(items[0]);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
case 'End': {
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
const items = visibleMenuItems();
|
|
246
|
+
const last = items[items.length - 1];
|
|
247
|
+
if (last) focusItem(last);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
case 'ArrowRight': {
|
|
251
|
+
// On a group toggle: if collapsed, expand; if already expanded,
|
|
252
|
+
// move into first child item.
|
|
253
|
+
if (item.matches(GROUP_TOGGLE_SELECTOR)) {
|
|
254
|
+
event.preventDefault();
|
|
255
|
+
const group = item.closest<HTMLElement>(GROUP_SELECTOR);
|
|
256
|
+
if (!group) return;
|
|
257
|
+
const key = group.dataset.groupKey ?? '';
|
|
258
|
+
if (!expandedGroups.has(key)) {
|
|
259
|
+
expandedGroups.add(key);
|
|
260
|
+
syncGroupAttrs(group, key, true);
|
|
261
|
+
syncRovingTabindex();
|
|
262
|
+
onGroup?.(key, true);
|
|
263
|
+
} else {
|
|
264
|
+
const groupItems =
|
|
265
|
+
group.querySelector<HTMLElement>(
|
|
266
|
+
GROUP_ITEMS_SELECTOR,
|
|
267
|
+
);
|
|
268
|
+
const firstChild =
|
|
269
|
+
groupItems?.querySelector<HTMLElement>(
|
|
270
|
+
MENU_ITEM_SELECTOR,
|
|
271
|
+
);
|
|
272
|
+
if (firstChild) focusItem(firstChild);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
case 'ArrowLeft': {
|
|
278
|
+
// On a child of an expanded group: collapse parent group and
|
|
279
|
+
// move focus to its toggle.
|
|
280
|
+
const parentGroup =
|
|
281
|
+
item.parentElement?.closest<HTMLElement>(GROUP_SELECTOR);
|
|
282
|
+
if (parentGroup && !item.matches(GROUP_TOGGLE_SELECTOR)) {
|
|
283
|
+
event.preventDefault();
|
|
284
|
+
const key = parentGroup.dataset.groupKey ?? '';
|
|
285
|
+
if (expandedGroups.has(key)) {
|
|
286
|
+
expandedGroups.delete(key);
|
|
287
|
+
syncGroupAttrs(parentGroup, key, false);
|
|
288
|
+
syncRovingTabindex();
|
|
289
|
+
onGroup?.(key, false);
|
|
290
|
+
}
|
|
291
|
+
const toggle = parentGroup.querySelector<HTMLElement>(
|
|
292
|
+
GROUP_TOGGLE_SELECTOR,
|
|
293
|
+
);
|
|
294
|
+
if (toggle) focusItem(toggle);
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
case ' ': // Space
|
|
299
|
+
case 'Spacebar': {
|
|
300
|
+
// Native button activation handles Space on the group toggle
|
|
301
|
+
// already; for `<a>` links we forward Space to a click so links
|
|
302
|
+
// feel button-like inside a menu.
|
|
303
|
+
if (item.matches(LINK_SELECTOR)) {
|
|
304
|
+
event.preventDefault();
|
|
305
|
+
item.click();
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
default:
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function handleFocusIn(event: FocusEvent): void {
|
|
315
|
+
const target = event.target;
|
|
316
|
+
if (!(target instanceof HTMLElement)) return;
|
|
317
|
+
const item = target.closest<HTMLElement>(MENU_ITEM_SELECTOR);
|
|
318
|
+
if (!item) return;
|
|
319
|
+
// Promote the focused item to tabindex=0; demote the previous.
|
|
320
|
+
const items = el.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR);
|
|
321
|
+
items.forEach((it) => {
|
|
322
|
+
it.setAttribute('tabindex', it === item ? '0' : '-1');
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// Public API.
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
function collapse(): void {
|
|
331
|
+
if (collapsedState) return;
|
|
332
|
+
collapsedState = true;
|
|
333
|
+
el.setAttribute('data-state', 'collapsed');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function expand(): void {
|
|
337
|
+
if (!collapsedState) return;
|
|
338
|
+
collapsedState = false;
|
|
339
|
+
el.setAttribute('data-state', 'expanded');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function isCollapsed(): boolean {
|
|
343
|
+
return collapsedState;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function openDrawer(): void {
|
|
347
|
+
if (drawerOpenState) return;
|
|
348
|
+
drawerOpenState = true;
|
|
349
|
+
|
|
350
|
+
// Capture trigger for restore-on-close.
|
|
351
|
+
const active = document.activeElement;
|
|
352
|
+
triggerEl =
|
|
353
|
+
active instanceof HTMLElement &&
|
|
354
|
+
active !== document.body &&
|
|
355
|
+
!el.contains(active)
|
|
356
|
+
? active
|
|
357
|
+
: null;
|
|
358
|
+
|
|
359
|
+
el.setAttribute('data-drawer-state', 'open');
|
|
360
|
+
|
|
361
|
+
// Promote the panel to a modal dialog while drawer is open.
|
|
362
|
+
// ARIA requires `role="dialog"` to have an accessible name, so
|
|
363
|
+
// mirror the wrapper `<nav>`'s `aria-label` onto the panel. If the
|
|
364
|
+
// panel already has its own `aria-label` (consumer-supplied), leave
|
|
365
|
+
// it alone — closeDrawer() only strips what we added.
|
|
366
|
+
panel.setAttribute('role', 'dialog');
|
|
367
|
+
panel.setAttribute('aria-modal', 'true');
|
|
368
|
+
if (!panel.hasAttribute('aria-label')) {
|
|
369
|
+
const wrapperLabel = el.getAttribute('aria-label') ?? 'Navigation';
|
|
370
|
+
panel.setAttribute('aria-label', wrapperLabel);
|
|
371
|
+
panelAriaLabelSetByController = true;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
unlockScrollFn = lockScroll();
|
|
375
|
+
trap = createFocusTrap(panel);
|
|
376
|
+
|
|
377
|
+
// Move focus inside the panel — first menu item, deferred a frame
|
|
378
|
+
// so any CSS slide-in transition doesn't race with focus.
|
|
379
|
+
requestAnimationFrame(() => {
|
|
380
|
+
if (!drawerOpenState) return;
|
|
381
|
+
const first = visibleMenuItems()[0];
|
|
382
|
+
if (first) {
|
|
383
|
+
focusItem(first);
|
|
384
|
+
} else {
|
|
385
|
+
// Fall back to focusing the panel so screen readers announce
|
|
386
|
+
// the drawer. The panel needs to be focusable; if it isn't,
|
|
387
|
+
// give it tabindex=-1 transiently. Track that we set it so
|
|
388
|
+
// closeDrawer() can remove it again — we don't want to leave a
|
|
389
|
+
// stray `tabindex` on the panel across open/close cycles.
|
|
390
|
+
if (!panel.hasAttribute('tabindex')) {
|
|
391
|
+
panel.setAttribute('tabindex', '-1');
|
|
392
|
+
panelTabindexWasSetByController = true;
|
|
393
|
+
}
|
|
394
|
+
panel.focus();
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function closeDrawer(): void {
|
|
400
|
+
if (!drawerOpenState) return;
|
|
401
|
+
drawerOpenState = false;
|
|
402
|
+
|
|
403
|
+
el.setAttribute('data-drawer-state', 'closed');
|
|
404
|
+
panel.removeAttribute('role');
|
|
405
|
+
panel.removeAttribute('aria-modal');
|
|
406
|
+
if (panelAriaLabelSetByController) {
|
|
407
|
+
panel.removeAttribute('aria-label');
|
|
408
|
+
panelAriaLabelSetByController = false;
|
|
409
|
+
}
|
|
410
|
+
// If we added the transient `tabindex="-1"` focus fallback in
|
|
411
|
+
// openDrawer(), remove it now so it doesn't persist across
|
|
412
|
+
// open/close cycles.
|
|
413
|
+
if (panelTabindexWasSetByController) {
|
|
414
|
+
panel.removeAttribute('tabindex');
|
|
415
|
+
panelTabindexWasSetByController = false;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
trap?.release();
|
|
419
|
+
trap = null;
|
|
420
|
+
|
|
421
|
+
unlockScrollFn?.();
|
|
422
|
+
unlockScrollFn = null;
|
|
423
|
+
|
|
424
|
+
// Restore focus to the trigger that opened the drawer.
|
|
425
|
+
const trigger = triggerEl;
|
|
426
|
+
triggerEl = null;
|
|
427
|
+
if (trigger && document.contains(trigger)) {
|
|
428
|
+
requestAnimationFrame(() => trigger.focus());
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function isDrawerOpen(): boolean {
|
|
433
|
+
return drawerOpenState;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function expandGroup(key: string): void {
|
|
437
|
+
if (!key || expandedGroups.has(key)) return;
|
|
438
|
+
expandedGroups.add(key);
|
|
439
|
+
const group = findGroup(key);
|
|
440
|
+
if (group) {
|
|
441
|
+
syncGroupAttrs(group, key, true);
|
|
442
|
+
syncRovingTabindex();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function collapseGroup(key: string): void {
|
|
447
|
+
if (!key || !expandedGroups.has(key)) return;
|
|
448
|
+
expandedGroups.delete(key);
|
|
449
|
+
const group = findGroup(key);
|
|
450
|
+
if (group) {
|
|
451
|
+
syncGroupAttrs(group, key, false);
|
|
452
|
+
syncRovingTabindex();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function isGroupExpanded(key: string): boolean {
|
|
457
|
+
return expandedGroups.has(key);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function setActiveHref(href: string | null | undefined): void {
|
|
461
|
+
activeHref = href ?? null;
|
|
462
|
+
applyActiveHref();
|
|
463
|
+
// Active item changed — re-roving-tabindex so the active item gets
|
|
464
|
+
// tabindex=0 (or the first item if no active match).
|
|
465
|
+
syncRovingTabindex();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function teardown(): void {
|
|
469
|
+
if (drawerOpenState) closeDrawer();
|
|
470
|
+
el.removeEventListener('click', handleClick);
|
|
471
|
+
el.removeEventListener('keydown', handleKeydown);
|
|
472
|
+
el.removeEventListener('focusin', handleFocusIn);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
// Internal helpers.
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
function findGroup(key: string): HTMLElement | null {
|
|
480
|
+
return el.querySelector<HTMLElement>(
|
|
481
|
+
`${GROUP_SELECTOR}[data-group-key="${cssEscape(key)}"]`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function syncGroupAttrs(
|
|
486
|
+
group: HTMLElement,
|
|
487
|
+
_key: string,
|
|
488
|
+
expanded: boolean,
|
|
489
|
+
): void {
|
|
490
|
+
group.setAttribute('data-state', expanded ? 'expanded' : 'collapsed');
|
|
491
|
+
const toggle = group.querySelector<HTMLElement>(GROUP_TOGGLE_SELECTOR);
|
|
492
|
+
if (toggle)
|
|
493
|
+
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
494
|
+
const items = group.querySelector<HTMLElement>(GROUP_ITEMS_SELECTOR);
|
|
495
|
+
if (items) items.hidden = !expanded;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function applyActiveHref(): void {
|
|
499
|
+
const links = el.querySelectorAll<HTMLAnchorElement>(LINK_SELECTOR);
|
|
500
|
+
links.forEach((link) => {
|
|
501
|
+
const href = link.getAttribute('href') ?? '';
|
|
502
|
+
const active = isHrefActive(activeHref, href);
|
|
503
|
+
if (active) {
|
|
504
|
+
link.setAttribute('aria-current', 'page');
|
|
505
|
+
} else {
|
|
506
|
+
link.removeAttribute('aria-current');
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// Group active-state — true when any descendant link is active.
|
|
511
|
+
el.querySelectorAll<HTMLElement>(GROUP_SELECTOR).forEach((group) => {
|
|
512
|
+
const hasActive = !!group.querySelector('[aria-current="page"]');
|
|
513
|
+
if (hasActive) {
|
|
514
|
+
group.setAttribute('data-active', 'true');
|
|
515
|
+
} else {
|
|
516
|
+
group.removeAttribute('data-active');
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function visibleMenuItems(): HTMLElement[] {
|
|
522
|
+
// Items that aren't disabled, aren't hidden by an ancestor with
|
|
523
|
+
// `hidden`, and aren't inside a collapsed group.
|
|
524
|
+
return Array.from(
|
|
525
|
+
el.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR),
|
|
526
|
+
).filter((item) => !isInsideCollapsedGroup(item));
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function isInsideCollapsedGroup(item: HTMLElement): boolean {
|
|
530
|
+
// Walk up looking for an ancestor `[data-sidebar-group-items]` that
|
|
531
|
+
// is hidden — if found, the item is inside a collapsed group.
|
|
532
|
+
let cur: HTMLElement | null = item.parentElement;
|
|
533
|
+
while (cur && cur !== el) {
|
|
534
|
+
if (cur.matches(GROUP_ITEMS_SELECTOR) && cur.hidden) return true;
|
|
535
|
+
cur = cur.parentElement;
|
|
536
|
+
}
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function syncRovingTabindex(): void {
|
|
541
|
+
const items = Array.from(
|
|
542
|
+
el.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR),
|
|
543
|
+
);
|
|
544
|
+
if (items.length === 0) return;
|
|
545
|
+
// Prefer the active item; fall back to the first visible item.
|
|
546
|
+
const activeItem =
|
|
547
|
+
items.find(
|
|
548
|
+
(it) =>
|
|
549
|
+
it.getAttribute('aria-current') === 'page' &&
|
|
550
|
+
!isInsideCollapsedGroup(it),
|
|
551
|
+
) ??
|
|
552
|
+
items.find((it) => !isInsideCollapsedGroup(it)) ??
|
|
553
|
+
items[0];
|
|
554
|
+
items.forEach((it) => {
|
|
555
|
+
it.setAttribute('tabindex', it === activeItem ? '0' : '-1');
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function focusItem(item: HTMLElement): void {
|
|
560
|
+
// Roving tabindex: promote the target before focusing so the
|
|
561
|
+
// following focusin handler doesn't fight us.
|
|
562
|
+
el.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR).forEach((it) => {
|
|
563
|
+
it.setAttribute('tabindex', it === item ? '0' : '-1');
|
|
564
|
+
});
|
|
565
|
+
item.focus();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// -- public surface -- //
|
|
569
|
+
return {
|
|
570
|
+
collapse,
|
|
571
|
+
expand,
|
|
572
|
+
isCollapsed,
|
|
573
|
+
openDrawer,
|
|
574
|
+
closeDrawer,
|
|
575
|
+
isDrawerOpen,
|
|
576
|
+
expandGroup,
|
|
577
|
+
collapseGroup,
|
|
578
|
+
isGroupExpanded,
|
|
579
|
+
setActiveHref,
|
|
580
|
+
teardown,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Lightweight CSS.escape fallback for the few places we build attribute
|
|
586
|
+
* selectors from group keys. Not exhaustive — group keys are
|
|
587
|
+
* kebab-case slugs by convention so the common cases work; we still
|
|
588
|
+
* escape quotes and backslashes for safety.
|
|
589
|
+
*/
|
|
590
|
+
function cssEscape(value: string): string {
|
|
591
|
+
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
|
592
|
+
return CSS.escape(value);
|
|
593
|
+
}
|
|
594
|
+
return value.replace(/["\\]/g, '\\$&');
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Public helper — kebab-case slug for deriving a group key from a
|
|
599
|
+
* label when the consumer didn't supply an explicit `key`. Lowercases,
|
|
600
|
+
* strips diacritics, replaces non-alphanumerics with `-`, and trims
|
|
601
|
+
* leading/trailing dashes.
|
|
602
|
+
*/
|
|
603
|
+
export function deriveGroupKey(label: string): string {
|
|
604
|
+
return label
|
|
605
|
+
.normalize('NFKD')
|
|
606
|
+
.replace(/[̀-ͯ]/g, '')
|
|
607
|
+
.toLowerCase()
|
|
608
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
609
|
+
.replace(/^-+|-+$/g, '');
|
|
610
|
+
}
|