@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 +4 -2
- package/src/components/AppModal.vue +140 -29
- package/src/components/AppPopover.vue +29 -7
- package/src/components/AppProgress.vue +113 -0
- package/src/components/AppSelect.vue +3 -0
- package/src/components/AppTabs.vue +55 -11
- package/src/components/AppTabsRouted.vue +76 -11
- package/src/components/AppToast.vue +90 -4
- package/src/components/tab-keyboard.ts +75 -0
- package/src/composables/use-confirm.ts +5 -0
- package/src/composables/use-popover.ts +33 -4
- package/src/composables/use-scroll-lock.ts +61 -0
- package/src/index.ts +14 -4
- package/src/lib/focus.ts +67 -0
- package/src/stores/toastStore.ts +42 -4
- package/src/tokens.css +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@karyl-chan/ui",
|
|
3
|
-
"version": "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,
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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 =
|
|
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
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
function
|
|
63
|
-
if (
|
|
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:
|
|
76
|
-
onClose:
|
|
106
|
+
closeOnEscape: true,
|
|
107
|
+
onClose: onEscape
|
|
77
108
|
});
|
|
78
109
|
|
|
79
|
-
|
|
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
|
-
@
|
|
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="
|
|
99
|
-
:aria-label="
|
|
198
|
+
:aria-labelledby="labelledBy"
|
|
199
|
+
:aria-label="label"
|
|
200
|
+
@keydown="onPanelKeydown"
|
|
100
201
|
>
|
|
101
|
-
<header v-if="
|
|
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="
|
|
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
|
-
|
|
139
|
-
:aria-
|
|
241
|
+
tabindex="-1"
|
|
242
|
+
:aria-labelledby="labelledBy"
|
|
243
|
+
:aria-label="label"
|
|
244
|
+
@keydown="onPanelKeydown"
|
|
140
245
|
>
|
|
141
|
-
<header v-if="
|
|
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="
|
|
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
|
-
/**
|
|
30
|
-
|
|
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>
|