@karyl-chan/ui 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karyl-chan/ui",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Shared Vue UI primitives, composables, and design tokens used by karyl-chan's bot frontend and plugin webuis.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,6 +44,7 @@
44
44
  "devDependencies": {
45
45
  "@iconify/vue": "^5.0.0",
46
46
  "@popperjs/core": "^2.11.8",
47
+ "@vitejs/plugin-vue": "^5.2.4",
47
48
  "@vue/test-utils": "^2.4.8",
48
49
  "@vue/tsconfig": "^0.7.0",
49
50
  "jsdom": "^29.0.2",
@@ -57,6 +58,7 @@
57
58
  "vue-tsc": "^2.2.0"
58
59
  },
59
60
  "scripts": {
60
- "typecheck": "vue-tsc --noEmit"
61
+ "typecheck": "vue-tsc --noEmit",
62
+ "test": "vitest run"
61
63
  }
62
64
  }
@@ -1,8 +1,11 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, onUnmounted, toRef, useId } from 'vue';
2
+ import { computed, nextTick, onBeforeUnmount, ref, useId, useSlots, watch } from 'vue';
3
3
  import { Icon } from '@iconify/vue';
4
4
  import { useBreakpoint } from '../composables/use-breakpoint';
5
5
  import { useDrawer } from '../composables/use-drawer';
6
+ import { useEscapeStack } from '../composables/use-escape-stack';
7
+ import { useBodyScrollLock } from '../composables/use-scroll-lock';
8
+ import { getFocusable, trapTabKey } from '../lib/focus';
6
9
 
7
10
  /**
8
11
  * Viewport-aware modal:
@@ -19,14 +22,31 @@ import { useDrawer } from '../composables/use-drawer';
19
22
  * full-bleed content (tables, pickers, croppers) that manages its own
20
23
  * edges.
21
24
  *
22
- * `closeOnBackdrop` / `closeOnEscape` default to true. Escape closure on
23
- * desktop is wired locally; on mobile it's handled by useDrawer's escape
24
- * stack. Backdrop click emits `close` either way.
25
+ * `closeOnBackdrop` / `closeOnEscape` default to true. Escape goes
26
+ * through the shared escape stack on both branches, so with stacked
27
+ * overlays only the topmost one closes. Backdrop close requires the
28
+ * press to START and END on the backdrop — a text-selection drag that
29
+ * ends outside the panel doesn't dismiss.
30
+ *
31
+ * Accessibility: the panel is `role="dialog"` + `aria-modal`, labelled by
32
+ * the title (or `ariaLabel` when there is none). On open, focus moves to
33
+ * the first `[autofocus]` element, else the first focusable control in
34
+ * the body, else the panel itself; Tab / Shift+Tab cycle inside the
35
+ * panel; on close focus returns to whatever had it before. While any
36
+ * AppModal is open the page behind it can't scroll (ref-counted, so
37
+ * stacked modals behave).
25
38
  */
