@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.
@@ -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 { 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,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
- export function createVirtualElement(x: number, y: number): VirtualElement {
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
- const scrollParents = getScrollParents(referenceEl);
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
- if (r && (r as HTMLElement).contains?.(target)) return true;
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 { usePopover } from "./composables/use-popover";
83
- export type { Placement, UsePopoverOptions } from "./composables/use-popover";
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";