@skewedaspect/sleekspace-ui 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/components/Button/SkButton.vue.d.ts +8 -0
  2. package/dist/components/NavBar/SkNavBar.vue.d.ts +1 -0
  3. package/dist/components/NavBar/context.d.ts +3 -0
  4. package/dist/components/Page/SkPage.vue.d.ts +127 -30
  5. package/dist/components/Page/SkPageSidebarToggle.vue.d.ts +41 -0
  6. package/dist/components/Page/index.d.ts +1 -0
  7. package/dist/components/Page/types.d.ts +28 -5
  8. package/dist/components/ScrollArea/SkScrollArea.vue.d.ts +9 -0
  9. package/dist/components/Select/SkSelectItem.vue.d.ts +6 -18
  10. package/dist/components/Sidebar/SkSidebar.vue.d.ts +9 -1
  11. package/dist/components/Skeleton/SkSkeleton.vue.d.ts +2 -2
  12. package/dist/components/Tabs/SkTabs.vue.d.ts +1 -1
  13. package/dist/components/TreeView/SkTreeView.vue.d.ts +5 -5
  14. package/dist/composables/useFocusTrap.d.ts +17 -0
  15. package/dist/composables/useFocusTrap.test.d.ts +1 -0
  16. package/dist/composables/usePageDrawer.d.ts +35 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/sleekspace-ui.css +984 -291
  19. package/dist/sleekspace-ui.es.js +31559 -29868
  20. package/dist/sleekspace-ui.umd.js +32210 -30438
  21. package/dist/styles/mixins/fluidSize.test.d.ts +1 -0
  22. package/dist/tokens.css +60 -0
  23. package/llms-full.txt +6349 -0
  24. package/llms.txt +46 -0
  25. package/package.json +16 -11
  26. package/src/components/Button/SkButton.vue +25 -13
  27. package/src/components/NavBar/SkNavBar.vue +12 -1
  28. package/src/components/NavBar/context.ts +16 -0
  29. package/src/components/Page/SkPage.vue +460 -72
  30. package/src/components/Page/SkPageSidebarToggle.vue +148 -0
  31. package/src/components/Page/index.ts +1 -0
  32. package/src/components/Page/types.ts +30 -5
  33. package/src/components/ScrollArea/SkScrollArea.vue +12 -0
  34. package/src/components/Select/SkSelectItem.vue +2 -2
  35. package/src/components/Sidebar/SkSidebar.vue +10 -0
  36. package/src/components/TreeView/SkTreeView.vue +6 -6
  37. package/src/composables/useFocusTrap.test.ts +184 -0
  38. package/src/composables/useFocusTrap.ts +141 -0
  39. package/src/composables/usePageDrawer.ts +96 -0
  40. package/src/global.d.ts +1 -0
  41. package/src/index.ts +5 -0
  42. package/src/styles/components/_accordion.scss +15 -0
  43. package/src/styles/components/_alert.scss +1 -0
  44. package/src/styles/components/_avatar.scss +1 -0
  45. package/src/styles/components/_breadcrumbs.scss +7 -0
  46. package/src/styles/components/_button.scss +291 -214
  47. package/src/styles/components/_checkbox.scss +9 -1
  48. package/src/styles/components/_collapsible.scss +15 -0
  49. package/src/styles/components/_color-picker.scss +4 -1
  50. package/src/styles/components/_input.scss +1 -0
  51. package/src/styles/components/_listbox.scss +8 -2
  52. package/src/styles/components/_menu.scss +9 -2
  53. package/src/styles/components/_modal.scss +18 -2
  54. package/src/styles/components/_navbar.scss +22 -6
  55. package/src/styles/components/_number-input.scss +1 -0
  56. package/src/styles/components/_page.scss +220 -12
  57. package/src/styles/components/_pagination.scss +10 -1
  58. package/src/styles/components/_panel.scss +8 -3
  59. package/src/styles/components/_popover.scss +15 -2
  60. package/src/styles/components/_progress.scss +14 -0
  61. package/src/styles/components/_radio.scss +8 -1
  62. package/src/styles/components/_scroll-area.scss +56 -0
  63. package/src/styles/components/_select.scss +3 -1
  64. package/src/styles/components/_sidebar.scss +78 -38
  65. package/src/styles/components/_skeleton.scss +18 -0
  66. package/src/styles/components/_slider.scss +1 -0
  67. package/src/styles/components/_spinner.scss +15 -0
  68. package/src/styles/components/_switch.scss +5 -0
  69. package/src/styles/components/_table.scss +1 -0
  70. package/src/styles/components/_tabs.scss +6 -0
  71. package/src/styles/components/_tag.scss +2 -0
  72. package/src/styles/components/_tags-input.scss +1 -0
  73. package/src/styles/components/_textarea.scss +1 -0
  74. package/src/styles/components/_toast.scss +16 -1
  75. package/src/styles/components/_toolbar.scss +2 -0
  76. package/src/styles/components/_tooltip.scss +14 -1
  77. package/src/styles/components/_tree-view.scss +6 -1
  78. package/src/styles/mixins/_index.scss +1 -0
  79. package/src/styles/mixins/_responsive.scss +184 -0
  80. package/src/styles/mixins/fluidSize.test.ts +149 -0
  81. package/src/styles/tokens/_foundation-breakpoints.scss +26 -0
  82. package/src/styles/tokens/_foundation-z-index.scss +38 -0
  83. package/src/styles/tokens/index.scss +2 -0
  84. package/web-types.json +194 -14