26
39
  const props = withDefaults(defineProps<{
27
40
  visible: boolean;
28
41
  /** Heading rendered in the panel's header. Hidden when blank. */
29
42
  title?: string;
43
+ /**
44
+ * Accessible name for the dialog when there is no `title` / `#header`
45
+ * (those label the dialog via aria-labelledby and take precedence).
46
+ */
47
+ ariaLabel?: string;
48
+ /** Accessible label of the header close button. Default: 'Close'. */
49
+ closeLabel?: string;
30
50
  closeOnBackdrop?: boolean;
31
51
  closeOnEscape?: boolean;
32
52
  /** Width of the desktop panel; mobile drawer ignores this. */
@@ -38,6 +58,8 @@ const props = withDefaults(defineProps<{
38
58
  padding?: string;
39
59
  }>(), {
40
60
  title: '',
61
+ ariaLabel: undefined,
62
+ closeLabel: 'Close',
41
63
  closeOnBackdrop: true,
42
64
  closeOnEscape: true,
43
65
  width: 'min(440px, 92vw)',
@@ -48,37 +70,112 @@ const emit = defineEmits<{
48
70
  (e: 'close'): void;
49
71
  }>();
50
72
 
73
+ const slots = useSlots();
74
+
51
75
  const { isMobile } = useBreakpoint();
52
- const visibleRef = toRef(props, 'visible');
76
+ const visibleRef = computed(() => props.visible);
53
77
  const desktopVisible = computed(() => props.visible && !isMobile.value);
54
78
  const drawerVisible = computed(() => props.visible && isMobile.value);
55
79
 
56
80
  const titleId = useId();
81
+ const hasHeader = computed(() => !!props.title || !!slots.header);
82
+ const labelledBy = computed(() => (hasHeader.value ? titleId : undefined));
83
+ const label = computed(() => (hasHeader.value ? undefined : props.ariaLabel || undefined));
57
84
 
58
- // Desktop Escape handler — useDrawer already covers the mobile branch
59
- // via its escape stack, so we only listen when the popover branch is
60
- // active. Listening unconditionally would let a single Escape close two
61
- // stacked drawers (e.g. user-context-menu drawer over a modal drawer).
62
- function onWindowKey(event: KeyboardEvent) {
63
- if (!visibleRef.value || !props.closeOnEscape || isMobile.value) return;
64
- if (event.key === 'Escape') {
65
- event.preventDefault();
66
- emit('close');
67
- }
85
+ // Escape is checked at call time (not captured at setup) so toggling
86
+ // `closeOnEscape` — e.g. AppConfirmDialog while `loading` — takes effect
87
+ // immediately. A modal with `closeOnEscape: false` still swallows the
88
+ // key instead of letting it fall through to an overlay underneath.
89
+ function onEscape() {
90
+ if (props.closeOnEscape) emit('close');
68
91
  }
69
- onMounted(() => window.addEventListener('keydown', onWindowKey));
70
- onUnmounted(() => window.removeEventListener('keydown', onWindowKey));
71
92
 
93
+ // Desktop branch: register on the shared escape stack so only the
94
+ // topmost overlay (modal, drawer or popover) reacts to a single Escape.
95
+ const escapeStack = useEscapeStack();
96
+ watch(desktopVisible, (v) => {
97
+ if (v) escapeStack.register(onEscape);
98
+ else escapeStack.unregister();
99
+ }, { immediate: true });
100
+ onBeforeUnmount(escapeStack.unregister);
101
+
102
+ // Mobile branch: useDrawer owns its own escape-stack entry.
72
103
  const { backdropClass, panelClass, backdropTransition, panelTransition } = useDrawer({
73
104
  visible: drawerVisible,
74
105
  placement: 'bottom',
75
- closeOnEscape: props.closeOnEscape,
76
- onClose: () => emit('close')
106
+ closeOnEscape: true,
107
+ onClose: onEscape
77
108
  });
78
109
 
79
- function onBackdropClick() {
110
+ useBodyScrollLock(visibleRef);
111
+
112
+ // Backdrop close only when the press both started and ended on the
113
+ // backdrop itself.
114
+ let pressStartedOnBackdrop = false;
115
+ function onBackdropPointerDown(event: PointerEvent | MouseEvent) {
116
+ pressStartedOnBackdrop = event.target === event.currentTarget;
117
+ }
118
+ function onBackdropClick(event: MouseEvent) {
119
+ const startedHere = pressStartedOnBackdrop;
120
+ pressStartedOnBackdrop = false;
121
+ if (event.target !== event.currentTarget || !startedHere) return;
80
122
  if (props.closeOnBackdrop) emit('close');
81
123
  }
124
+
125
+ // ── Focus management ────────────────────────────────────────────────
126
+ const desktopPanel = ref<HTMLElement | null>(null);
127
+ const drawerPanel = ref<HTMLElement | null>(null);
128
+ const activePanel = () => (isMobile.value ? drawerPanel.value : desktopPanel.value);
129
+
130
+ let returnFocusTo: HTMLElement | null = null;
131
+
132
+ function focusInitial() {
133
+ const panel = activePanel();
134
+ if (!panel) return;
135
+ const active = document.activeElement;
136
+ if (active && panel.contains(active) && active !== panel) return;
137
+ const auto = panel.querySelector<HTMLElement>('[autofocus]');
138
+ const body = panel.querySelector<HTMLElement>('.app-modal-body, .app-modal-drawer-body');
139
+ const target = auto ?? (body ? getFocusable(body)[0] : undefined) ?? panel;
140
+ target.focus({ preventScroll: true });
141
+ }
142
+
143
+ function restoreFocus() {
144
+ const el = returnFocusTo;
145
+ returnFocusTo = null;
146
+ if (!el || !el.isConnected) return;
147
+ // Don't yank focus back if the user (or the caller) already moved it
148
+ // somewhere meaningful outside the dialog.
149
+ const active = document.activeElement;
150
+ const panels = [desktopPanel.value, drawerPanel.value];
151
+ const focusInDialog = !!active && panels.some((p) => p?.contains(active));
152
+ if (!active || active === document.body || focusInDialog) {
153
+ el.focus({ preventScroll: true });
154
+ }
155
+ }
156
+
157
+ watch(visibleRef, (v, was) => {
158
+ if (v && !was) {
159
+ const active = document.activeElement;
160
+ returnFocusTo = active instanceof HTMLElement && active !== document.body ? active : null;
161
+ } else if (!v && was) {
162
+ restoreFocus();
163
+ }
164
+ }, { immediate: true });
165
+
166
+ // Move focus in whenever a branch's panel appears — on open, and when a
167
+ // breakpoint flip swaps the desktop card for the mobile drawer.
168
+ watch([desktopVisible, drawerVisible], ([d, m]) => {
169
+ if (d || m) nextTick(focusInitial);
170
+ }, { immediate: true, flush: 'post' });
171
+
172
+ onBeforeUnmount(() => {
173
+ if (props.visible) restoreFocus();
174
+ });
175
+
176
+ function onPanelKeydown(event: KeyboardEvent) {
177
+ trapTabKey(event, event.currentTarget as HTMLElement);
178
+ }
82
179
  </script>
83
180
 
84
181
  <template>
@@ -88,24 +185,28 @@ function onBackdropClick() {
88
185
  <div
89
186
  v-if="desktopVisible"
90
187
  class="app-modal-backdrop"
91
- @click.self="onBackdropClick"
188
+ @pointerdown="onBackdropPointerDown"
189
+ @click="onBackdropClick"
92
190
  >
93
191
  <div
192
+ ref="desktopPanel"
94
193
  class="app-modal-panel"
95
194
  role="dialog"
96
195
  aria-modal="true"
196
+ tabindex="-1"
97
197
  :style="{ width }"
98
- :aria-labelledby="title || $slots.header ? titleId : undefined"
99
- :aria-label="title || $slots.header ? undefined : title || undefined"
198
+ :aria-labelledby="labelledBy"
199
+ :aria-label="label"
200
+ @keydown="onPanelKeydown"
100
201
  >
101
- <header v-if="title || $slots.header" class="app-modal-head">
202
+ <header v-if="hasHeader" class="app-modal-head">
102
203
  <span :id="titleId" class="app-modal-title">
103
204
  <slot name="header">{{ title }}</slot>
104
205
  </span>
105
206
  <button
106
207
  type="button"
107
208
  class="app-modal-close"
108
- :aria-label="'Close'"
209
+ :aria-label="closeLabel"
109
210
  @click="emit('close')"
110
211
  >
111
212
  <Icon icon="material-symbols:close-rounded" width="18" height="18" />
@@ -125,27 +226,31 @@ function onBackdropClick() {
125
226
  <div
126
227
  v-if="drawerVisible"
127
228
  :class="[backdropClass, 'app-modal-drawer-backdrop']"
229
+ @pointerdown="onBackdropPointerDown"
128
230
  @click="onBackdropClick"
129
231
  />
130
232
  </Transition>
131
233
  <Transition :name="panelTransition">
132
234
  <div
133
235
  v-if="drawerVisible"
236
+ ref="drawerPanel"
134
237
  :class="[panelClass, 'app-modal-drawer-panel']"
135
238
  data-placement="bottom"
136
239
  role="dialog"
137
240
  aria-modal="true"
138
- :aria-labelledby="title || $slots.header ? titleId : undefined"
139
- :aria-label="title || $slots.header ? undefined : title || undefined"
241
+ tabindex="-1"
242
+ :aria-labelledby="labelledBy"
243
+ :aria-label="label"
244
+ @keydown="onPanelKeydown"
140
245
  >
141
- <header v-if="title || $slots.header" class="app-modal-drawer-head">
246
+ <header v-if="hasHeader" class="app-modal-drawer-head">
142
247
  <span :id="titleId" class="app-modal-title">
143
248
  <slot name="header">{{ title }}</slot>
144
249
  </span>
145
250
  <button
146
251
  type="button"
147
252
  class="app-modal-close"
148
- :aria-label="'Close'"
253
+ :aria-label="closeLabel"
149
254
  @click="emit('close')"
150
255
  >
151
256
  <Icon icon="material-symbols:close-rounded" width="18" height="18" />
@@ -180,6 +285,12 @@ function onBackdropClick() {
180
285
  flex-direction: column;
181
286
  overflow: hidden;
182
287
  }
288
+ .app-modal-panel:focus,
289
+ .app-modal-drawer-panel:focus {
290
+ /* The panel only takes focus as a fallback target (tabindex=-1);
291
+ don't paint a ring around the whole card. */
292
+ outline: none;
293
+ }
183
294
  .app-modal-head {
184
295
  display: flex;
185
296
  align-items: center;
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, watch } from 'vue';
3
3
  import { useBreakpoint } from '../composables/use-breakpoint';
4
- import { usePopover, type Placement } from '../composables/use-popover';
4
+ import { usePopover, type Placement, type VirtualElement } from '../composables/use-popover';
5
5
  import { useDrawer, type DrawerPlacement } from '../composables/use-drawer';
6
6
 
7
7
  /**
@@ -18,16 +18,26 @@ import { useDrawer, type DrawerPlacement } from '../composables/use-drawer';
18
18
  * 2. `referenceEl` prop — for cases where the trigger can't live
19
19
  * inside this component (e.g., per-row react buttons in a list
20
20
  * that share a single popover instance). No click wiring; the
21
- * caller drives open state directly.
21
+ * caller drives open state directly. Accepts either a real
22
+ * HTMLElement or a Popper `VirtualElement` (`{ getBoundingClientRect,
23
+ * contextElement? }`, e.g. from `createVirtualElement(x, y)`) to
24
+ * anchor at a point / text selection / canvas coordinate. Passing a
25
+ * new object repositions an open popover; if you mutate the same
26
+ * virtual element in place, call the exposed `update()` instead.
22
27
  *
23
28
  * If both are provided, `referenceEl` wins.
24
29
  *
25
30
  * Open state is `v-model:open`; if the caller doesn't bind, the
26
- * component manages it internally.
31
+ * component manages it internally. (`open` defaults to `undefined`, not
32
+ * `false` — Vue would otherwise cast the absent boolean prop to `false`
33
+ * and pin an unbound popover shut.)
27
34
  */
28
35
  const props = withDefaults(defineProps<{
29
- /** External reference element. Overrides the #trigger slot if set. */
30
- referenceEl?: HTMLElement | null;
36
+ /**
37
+ * External anchor. Overrides the #trigger slot if set. Either a real
38
+ * element or a Popper VirtualElement (point / range anchor).
39
+ */
40
+ referenceEl?: HTMLElement | VirtualElement | null;
31
41
  /** Controlled open state; two-way via v-model:open. Optional. */
32
42
  open?: boolean;
33
43
  placement?: Placement;
@@ -43,6 +53,9 @@ const props = withDefaults(defineProps<{
43
53
  sameWidth?: boolean;
44
54
  }>(), {
45
55
  referenceEl: null,
56
+ // Explicit `undefined` default stops Vue's boolean casting (absent
57
+ // boolean prop → false), which made uncontrolled mode never open.
58
+ open: undefined,
46
59
  placement: 'bottom-start',
47
60
  offset: () => [0, 8],
48
61
  drawerPlacement: 'bottom',
@@ -80,7 +93,7 @@ const contentEl = ref<HTMLElement | null>(null);
80
93
  // template: `v-if="!referenceEl"` reads the prop, not this computed,
81
94
  // which avoids a mount → unmount loop when the slot wrapper's presence
82
95
  // feeds back into its own v-if condition.
83
- const anchorRef = computed<HTMLElement | null>(() => {
96
+ const anchorRef = computed<HTMLElement | VirtualElement | null>(() => {
84
97
  if (props.referenceEl) return props.referenceEl;
85
98
  const wrap = triggerWrapRef.value;
86
99
  if (!wrap) return null;
@@ -95,7 +108,7 @@ const anchorRef = computed<HTMLElement | null>(() => {
95
108
  const popoverVisible = computed(() => !isMobile.value && isOpen.value);
96
109
  const drawerVisible = computed(() => isMobile.value && isOpen.value);
97
110
 
98
- usePopover(anchorRef, contentEl, {
111
+ const { update } = usePopover(anchorRef, contentEl, {
99
112
  placement: props.placement,
100
113
  trigger: 'manual',
101
114
  offset: props.offset,
@@ -141,6 +154,15 @@ watch(isMobile, (mobile) => {
141
154
  function onContentClick() {
142
155
  if (props.closeOnContentClick) close();
143
156
  }
157
+
158
+ defineExpose({
159
+ open,
160
+ close,
161
+ toggle,
162
+ /** Recompute the desktop popover position (e.g. after mutating a
163
+ * VirtualElement anchor in place). No-op while closed / on mobile. */
164
+ update
165
+ });
144
166
  </script>
145
167
 
146
168
  <template>
@@ -0,0 +1,113 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+
4
+ /**
5
+ * Linear progress bar.
6
+ *
7
+ * - `value` 0–100 → determinate: `role="progressbar"` with
8
+ * `aria-valuenow` / `aria-valuemin` / `aria-valuemax`. Out-of-range
9
+ * values are clamped; non-finite values count as indeterminate.
10
+ * - `value` omitted → indeterminate: a sliding bar, no aria-value*
11
+ * attributes. Under `prefers-reduced-motion: reduce` the bar stops
12
+ * moving and renders as a static dimmed fill instead.
13
+ *
14
+ * `label` is the accessible name (aria-label). To label it by visible
15
+ * text instead, pass `aria-labelledby` — attrs fall through to the root.
16
+ * `tone` follows AppBadge's semantic palette; `size` is the track height
17
+ * (sm 4px, md 8px).
18
+ */
19
+
20
+ export type ProgressTone = 'accent' | 'success' | 'warn' | 'danger';
21
+ export type ProgressSize = 'sm' | 'md';
22
+
23
+ const props = withDefaults(defineProps<{
24
+ /** 0–100. Omit (undefined) for an indeterminate bar. */
25
+ value?: number;
26
+ /** Accessible name of the progress bar. */
27
+ label?: string;
28
+ size?: ProgressSize;
29
+ tone?: ProgressTone;
30
+ }>(), {
31
+ value: undefined,
32
+ label: undefined,
33
+ size: 'md',
34
+ tone: 'accent'
35
+ });
36
+
37
+ const determinate = computed(() =>
38
+ typeof props.value === 'number' && Number.isFinite(props.value)
39
+ );
40
+ const clamped = computed(() =>
41
+ determinate.value ? Math.min(100, Math.max(0, props.value as number)) : 0
42
+ );
43
+ const classes = computed(() => [
44
+ 'app-progress',
45
+ `app-progress--${props.size}`,
46
+ `app-progress--tone-${props.tone}`,
47
+ { 'app-progress--indeterminate': !determinate.value }
48
+ ]);
49
+ </script>
50
+
51
+ <template>
52
+ <div
53
+ :class="classes"
54
+ role="progressbar"
55
+ :aria-label="label || undefined"
56
+ :aria-valuenow="determinate ? clamped : undefined"
57
+ :aria-valuemin="determinate ? 0 : undefined"
58
+ :aria-valuemax="determinate ? 100 : undefined"
59
+ >
60
+ <div
61
+ class="app-progress__bar"
62
+ :style="determinate ? { width: `${clamped}%` } : undefined"
63
+ />
64
+ </div>
65
+ </template>
66
+
67
+ <style scoped>
68
+ .app-progress {
69
+ --app-progress-fill: var(--accent);
70
+ position: relative;
71
+ width: 100%;
72
+ overflow: hidden;
73
+ border-radius: var(--radius-pill, 999px);
74
+ background: var(--bg-surface-2);
75
+ box-shadow: inset 0 0 0 1px var(--border);
76
+ }
77
+ .app-progress--sm { height: 4px; }
78
+ .app-progress--md { height: 8px; }
79
+
80
+ .app-progress--tone-accent { --app-progress-fill: var(--accent); }
81
+ .app-progress--tone-success { --app-progress-fill: var(--success-text); }
82
+ .app-progress--tone-warn { --app-progress-fill: var(--warn-text); }
83
+ .app-progress--tone-danger { --app-progress-fill: var(--danger); }
84
+
85
+ .app-progress__bar {
86
+ height: 100%;
87
+ border-radius: inherit;
88
+ background: var(--app-progress-fill);
89
+ transition: width var(--transition-slow, 0.2s) ease;
90
+ }
91
+
92
+ .app-progress--indeterminate .app-progress__bar {
93
+ position: absolute;
94
+ top: 0;
95
+ left: 0;
96
+ width: 40%;
97
+ animation: app-progress-slide 1.2s ease-in-out infinite;
98
+ }
99
+
100
+ @keyframes app-progress-slide {
101
+ from { transform: translateX(-100%); }
102
+ to { transform: translateX(250%); }
103
+ }
104
+
105
+ @media (prefers-reduced-motion: reduce) {
106
+ .app-progress__bar { transition: none; }
107
+ .app-progress--indeterminate .app-progress__bar {
108
+ animation: none;
109
+ width: 100%;
110
+ opacity: 0.45;
111
+ }
112
+ }
113
+ </style>
@@ -31,6 +31,9 @@ withDefaults(defineProps<{
31
31
  /** Make the popup width match the trigger's width. Default: true. */
32
32
  sameWidth?: boolean;
33
33
  }>(), {
34
+ // `undefined` (not Vue's boolean-cast `false`) so an unbound AppSelect
35
+ // falls through to AppPopover's internal open state.
36
+ open: undefined,
34
37
  placement: 'bottom-start',
35
38
  drawerPlacement: 'bottom',
36
39
  closeOnItemClick: true,
@@ -1,9 +1,10 @@
1
1
  <script setup lang="ts">
2
- import { computed } from 'vue';
2
+ import { computed, useId } from 'vue';
3
3
  import { Icon } from '@iconify/vue';
4
4
  import { useBreakpoint } from '../composables/use-breakpoint';
5
5
  import AppSelectField, { type SelectOption } from './AppSelectField.vue';
6
6
  import type { TabDef } from './tab-types';
7
+ import { onTablistKeydown, rovingTabKey } from './tab-keyboard';
7
8
 
8
9
  export type { TabDef };
9
10
 
@@ -21,6 +22,12 @@ export type { TabDef };
21
22
  * Mobile (≤768px):
22
23
  * - Both rows collapse to AppSelectField dropdowns stacked vertically.
23
24
  *
25
+ * Keyboard (desktop, WAI-ARIA tabs pattern): each tablist is a single
26
+ * Tab stop (roving tabindex on the selected tab); ArrowLeft/ArrowRight
27
+ * (ArrowUp/ArrowDown in the vertical sidebar) and Home/End move between
28
+ * tabs, skipping disabled ones, and select as they go. The panel is a
29
+ * `role="tabpanel"` labelled by the selected sub-tab (or primary tab).
30
+ *
24
31
  * If you need URL-synced tabs with deep-linkable hrefs, use
25
32
  * `AppTabsRouted` from this package (a thin wrapper that adds
26
33
  * `vue-router` integration on top).
@@ -65,6 +72,27 @@ const primaryOptions = computed<SelectOption<string>[]>(() =>
65
72
  const subOptions = computed<SelectOption<string>[]>(() =>
66
73
  props.subTabs.map(t => ({ value: t.key, label: t.label }))
67
74
  );
75
+
76
+ // ── ARIA wiring ──────────────────────────────────────────────────────
77
+ // Ids are index-based (keys may contain characters that aren't valid in
78
+ // an id). One panel serves every tab, so all tabs point at it.
79
+ const uid = useId();
80
+ const panelId = `${uid}-panel`;
81
+ const tabId = (i: number) => `${uid}-tab-${i}`;
82
+ const subTabId = (i: number) => `${uid}-sub-${i}`;
83
+ const primaryRoving = computed(() => rovingTabKey(props.tabs, props.modelValue));
84
+ const subRoving = computed(() => rovingTabKey(props.subTabs, props.subModelValue));
85
+ const panelLabelledBy = computed(() => {
86
+ if (hasSub.value) {
87
+ const si = props.subTabs.findIndex(t => t.key === props.subModelValue);
88
+ if (si !== -1) return subTabId(si);
89
+ }
90
+ const pi = props.tabs.findIndex(t => t.key === props.modelValue);
91
+ return pi !== -1 ? tabId(pi) : undefined;
92
+ });
93
+ function onPrimaryKeydown(e: KeyboardEvent) { onTablistKeydown(e, props.tabs, pickPrimary); }
94
+ function onSubKeydown(e: KeyboardEvent) { onTablistKeydown(e, props.subTabs, pickSub); }
95
+ function onSubSideKeydown(e: KeyboardEvent) { onTablistKeydown(e, props.subTabs, pickSub, 'vertical'); }
68
96
  </script>
69
97
 
70
98
  <template>
@@ -93,14 +121,18 @@ const subOptions = computed<SelectOption<string>[]>(() =>
93
121
  v-else-if="!hasSub || subLayout === 'top'"
94
122
  :class="['tabs-root', 'desktop', 'top', { underline: variant === 'underline' }]"
95
123
  >
96
- <nav class="primary-row" role="tablist">
124
+ <nav class="primary-row" role="tablist" @keydown="onPrimaryKeydown">
97
125
  <button
98
- v-for="t in tabs"
126
+ v-for="(t, i) in tabs"
127
+ :id="tabId(i)"
99
128
  :key="t.key"
100
129
  type="button"
101
130
  role="tab"
102
131
  :class="['tab', { active: t.key === modelValue }]"
103
132
  :aria-selected="t.key === modelValue"
133
+ :aria-controls="panelId"
134
+ :tabindex="t.key === primaryRoving ? 0 : -1"
135
+ :data-tab-key="t.key"
104
136
  :disabled="t.disabled"
105
137
  @click="!t.disabled && pickPrimary(t.key)"
106
138
  >
@@ -108,14 +140,18 @@ const subOptions = computed<SelectOption<string>[]>(() =>
108
140
  <span>{{ t.label }}</span>
109
141
  </button>
110
142
  </nav>
111
- <nav v-if="hasSub" class="sub-row" role="tablist">
143
+ <nav v-if="hasSub" class="sub-row" role="tablist" @keydown="onSubKeydown">
112
144
  <button
113
- v-for="t in subTabs"
145
+ v-for="(t, i) in subTabs"
146
+ :id="subTabId(i)"
114
147
  :key="t.key"
115
148
  type="button"
116
149
  role="tab"
117
150
  :class="['sub-tab', { active: t.key === subModelValue }]"
118
151
  :aria-selected="t.key === subModelValue"
152
+ :aria-controls="panelId"
153
+ :tabindex="t.key === subRoving ? 0 : -1"
154
+ :data-tab-key="t.key"
119
155
  :disabled="t.disabled"
120
156
  @click="!t.disabled && pickSub(t.key)"
121
157
  >
@@ -123,21 +159,25 @@ const subOptions = computed<SelectOption<string>[]>(() =>
123
159
  <span>{{ t.label }}</span>
124
160
  </button>
125
161
  </nav>
126
- <div class="panel">
162
+ <div :id="panelId" class="panel" role="tabpanel" :aria-labelledby="panelLabelledBy">
127
163
  <slot />
128
164
  </div>
129
165
  </div>
130
166
 
131
167
  <!-- Desktop, sub-tabs as a vertical sidebar. -->
132
168
  <div v-else :class="['tabs-root', 'desktop', 'sidebar', { underline: variant === 'underline' }]">
133
- <nav class="primary-row" role="tablist">
169
+ <nav class="primary-row" role="tablist" @keydown="onPrimaryKeydown">
134
170
  <button
135
- v-for="t in tabs"
171
+ v-for="(t, i) in tabs"
172
+ :id="tabId(i)"
136
173
  :key="t.key"
137
174
  type="button"
138
175
  role="tab"
139
176
  :class="['tab', { active: t.key === modelValue }]"
140
177
  :aria-selected="t.key === modelValue"
178
+ :aria-controls="panelId"
179
+ :tabindex="t.key === primaryRoving ? 0 : -1"
180
+ :data-tab-key="t.key"
141
181
  :disabled="t.disabled"
142
182
  @click="!t.disabled && pickPrimary(t.key)"
143
183
  >
@@ -146,14 +186,18 @@ const subOptions = computed<SelectOption<string>[]>(() =>
146
186
  </button>
147
187
  </nav>
148
188
  <div class="split">
149
- <nav class="sub-side" role="tablist">
189
+ <nav class="sub-side" role="tablist" aria-orientation="vertical" @keydown="onSubSideKeydown">
150
190
  <button
151
- v-for="t in subTabs"
191
+ v-for="(t, i) in subTabs"
192
+ :id="subTabId(i)"
152
193
  :key="t.key"
153
194
  type="button"
154
195
  role="tab"
155
196
  :class="['sub-side-tab', { active: t.key === subModelValue }]"
156
197
  :aria-selected="t.key === subModelValue"
198
+ :aria-controls="panelId"
199
+ :tabindex="t.key === subRoving ? 0 : -1"
200
+ :data-tab-key="t.key"
157
201
  :disabled="t.disabled"
158
202
  @click="!t.disabled && pickSub(t.key)"
159
203
  >
@@ -161,7 +205,7 @@ const subOptions = computed<SelectOption<string>[]>(() =>
161
205
  <span>{{ t.label }}</span>
162
206
  </button>
163
207
  </nav>
164
- <div class="panel side-panel">
208
+ <div :id="panelId" class="panel side-panel" role="tabpanel" :aria-labelledby="panelLabelledBy">
165
209
  <slot />
166
210
  </div>
167
211
  </div>