@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
|
@@ -15,16 +15,22 @@
|
|
|
15
15
|
* which does NOT import vue-router and so doesn't pull it into your
|
|
16
16
|
* plugin SPA's bundle.
|
|
17
17
|
*
|
|
18
|
+
* Keyboard / ARIA behaviour matches AppTabs (roving tabindex, Arrow /
|
|
19
|
+
* Home / End navigation skipping disabled tabs, tabpanel wiring); a
|
|
20
|
+
* keyboard move updates the URL with `router.replace` so arrowing across
|
|
21
|
+
* tabs does not flood the history stack.
|
|
22
|
+
*
|
|
18
23
|
* NB: separate component (vs an `routed` prop on AppTabs) so that
|
|
19
24
|
* plugin SPAs that never use routed tabs don't transitively depend on
|
|
20
25
|
* `vue-router`.
|
|
21
26
|
*/
|
|
22
|
-
import { computed, ref, watch } from 'vue';
|
|
27
|
+
import { computed, ref, useId, watch } from 'vue';
|
|
23
28
|
import { useRoute, useRouter, RouterLink, type LocationQueryRaw } from 'vue-router';
|
|
24
29
|
import { Icon } from '@iconify/vue';
|
|
25
30
|
import { useBreakpoint } from '../composables/use-breakpoint';
|
|
26
31
|
import AppSelectField, { type SelectOption } from './AppSelectField.vue';
|
|
27
32
|
import type { TabDef } from './tab-types';
|
|
33
|
+
import { onTablistKeydown, rovingTabKey } from './tab-keyboard';
|
|
28
34
|
|
|
29
35
|
const props = withDefaults(defineProps<{
|
|
30
36
|
modelValue: string;
|
|
@@ -133,6 +139,27 @@ function subLinkTo(key: string) {
|
|
|
133
139
|
q[subKey.value] = key === defaultSub.value ? undefined : key;
|
|
134
140
|
return { path: route.path, query: q };
|
|
135
141
|
}
|
|
142
|
+
|
|
143
|
+
// -- ARIA wiring (mirrors AppTabs) --------------------------------------
|
|
144
|
+
const uid = useId();
|
|
145
|
+
const panelId = `${uid}-panel`;
|
|
146
|
+
const tabId = (i: number) => `${uid}-tab-${i}`;
|
|
147
|
+
const subTabId = (i: number) => `${uid}-sub-${i}`;
|
|
148
|
+
const primaryRoving = computed(() => rovingTabKey(props.tabs, props.modelValue));
|
|
149
|
+
const subRoving = computed(() => rovingTabKey(props.subTabs, props.subModelValue));
|
|
150
|
+
const panelLabelledBy = computed(() => {
|
|
151
|
+
if (hasSub.value) {
|
|
152
|
+
const si = props.subTabs.findIndex(t => t.key === props.subModelValue);
|
|
153
|
+
if (si !== -1) return subTabId(si);
|
|
154
|
+
}
|
|
155
|
+
const pi = props.tabs.findIndex(t => t.key === props.modelValue);
|
|
156
|
+
return pi !== -1 ? tabId(pi) : undefined;
|
|
157
|
+
});
|
|
158
|
+
function keyPrimary(key: string) { router.replace(tabLinkTo(key)); }
|
|
159
|
+
function keySub(key: string) { router.replace(subLinkTo(key)); }
|
|
160
|
+
function onPrimaryKeydown(e: KeyboardEvent) { onTablistKeydown(e, props.tabs, keyPrimary); }
|
|
161
|
+
function onSubKeydown(e: KeyboardEvent) { onTablistKeydown(e, props.subTabs, keySub); }
|
|
162
|
+
function onSubSideKeydown(e: KeyboardEvent) { onTablistKeydown(e, props.subTabs, keySub, 'vertical'); }
|
|
136
163
|
</script>
|
|
137
164
|
|
|
138
165
|
<template>
|
|
@@ -161,24 +188,32 @@ function subLinkTo(key: string) {
|
|
|
161
188
|
v-else-if="!hasSub || subLayout === 'top'"
|
|
162
189
|
:class="['tabs-root', 'desktop', 'top', { underline: variant === 'underline' }]"
|
|
163
190
|
>
|
|
164
|
-
<nav class="primary-row" role="tablist">
|
|
165
|
-
<template v-for="t in tabs" :key="t.key">
|
|
191
|
+
<nav class="primary-row" role="tablist" @keydown="onPrimaryKeydown">
|
|
192
|
+
<template v-for="(t, i) in tabs" :key="t.key">
|
|
166
193
|
<RouterLink
|
|
167
194
|
v-if="!t.disabled"
|
|
195
|
+
:id="tabId(i)"
|
|
168
196
|
:to="tabLinkTo(t.key)"
|
|
169
197
|
role="tab"
|
|
170
198
|
:class="['tab', { active: t.key === modelValue }]"
|
|
171
199
|
:aria-selected="t.key === modelValue"
|
|
200
|
+
:aria-controls="panelId"
|
|
201
|
+
:tabindex="t.key === primaryRoving ? 0 : -1"
|
|
202
|
+
:data-tab-key="t.key"
|
|
172
203
|
>
|
|
173
204
|
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
174
205
|
<span>{{ t.label }}</span>
|
|
175
206
|
</RouterLink>
|
|
176
207
|
<button
|
|
177
208
|
v-else
|
|
209
|
+
:id="tabId(i)"
|
|
178
210
|
type="button"
|
|
179
211
|
role="tab"
|
|
180
212
|
:class="['tab', { active: t.key === modelValue }]"
|
|
181
213
|
:aria-selected="t.key === modelValue"
|
|
214
|
+
:aria-controls="panelId"
|
|
215
|
+
tabindex="-1"
|
|
216
|
+
:data-tab-key="t.key"
|
|
182
217
|
disabled
|
|
183
218
|
>
|
|
184
219
|
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
@@ -186,24 +221,32 @@ function subLinkTo(key: string) {
|
|
|
186
221
|
</button>
|
|
187
222
|
</template>
|
|
188
223
|
</nav>
|
|
189
|
-
<nav v-if="hasSub" class="sub-row" role="tablist">
|
|
190
|
-
<template v-for="t in subTabs" :key="t.key">
|
|
224
|
+
<nav v-if="hasSub" class="sub-row" role="tablist" @keydown="onSubKeydown">
|
|
225
|
+
<template v-for="(t, i) in subTabs" :key="t.key">
|
|
191
226
|
<RouterLink
|
|
192
227
|
v-if="!t.disabled"
|
|
228
|
+
:id="subTabId(i)"
|
|
193
229
|
:to="subLinkTo(t.key)"
|
|
194
230
|
role="tab"
|
|
195
231
|
:class="['sub-tab', { active: t.key === subModelValue }]"
|
|
196
232
|
:aria-selected="t.key === subModelValue"
|
|
233
|
+
:aria-controls="panelId"
|
|
234
|
+
:tabindex="t.key === subRoving ? 0 : -1"
|
|
235
|
+
:data-tab-key="t.key"
|
|
197
236
|
>
|
|
198
237
|
<Icon v-if="t.icon" :icon="t.icon" width="14" height="14" />
|
|
199
238
|
<span>{{ t.label }}</span>
|
|
200
239
|
</RouterLink>
|
|
201
240
|
<button
|
|
202
241
|
v-else
|
|
242
|
+
:id="subTabId(i)"
|
|
203
243
|
type="button"
|
|
204
244
|
role="tab"
|
|
205
245
|
:class="['sub-tab', { active: t.key === subModelValue }]"
|
|
206
246
|
:aria-selected="t.key === subModelValue"
|
|
247
|
+
:aria-controls="panelId"
|
|
248
|
+
tabindex="-1"
|
|
249
|
+
:data-tab-key="t.key"
|
|
207
250
|
disabled
|
|
208
251
|
>
|
|
209
252
|
<Icon v-if="t.icon" :icon="t.icon" width="14" height="14" />
|
|
@@ -211,31 +254,39 @@ function subLinkTo(key: string) {
|
|
|
211
254
|
</button>
|
|
212
255
|
</template>
|
|
213
256
|
</nav>
|
|
214
|
-
<div class="panel">
|
|
257
|
+
<div :id="panelId" class="panel" role="tabpanel" :aria-labelledby="panelLabelledBy">
|
|
215
258
|
<slot />
|
|
216
259
|
</div>
|
|
217
260
|
</div>
|
|
218
261
|
|
|
219
262
|
<!-- Desktop, sub-tabs as a vertical sidebar. -->
|
|
220
263
|
<div v-else :class="['tabs-root', 'desktop', 'sidebar', { underline: variant === 'underline' }]">
|
|
221
|
-
<nav class="primary-row" role="tablist">
|
|
222
|
-
<template v-for="t in tabs" :key="t.key">
|
|
264
|
+
<nav class="primary-row" role="tablist" @keydown="onPrimaryKeydown">
|
|
265
|
+
<template v-for="(t, i) in tabs" :key="t.key">
|
|
223
266
|
<RouterLink
|
|
224
267
|
v-if="!t.disabled"
|
|
268
|
+
:id="tabId(i)"
|
|
225
269
|
:to="tabLinkTo(t.key)"
|
|
226
270
|
role="tab"
|
|
227
271
|
:class="['tab', { active: t.key === modelValue }]"
|
|
228
272
|
:aria-selected="t.key === modelValue"
|
|
273
|
+
:aria-controls="panelId"
|
|
274
|
+
:tabindex="t.key === primaryRoving ? 0 : -1"
|
|
275
|
+
:data-tab-key="t.key"
|
|
229
276
|
>
|
|
230
277
|
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
231
278
|
<span>{{ t.label }}</span>
|
|
232
279
|
</RouterLink>
|
|
233
280
|
<button
|
|
234
281
|
v-else
|
|
282
|
+
:id="tabId(i)"
|
|
235
283
|
type="button"
|
|
236
284
|
role="tab"
|
|
237
285
|
:class="['tab', { active: t.key === modelValue }]"
|
|
238
286
|
:aria-selected="t.key === modelValue"
|
|
287
|
+
:aria-controls="panelId"
|
|
288
|
+
tabindex="-1"
|
|
289
|
+
:data-tab-key="t.key"
|
|
239
290
|
disabled
|
|
240
291
|
>
|
|
241
292
|
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
@@ -244,24 +295,32 @@ function subLinkTo(key: string) {
|
|
|
244
295
|
</template>
|
|
245
296
|
</nav>
|
|
246
297
|
<div class="split">
|
|
247
|
-
<nav class="sub-side" role="tablist">
|
|
248
|
-
<template v-for="t in subTabs" :key="t.key">
|
|
298
|
+
<nav class="sub-side" role="tablist" aria-orientation="vertical" @keydown="onSubSideKeydown">
|
|
299
|
+
<template v-for="(t, i) in subTabs" :key="t.key">
|
|
249
300
|
<RouterLink
|
|
250
301
|
v-if="!t.disabled"
|
|
302
|
+
:id="subTabId(i)"
|
|
251
303
|
:to="subLinkTo(t.key)"
|
|
252
304
|
role="tab"
|
|
253
305
|
:class="['sub-side-tab', { active: t.key === subModelValue }]"
|
|
254
306
|
:aria-selected="t.key === subModelValue"
|
|
307
|
+
:aria-controls="panelId"
|
|
308
|
+
:tabindex="t.key === subRoving ? 0 : -1"
|
|
309
|
+
:data-tab-key="t.key"
|
|
255
310
|
>
|
|
256
311
|
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
257
312
|
<span>{{ t.label }}</span>
|
|
258
313
|
</RouterLink>
|
|
259
314
|
<button
|
|
260
315
|
v-else
|
|
316
|
+
:id="subTabId(i)"
|
|
261
317
|
type="button"
|
|
262
318
|
role="tab"
|
|
263
319
|
:class="['sub-side-tab', { active: t.key === subModelValue }]"
|
|
264
320
|
:aria-selected="t.key === subModelValue"
|
|
321
|
+
:aria-controls="panelId"
|
|
322
|
+
tabindex="-1"
|
|
323
|
+
:data-tab-key="t.key"
|
|
265
324
|
disabled
|
|
266
325
|
>
|
|
267
326
|
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
@@ -269,7 +328,7 @@ function subLinkTo(key: string) {
|
|
|
269
328
|
</button>
|
|
270
329
|
</template>
|
|
271
330
|
</nav>
|
|
272
|
-
<div class="panel side-panel">
|
|
331
|
+
<div :id="panelId" class="panel side-panel" role="tabpanel" :aria-labelledby="panelLabelledBy">
|
|
273
332
|
<slot />
|
|
274
333
|
</div>
|
|
275
334
|
</div>
|
|
@@ -282,6 +341,8 @@ function subLinkTo(key: string) {
|
|
|
282
341
|
flex-direction: column;
|
|
283
342
|
gap: 0.7rem;
|
|
284
343
|
min-height: 0;
|
|
344
|
+
/* Span the parent's cross-axis - see AppTabs.vue. */
|
|
345
|
+
width: 100%;
|
|
285
346
|
flex: 1;
|
|
286
347
|
}
|
|
287
348
|
|
|
@@ -290,7 +351,10 @@ function subLinkTo(key: string) {
|
|
|
290
351
|
gap: 0.4rem;
|
|
291
352
|
border-bottom: 1px solid var(--border);
|
|
292
353
|
padding-bottom: 0.5rem;
|
|
354
|
+
/* Horizontal scroll only; `overflow-y: hidden` stops the implicit
|
|
355
|
+
vertical scrollbar - see AppTabs.vue. */
|
|
293
356
|
overflow-x: auto;
|
|
357
|
+
overflow-y: hidden;
|
|
294
358
|
scrollbar-width: thin;
|
|
295
359
|
flex-shrink: 0;
|
|
296
360
|
}
|
|
@@ -348,6 +412,7 @@ function subLinkTo(key: string) {
|
|
|
348
412
|
gap: 0.6rem;
|
|
349
413
|
border-bottom: 1px solid var(--border);
|
|
350
414
|
overflow-x: auto;
|
|
415
|
+
overflow-y: hidden;
|
|
351
416
|
scrollbar-width: thin;
|
|
352
417
|
margin-top: -0.3rem;
|
|
353
418
|
flex-shrink: 0;
|
|
@@ -1,7 +1,35 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { Icon } from '@iconify/vue';
|
|
3
|
+
import { useToastStore, type ToastItem } from '../stores/toastStore';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Toast host — mount once near the app root. Renders `useToastStore()`
|
|
7
|
+
* items bottom-right.
|
|
8
|
+
*
|
|
9
|
+
* - `error` / `warn` toasts are `role="alert"` (assertive); `info` /
|
|
10
|
+
* `success` are `role="status"` + `aria-live="polite"`.
|
|
11
|
+
* - Timed toasts dismiss on click (as before); sticky toasts
|
|
12
|
+
* (`duration: 0`) only via their close button, so a stray click
|
|
13
|
+
* doesn't throw away a message the user is meant to act on.
|
|
14
|
+
*/
|
|
15
|
+
withDefaults(defineProps<{
|
|
16
|
+
/** Accessible label of a sticky toast's close button. */
|
|
17
|
+
closeLabel?: string;
|
|
18
|
+
}>(), {
|
|
19
|
+
closeLabel: 'Dismiss'
|
|
20
|
+
});
|
|
3
21
|
|
|
4
22
|
const toast = useToastStore();
|
|
23
|
+
|
|
24
|
+
function isSticky(item: ToastItem) {
|
|
25
|
+
return item.duration === 0;
|
|
26
|
+
}
|
|
27
|
+
function isAssertive(item: ToastItem) {
|
|
28
|
+
return item.type === 'error' || item.type === 'warn';
|
|
29
|
+
}
|
|
30
|
+
function onItemClick(item: ToastItem) {
|
|
31
|
+
if (!isSticky(item)) toast.dismiss(item.id);
|
|
32
|
+
}
|
|
5
33
|
</script>
|
|
6
34
|
|
|
7
35
|
<template>
|
|
@@ -11,10 +39,21 @@ const toast = useToastStore();
|
|
|
11
39
|
v-for="item in toast.items"
|
|
12
40
|
:key="item.id"
|
|
13
41
|
class="toast-item"
|
|
14
|
-
:class="`toast-item--${item.type}
|
|
15
|
-
|
|
42
|
+
:class="[`toast-item--${item.type}`, { 'toast-item--sticky': isSticky(item) }]"
|
|
43
|
+
:role="isAssertive(item) ? 'alert' : 'status'"
|
|
44
|
+
:aria-live="isAssertive(item) ? undefined : 'polite'"
|
|
45
|
+
@click="onItemClick(item)"
|
|
16
46
|
>
|
|
17
|
-
{{ item.message }}
|
|
47
|
+
<span class="toast-message">{{ item.message }}</span>
|
|
48
|
+
<button
|
|
49
|
+
v-if="isSticky(item)"
|
|
50
|
+
type="button"
|
|
51
|
+
class="toast-close"
|
|
52
|
+
:aria-label="closeLabel"
|
|
53
|
+
@click.stop="toast.dismiss(item.id)"
|
|
54
|
+
>
|
|
55
|
+
<Icon icon="material-symbols:close-rounded" width="16" height="16" />
|
|
56
|
+
</button>
|
|
18
57
|
</div>
|
|
19
58
|
</TransitionGroup>
|
|
20
59
|
</Teleport>
|
|
@@ -40,7 +79,34 @@ const toast = useToastStore();
|
|
|
40
79
|
font-size: 0.82rem;
|
|
41
80
|
line-height: 1.4;
|
|
42
81
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
|
|
82
|
+
display: flex;
|
|
83
|
+
align-items: flex-start;
|
|
84
|
+
gap: 0.5rem;
|
|
85
|
+
}
|
|
86
|
+
.toast-item--sticky {
|
|
87
|
+
cursor: default;
|
|
88
|
+
padding-right: 0.35rem;
|
|
43
89
|
}
|
|
90
|
+
.toast-message {
|
|
91
|
+
flex: 1;
|
|
92
|
+
min-width: 0;
|
|
93
|
+
overflow-wrap: anywhere;
|
|
94
|
+
}
|
|
95
|
+
.toast-close {
|
|
96
|
+
flex-shrink: 0;
|
|
97
|
+
background: none;
|
|
98
|
+
border: none;
|
|
99
|
+
color: inherit;
|
|
100
|
+
opacity: 0.75;
|
|
101
|
+
cursor: pointer;
|
|
102
|
+
padding: 0.1rem;
|
|
103
|
+
margin: -0.1rem 0;
|
|
104
|
+
border-radius: var(--radius-sm, 4px);
|
|
105
|
+
display: inline-flex;
|
|
106
|
+
align-items: center;
|
|
107
|
+
justify-content: center;
|
|
108
|
+
}
|
|
109
|
+
.toast-close:hover { opacity: 1; }
|
|
44
110
|
.toast-item--error {
|
|
45
111
|
background: var(--danger, #ef4444);
|
|
46
112
|
color: #fff;
|
|
@@ -50,6 +116,16 @@ const toast = useToastStore();
|
|
|
50
116
|
color: var(--text, #cdd6f4);
|
|
51
117
|
border: 1px solid var(--border, #45475a);
|
|
52
118
|
}
|
|
119
|
+
.toast-item--success {
|
|
120
|
+
background: var(--success-bg, #d1fae5);
|
|
121
|
+
color: var(--success-text, #065f46);
|
|
122
|
+
border: 1px solid var(--border, #45475a);
|
|
123
|
+
}
|
|
124
|
+
.toast-item--warn {
|
|
125
|
+
background: var(--warn-bg, #fef3c7);
|
|
126
|
+
color: var(--warn-text, #92400e);
|
|
127
|
+
border: 1px solid var(--border, #45475a);
|
|
128
|
+
}
|
|
53
129
|
.toast-enter-active,
|
|
54
130
|
.toast-leave-active {
|
|
55
131
|
transition: all 0.25s ease;
|
|
@@ -62,4 +138,14 @@ const toast = useToastStore();
|
|
|
62
138
|
opacity: 0;
|
|
63
139
|
transform: translateX(1rem);
|
|
64
140
|
}
|
|
141
|
+
@media (prefers-reduced-motion: reduce) {
|
|
142
|
+
.toast-enter-active,
|
|
143
|
+
.toast-leave-active {
|
|
144
|
+
transition: opacity 0.15s ease;
|
|
145
|
+
}
|
|
146
|
+
.toast-enter-from,
|
|
147
|
+
.toast-leave-to {
|
|
148
|
+
transform: none;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
65
151
|
</style>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared WAI-ARIA tabs keyboard model for `AppTabs` and `AppTabsRouted`:
|
|
3
|
+
* roving tabindex + ArrowLeft/ArrowRight (ArrowUp/ArrowDown on vertical
|
|
4
|
+
* lists) / Home / End, skipping disabled tabs and wrapping at the ends.
|
|
5
|
+
* Activation is automatic — moving focus selects the tab.
|
|
6
|
+
*/
|
|
7
|
+
import type { TabDef } from "./tab-types";
|
|
8
|
+
|
|
9
|
+
export type TabOrientation = "horizontal" | "vertical";
|
|
10
|
+
|
|
11
|
+
/** Key of the tab that should be in the Tab sequence (tabindex=0). */
|
|
12
|
+
export function rovingTabKey(
|
|
13
|
+
tabs: readonly TabDef[],
|
|
14
|
+
selected: string | undefined,
|
|
15
|
+
): string | undefined {
|
|
16
|
+
if (selected !== undefined && tabs.some((t) => t.key === selected && !t.disabled)) {
|
|
17
|
+
return selected;
|
|
18
|
+
}
|
|
19
|
+
return tabs.find((t) => !t.disabled)?.key;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Key of the tab a navigation key moves to, or null when the key isn't a
|
|
24
|
+
* tab-navigation key (or there is nowhere to go).
|
|
25
|
+
*/
|
|
26
|
+
export function nextTabKey(
|
|
27
|
+
tabs: readonly TabDef[],
|
|
28
|
+
current: string | undefined,
|
|
29
|
+
key: string,
|
|
30
|
+
orientation: TabOrientation = "horizontal",
|
|
31
|
+
): string | null {
|
|
32
|
+
const enabled = tabs.filter((t) => !t.disabled);
|
|
33
|
+
if (enabled.length === 0) return null;
|
|
34
|
+
|
|
35
|
+
const forward =
|
|
36
|
+
key === "ArrowRight" || (orientation === "vertical" && key === "ArrowDown");
|
|
37
|
+
const backward =
|
|
38
|
+
key === "ArrowLeft" || (orientation === "vertical" && key === "ArrowUp");
|
|
39
|
+
|
|
40
|
+
if (key === "Home") return enabled[0].key;
|
|
41
|
+
if (key === "End") return enabled[enabled.length - 1].key;
|
|
42
|
+
if (!forward && !backward) return null;
|
|
43
|
+
|
|
44
|
+
const idx = enabled.findIndex((t) => t.key === current);
|
|
45
|
+
if (idx === -1) return forward ? enabled[0].key : enabled[enabled.length - 1].key;
|
|
46
|
+
const step = forward ? 1 : -1;
|
|
47
|
+
return enabled[(idx + step + enabled.length) % enabled.length].key;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* keydown handler for a `role="tablist"` element whose tabs carry
|
|
52
|
+
* `data-tab-key`. Moves focus to the target tab and calls `activate`.
|
|
53
|
+
*/
|
|
54
|
+
export function onTablistKeydown(
|
|
55
|
+
event: KeyboardEvent,
|
|
56
|
+
tabs: readonly TabDef[],
|
|
57
|
+
activate: (key: string) => void,
|
|
58
|
+
orientation: TabOrientation = "horizontal",
|
|
59
|
+
): void {
|
|
60
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
61
|
+
const tabEl = (event.target as HTMLElement | null)?.closest<HTMLElement>(
|
|
62
|
+
'[role="tab"]',
|
|
63
|
+
);
|
|
64
|
+
if (!tabEl) return;
|
|
65
|
+
const current = tabEl.dataset.tabKey;
|
|
66
|
+
const next = nextTabKey(tabs, current, event.key, orientation);
|
|
67
|
+
if (next === null) return;
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
const list = event.currentTarget as HTMLElement;
|
|
70
|
+
const target = Array.from(
|
|
71
|
+
list.querySelectorAll<HTMLElement>('[role="tab"]'),
|
|
72
|
+
).find((el) => el.dataset.tabKey === next);
|
|
73
|
+
target?.focus();
|
|
74
|
+
if (next !== current) activate(next);
|
|
75
|
+
}
|
|
@@ -18,7 +18,12 @@ const pending = ref<PendingConfirm | null>(null);
|
|
|
18
18
|
export function useConfirm() {
|
|
19
19
|
function confirm(opts: ConfirmOptions): Promise<boolean> {
|
|
20
20
|
return new Promise<boolean>((resolve) => {
|
|
21
|
+
// Only one global confirm can be on screen. A newer request
|
|
22
|
+
// supersedes the old one — settle the old promise as "not
|
|
23
|
+
// confirmed" so its awaiter doesn't hang forever.
|
|
24
|
+
const superseded = pending.value;
|
|
21
25
|
pending.value = { options: opts, resolve };
|
|
26
|
+
superseded?.resolve(false);
|
|
22
27
|
});
|
|
23
28
|
}
|
|
24
29
|
|
|
@@ -201,8 +201,22 @@ const DEFAULT_OPTIONS: Required<
|
|
|
201
201
|
transition: true,
|
|
202
202
|
};
|
|
203
203
|
|
|
204
|
-
|
|
204
|
+
/**
|
|
205
|
+
* Build a Popper `VirtualElement` anchored at a viewport point — pass it
|
|
206
|
+
* as `AppPopover`'s `referenceEl` (or to `usePopover`) to position a
|
|
207
|
+
* popover at a cursor / canvas coordinate instead of a DOM element.
|
|
208
|
+
*
|
|
209
|
+
* `contextElement` (optional) tells Popper which element's scroll
|
|
210
|
+
* containers to track, so the popover follows the anchor while that
|
|
211
|
+
* region scrolls.
|
|
212
|
+
*/
|
|
213
|
+
export function createVirtualElement(
|
|
214
|
+
x: number,
|
|
215
|
+
y: number,
|
|
216
|
+
contextElement?: Element,
|
|
217
|
+
): VirtualElement {
|
|
205
218
|
return {
|
|
219
|
+
contextElement,
|
|
206
220
|
getBoundingClientRect: () => ({
|
|
207
221
|
width: 0,
|
|
208
222
|
height: 0,
|
|
@@ -223,6 +237,11 @@ export function createVirtualElementFromEvent(
|
|
|
223
237
|
return createVirtualElement(event.clientX, event.clientY);
|
|
224
238
|
}
|
|
225
239
|
|
|
240
|
+
/** True when `ref` is a real DOM element rather than a Popper VirtualElement. */
|
|
241
|
+
export function isDomElement(ref: unknown): ref is Element {
|
|
242
|
+
return typeof Element !== "undefined" && ref instanceof Element;
|
|
243
|
+
}
|
|
244
|
+
|
|
226
245
|
export function getScrollParent(element: HTMLElement): HTMLElement | Window {
|
|
227
246
|
if (!element) return window;
|
|
228
247
|
|
|
@@ -865,7 +884,6 @@ export function createPopover(
|
|
|
865
884
|
* 設置關閉相關的事件監聽器
|
|
866
885
|
*/
|
|
867
886
|
function setupCloseListeners() {
|
|
868
|
-
const referenceEl = reference as HTMLElement;
|
|
869
887
|
|
|
870
888
|
if (mergedOptions.closeOnContentClick) {
|
|
871
889
|
const handleContentClick = () => hide();
|
|
@@ -876,7 +894,15 @@ export function createPopover(
|
|
|
876
894
|
}
|
|
877
895
|
|
|
878
896
|
if (mergedOptions.hideOnScroll) {
|
|
879
|
-
|
|
897
|
+
// A VirtualElement has no DOM ancestry — getComputedStyle() on it
|
|
898
|
+
// throws. Fall back to its contextElement, else just the window.
|
|
899
|
+
const scrollRoot = isDomElement(reference)
|
|
900
|
+
? reference
|
|
901
|
+
: reference.contextElement;
|
|
902
|
+
const scrollParents: (HTMLElement | Window)[] =
|
|
903
|
+
scrollRoot instanceof HTMLElement
|
|
904
|
+
? getScrollParents(scrollRoot)
|
|
905
|
+
: [window];
|
|
880
906
|
const handleScroll = () => {
|
|
881
907
|
if (isVisible) hide();
|
|
882
908
|
};
|
|
@@ -1165,7 +1191,10 @@ export function usePopover(
|
|
|
1165
1191
|
const c = unref(contentRef);
|
|
1166
1192
|
const r = unref(referenceRef);
|
|
1167
1193
|
if (c && c.contains(target)) return true;
|
|
1168
|
-
|
|
1194
|
+
// Only a real element can "contain" the click; a VirtualElement
|
|
1195
|
+
// anchor (point / range) has no DOM extent, so any click outside
|
|
1196
|
+
// the content counts as outside.
|
|
1197
|
+
if (r && isDomElement(r) && r.contains(target)) return true;
|
|
1169
1198
|
return false;
|
|
1170
1199
|
},
|
|
1171
1200
|
close: () => instance.value?.hide(),
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { onBeforeUnmount, watch, type Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ref-counted `<body>` scroll lock shared by every overlay that wants the
|
|
5
|
+
* page behind it frozen (AppModal today). Stacked modals each take a
|
|
6
|
+
* lock; the body is only restored when the LAST holder releases, so
|
|
7
|
+
* closing the top modal of two doesn't unfreeze the page under the one
|
|
8
|
+
* still open.
|
|
9
|
+
*
|
|
10
|
+
* While locked, `overflow: hidden` is set on <body> and the vanished
|
|
11
|
+
* scrollbar's width is added to its right padding so content doesn't
|
|
12
|
+
* jump sideways. The original inline values are restored on release.
|
|
13
|
+
*/
|
|
14
|
+
let holders = 0;
|
|
15
|
+
let savedOverflow = "";
|
|
16
|
+
let savedPaddingRight = "";
|
|
17
|
+
|
|
18
|
+
function acquire() {
|
|
19
|
+
if (typeof document === "undefined") return;
|
|
20
|
+
holders++;
|
|
21
|
+
if (holders > 1) return;
|
|
22
|
+
const body = document.body;
|
|
23
|
+
savedOverflow = body.style.overflow;
|
|
24
|
+
savedPaddingRight = body.style.paddingRight;
|
|
25
|
+
const scrollbar =
|
|
26
|
+
window.innerWidth - document.documentElement.clientWidth;
|
|
27
|
+
if (scrollbar > 0) {
|
|
28
|
+
const current = parseFloat(getComputedStyle(body).paddingRight) || 0;
|
|
29
|
+
body.style.paddingRight = `${current + scrollbar}px`;
|
|
30
|
+
}
|
|
31
|
+
body.style.overflow = "hidden";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function release() {
|
|
35
|
+
if (typeof document === "undefined" || holders === 0) return;
|
|
36
|
+
holders--;
|
|
37
|
+
if (holders > 0) return;
|
|
38
|
+
document.body.style.overflow = savedOverflow;
|
|
39
|
+
document.body.style.paddingRight = savedPaddingRight;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Hold a body scroll lock while `active` is true. Released automatically
|
|
44
|
+
* when `active` turns false or the owning component unmounts.
|
|
45
|
+
*/
|
|
46
|
+
export function useBodyScrollLock(active: Ref<boolean>): void {
|
|
47
|
+
let held = false;
|
|
48
|
+
const set = (on: boolean) => {
|
|
49
|
+
if (on === held) return;
|
|
50
|
+
held = on;
|
|
51
|
+
if (on) acquire();
|
|
52
|
+
else release();
|
|
53
|
+
};
|
|
54
|
+
watch(active, set, { immediate: true });
|
|
55
|
+
onBeforeUnmount(() => set(false));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Test-only: current number of lock holders. */
|
|
59
|
+
export function _scrollLockHolders(): number {
|
|
60
|
+
return holders;
|
|
61
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,8 @@ export { default as AppMenu } from "./components/AppMenu.vue";
|
|
|
31
31
|
export { default as AppMenuItem } from "./components/AppMenuItem.vue";
|
|
32
32
|
export { default as AppModal } from "./components/AppModal.vue";
|
|
33
33
|
export { default as AppPopover } from "./components/AppPopover.vue";
|
|
34
|
+
export { default as AppProgress } from "./components/AppProgress.vue";
|
|
35
|
+
export type { ProgressSize, ProgressTone } from "./components/AppProgress.vue";
|
|
34
36
|
export { default as AppSelect } from "./components/AppSelect.vue";
|
|
35
37
|
export { default as AppSelectField } from "./components/AppSelectField.vue";
|
|
36
38
|
export type { SelectOption } from "./components/AppSelectField.vue";
|
|
@@ -79,10 +81,18 @@ export { useEscapeStack } from "./composables/use-escape-stack";
|
|
|
79
81
|
export { useFileDrop } from "./composables/use-file-drop";
|
|
80
82
|
export type { FileDropHandlers } from "./composables/use-file-drop";
|
|
81
83
|
export { useLongPress } from "./composables/use-long-press";
|
|
82
|
-
export {
|
|
83
|
-
|
|
84
|
+
export {
|
|
85
|
+
usePopover,
|
|
86
|
+
createVirtualElement,
|
|
87
|
+
createVirtualElementFromEvent,
|
|
88
|
+
} from "./composables/use-popover";
|
|
89
|
+
export type {
|
|
90
|
+
Placement,
|
|
91
|
+
UsePopoverOptions,
|
|
92
|
+
VirtualElement,
|
|
93
|
+
} from "./composables/use-popover";
|
|
84
94
|
export { useShiftKey } from "./composables/use-shift-key";
|
|
85
95
|
|
|
86
96
|
// ── Stores ───────────────────────────────────────────────────────────────
|
|
87
|
-
export { useToastStore } from "./stores/toastStore";
|
|
88
|
-
export type { ToastItem } from "./stores/toastStore";
|
|
97
|
+
export { useToastStore, DEFAULT_TOAST_DURATION } from "./stores/toastStore";
|
|
98
|
+
export type { ToastItem, ToastType, ToastOptions } from "./stores/toastStore";
|