@@ -0,0 +1,148 @@
1
+ <!----------------------------------------------------------------------------------------------------------------------
2
+ - Page Sidebar Toggle Component
3
+ --------------------------------------------------------------------------------------------------------------------->
4
+
5
+ <template>
6
+ <SkButton
7
+ v-if="drawer.isAvailable.value"
8
+ variant="ghost"
9
+ dense
10
+ :kind="effectiveKind"
11
+ :pressed="drawer.isOpen.value"
12
+ :aria-expanded="drawer.isOpen.value"
13
+ :aria-label="ariaLabel"
14
+ :class="classes"
15
+ @click="drawer.toggle"
16
+ >
17
+ <template #icon>
18
+ <slot>
19
+ <!-- Default glyphs: hamburger for the sidebar, vertical kebab for the aside. Override
20
+ via the default slot with any inline-sized element that inherits currentColor. -->
21
+ <svg
22
+ v-if="side === 'sidebar'"
23
+ viewBox="0 0 20 20"
24
+ width="20"
25
+ height="20"
26
+ aria-hidden="true"
27
+ focusable="false"
28
+ >
29
+ <path
30
+ d="M3 5h14M3 10h14M3 15h14"
31
+ stroke="currentColor"
32
+ stroke-width="2"
33
+ stroke-linecap="round"
34
+ fill="none"
35
+ />
36
+ </svg>
37
+ <svg
38
+ v-else
39
+ viewBox="0 0 20 20"
40
+ width="20"
41
+ height="20"
42
+ aria-hidden="true"
43
+ focusable="false"
44
+ >
45
+ <circle cx="10" cy="4" r="1.6" fill="currentColor" />
46
+ <circle cx="10" cy="10" r="1.6" fill="currentColor" />
47
+ <circle cx="10" cy="16" r="1.6" fill="currentColor" />
48
+ </svg>
49
+ </slot>
50
+ </template>
51
+ </SkButton>
52
+ </template>
53
+
54
+ <!--------------------------------------------------------------------------------------------------------------------->
55
+
56
+ <script setup lang="ts">
57
+ /**
58
+ * @component SkPageSidebarToggle
59
+ * @description Button that toggles one of SkPage's drawers. Thin wrapper around SkButton -- inherits the
60
+ * design system's chrome and ghost-variant hover/pressed behavior. Auto-connects to the nearest SkPage
61
+ * via provide/inject, so no wiring is required; hides itself when the targeted panel is persistent.
62
+ * Use `side="sidebar"` (default) for the left drawer and `side="aside"` for the right. Default glyph is
63
+ * a hamburger for the sidebar and a vertical kebab for the aside so both toggles can live in the same
64
+ * navbar without visual collision; override via the default slot if you want something else.
65
+ *
66
+ * @example Twin toggles in a navbar
67
+ * ```vue
68
+ * <SkNavBar>
69
+ * <template #leading><SkPageSidebarToggle /></template>
70
+ * <template #brand>MyApp</template>
71
+ * <template #actions><SkPageSidebarToggle side="aside" /></template>
72
+ * </SkNavBar>
73
+ * ```
74
+ *
75
+ * @slot default - Custom glyph. Replaces the built-in SVG. Should be inline-sized and inherit
76
+ * `currentColor`.
77
+ */
78
+
79
+ import { computed, inject } from 'vue';
80
+
81
+ // Types
82
+ import type { SkButtonKind } from '../Button/types';
83
+ import type { SkPageDrawerSide } from './types';
84
+
85
+ // Components
86
+ import SkButton from '../Button/SkButton.vue';
87
+
88
+ // Composables
89
+ import { usePageDrawer } from '@/composables/usePageDrawer';
90
+ import { NAVBAR_KIND_KEY } from '../NavBar/context';
91
+
92
+ //------------------------------------------------------------------------------------------------------------------
93
+
94
+ export interface SkPageSidebarToggleComponentProps
95
+ {
96
+ /**
97
+ * Which drawer this toggle controls. `sidebar` binds to the left drawer; `aside` binds to the right.
98
+ * @default 'sidebar'
99
+ */
100
+ side ?: SkPageDrawerSide;
101
+
102
+ /**
103
+ * Override the toggle's color kind. By default the toggle inherits the enclosing SkNavBar's
104
+ * kind (when placed inside one) and falls back to `neutral` otherwise. Pass a kind explicitly
105
+ * to force a specific color scheme.
106
+ */
107
+ kind ?: SkButtonKind;
108
+
109
+ /**
110
+ * Accessible label for the toggle. Screen readers announce this; sighted users see only the glyph.
111
+ * Defaults to a label matching `side`.
112
+ */
113
+ ariaLabel ?: string;
114
+ }
115
+
116
+ //------------------------------------------------------------------------------------------------------------------
117
+
118
+ const props = withDefaults(defineProps<SkPageSidebarToggleComponentProps>(), {
119
+ side: 'sidebar',
120
+ kind: undefined,
121
+ ariaLabel: undefined,
122
+ });
123
+
124
+ //------------------------------------------------------------------------------------------------------------------
125
+
126
+ const drawer = usePageDrawer(props.side);
127
+ const navbarKind = inject(NAVBAR_KIND_KEY, null);
128
+
129
+ const effectiveKind = computed<SkButtonKind>(() =>
130
+ {
131
+ if(props.kind) { return props.kind; }
132
+ if(navbarKind) { return navbarKind.value as SkButtonKind; }
133
+ return 'neutral';
134
+ });
135
+
136
+ const ariaLabel = computed<string>(() =>
137
+ {
138
+ if(props.ariaLabel) { return props.ariaLabel; }
139
+ return props.side === 'aside' ? 'Toggle aside' : 'Toggle sidebar';
140
+ });
141
+
142
+ const classes = computed(() => ({
143
+ 'sk-page-sidebar-toggle': true,
144
+ [`sk-page-sidebar-toggle-${ props.side }`]: true,
145
+ }));
146
+ </script>
147
+
148
+ <!--------------------------------------------------------------------------------------------------------------------->
@@ -3,6 +3,7 @@
3
3
  //----------------------------------------------------------------------------------------------------------------------
