@karyl-chan/ui 0.3.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 +95 -14
- package/src/components/AppTabsRouted.vue +109 -14
- package/src/components/AppToast.vue +90 -4
- package/src/components/Cluster.vue +59 -0
- package/src/components/Spacer.vue +23 -0
- package/src/components/Stack.vue +70 -0
- 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 +27 -4
- package/src/lib/focus.ts +67 -0
- package/src/lib/space.ts +44 -0
- package/src/stores/toastStore.ts +42 -4
- package/src/tokens.css +21 -0
|
@@ -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).
|
|
@@ -32,10 +39,19 @@ const props = withDefaults(defineProps<{
|
|
|
32
39
|
subTabs?: TabDef[];
|
|
33
40
|
/** Where sub-tabs render on desktop. Has no effect on mobile. */
|
|
34
41
|
subLayout?: 'top' | 'sidebar';
|
|
42
|
+
/**
|
|
43
|
+
* Primary-tab appearance (desktop only — mobile always uses the
|
|
44
|
+
* dropdown). `'pill'` (default) = filled/bordered pill buttons;
|
|
45
|
+
* `'underline'` = borderless tabs with an accent underline on the
|
|
46
|
+
* active one (the quieter, header-strip look). Sub-tabs are always
|
|
47
|
+
* underline-styled regardless.
|
|
48
|
+
*/
|
|
49
|
+
variant?: 'pill' | 'underline';
|
|
35
50
|
}>(), {
|
|
36
51
|
subModelValue: undefined,
|
|
37
52
|
subTabs: () => [],
|
|
38
|
-
subLayout: 'top'
|
|
53
|
+
subLayout: 'top',
|
|
54
|
+
variant: 'pill'
|
|
39
55
|
});
|
|
40
56
|
|
|
41
57
|
const emit = defineEmits<{
|
|
@@ -56,6 +72,27 @@ const primaryOptions = computed<SelectOption<string>[]>(() =>
|
|
|
56
72
|
const subOptions = computed<SelectOption<string>[]>(() =>
|
|
57
73
|
props.subTabs.map(t => ({ value: t.key, label: t.label }))
|
|
58
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'); }
|
|
59
96
|
</script>
|
|
60
97
|
|
|
61
98
|
<template>
|
|
@@ -82,16 +119,20 @@ const subOptions = computed<SelectOption<string>[]>(() =>
|
|
|
82
119
|
<!-- Desktop, sub-tabs below primary. -->
|
|
83
120
|
<div
|
|
84
121
|
v-else-if="!hasSub || subLayout === 'top'"
|
|
85
|
-
class="tabs-root desktop top"
|
|
122
|
+
:class="['tabs-root', 'desktop', 'top', { underline: variant === 'underline' }]"
|
|
86
123
|
>
|
|
87
|
-
<nav class="primary-row" role="tablist">
|
|
124
|
+
<nav class="primary-row" role="tablist" @keydown="onPrimaryKeydown">
|
|
88
125
|
<button
|
|
89
|
-
v-for="t in tabs"
|
|
126
|
+
v-for="(t, i) in tabs"
|
|
127
|
+
:id="tabId(i)"
|
|
90
128
|
:key="t.key"
|
|
91
129
|
type="button"
|
|
92
130
|
role="tab"
|
|
93
131
|
:class="['tab', { active: t.key === modelValue }]"
|
|
94
132
|
:aria-selected="t.key === modelValue"
|
|
133
|
+
:aria-controls="panelId"
|
|
134
|
+
:tabindex="t.key === primaryRoving ? 0 : -1"
|
|
135
|
+
:data-tab-key="t.key"
|
|
95
136
|
:disabled="t.disabled"
|
|
96
137
|
@click="!t.disabled && pickPrimary(t.key)"
|
|
97
138
|
>
|
|
@@ -99,14 +140,18 @@ const subOptions = computed<SelectOption<string>[]>(() =>
|
|
|
99
140
|
<span>{{ t.label }}</span>
|
|
100
141
|
</button>
|
|
101
142
|
</nav>
|
|
102
|
-
<nav v-if="hasSub" class="sub-row" role="tablist">
|
|
143
|
+
<nav v-if="hasSub" class="sub-row" role="tablist" @keydown="onSubKeydown">
|
|
103
144
|
<button
|
|
104
|
-
v-for="t in subTabs"
|
|
145
|
+
v-for="(t, i) in subTabs"
|
|
146
|
+
:id="subTabId(i)"
|
|
105
147
|
:key="t.key"
|
|
106
148
|
type="button"
|
|
107
149
|
role="tab"
|
|
108
150
|
:class="['sub-tab', { active: t.key === subModelValue }]"
|
|
109
151
|
:aria-selected="t.key === subModelValue"
|
|
152
|
+
:aria-controls="panelId"
|
|
153
|
+
:tabindex="t.key === subRoving ? 0 : -1"
|
|
154
|
+
:data-tab-key="t.key"
|
|
110
155
|
:disabled="t.disabled"
|
|
111
156
|
@click="!t.disabled && pickSub(t.key)"
|
|
112
157
|
>
|
|
@@ -114,21 +159,25 @@ const subOptions = computed<SelectOption<string>[]>(() =>
|
|
|
114
159
|
<span>{{ t.label }}</span>
|
|
115
160
|
</button>
|
|
116
161
|
</nav>
|
|
117
|
-
<div class="panel">
|
|
162
|
+
<div :id="panelId" class="panel" role="tabpanel" :aria-labelledby="panelLabelledBy">
|
|
118
163
|
<slot />
|
|
119
164
|
</div>
|
|
120
165
|
</div>
|
|
121
166
|
|
|
122
167
|
<!-- Desktop, sub-tabs as a vertical sidebar. -->
|
|
123
|
-
<div v-else class="tabs-root desktop sidebar">
|
|
124
|
-
<nav class="primary-row" role="tablist">
|
|
168
|
+
<div v-else :class="['tabs-root', 'desktop', 'sidebar', { underline: variant === 'underline' }]">
|
|
169
|
+
<nav class="primary-row" role="tablist" @keydown="onPrimaryKeydown">
|
|
125
170
|
<button
|
|
126
|
-
v-for="t in tabs"
|
|
171
|
+
v-for="(t, i) in tabs"
|
|
172
|
+
:id="tabId(i)"
|
|
127
173
|
:key="t.key"
|
|
128
174
|
type="button"
|
|
129
175
|
role="tab"
|
|
130
176
|
:class="['tab', { active: t.key === modelValue }]"
|
|
131
177
|
:aria-selected="t.key === modelValue"
|
|
178
|
+
:aria-controls="panelId"
|
|
179
|
+
:tabindex="t.key === primaryRoving ? 0 : -1"
|
|
180
|
+
:data-tab-key="t.key"
|
|
132
181
|
:disabled="t.disabled"
|
|
133
182
|
@click="!t.disabled && pickPrimary(t.key)"
|
|
134
183
|
>
|
|
@@ -137,14 +186,18 @@ const subOptions = computed<SelectOption<string>[]>(() =>
|
|
|
137
186
|
</button>
|
|
138
187
|
</nav>
|
|
139
188
|
<div class="split">
|
|
140
|
-
<nav class="sub-side" role="tablist">
|
|
189
|
+
<nav class="sub-side" role="tablist" aria-orientation="vertical" @keydown="onSubSideKeydown">
|
|
141
190
|
<button
|
|
142
|
-
v-for="t in subTabs"
|
|
191
|
+
v-for="(t, i) in subTabs"
|
|
192
|
+
:id="subTabId(i)"
|
|
143
193
|
:key="t.key"
|
|
144
194
|
type="button"
|
|
145
195
|
role="tab"
|
|
146
196
|
:class="['sub-side-tab', { active: t.key === subModelValue }]"
|
|
147
197
|
:aria-selected="t.key === subModelValue"
|
|
198
|
+
:aria-controls="panelId"
|
|
199
|
+
:tabindex="t.key === subRoving ? 0 : -1"
|
|
200
|
+
:data-tab-key="t.key"
|
|
148
201
|
:disabled="t.disabled"
|
|
149
202
|
@click="!t.disabled && pickSub(t.key)"
|
|
150
203
|
>
|
|
@@ -152,7 +205,7 @@ const subOptions = computed<SelectOption<string>[]>(() =>
|
|
|
152
205
|
<span>{{ t.label }}</span>
|
|
153
206
|
</button>
|
|
154
207
|
</nav>
|
|
155
|
-
<div class="panel side-panel">
|
|
208
|
+
<div :id="panelId" class="panel side-panel" role="tabpanel" :aria-labelledby="panelLabelledBy">
|
|
156
209
|
<slot />
|
|
157
210
|
</div>
|
|
158
211
|
</div>
|
|
@@ -213,6 +266,34 @@ const subOptions = computed<SelectOption<string>[]>(() =>
|
|
|
213
266
|
}
|
|
214
267
|
.tab:disabled { opacity: 0.45; cursor: default; }
|
|
215
268
|
|
|
269
|
+
/* Underline variant — borderless primary tabs with an accent underline on
|
|
270
|
+
the active one (mirrors the plugin-aktest nav strip and the sub-tab
|
|
271
|
+
treatment). Railless: the row drops its bottom border so each tab owns
|
|
272
|
+
its own indicator. */
|
|
273
|
+
.tabs-root.underline .primary-row {
|
|
274
|
+
border-bottom: none;
|
|
275
|
+
padding-bottom: 0;
|
|
276
|
+
gap: 0.15rem;
|
|
277
|
+
}
|
|
278
|
+
.tabs-root.underline .tab {
|
|
279
|
+
background: none;
|
|
280
|
+
border: none;
|
|
281
|
+
border-radius: 0;
|
|
282
|
+
border-bottom: 2px solid transparent;
|
|
283
|
+
color: var(--text-muted);
|
|
284
|
+
padding: 0.45rem 0.6rem;
|
|
285
|
+
}
|
|
286
|
+
.tabs-root.underline .tab:hover:not(:disabled) {
|
|
287
|
+
background: none;
|
|
288
|
+
color: var(--text);
|
|
289
|
+
}
|
|
290
|
+
.tabs-root.underline .tab.active {
|
|
291
|
+
background: none;
|
|
292
|
+
border-color: transparent;
|
|
293
|
+
border-bottom-color: var(--accent);
|
|
294
|
+
color: var(--accent-text-strong);
|
|
295
|
+
}
|
|
296
|
+
|
|
216
297
|
.sub-row {
|
|
217
298
|
display: flex;
|
|
218
299
|
gap: 0.6rem;
|
|
@@ -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;
|
|
@@ -36,11 +42,16 @@ const props = withDefaults(defineProps<{
|
|
|
36
42
|
* routed AppTabs share a page. Empty (default) = plain `tab` /
|
|
37
43
|
* `sub`; non-empty = `${name}-tab` / `${name}-sub`. */
|
|
38
44
|
name?: string;
|
|
45
|
+
/** Primary-tab appearance (desktop only). `'pill'` (default) = filled
|
|
46
|
+
* pill buttons; `'underline'` = borderless with an accent underline on
|
|
47
|
+
* the active tab. Matches AppTabs' `variant`. */
|
|
48
|
+
variant?: 'pill' | 'underline';
|
|
39
49
|
}>(), {
|
|
40
50
|
subModelValue: undefined,
|
|
41
51
|
subTabs: () => [],
|
|
42
52
|
subLayout: 'top',
|
|
43
|
-
name: ''
|
|
53
|
+
name: '',
|
|
54
|
+
variant: 'pill'
|
|
44
55
|
});
|
|
45
56
|
|
|
46
57
|
const emit = defineEmits<{
|
|
@@ -128,6 +139,27 @@ function subLinkTo(key: string) {
|
|
|
128
139
|
q[subKey.value] = key === defaultSub.value ? undefined : key;
|
|
129
140
|
return { path: route.path, query: q };
|
|
130
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'); }
|
|
131
163
|
</script>
|
|
132
164
|
|
|
133
165
|
<template>
|
|
@@ -154,26 +186,34 @@ function subLinkTo(key: string) {
|
|
|
154
186
|
<!-- Desktop, sub-tabs below primary. -->
|
|
155
187
|
<div
|
|
156
188
|
v-else-if="!hasSub || subLayout === 'top'"
|
|
157
|
-
class="tabs-root desktop top"
|
|
189
|
+
:class="['tabs-root', 'desktop', 'top', { underline: variant === 'underline' }]"
|
|
158
190
|
>
|
|
159
|
-
<nav class="primary-row" role="tablist">
|
|
160
|
-
<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">
|
|
161
193
|
<RouterLink
|
|
162
194
|
v-if="!t.disabled"
|
|
195
|
+
:id="tabId(i)"
|
|
163
196
|
:to="tabLinkTo(t.key)"
|
|
164
197
|
role="tab"
|
|
165
198
|
:class="['tab', { active: t.key === modelValue }]"
|
|
166
199
|
:aria-selected="t.key === modelValue"
|
|
200
|
+
:aria-controls="panelId"
|
|
201
|
+
:tabindex="t.key === primaryRoving ? 0 : -1"
|
|
202
|
+
:data-tab-key="t.key"
|
|
167
203
|
>
|
|
168
204
|
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
169
205
|
<span>{{ t.label }}</span>
|
|
170
206
|
</RouterLink>
|
|
171
207
|
<button
|
|
172
208
|
v-else
|
|
209
|
+
:id="tabId(i)"
|
|
173
210
|
type="button"
|
|
174
211
|
role="tab"
|
|
175
212
|
:class="['tab', { active: t.key === modelValue }]"
|
|
176
213
|
:aria-selected="t.key === modelValue"
|
|
214
|
+
:aria-controls="panelId"
|
|
215
|
+
tabindex="-1"
|
|
216
|
+
:data-tab-key="t.key"
|
|
177
217
|
disabled
|
|
178
218
|
>
|
|
179
219
|
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
@@ -181,24 +221,32 @@ function subLinkTo(key: string) {
|
|
|
181
221
|
</button>
|
|
182
222
|
</template>
|
|
183
223
|
</nav>
|
|
184
|
-
<nav v-if="hasSub" class="sub-row" role="tablist">
|
|
185
|
-
<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">
|
|
186
226
|
<RouterLink
|
|
187
227
|
v-if="!t.disabled"
|
|
228
|
+
:id="subTabId(i)"
|
|
188
229
|
:to="subLinkTo(t.key)"
|
|
189
230
|
role="tab"
|
|
190
231
|
:class="['sub-tab', { active: t.key === subModelValue }]"
|
|
191
232
|
:aria-selected="t.key === subModelValue"
|
|
233
|
+
:aria-controls="panelId"
|
|
234
|
+
:tabindex="t.key === subRoving ? 0 : -1"
|
|
235
|
+
:data-tab-key="t.key"
|
|
192
236
|
>
|
|
193
237
|
<Icon v-if="t.icon" :icon="t.icon" width="14" height="14" />
|
|
194
238
|
<span>{{ t.label }}</span>
|
|
195
239
|
</RouterLink>
|
|
196
240
|
<button
|
|
197
241
|
v-else
|
|
242
|
+
:id="subTabId(i)"
|
|
198
243
|
type="button"
|
|
199
244
|
role="tab"
|
|
200
245
|
:class="['sub-tab', { active: t.key === subModelValue }]"
|
|
201
246
|
:aria-selected="t.key === subModelValue"
|
|
247
|
+
:aria-controls="panelId"
|
|
248
|
+
tabindex="-1"
|
|
249
|
+
:data-tab-key="t.key"
|
|
202
250
|
disabled
|
|
203
251
|
>
|
|
204
252
|
<Icon v-if="t.icon" :icon="t.icon" width="14" height="14" />
|
|
@@ -206,31 +254,39 @@ function subLinkTo(key: string) {
|
|
|
206
254
|
</button>
|
|
207
255
|
</template>
|
|
208
256
|
</nav>
|
|
209
|
-
<div class="panel">
|
|
257
|
+
<div :id="panelId" class="panel" role="tabpanel" :aria-labelledby="panelLabelledBy">
|
|
210
258
|
<slot />
|
|
211
259
|
</div>
|
|
212
260
|
</div>
|
|
213
261
|
|
|
214
262
|
<!-- Desktop, sub-tabs as a vertical sidebar. -->
|
|
215
|
-
<div v-else class="tabs-root desktop sidebar">
|
|
216
|
-
<nav class="primary-row" role="tablist">
|
|
217
|
-
<template v-for="t in tabs" :key="t.key">
|
|
263
|
+
<div v-else :class="['tabs-root', 'desktop', 'sidebar', { underline: variant === 'underline' }]">
|
|
264
|
+
<nav class="primary-row" role="tablist" @keydown="onPrimaryKeydown">
|
|
265
|
+
<template v-for="(t, i) in tabs" :key="t.key">
|
|
218
266
|
<RouterLink
|
|
219
267
|
v-if="!t.disabled"
|
|
268
|
+
:id="tabId(i)"
|
|
220
269
|
:to="tabLinkTo(t.key)"
|
|
221
270
|
role="tab"
|
|
222
271
|
:class="['tab', { active: t.key === modelValue }]"
|
|
223
272
|
:aria-selected="t.key === modelValue"
|
|
273
|
+
:aria-controls="panelId"
|
|
274
|
+
:tabindex="t.key === primaryRoving ? 0 : -1"
|
|
275
|
+
:data-tab-key="t.key"
|
|
224
276
|
>
|
|
225
277
|
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
226
278
|
<span>{{ t.label }}</span>
|
|
227
279
|
</RouterLink>
|
|
228
280
|
<button
|
|
229
281
|
v-else
|
|
282
|
+
:id="tabId(i)"
|
|
230
283
|
type="button"
|
|
231
284
|
role="tab"
|
|
232
285
|
:class="['tab', { active: t.key === modelValue }]"
|
|
233
286
|
:aria-selected="t.key === modelValue"
|
|
287
|
+
:aria-controls="panelId"
|
|
288
|
+
tabindex="-1"
|
|
289
|
+
:data-tab-key="t.key"
|
|
234
290
|
disabled
|
|
235
291
|
>
|
|
236
292
|
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
@@ -239,24 +295,32 @@ function subLinkTo(key: string) {
|
|
|
239
295
|
</template>
|
|
240
296
|
</nav>
|
|
241
297
|
<div class="split">
|
|
242
|
-
<nav class="sub-side" role="tablist">
|
|
243
|
-
<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">
|
|
244
300
|
<RouterLink
|
|
245
301
|
v-if="!t.disabled"
|
|
302
|
+
:id="subTabId(i)"
|
|
246
303
|
:to="subLinkTo(t.key)"
|
|
247
304
|
role="tab"
|
|
248
305
|
:class="['sub-side-tab', { active: t.key === subModelValue }]"
|
|
249
306
|
:aria-selected="t.key === subModelValue"
|
|
307
|
+
:aria-controls="panelId"
|
|
308
|
+
:tabindex="t.key === subRoving ? 0 : -1"
|
|
309
|
+
:data-tab-key="t.key"
|
|
250
310
|
>
|
|
251
311
|
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
252
312
|
<span>{{ t.label }}</span>
|
|
253
313
|
</RouterLink>
|
|
254
314
|
<button
|
|
255
315
|
v-else
|
|
316
|
+
:id="subTabId(i)"
|
|
256
317
|
type="button"
|
|
257
318
|
role="tab"
|
|
258
319
|
:class="['sub-side-tab', { active: t.key === subModelValue }]"
|
|
259
320
|
:aria-selected="t.key === subModelValue"
|
|
321
|
+
:aria-controls="panelId"
|
|
322
|
+
tabindex="-1"
|
|
323
|
+
:data-tab-key="t.key"
|
|
260
324
|
disabled
|
|
261
325
|
>
|
|
262
326
|
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
@@ -264,7 +328,7 @@ function subLinkTo(key: string) {
|
|
|
264
328
|
</button>
|
|
265
329
|
</template>
|
|
266
330
|
</nav>
|
|
267
|
-
<div class="panel side-panel">
|
|
331
|
+
<div :id="panelId" class="panel side-panel" role="tabpanel" :aria-labelledby="panelLabelledBy">
|
|
268
332
|
<slot />
|
|
269
333
|
</div>
|
|
270
334
|
</div>
|
|
@@ -277,6 +341,8 @@ function subLinkTo(key: string) {
|
|
|
277
341
|
flex-direction: column;
|
|
278
342
|
gap: 0.7rem;
|
|
279
343
|
min-height: 0;
|
|
344
|
+
/* Span the parent's cross-axis - see AppTabs.vue. */
|
|
345
|
+
width: 100%;
|
|
280
346
|
flex: 1;
|
|
281
347
|
}
|
|
282
348
|
|
|
@@ -285,7 +351,10 @@ function subLinkTo(key: string) {
|
|
|
285
351
|
gap: 0.4rem;
|
|
286
352
|
border-bottom: 1px solid var(--border);
|
|
287
353
|
padding-bottom: 0.5rem;
|
|
354
|
+
/* Horizontal scroll only; `overflow-y: hidden` stops the implicit
|
|
355
|
+
vertical scrollbar - see AppTabs.vue. */
|
|
288
356
|
overflow-x: auto;
|
|
357
|
+
overflow-y: hidden;
|
|
289
358
|
scrollbar-width: thin;
|
|
290
359
|
flex-shrink: 0;
|
|
291
360
|
}
|
|
@@ -313,11 +382,37 @@ function subLinkTo(key: string) {
|
|
|
313
382
|
}
|
|
314
383
|
.tab:disabled { opacity: 0.45; cursor: default; }
|
|
315
384
|
|
|
385
|
+
/* Underline variant — see AppTabs.vue. Kept in sync with its plain sibling. */
|
|
386
|
+
.tabs-root.underline .primary-row {
|
|
387
|
+
border-bottom: none;
|
|
388
|
+
padding-bottom: 0;
|
|
389
|
+
gap: 0.15rem;
|
|
390
|
+
}
|
|
391
|
+
.tabs-root.underline .tab {
|
|
392
|
+
background: none;
|
|
393
|
+
border: none;
|
|
394
|
+
border-radius: 0;
|
|
395
|
+
border-bottom: 2px solid transparent;
|
|
396
|
+
color: var(--text-muted);
|
|
397
|
+
padding: 0.45rem 0.6rem;
|
|
398
|
+
}
|
|
399
|
+
.tabs-root.underline .tab:hover:not(:disabled) {
|
|
400
|
+
background: none;
|
|
401
|
+
color: var(--text);
|
|
402
|
+
}
|
|
403
|
+
.tabs-root.underline .tab.active {
|
|
404
|
+
background: none;
|
|
405
|
+
border-color: transparent;
|
|
406
|
+
border-bottom-color: var(--accent);
|
|
407
|
+
color: var(--accent-text-strong);
|
|
408
|
+
}
|
|
409
|
+
|
|
316
410
|
.sub-row {
|
|
317
411
|
display: flex;
|
|
318
412
|
gap: 0.6rem;
|
|
319
413
|
border-bottom: 1px solid var(--border);
|
|
320
414
|
overflow-x: auto;
|
|
415
|
+
overflow-y: hidden;
|
|
321
416
|
scrollbar-width: thin;
|
|
322
417
|
margin-top: -0.3rem;
|
|
323
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,59 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Cluster — a horizontal group that wraps, owning the gap between items.
|
|
4
|
+
*
|
|
5
|
+
* For toolbars, tag/badge rows, button groups — anywhere items flow
|
|
6
|
+
* horizontally and should wrap rather than overflow. Defaults to
|
|
7
|
+
* vertically-centered, wrapping items. Pair with <Spacer /> to push a
|
|
8
|
+
* trailing item to the end instead of a one-off `margin-left:auto`:
|
|
9
|
+
*
|
|
10
|
+
* <Cluster>
|
|
11
|
+
* <h2>Title</h2>
|
|
12
|
+
* <Spacer />
|
|
13
|
+
* <AppButton>Action</AppButton>
|
|
14
|
+
* </Cluster>
|
|
15
|
+
*
|
|
16
|
+
* `gap` takes a spacing-scale step (0–8 → --space-*) or a raw CSS length.
|
|
17
|
+
*/
|
|
18
|
+
import { computed, type CSSProperties } from "vue";
|
|
19
|
+
import {
|
|
20
|
+
resolveSpace,
|
|
21
|
+
ALIGN_ITEMS,
|
|
22
|
+
JUSTIFY_CONTENT,
|
|
23
|
+
type Align,
|
|
24
|
+
type Justify,
|
|
25
|
+
} from "../lib/space";
|
|
26
|
+
|
|
27
|
+
const props = withDefaults(
|
|
28
|
+
defineProps<{
|
|
29
|
+
gap?: string | number;
|
|
30
|
+
align?: Align;
|
|
31
|
+
justify?: Justify;
|
|
32
|
+
/** Wrap onto multiple lines when items don't fit. On by default —
|
|
33
|
+
* that's the whole point of a cluster. */
|
|
34
|
+
wrap?: boolean;
|
|
35
|
+
}>(),
|
|
36
|
+
{
|
|
37
|
+
gap: 3,
|
|
38
|
+
align: "center",
|
|
39
|
+
justify: "start",
|
|
40
|
+
wrap: true,
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const style = computed<CSSProperties>(() => ({
|
|
45
|
+
display: "flex",
|
|
46
|
+
flexWrap: props.wrap ? "wrap" : "nowrap",
|
|
47
|
+
gap: resolveSpace(props.gap),
|
|
48
|
+
alignItems: ALIGN_ITEMS[props.align],
|
|
49
|
+
justifyContent: JUSTIFY_CONTENT[props.justify],
|
|
50
|
+
}));
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<div class="ui-cluster" :style="style"><slot /></div>
|
|
55
|
+
</template>
|
|
56
|
+
|
|
57
|
+
<style scoped>
|
|
58
|
+
.ui-cluster { min-width: 0; }
|
|
59
|
+
</style>
|