@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.
@@ -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 { useToastStore } from '../stores/toastStore';
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
- @click="toast.dismiss(item.id)"
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>