4
4
 
5
5
  export { default as SkPage } from './SkPage.vue';
6
+ export { default as SkPageSidebarToggle } from './SkPageSidebarToggle.vue';
6
7
  export * from './types';
7
8
 
8
9
  //----------------------------------------------------------------------------------------------------------------------
@@ -6,22 +6,47 @@ import type { SkThemeName } from '../Theme/types';
6
6
 
7
7
  //----------------------------------------------------------------------------------------------------------------------
8
8
 
9
- /** Sidebar position */
10
- export type SkPageSidebarPosition = 'left' | 'right';
9
+ /**
10
+ * Sidebar / aside rendering mode.
11
+ * - `auto`: persistent above the matching breakpoint, drawer below. Default.
12
+ * - `persistent`: always inline, regardless of viewport size.
13
+ * - `drawer`: always off-canvas, opens over content.
14
+ */
15
+ export type SkPagePanelMode = 'auto' | 'persistent' | 'drawer';
16
+
17
+ /** @deprecated Use `SkPagePanelMode`. Retained as an alias. */
18
+ export type SkPageSidebarMode = SkPagePanelMode;
19
+
20
+ /** Which drawer a toggle button controls. */
21
+ export type SkPageDrawerSide = 'sidebar' | 'aside';
11
22
 
12
23
  /** Page props interface */
