@pienter/ui 0.2.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.
- package/CHANGELOG.md +259 -0
- package/CONVENTIONS.md +297 -385
- package/README.md +3 -18
- package/components/feedback/toast/toast.css +1 -1
- package/components/form/combobox/Combobox.vue +21 -38
- package/components/form/combobox/combobox.css +1 -1
- package/components/form/form/Form.vue +1 -2
- package/components/form/label/label.css +1 -1
- package/components/form/number-field/NumberField.vue +1 -1
- package/components/form/number-field/number-field.css +1 -1
- package/components/form/radio-group/RadioGroup.vue +2 -5
- package/components/form/slider/slider.css +2 -3
- package/components/form/tags-input/tags-input.css +1 -2
- package/components/form/textarea/textarea.css +1 -1
- package/components/layout/accordion/Accordion.vue +6 -27
- package/components/layout/collapsible/Collapsible.vue +9 -19
- package/components/layout/table/table.css +3 -3
- package/components/navigation/pagination/Pagination.vue +1 -1
- package/components/navigation/pagination/PaginationFooter.vue +23 -0
- package/components/navigation/pagination/pagination.css +128 -29
- package/components/navigation/sidebar/Sidebar.vue +326 -84
- package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
- package/components/navigation/sidebar/context.ts +21 -0
- package/components/navigation/sidebar/sidebar.css +346 -78
- package/components/navigation/sidebar/types.ts +13 -1
- package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
- package/components/overlay/command/Command.vue +10 -38
- package/components/overlay/command/command.css +1 -1
- package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
- package/components/overlay/modal/Modal.vue +7 -28
- package/components/overlay/popover/Popover.vue +9 -31
- package/components/overlay/sheet/Sheet.vue +7 -28
- package/components/overlay/tooltip/Tooltip.vue +14 -19
- package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
- package/composables/useDisclosure.ts +113 -0
- package/composables/useEventListener.ts +16 -0
- package/{utils/controllers/menu.ts → composables/useMenu.ts} +66 -102
- package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
- package/package.json +6 -38
- package/styles/0-settings/colors.css +8 -1
- package/styles/0-settings/layout.css +18 -0
- package/styles/0-settings/motion.css +2 -2
- package/styles/0-settings/spacing.css +3 -1
- package/utils/navigation/sidebar.ts +97 -0
- package/utils/validation/form.ts +6 -9
- package/components/action/button/Button.astro +0 -95
- package/components/action/button/IconButton.astro +0 -86
- package/components/display/avatar/Avatar.astro +0 -17
- package/components/display/avatar/AvatarStack.astro +0 -9
- package/components/display/badge/Badge.astro +0 -15
- package/components/display/empty/Empty.astro +0 -9
- package/components/display/icon/Icon.astro +0 -52
- package/components/feedback/alert/Alert.astro +0 -52
- package/components/feedback/progress/Progress.astro +0 -68
- package/components/feedback/skeleton/Skeleton.astro +0 -32
- package/components/feedback/spinner/Spinner.astro +0 -25
- package/components/feedback/toast/Toast.astro +0 -50
- package/components/form/checkbox/Checkbox.astro +0 -79
- package/components/form/date-input/DateInput.astro +0 -105
- package/components/form/form/Form.astro +0 -106
- package/components/form/input-otp/InputOTP.astro +0 -147
- package/components/form/label/Label.astro +0 -13
- package/components/form/number-field/NumberField.astro +0 -142
- package/components/form/radio-group/RadioGroup.astro +0 -105
- package/components/form/select/Select.astro +0 -105
- package/components/form/slider/Slider.astro +0 -205
- package/components/form/switch/Switch.astro +0 -75
- package/components/form/tags-input/TagsInput.astro +0 -153
- package/components/form/text-input/TextInput.astro +0 -84
- package/components/form/textarea/Textarea.astro +0 -86
- package/components/layout/card/Card.astro +0 -13
- package/components/layout/separator/Separator.astro +0 -31
- package/components/layout/table/Table.astro +0 -116
- package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
- package/components/navigation/navbar/Navbar.astro +0 -62
- package/components/navigation/sidebar/Sidebar.astro +0 -132
- package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
- package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
- package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
- package/components/overlay/modal/Modal.astro +0 -66
- package/utils/controllers/disclosure.ts +0 -117
- package/utils/controllers/form.ts +0 -524
- package/utils/controllers/index.ts +0 -39
- package/utils/controllers/number-field.ts +0 -103
- package/utils/controllers/otp.ts +0 -252
- package/utils/controllers/sidebar.ts +0 -610
- package/utils/controllers/slider.ts +0 -336
- package/utils/controllers/tags-input.ts +0 -255
- package/utils/controllers/toast.ts +0 -426
|
@@ -4,67 +4,102 @@
|
|
|
4
4
|
class="pui-sidebar"
|
|
5
5
|
data-pui-sidebar
|
|
6
6
|
:data-state="collapsed ? 'collapsed' : 'expanded'"
|
|
7
|
-
data-drawer-state="closed"
|
|
7
|
+
:data-drawer-state="drawerOpen ? 'open' : 'closed'"
|
|
8
|
+
:data-collapsed-submenu="collapsedSubmenu"
|
|
8
9
|
:aria-label="ariaLabel"
|
|
10
|
+
@pointerleave="onPointerLeave"
|
|
11
|
+
@focusout="onFocusOut"
|
|
9
12
|
>
|
|
10
13
|
<div
|
|
11
14
|
class="pui-sidebar__backdrop"
|
|
12
15
|
data-sidebar-backdrop
|
|
13
16
|
aria-hidden="true"
|
|
17
|
+
@click="emit('update:drawerOpen', false)"
|
|
14
18
|
/>
|
|
15
|
-
<div
|
|
16
|
-
|
|
19
|
+
<div
|
|
20
|
+
ref="panelRef"
|
|
21
|
+
class="pui-sidebar__panel"
|
|
22
|
+
data-sidebar-panel
|
|
23
|
+
v-bind="drawerAria"
|
|
24
|
+
>
|
|
25
|
+
<div
|
|
26
|
+
v-if="$slots.brand"
|
|
27
|
+
ref="brandRef"
|
|
28
|
+
class="pui-sidebar__brand"
|
|
29
|
+
data-sidebar-brand
|
|
30
|
+
>
|
|
17
31
|
<slot name="brand" />
|
|
18
32
|
</div>
|
|
19
33
|
|
|
20
|
-
<ul class="pui-
|
|
21
|
-
<
|
|
22
|
-
v-for="(item, idx) in
|
|
34
|
+
<ul class="pui-sidebar__top-nav" data-sidebar-menu>
|
|
35
|
+
<SidebarMenuItem
|
|
36
|
+
v-for="(item, idx) in topItems"
|
|
23
37
|
:key="itemKey(item, idx)"
|
|
24
38
|
:item="item"
|
|
25
39
|
/>
|
|
26
40
|
</ul>
|
|
27
41
|
|
|
28
|
-
<div
|
|
29
|
-
|
|
42
|
+
<div
|
|
43
|
+
v-if="$slots.bottom || bottomItems.length"
|
|
44
|
+
class="pui-sidebar__bottom"
|
|
45
|
+
>
|
|
46
|
+
<ul
|
|
47
|
+
v-if="bottomItems.length"
|
|
48
|
+
class="pui-sidebar__bottom-nav"
|
|
49
|
+
data-sidebar-menu
|
|
50
|
+
>
|
|
51
|
+
<SidebarMenuItem
|
|
52
|
+
v-for="(item, idx) in bottomItems"
|
|
53
|
+
:key="itemKey(item, idx)"
|
|
54
|
+
:item="item"
|
|
55
|
+
/>
|
|
56
|
+
</ul>
|
|
57
|
+
<slot name="bottom" />
|
|
30
58
|
</div>
|
|
31
59
|
</div>
|
|
32
60
|
</nav>
|
|
33
61
|
</template>
|
|
34
62
|
|
|
35
63
|
<script setup lang="ts">
|
|
36
|
-
|
|
64
|
+
// Reactive orchestration over the primitives in
|
|
65
|
+
// `utils/navigation/sidebar.ts`. See ADR 0007.
|
|
66
|
+
import { computed, nextTick, onBeforeUnmount, provide, ref, watch } from 'vue';
|
|
67
|
+
import { createFocusTrap, type FocusTrap } from '../../../utils/a11y/focus.js';
|
|
68
|
+
import { lockScroll } from '../../../utils/dom/scroll-lock.js';
|
|
37
69
|
import {
|
|
38
|
-
|
|
70
|
+
computeSubmenuPosition,
|
|
39
71
|
deriveGroupKey,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
72
|
+
isSubmenuActive,
|
|
73
|
+
SUBMENU_CLOSE_DELAY,
|
|
74
|
+
type SubmenuPosition,
|
|
75
|
+
} from '../../../utils/navigation/sidebar.js';
|
|
76
|
+
import { useWindowListener } from '../../../composables/useEventListener.js';
|
|
77
|
+
import SidebarMenuItem from './SidebarMenuItem.vue';
|
|
78
|
+
import { SIDEBAR_CONTEXT } from './context.js';
|
|
43
79
|
import type { SidebarItem } from './types.js';
|
|
44
80
|
|
|
45
81
|
const props = withDefaults(
|
|
46
82
|
defineProps<{
|
|
47
|
-
|
|
83
|
+
/** The main navigation — the top nav of the column. */
|
|
84
|
+
topItems: SidebarItem[];
|
|
85
|
+
/** Pinned to the foot of the column, below a divider. */
|
|
86
|
+
bottomItems?: SidebarItem[];
|
|
48
87
|
activeHref?: string | null;
|
|
49
88
|
collapsed?: boolean;
|
|
50
89
|
drawerOpen?: boolean;
|
|
51
|
-
/**
|
|
52
|
-
* Controlled set of expanded group keys. Leave undefined to let the
|
|
53
|
-
* controller honor `defaultOpen` markup hints — passing an explicit
|
|
54
|
-
* array (even `[]`) signals "consumer is in control" and overrides
|
|
55
|
-
* markup defaults.
|
|
56
|
-
*/
|
|
90
|
+
/** Leave undefined to honor each group's `defaultOpen`. */
|
|
57
91
|
expandedGroups?: string[];
|
|
92
|
+
/** `'panel'` gives a collapsed group a flyout beside the rail. */
|
|
93
|
+
collapsedSubmenu?: 'hidden' | 'panel';
|
|
58
94
|
ariaLabel?: string;
|
|
59
95
|
drawerBreakpoint?: number;
|
|
60
96
|
}>(),
|
|
61
97
|
{
|
|
98
|
+
bottomItems: () => [],
|
|
62
99
|
activeHref: null,
|
|
63
100
|
collapsed: false,
|
|
64
101
|
drawerOpen: false,
|
|
65
|
-
|
|
66
|
-
// only fires when `config.expandedGroups` is undefined. With
|
|
67
|
-
// `() => []`, the heuristic was permanently disabled from Vue.
|
|
102
|
+
collapsedSubmenu: 'hidden',
|
|
68
103
|
expandedGroups: undefined,
|
|
69
104
|
ariaLabel: 'Main navigation',
|
|
70
105
|
drawerBreakpoint: 768,
|
|
@@ -72,13 +107,13 @@ const props = withDefaults(
|
|
|
72
107
|
);
|
|
73
108
|
|
|
74
109
|
const emit = defineEmits<{
|
|
75
|
-
'update:collapsed': [value: boolean];
|
|
76
110
|
'update:drawerOpen': [value: boolean];
|
|
77
111
|
'update:expandedGroups': [value: string[]];
|
|
78
112
|
}>();
|
|
79
113
|
|
|
80
114
|
const rootRef = ref<HTMLElement | null>(null);
|
|
81
|
-
|
|
115
|
+
const panelRef = ref<HTMLElement | null>(null);
|
|
116
|
+
const brandRef = ref<HTMLElement | null>(null);
|
|
82
117
|
|
|
83
118
|
function itemKey(item: SidebarItem, idx: number): string {
|
|
84
119
|
if (item.type === 'link') return `link:${item.href}`;
|
|
@@ -87,85 +122,292 @@ function itemKey(item: SidebarItem, idx: number): string {
|
|
|
87
122
|
return `section:${item.label}:${idx}`;
|
|
88
123
|
}
|
|
89
124
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
125
|
+
function defaultOpenKeys(items: SidebarItem[]): string[] {
|
|
126
|
+
return items.flatMap((item) =>
|
|
127
|
+
item.type === 'group'
|
|
128
|
+
? [
|
|
129
|
+
...(item.defaultOpen
|
|
130
|
+
? [item.key ?? deriveGroupKey(item.label)]
|
|
131
|
+
: []),
|
|
132
|
+
...defaultOpenKeys(item.items),
|
|
133
|
+
]
|
|
134
|
+
: [],
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const expandedKeys = ref<Set<string>>(
|
|
139
|
+
new Set(
|
|
140
|
+
props.expandedGroups ??
|
|
141
|
+
defaultOpenKeys([...props.topItems, ...props.bottomItems]),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
108
144
|
|
|
109
145
|
watch(
|
|
110
|
-
() => props.
|
|
111
|
-
(
|
|
112
|
-
if (
|
|
113
|
-
if (v) control.collapse();
|
|
114
|
-
else control.expand();
|
|
146
|
+
() => props.expandedGroups,
|
|
147
|
+
(next) => {
|
|
148
|
+
if (next) expandedKeys.value = new Set(next);
|
|
115
149
|
},
|
|
150
|
+
{ deep: true },
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const viewportWidth = ref(
|
|
154
|
+
typeof window === 'undefined'
|
|
155
|
+
? Number.POSITIVE_INFINITY
|
|
156
|
+
: window.innerWidth,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const submenuActive = computed(() =>
|
|
160
|
+
isSubmenuActive({
|
|
161
|
+
policy: props.collapsedSubmenu,
|
|
162
|
+
collapsed: props.collapsed,
|
|
163
|
+
viewportWidth: viewportWidth.value,
|
|
164
|
+
drawerBreakpoint: props.drawerBreakpoint,
|
|
165
|
+
}),
|
|
116
166
|
);
|
|
117
167
|
|
|
168
|
+
const openKey = ref<string | null>(null);
|
|
169
|
+
/** Opened by click; survives pointer-out, unlike a hover/focus open. */
|
|
170
|
+
const pinned = ref(false);
|
|
171
|
+
const submenuPos = ref<SubmenuPosition | null>(null);
|
|
172
|
+
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
173
|
+
|
|
174
|
+
function clearCloseTimer(): void {
|
|
175
|
+
if (closeTimer === null) return;
|
|
176
|
+
clearTimeout(closeTimer);
|
|
177
|
+
closeTimer = null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function closeSubmenu(): void {
|
|
181
|
+
clearCloseTimer();
|
|
182
|
+
openKey.value = null;
|
|
183
|
+
pinned.value = false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function scheduleClose(): void {
|
|
187
|
+
if (!openKey.value || pinned.value) return;
|
|
188
|
+
clearCloseTimer();
|
|
189
|
+
// Grace period so a diagonal path into the flyout doesn't drop it.
|
|
190
|
+
closeTimer = setTimeout(closeSubmenu, SUBMENU_CLOSE_DELAY);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** One measurement serves every flyout: same edge, same height. */
|
|
194
|
+
function measureSubmenu(): void {
|
|
195
|
+
const root = rootRef.value;
|
|
196
|
+
if (!root) return;
|
|
197
|
+
submenuPos.value = computeSubmenuPosition(
|
|
198
|
+
root.getBoundingClientRect(),
|
|
199
|
+
brandRef.value?.getBoundingClientRect() ?? null,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
118
203
|
watch(
|
|
119
|
-
|
|
120
|
-
(
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
else control.closeDrawer();
|
|
204
|
+
submenuActive,
|
|
205
|
+
(active) => {
|
|
206
|
+
if (active) void nextTick(measureSubmenu);
|
|
207
|
+
else closeSubmenu();
|
|
124
208
|
},
|
|
209
|
+
{ immediate: true },
|
|
125
210
|
);
|
|
126
211
|
|
|
127
|
-
watch(
|
|
128
|
-
()
|
|
129
|
-
|
|
130
|
-
|
|
212
|
+
watch(openKey, (key) => {
|
|
213
|
+
if (key) void nextTick(measureSubmenu);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
useWindowListener(
|
|
217
|
+
'resize',
|
|
218
|
+
() => {
|
|
219
|
+
viewportWidth.value = window.innerWidth;
|
|
220
|
+
if (submenuActive.value) measureSubmenu();
|
|
131
221
|
},
|
|
222
|
+
{ passive: true },
|
|
132
223
|
);
|
|
133
224
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
// any currently-expanded group that's no longer in `next`.
|
|
140
|
-
const set = new Set(next);
|
|
141
|
-
// Walk the markup to find every group key — that's the universe.
|
|
142
|
-
if (!rootRef.value) return;
|
|
143
|
-
const groups = rootRef.value.querySelectorAll<HTMLElement>(
|
|
144
|
-
'[data-sidebar-group][data-group-key]',
|
|
145
|
-
);
|
|
146
|
-
groups.forEach((el) => {
|
|
147
|
-
const key = el.dataset.groupKey ?? '';
|
|
148
|
-
if (!key) return;
|
|
149
|
-
if (set.has(key)) control!.expandGroup(key);
|
|
150
|
-
else control!.collapseGroup(key);
|
|
151
|
-
});
|
|
225
|
+
// Capture phase catches scrolls in any ancestor.
|
|
226
|
+
useWindowListener(
|
|
227
|
+
'scroll',
|
|
228
|
+
() => {
|
|
229
|
+
if (openKey.value) measureSubmenu();
|
|
152
230
|
},
|
|
153
|
-
{
|
|
231
|
+
{ capture: true, passive: true },
|
|
154
232
|
);
|
|
155
233
|
|
|
234
|
+
function onToggle(key: string, isTopLevel: boolean): void {
|
|
235
|
+
// A collapsed rail pins the flyout instead of rewriting `expandedGroups`,
|
|
236
|
+
// which describes the expanded sidebar.
|
|
237
|
+
if (submenuActive.value && isTopLevel) {
|
|
238
|
+
if (openKey.value === key && pinned.value) {
|
|
239
|
+
closeSubmenu();
|
|
240
|
+
} else {
|
|
241
|
+
clearCloseTimer();
|
|
242
|
+
openKey.value = key;
|
|
243
|
+
pinned.value = true;
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const next = new Set(expandedKeys.value);
|
|
249
|
+
if (next.has(key)) next.delete(key);
|
|
250
|
+
else next.add(key);
|
|
251
|
+
expandedKeys.value = next;
|
|
252
|
+
emit('update:expandedGroups', [...next]);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Pointer and focus share this path, so the two cannot drift apart. */
|
|
256
|
+
function onEntryEnter(topLevelKey: string | null): void {
|
|
257
|
+
if (!submenuActive.value) return;
|
|
258
|
+
if (topLevelKey) {
|
|
259
|
+
clearCloseTimer();
|
|
260
|
+
if (openKey.value !== topLevelKey) {
|
|
261
|
+
openKey.value = topLevelKey;
|
|
262
|
+
pinned.value = false;
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!pinned.value) scheduleClose();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
provide(SIDEBAR_CONTEXT, {
|
|
270
|
+
activeHref: computed(() => props.activeHref),
|
|
271
|
+
expandedKeys,
|
|
272
|
+
openKey,
|
|
273
|
+
submenuActive,
|
|
274
|
+
submenuPos,
|
|
275
|
+
onToggle,
|
|
276
|
+
onEntryEnter,
|
|
277
|
+
onEntryKeydown,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
function onPointerLeave(): void {
|
|
281
|
+
if (!submenuActive.value || pinned.value) return;
|
|
282
|
+
scheduleClose();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function onFocusOut(event: FocusEvent): void {
|
|
286
|
+
if (!openKey.value) return;
|
|
287
|
+
const next = event.relatedTarget;
|
|
288
|
+
// A pin survives the pointer, not the focus.
|
|
289
|
+
if (next instanceof Node && rootRef.value?.contains(next)) return;
|
|
290
|
+
closeSubmenu();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Entries in DOM order that focus can actually reach. */
|
|
294
|
+
function focusableEntries(): HTMLElement[] {
|
|
295
|
+
const root = rootRef.value;
|
|
296
|
+
if (!root) return [];
|
|
297
|
+
return [
|
|
298
|
+
...root.querySelectorAll<HTMLElement>(
|
|
299
|
+
'[data-sidebar-menu-item]:not([data-disabled="true"])',
|
|
300
|
+
),
|
|
301
|
+
// `visibilityProperty` is not the default, and a closed flyout hides
|
|
302
|
+
// with `visibility` — without it, Arrow-Down walks into dead entries.
|
|
303
|
+
].filter((el) => el.checkVisibility({ visibilityProperty: true }));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function onEntryKeydown(event: KeyboardEvent): void {
|
|
307
|
+
// The flyout is the innermost dismissible layer, so it goes first.
|
|
308
|
+
if (event.key === 'Escape' && openKey.value) {
|
|
309
|
+
event.preventDefault();
|
|
310
|
+
const selector = `[data-group-key="${CSS.escape(openKey.value)}"] [data-sidebar-group-toggle]`;
|
|
311
|
+
const toggle = rootRef.value?.querySelector<HTMLElement>(selector);
|
|
312
|
+
closeSubmenu();
|
|
313
|
+
toggle?.focus();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (event.key === 'Escape' && props.drawerOpen) {
|
|
318
|
+
event.preventDefault();
|
|
319
|
+
emit('update:drawerOpen', false);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const item = event.currentTarget;
|
|
324
|
+
if (!(item instanceof HTMLElement)) return;
|
|
325
|
+
|
|
326
|
+
const entries = focusableEntries();
|
|
327
|
+
const idx = entries.indexOf(item);
|
|
328
|
+
|
|
329
|
+
switch (event.key) {
|
|
330
|
+
case 'ArrowDown':
|
|
331
|
+
event.preventDefault();
|
|
332
|
+
entries[Math.min(idx + 1, entries.length - 1)]?.focus();
|
|
333
|
+
return;
|
|
334
|
+
case 'ArrowUp':
|
|
335
|
+
event.preventDefault();
|
|
336
|
+
entries[Math.max(idx - 1, 0)]?.focus();
|
|
337
|
+
return;
|
|
338
|
+
case 'Home':
|
|
339
|
+
event.preventDefault();
|
|
340
|
+
entries[0]?.focus();
|
|
341
|
+
return;
|
|
342
|
+
case 'End':
|
|
343
|
+
event.preventDefault();
|
|
344
|
+
entries[entries.length - 1]?.focus();
|
|
345
|
+
return;
|
|
346
|
+
case ' ':
|
|
347
|
+
case 'Spacebar':
|
|
348
|
+
// `<a>` doesn't activate on Space natively.
|
|
349
|
+
if (item.matches('[data-sidebar-link]')) {
|
|
350
|
+
event.preventDefault();
|
|
351
|
+
item.click();
|
|
352
|
+
}
|
|
353
|
+
return;
|
|
354
|
+
default:
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const drawerAria = computed(() =>
|
|
360
|
+
props.drawerOpen
|
|
361
|
+
? {
|
|
362
|
+
role: 'dialog',
|
|
363
|
+
// Booleans, so Vue renders "true"/"false" instead of dropping it.
|
|
364
|
+
'aria-modal': true,
|
|
365
|
+
'aria-label': props.ariaLabel,
|
|
366
|
+
}
|
|
367
|
+
: {},
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
let trap: FocusTrap | null = null;
|
|
371
|
+
let unlockScroll: (() => void) | null = null;
|
|
372
|
+
let triggerEl: HTMLElement | null = null;
|
|
373
|
+
|
|
156
374
|
watch(
|
|
157
|
-
() => props.
|
|
158
|
-
() => {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
375
|
+
() => props.drawerOpen,
|
|
376
|
+
async (open) => {
|
|
377
|
+
if (open) {
|
|
378
|
+
const active = document.activeElement;
|
|
379
|
+
triggerEl =
|
|
380
|
+
active instanceof HTMLElement &&
|
|
381
|
+
active !== document.body &&
|
|
382
|
+
!rootRef.value?.contains(active)
|
|
383
|
+
? active
|
|
384
|
+
: null;
|
|
385
|
+
|
|
386
|
+
unlockScroll = lockScroll();
|
|
387
|
+
await nextTick();
|
|
388
|
+
if (!panelRef.value) return;
|
|
389
|
+
trap = createFocusTrap(panelRef.value);
|
|
390
|
+
const first = focusableEntries()[0];
|
|
391
|
+
if (first) first.focus();
|
|
392
|
+
else panelRef.value.focus();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
trap?.release();
|
|
397
|
+
trap = null;
|
|
398
|
+
unlockScroll?.();
|
|
399
|
+
unlockScroll = null;
|
|
400
|
+
|
|
401
|
+
const trigger = triggerEl;
|
|
402
|
+
triggerEl = null;
|
|
403
|
+
if (trigger && document.contains(trigger)) trigger.focus();
|
|
162
404
|
},
|
|
163
|
-
{ deep: true },
|
|
164
405
|
);
|
|
165
406
|
|
|
166
407
|
onBeforeUnmount(() => {
|
|
167
|
-
|
|
168
|
-
|
|
408
|
+
clearCloseTimer();
|
|
409
|
+
trap?.release();
|
|
410
|
+
unlockScroll?.();
|
|
169
411
|
});
|
|
170
412
|
</script>
|
|
171
413
|
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<li v-if="item.type === 'link'" class="pui-sidebar__item">
|
|
3
|
+
<a
|
|
4
|
+
class="pui-sidebar__link"
|
|
5
|
+
data-sidebar-menu-item
|
|
6
|
+
data-sidebar-link
|
|
7
|
+
:href="item.href"
|
|
8
|
+
:data-disabled="item.disabled ? 'true' : undefined"
|
|
9
|
+
:aria-disabled="item.disabled ? 'true' : undefined"
|
|
10
|
+
:tabindex="item.disabled ? '-1' : undefined"
|
|
11
|
+
:aria-current="isCurrent ? 'page' : undefined"
|
|
12
|
+
@pointerover="ctx.onEntryEnter(topLevelKey)"
|
|
13
|
+
@focus="ctx.onEntryEnter(topLevelKey)"
|
|
14
|
+
@keydown="ctx.onEntryKeydown"
|
|
15
|
+
>
|
|
16
|
+
<Icon v-if="item.icon" :name="item.icon as IconName" size="sm" />
|
|
17
|
+
<span class="pui-sidebar__link-label">{{ item.label }}</span>
|
|
18
|
+
<span v-if="item.badge !== undefined" class="pui-sidebar__badge">{{
|
|
19
|
+
item.badge
|
|
20
|
+
}}</span>
|
|
21
|
+
</a>
|
|
22
|
+
</li>
|
|
23
|
+
|
|
24
|
+
<li
|
|
25
|
+
v-else-if="item.type === 'group'"
|
|
26
|
+
class="pui-sidebar__group"
|
|
27
|
+
data-sidebar-group
|
|
28
|
+
:data-group-key="groupKey"
|
|
29
|
+
:data-default-open="item.defaultOpen ? 'true' : undefined"
|
|
30
|
+
:data-state="isExpanded ? 'expanded' : 'collapsed'"
|
|
31
|
+
:data-active="containsCurrent ? 'true' : undefined"
|
|
32
|
+
:data-submenu-state="submenuState"
|
|
33
|
+
>
|
|
34
|
+
<!-- With an `href` the row is a link and the caret is its own
|
|
35
|
+
toggle; one control cannot both navigate and expand in place.
|
|
36
|
+
Without one, the whole row is the toggle. -->
|
|
37
|
+
<component
|
|
38
|
+
:is="item.href ? 'a' : 'button'"
|
|
39
|
+
class="pui-sidebar__group-toggle"
|
|
40
|
+
data-sidebar-menu-item
|
|
41
|
+
data-sidebar-group-toggle
|
|
42
|
+
:data-sidebar-link="item.href ? '' : undefined"
|
|
43
|
+
:type="item.href ? undefined : 'button'"
|
|
44
|
+
:href="item.href"
|
|
45
|
+
:aria-current="isCurrent ? 'page' : undefined"
|
|
46
|
+
:aria-expanded="
|
|
47
|
+
item.href && !isFlyout ? undefined : isFlyoutOrExpanded
|
|
48
|
+
"
|
|
49
|
+
:aria-controls="item.href && !isFlyout ? undefined : itemsId"
|
|
50
|
+
@click="item.href ? undefined : ctx.onToggle(groupKey, isTopLevel)"
|
|
51
|
+
@pointerover="ctx.onEntryEnter(ownKey)"
|
|
52
|
+
@focus="ctx.onEntryEnter(ownKey)"
|
|
53
|
+
@keydown="ctx.onEntryKeydown"
|
|
54
|
+
>
|
|
55
|
+
<Icon v-if="item.icon" :name="item.icon as IconName" size="sm" />
|
|
56
|
+
<span class="pui-sidebar__link-label">{{ item.label }}</span>
|
|
57
|
+
<Icon
|
|
58
|
+
v-if="!item.href"
|
|
59
|
+
name="chevron-down"
|
|
60
|
+
size="sm"
|
|
61
|
+
class="pui-sidebar__group-caret"
|
|
62
|
+
/>
|
|
63
|
+
</component>
|
|
64
|
+
<button
|
|
65
|
+
v-if="item.href"
|
|
66
|
+
type="button"
|
|
67
|
+
class="pui-sidebar__group-caret-toggle"
|
|
68
|
+
:aria-expanded="isFlyoutOrExpanded"
|
|
69
|
+
:aria-controls="itemsId"
|
|
70
|
+
:aria-label="`${isFlyoutOrExpanded ? 'Collapse' : 'Expand'} ${item.label}`"
|
|
71
|
+
@click="ctx.onToggle(groupKey, isTopLevel)"
|
|
72
|
+
>
|
|
73
|
+
<Icon
|
|
74
|
+
name="chevron-down"
|
|
75
|
+
size="sm"
|
|
76
|
+
class="pui-sidebar__group-caret"
|
|
77
|
+
/>
|
|
78
|
+
</button>
|
|
79
|
+
<ul
|
|
80
|
+
:id="itemsId"
|
|
81
|
+
class="pui-sidebar__group-items"
|
|
82
|
+
data-sidebar-group-items
|
|
83
|
+
role="group"
|
|
84
|
+
:hidden="isFlyout ? false : !isExpanded"
|
|
85
|
+
:style="flyoutStyle"
|
|
86
|
+
>
|
|
87
|
+
<SidebarMenuItem
|
|
88
|
+
v-for="(child, idx) in item.items"
|
|
89
|
+
:key="childKey(child, idx)"
|
|
90
|
+
:item="child"
|
|
91
|
+
:top-level-key="ownKey"
|
|
92
|
+
/>
|
|
93
|
+
</ul>
|
|
94
|
+
</li>
|
|
95
|
+
|
|
96
|
+
<li
|
|
97
|
+
v-else-if="item.type === 'section'"
|
|
98
|
+
class="pui-sidebar__section"
|
|
99
|
+
role="presentation"
|
|
100
|
+
>
|
|
101
|
+
<span class="pui-sidebar__section-label">{{ item.label }}</span>
|
|
102
|
+
</li>
|
|
103
|
+
</template>
|
|
104
|
+
|
|
105
|
+
<script setup lang="ts">
|
|
106
|
+
import { computed, inject, type CSSProperties } from 'vue';
|
|
107
|
+
import Icon from '../../display/icon/Icon.vue';
|
|
108
|
+
import type { IconName } from '../../../icons/index.js';
|
|
109
|
+
import { generateId } from '../../../utils/a11y/id.js';
|
|
110
|
+
import {
|
|
111
|
+
deriveGroupKey,
|
|
112
|
+
groupContainsActive,
|
|
113
|
+
isHrefActive,
|
|
114
|
+
} from '../../../utils/navigation/sidebar.js';
|
|
115
|
+
import { SIDEBAR_CONTEXT } from './context.js';
|
|
116
|
+
import type { SidebarItem } from './types.js';
|
|
117
|
+
|
|
118
|
+
const props = withDefaults(
|
|
119
|
+
defineProps<{
|
|
120
|
+
item: SidebarItem;
|
|
121
|
+
/** The rail group this entry belongs to; null if it has none. */
|
|
122
|
+
topLevelKey?: string | null;
|
|
123
|
+
}>(),
|
|
124
|
+
{ topLevelKey: null },
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const ctx = inject(SIDEBAR_CONTEXT)!;
|
|
128
|
+
|
|
129
|
+
const itemsId = generateId('pui-sidebar-group');
|
|
130
|
+
|
|
131
|
+
const groupKey = computed(() =>
|
|
132
|
+
props.item.type === 'group'
|
|
133
|
+
? (props.item.key ?? deriveGroupKey(props.item.label))
|
|
134
|
+
: '',
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const isTopLevel = computed(() => props.topLevelKey === null);
|
|
138
|
+
|
|
139
|
+
/** What descendants inherit as their `topLevelKey`. */
|
|
140
|
+
const ownKey = computed(() =>
|
|
141
|
+
props.item.type === 'group' && isTopLevel.value
|
|
142
|
+
? groupKey.value
|
|
143
|
+
: props.topLevelKey,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const isCurrent = computed(() => {
|
|
147
|
+
const href =
|
|
148
|
+
props.item.type === 'link'
|
|
149
|
+
? props.item.href
|
|
150
|
+
: props.item.type === 'group'
|
|
151
|
+
? props.item.href
|
|
152
|
+
: undefined;
|
|
153
|
+
return href !== undefined && isHrefActive(ctx.activeHref.value, href);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const containsCurrent = computed(
|
|
157
|
+
() =>
|
|
158
|
+
props.item.type === 'group' &&
|
|
159
|
+
groupContainsActive(props.item.items, ctx.activeHref.value),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const isExpanded = computed(() => ctx.expandedKeys.value.has(groupKey.value));
|
|
163
|
+
|
|
164
|
+
const isFlyout = computed(
|
|
165
|
+
() =>
|
|
166
|
+
ctx.submenuActive.value &&
|
|
167
|
+
props.item.type === 'group' &&
|
|
168
|
+
isTopLevel.value,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const isFlyoutOpen = computed(
|
|
172
|
+
() => isFlyout.value && ctx.openKey.value === groupKey.value,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
/** What `aria-expanded` reports: flyout state on the rail, else disclosure. */
|
|
176
|
+
const isFlyoutOrExpanded = computed(() =>
|
|
177
|
+
isFlyout.value ? isFlyoutOpen.value : isExpanded.value,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// The stylesheet keys on the attribute's presence, not just its value.
|
|
181
|
+
const submenuState = computed(() =>
|
|
182
|
+
isFlyout.value ? (isFlyoutOpen.value ? 'open' : 'closed') : undefined,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Positioned even when closed: a fixed box with no coordinates falls back
|
|
186
|
+
// to its static position, inside the rail.
|
|
187
|
+
const flyoutStyle = computed<CSSProperties | undefined>(() => {
|
|
188
|
+
if (!isFlyout.value || !ctx.submenuPos.value) return undefined;
|
|
189
|
+
const { left, top, height } = ctx.submenuPos.value;
|
|
190
|
+
return { left: `${left}px`, top: `${top}px`, height: `${height}px` };
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
function childKey(child: SidebarItem, idx: number): string {
|
|
194
|
+
if (child.type === 'link') return `link:${child.href}`;
|
|
195
|
+
if (child.type === 'group')
|
|
196
|
+
return `group:${child.key ?? deriveGroupKey(child.label)}`;
|
|
197
|
+
return `section:${child.label}:${idx}`;
|
|
198
|
+
}
|
|
199
|
+
</script>
|