13
24
  export interface SkPageProps
14
25
  {
15
- /** Sidebar position */
16
- sidebarPosition ?: SkPageSidebarPosition;
17
26
  /** Fixed header (stays at top when scrolling) */
18
27
  fixedHeader ?: boolean;
19
28
  /** Fixed footer (stays at bottom when scrolling) */
20
29
  fixedFooter ?: boolean;
21
- /** Custom sidebar width */
30
+ /** Custom sidebar width. CSS length. */
22
31
  sidebarWidth ?: string;
32
+ /** Custom aside width. CSS length. */
33
+ asideWidth ?: string;
34
+ /** Sidebar rendering mode. Defaults to 'auto' (persistent above breakpoint, drawer below) */
35
+ sidebarMode ?: SkPagePanelMode;
36
+ /** Aside rendering mode. Defaults to 'auto'. */
37
+ asideMode ?: SkPagePanelMode;
38
+ /** Breakpoint below which sidebar `auto` mode switches to drawer. CSS length. Default: 1024px */
39
+ sidebarBreakpoint ?: string;
40
+ /** Breakpoint below which aside `auto` mode switches to drawer. CSS length. Default: 1024px */
41
+ asideBreakpoint ?: string;
42
+ /** Controlled sidebar drawer open state. When omitted, SkPage manages state internally */
43
+ sidebarOpen ?: boolean;
44
+ /** Controlled aside drawer open state. When omitted, SkPage manages state internally */
45
+ asideOpen ?: boolean;
23
46
  /** Optional theme name — when provided, SkPage acts as a theme provider */
24
47
  theme ?: SkThemeName;
48
+ /** Flush layout (no default gap/padding around main area or inside content) */
49
+ flush ?: boolean;
25
50
  }
26
51
 
27
52
  //----------------------------------------------------------------------------------------------------------------------
@@ -58,6 +58,15 @@
58
58
  kind ?: SkScrollAreaKind;
59
59
  baseColor ?: string;
60
60
  textColor ?: string;
61
+
62
+ /**
63
+ * When true, fade the scrollable edges with a CSS mask so content visibly tapers
64
+ * into/out of the viewport. Applies to whichever axis (or both) is scrollable.
65
+ * Override the fade distance via the `--sk-scroll-area-fade` custom property
66
+ * (default 1.5rem).
67
+ * @default false
68
+ */
69
+ fade ?: boolean;
61
70
  }
62
71
 
63
72
  //------------------------------------------------------------------------------------------------------------------
@@ -68,6 +77,7 @@
68
77
  kind: 'neutral',
69
78
  baseColor: undefined,
70
79
  textColor: undefined,
80
+ fade: false,
71
81
  });
72
82
 
73
83
  //------------------------------------------------------------------------------------------------------------------
@@ -81,6 +91,8 @@
81
91
  const classes = computed(() => ({
82
92
  'sk-scroll-area': true,
83
93
  [`sk-${ props.kind }`]: true,
94
+ [`sk-${ props.orientation }`]: true,
95
+ 'sk-fade': props.fade,
84
96
  }));
85
97
  </script>
86
98
 
@@ -82,8 +82,8 @@
82
82
  //------------------------------------------------------------------------------------------------------------------
83
83
 
84
84
  const textEl = useTemplateRef<InstanceType<typeof SelectItemText>>('textEl');
85
- const register = inject<(value : string, label : string) => void>('sk-select-register', undefined);
86
- const unregister = inject<(value : string) => void>('sk-select-unregister', undefined);
85
+ const register = inject<((value : string, label : string) => void) | undefined>('sk-select-register', undefined);
86
+ const unregister = inject<((value : string) => void) | undefined>('sk-select-unregister', undefined);
87
87
 
88
88
  onMounted(() =>
89
89
  {
@@ -86,6 +86,14 @@
86
86
  * @default 'left'
87
87
  */
88
88
  side ?: SkSidebarSide;
89
+
90
+ /**
91
+ * Opts out of the coarse-pointer touch-target floor on sidebar items (44px minimum on
92
+ * touch devices). Use in compact navigation contexts where density matters more than
93
+ * tap comfort. No effect on fine-pointer (mouse) devices.
94
+ * @default false
95
+ */
96
+ dense ?: boolean;
89
97
  }
90
98
 
91
99
  //------------------------------------------------------------------------------------------------------------------
@@ -94,6 +102,7 @@
94
102
  kind: 'neutral',
95
103
  width: '180px',
96
104
  side: 'left',
105
+ dense: false,
97
106
  });
98
107
 
99
108
  //------------------------------------------------------------------------------------------------------------------
@@ -114,6 +123,7 @@
114
123
  'sk-sidebar': true,
115
124
  [`sk-${ props.kind }`]: true,
116
125
  'sk-sidebar-right': props.side === 'right',
126
+ 'sk-dense': props.dense,
117
127
  };
118
128
  });
119
129
 
@@ -12,7 +12,7 @@
12
12
  :default-expanded="expandedKeys"
13
13
  :class="classes"
14
14
  :style="customColorStyles"
15
- @update:model-value="$emit('update:modelValue', $event)"
15
+ @update:model-value="$emit('update:modelValue', $event as T | T[])"
16
16
  >
17
17
  <template #default="{ flattenItems }">
18
18
  <slot :flatten-items="flattenItems" />
@@ -40,11 +40,11 @@
40
40
 
41
41
  //------------------------------------------------------------------------------------------------------------------
42
42
 
43
- export interface SkTreeViewComponentProps
43
+ export interface SkTreeViewComponentProps<TItem extends Record<string, unknown> = Record<string, unknown>>
44
44
  {
45
- items : T[];
46
- getKey : (item : T) => string;
47
- modelValue ?: T | T[];
45
+ items : TItem[];
46
+ getKey : (item : TItem) => string;
47
+ modelValue ?: TItem | TItem[];
48
48
  multiple ?: boolean;
49
49
  propagateSelect ?: boolean;
50
50
  kind ?: SkTreeViewKind;
@@ -55,7 +55,7 @@
55
55
 
56
56
  //------------------------------------------------------------------------------------------------------------------
57
57
 
58
- const props = withDefaults(defineProps<SkTreeViewComponentProps>(), {
58
+ const props = withDefaults(defineProps<SkTreeViewComponentProps<T>>(), {
59
59
  modelValue: undefined,
60
60
  multiple: false,
61
61
  propagateSelect: false,
@@ -0,0 +1,184 @@
1
+ //----------------------------------------------------------------------------------------------------------------------
2
+ // Focus Trap Composable Tests
3
+ //----------------------------------------------------------------------------------------------------------------------
4
+
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { defineComponent, h, nextTick, ref } from 'vue';
7
+ import { mount } from '@vue/test-utils';
8
+ import { useFocusTrap } from './useFocusTrap';
9
+
10
+ //----------------------------------------------------------------------------------------------------------------------
11
+ // Harness
12
+ //
13
+ // Renders a container with a few focusable elements plus an outside button. The active/container
14
+ // refs are exposed on the component instance so tests can flip the trap on and off.
15
+ //----------------------------------------------------------------------------------------------------------------------
16
+
17
+ function makeHarness(onEscape ?: () => void) : ReturnType<typeof defineComponent>
18
+ {
19
+ return defineComponent({
20
+ setup()
21
+ {
22
+ const active = ref<boolean>(false);
23
+ const container = ref<HTMLElement | null>(null);
24
+ useFocusTrap({ active, container, onEscape });
25
+ return { active, container };
26
+ },
27
+ render()
28
+ {
29
+ return h('div', [
30
+ h('button', { id: 'outside', type: 'button' }, 'outside'),
31
+ h(
32
+ 'div',
33
+ {
34
+ ref: 'container',
35
+ tabindex: '-1',
36
+ },
37
+ [
38
+ h('button', { id: 'first', type: 'button' }, 'first'),
39
+ h('input', { id: 'middle', type: 'text' }),
40
+ h('button', { id: 'last', type: 'button' }, 'last'),
41
+ ]
42
+ ),
43
+ ]);
44
+ },
45
+ });
46
+ }
47
+
48
+ //----------------------------------------------------------------------------------------------------------------------
49
+
50
+ describe('useFocusTrap', () =>
51
+ {
52
+ let root : HTMLDivElement;
53
+
54
+ beforeEach(() =>
55
+ {
56
+ // happy-dom's elementFromPoint + layout are limited, so stub getClientRects to return
57
+ // a non-empty list on all our fixture elements (getFocusable filters on rects.length).
58
+ Element.prototype.getClientRects = vi.fn(() => ({
59
+ length: 1,
60
+ item: () => null,
61
+ *[Symbol.iterator]() { yield { width: 1, height: 1 } as DOMRect; },
62
+ } as unknown as DOMRectList));
63
+
64
+ root = document.createElement('div');
65
+ document.body.appendChild(root);
66
+ });
67
+
68
+ afterEach(() =>
69
+ {
70
+ document.body.removeChild(root);
71
+ vi.restoreAllMocks();
72
+ });
73
+
74
+ //------------------------------------------------------------------------------------------------------------------
75
+
76
+ it('focuses the first focusable descendant when activated', async () =>
77
+ {
78
+ const wrapper = mount(makeHarness(), { attachTo: root });
79
+ wrapper.vm.active = true;
80
+ await nextTick();
81
+ // nextTick inside the watcher also runs an async nextTick before focusing; wait one more.
82
+ await nextTick();
83
+
84
+ const first = wrapper.find('#first').element as HTMLButtonElement;
85
+ expect(document.activeElement).toBe(first);
86
+ wrapper.unmount();
87
+ });
88
+
89
+ it('restores focus to the previously focused element on deactivate', async () =>
90
+ {
91
+ const wrapper = mount(makeHarness(), { attachTo: root });
92
+ const outside = wrapper.find('#outside').element as HTMLButtonElement;
93
+ outside.focus();
94
+
95
+ wrapper.vm.active = true;
96
+ await nextTick();
97
+ await nextTick();
98
+
99
+ wrapper.vm.active = false;
100
+ await nextTick();
101
+
102
+ expect(document.activeElement).toBe(outside);
103
+ wrapper.unmount();
104
+ });
105
+
106
+ it('tab from the last focusable cycles back to the first', async () =>
107
+ {
108
+ const wrapper = mount(makeHarness(), { attachTo: root });
109
+ wrapper.vm.active = true;
110
+ await nextTick();
111
+ await nextTick();
112
+
113
+ const last = wrapper.find('#last').element as HTMLButtonElement;
114
+ const first = wrapper.find('#first').element as HTMLButtonElement;
115
+ last.focus();
116
+
117
+ const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true });
118
+ document.dispatchEvent(event);
119
+ expect(document.activeElement).toBe(first);
120
+ wrapper.unmount();
121
+ });
122
+
123
+ it('shift-tab from the first focusable cycles to the last', async () =>
124
+ {
125
+ const wrapper = mount(makeHarness(), { attachTo: root });
126
+ wrapper.vm.active = true;
127
+ await nextTick();
128
+ await nextTick();
129
+
130
+ const first = wrapper.find('#first').element as HTMLButtonElement;
131
+ const last = wrapper.find('#last').element as HTMLButtonElement;
132
+ first.focus();
133
+
134
+ const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true });
135
+ document.dispatchEvent(event);
136
+ expect(document.activeElement).toBe(last);
137
+ wrapper.unmount();
138
+ });
139
+
140
+ it('if focus escapes to an element outside the container, tab pulls it back to first', async () =>
141
+ {
142
+ const wrapper = mount(makeHarness(), { attachTo: root });
143
+ wrapper.vm.active = true;
144
+ await nextTick();
145
+ await nextTick();
146
+
147
+ const outside = wrapper.find('#outside').element as HTMLButtonElement;
148
+ const first = wrapper.find('#first').element as HTMLButtonElement;
149
+ // Simulate something stealing focus outside the trap (e.g. a scripted focus call).
150
+ outside.focus();
151
+
152
+ const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true });
153
+ document.dispatchEvent(event);
154
+ expect(document.activeElement).toBe(first);
155
+ wrapper.unmount();
156
+ });
157
+
158
+ it('calls onEscape when Escape is pressed while active', async () =>
159
+ {
160
+ const onEscape = vi.fn();
161
+ const wrapper = mount(makeHarness(onEscape), { attachTo: root });
162
+ wrapper.vm.active = true;
163
+ await nextTick();
164
+ await nextTick();
165
+
166
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
167
+ expect(onEscape).toHaveBeenCalledTimes(1);
168
+ wrapper.unmount();
169
+ });
170
+
171
+ it('does not react to keypresses when inactive', async () =>
172
+ {
173
+ const onEscape = vi.fn();
174
+ const wrapper = mount(makeHarness(onEscape), { attachTo: root });
175
+ // Never activate.
176
+
177
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
178
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
179
+ expect(onEscape).not.toHaveBeenCalled();
180
+ wrapper.unmount();
181
+ });
182
+ });
183
+
184
+ //----------------------------------------------------------------------------------------------------------------------
@@ -0,0 +1,141 @@
1
+ //----------------------------------------------------------------------------------------------------------------------
2
+ // Focus Trap Composable
3
+ //
4
+ // Minimal, dependency-free focus trap for modal/drawer patterns. Traps tab cycling inside a
5
+ // container while active, saves the previously-focused element on activation, and restores it
6
+ // on deactivation. Pair with your own ESC handler and overlay click-to-close as needed.
7
+ //----------------------------------------------------------------------------------------------------------------------
8
+
9
+ import { type Ref, nextTick, watch } from 'vue';
10
+
11
+ //----------------------------------------------------------------------------------------------------------------------
12
+
13
+ const FOCUSABLE_SELECTOR = [
14
+ 'a[href]',
15
+ 'button:not([disabled])',
16
+ 'input:not([disabled]):not([type="hidden"])',
17
+ 'select:not([disabled])',
18
+ 'textarea:not([disabled])',
19
+ '[tabindex]:not([tabindex="-1"])',
20
+ 'audio[controls]',
21
+ 'video[controls]',
22
+ 'details > summary:first-of-type',
23
+ ].join(',');
24
+
25
+ function getFocusable(container : HTMLElement) : HTMLElement[]
26
+ {
27
+ const nodes = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
28
+ return Array.from(nodes).filter((el) =>
29
+ {
30
+ // Reject visually hidden or inert elements.
31
+ if(el.hasAttribute('inert')) { return false; }
32
+ if(el.getAttribute('aria-hidden') === 'true') { return false; }
33
+ const rects = el.getClientRects();
34
+ return rects.length > 0;
35
+ });
36
+ }
37
+
38
+ //----------------------------------------------------------------------------------------------------------------------
39
+
40
+ export interface UseFocusTrapOptions
41
+ {
42
+ /** When true, the trap is active: focus moves inside and tab cycling is constrained. */
43
+ active : Ref<boolean>;
44
+
45
+ /** Element whose descendants form the trap region. */
46
+ container : Ref<HTMLElement | null>;
47
+
48
+ /**
49
+ * Optional callback fired when the user presses Escape. Most consumers will use this to
50
+ * close the modal/drawer.
51
+ */
52
+ onEscape ?: () => void;
53
+ }
54
+
55
+ /**
56
+ * Trap focus inside a container while `active` is true. Restores focus to the previously
57
+ * active element when deactivated.
58
+ */
59
+ export function useFocusTrap(options : UseFocusTrapOptions) : void
60
+ {
61
+ const { active, container, onEscape } = options;
62
+
63
+ let previouslyFocused : HTMLElement | null = null;
64
+
65
+ function handleKeydown(event : KeyboardEvent) : void
66
+ {
67
+ if(!active.value || !container.value) { return; }
68
+
69
+ if(event.key === 'Escape' && onEscape)
70
+ {
71
+ event.stopPropagation();
72
+ onEscape();
73
+ return;
74
+ }
75
+
76
+ if(event.key !== 'Tab') { return; }
77
+
78
+ const focusable = getFocusable(container.value);
79
+ if(focusable.length === 0)
80
+ {
81
+ event.preventDefault();
82
+ return;
83
+ }
84
+
85
+ const first = focusable[0];
86
+ const last = focusable[focusable.length - 1];
87
+ const activeEl = document.activeElement as HTMLElement | null;
88
+
89
+ if(event.shiftKey && (activeEl === first || !container.value.contains(activeEl)))
90
+ {
91
+ event.preventDefault();
92
+ last.focus();
93
+ }
94
+ else if(!event.shiftKey && (activeEl === last || !container.value.contains(activeEl)))
95
+ {
96
+ event.preventDefault();
97
+ first.focus();
98
+ }
99
+ }
100
+
101
+ watch(active, async (isActive) =>
102
+ {
103
+ if(isActive)
104
+ {
105
+ previouslyFocused = document.activeElement as HTMLElement | null;
106
+ document.addEventListener('keydown', handleKeydown, true);
107
+
108
+ // Wait a tick for the container to mount, then focus the first focusable.
109
+ //
110
+ // `preventScroll: true` is load-bearing here: if the container is mid-transition and
111
+ // sits off-screen (e.g. a drawer whose enter-from transform parks it outside the
112
+ // viewport), the browser will otherwise scroll the nearest scrollable ancestor to
113
+ // bring the focused element into view -- yanking the entire page sideways while the
114
+ // drawer slides in.
115
+ await nextTick();
116
+ if(container.value)
117
+ {
118
+ const focusable = getFocusable(container.value);
119
+ if(focusable.length > 0)
120
+ {
121
+ focusable[0].focus({ preventScroll: true });
122
+ }
123
+ else
124
+ {
125
+ container.value.focus({ preventScroll: true });
126
+ }
127
+ }
128
+ }
129
+ else
130
+ {
131
+ document.removeEventListener('keydown', handleKeydown, true);
132
+ if(previouslyFocused && typeof previouslyFocused.focus === 'function')
133
+ {
134
+ previouslyFocused.focus({ preventScroll: true });
135
+ }
136
+ previouslyFocused = null;
137
+ }
138
+ });
139
+ }
140
+
141
+ //----------------------------------------------------------------------------------------------------------------------