vueless 1.5.3-beta.5 → 1.5.4-beta.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,11 +1,15 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { computed, nextTick } from "vue";
2
+ import { computed, nextTick, reactive } from "vue";
3
3
  import { mount } from "@vue/test-utils";
4
4
 
5
5
  import type { ComponentInternalInstance } from "vue";
6
6
 
7
7
  // TODO: Autogenerated, need to be reviewed
8
8
 
9
+ const mockState = vi.hoisted(() => ({
10
+ vuelessConfig: { components: {}, unstyled: false } as Record<string, unknown>,
11
+ }));
12
+
9
13
  // Mock the ui utils
10
14
  vi.mock("../../utils/ui.ts", () => ({
11
15
  cx: vi.fn((classes) => (Array.isArray(classes) ? classes.filter(Boolean).join(" ") : classes)),
@@ -29,7 +33,12 @@ vi.mock("../../utils/ui.ts", () => ({
29
33
  });
30
34
  }),
31
35
  setColor: vi.fn((classes, color) => classes?.replace(/{color}/g, color)),
32
- vuelessConfig: { components: {}, unstyled: false },
36
+ get vuelessConfig() {
37
+ return mockState.vuelessConfig;
38
+ },
39
+ setVuelessConfig: vi.fn((config) => {
40
+ mockState.vuelessConfig = config;
41
+ }),
33
42
  getMergedConfig: vi.fn((args) => {
34
43
  // Create a spy function that returns the expected merged config
35
44
  const { defaultConfig, globalConfig, propsConfig } = args;
@@ -492,6 +501,136 @@ describe("useUI", () => {
492
501
  });
493
502
  });
494
503
 
504
+ describe("Shared Config Cache", () => {
505
+ const sharedDefaultConfig = {
506
+ wrapper: { base: "wrapper-class {>icon}" },
507
+ icon: { base: "{UIcon}", defaults: { size: "sm" } },
508
+ };
509
+
510
+ function mockInstance(props: UnknownObject, name = "SharedComponent") {
511
+ vi.mocked(getCurrentInstance).mockReturnValue({
512
+ type: { __name: name },
513
+ props,
514
+ parent: null,
515
+ } as unknown as ComponentInternalInstance);
516
+ }
517
+
518
+ it("should share merged config between instances without overrides", () => {
519
+ const defaultConfig = { body: { base: "body-class" } };
520
+
521
+ mockInstance({ config: {} });
522
+ const first = useUI(defaultConfig);
523
+ const mergeCalls = vi.mocked(uiUtils.getMergedConfig).mock.calls.length;
524
+
525
+ mockInstance({ config: undefined });
526
+ const second = useUI(defaultConfig);
527
+
528
+ expect(second.config.value).toBe(first.config.value);
529
+ expect(second.bodyAttrs.value.class).toBe(first.bodyAttrs.value.class);
530
+ expect(uiUtils.getMergedConfig).toHaveBeenCalledTimes(mergeCalls);
531
+ });
532
+
533
+ it("should not share config between different components with the same default config", () => {
534
+ mockInstance({ config: {} }, "FirstComponent");
535
+ const first = useUI(sharedDefaultConfig);
536
+
537
+ mockInstance({ config: {} }, "SecondComponent");
538
+ const second = useUI(sharedDefaultConfig);
539
+
540
+ expect(second.config.value).not.toBe(first.config.value);
541
+ });
542
+
543
+ it("should merge props config privately without polluting the shared entry", () => {
544
+ const defaultConfig = { body: { base: "body-class" } };
545
+
546
+ mockInstance({ config: {} });
547
+ const shared = useUI(defaultConfig);
548
+
549
+ mockInstance({ config: { body: { base: "private-class" } } });
550
+ const custom = useUI(defaultConfig);
551
+
552
+ mockInstance({ config: {} });
553
+ const another = useUI(defaultConfig);
554
+
555
+ expect(custom.config.value).not.toBe(shared.config.value);
556
+ expect(custom.bodyAttrs.value.class).toContain("private-class");
557
+ expect(another.config.value).toBe(shared.config.value);
558
+ expect(another.bodyAttrs.value.class).toBe("body-class");
559
+ });
560
+
561
+ it("should merge config attr privately without polluting the shared entry", () => {
562
+ mockInstance({ config: {} });
563
+ const shared = useUI(sharedDefaultConfig);
564
+
565
+ vi.mocked(useAttrs).mockReturnValue({ class: "", config: { base: "attr-class" } });
566
+ const withAttrConfig = useUI(sharedDefaultConfig);
567
+
568
+ vi.mocked(useAttrs).mockReturnValue({ class: "" });
569
+ const another = useUI(sharedDefaultConfig);
570
+
571
+ expect(withAttrConfig.iconAttrs.value.config).toMatchObject({ base: "attr-class" });
572
+ expect(another.iconAttrs.value.config).toBe(shared.iconAttrs.value.config);
573
+ expect(another.iconAttrs.value.config).not.toMatchObject({ base: "attr-class" });
574
+ });
575
+
576
+ it("should switch between shared and private config when props.config changes", async () => {
577
+ const defaultConfig = { body: { base: "body-class" } };
578
+
579
+ mockInstance({ config: {} });
580
+ const shared = useUI(defaultConfig);
581
+
582
+ const props = reactive<UnknownObject>({ config: undefined });
583
+
584
+ mockInstance(props);
585
+ const result = useUI(defaultConfig);
586
+
587
+ expect(result.config.value).toBe(shared.config.value);
588
+
589
+ props.config = { body: { base: "private-class" } };
590
+ await nextTick();
591
+
592
+ expect(result.config.value).not.toBe(shared.config.value);
593
+ expect(result.bodyAttrs.value.class).toContain("private-class");
594
+
595
+ props.config = undefined;
596
+ await nextTick();
597
+
598
+ expect(result.config.value).toBe(shared.config.value);
599
+ expect(result.bodyAttrs.value.class).toBe("body-class");
600
+ });
601
+
602
+ it("should invalidate shared entries when vuelessConfig is replaced", () => {
603
+ const defaultConfig = { body: { base: "body-class" } };
604
+ const initialConfig = uiUtils.vuelessConfig;
605
+
606
+ mockInstance({ config: {} });
607
+ const before = useUI(defaultConfig);
608
+
609
+ uiUtils.setVuelessConfig({
610
+ components: { SharedComponent: { body: { base: "global-class" } } },
611
+ } as unknown as typeof initialConfig);
612
+
613
+ const after = useUI(defaultConfig);
614
+
615
+ uiUtils.setVuelessConfig(initialConfig);
616
+
617
+ expect(after.config.value).not.toBe(before.config.value);
618
+ expect(after.bodyAttrs.value.class).toBe("global-class");
619
+ });
620
+
621
+ it("should keep separate shared entries for styled and unstyled modes", () => {
622
+ const defaultConfig = { body: { base: "body-class" } };
623
+
624
+ mockInstance({ config: {} });
625
+ const styled = useUI(defaultConfig);
626
+
627
+ uiUtils.vuelessConfig.unstyled = true;
628
+ const unstyled = useUI(defaultConfig);
629
+
630
+ expect(unstyled.config.value).not.toBe(styled.config.value);
631
+ });
632
+ });
633
+
495
634
  describe("Integration Tests", () => {
496
635
  it("should work with real component mounting", () => {
497
636
  const component = mount(TestComponent);
@@ -1,5 +1,6 @@
1
1
  import { ref, watch, getCurrentInstance, toValue, useAttrs, computed } from "vue";
2
2
 
3
+ import { isEmptyValue } from "../utils/helper";
3
4
  import { cx, cva, setColor, vuelessConfig, getMergedConfig } from "../utils/ui";
4
5
  import {
5
6
  CVA_CONFIG_KEY,
@@ -17,9 +18,9 @@ import type {
17
18
  StateColors,
18
19
  MutatedProps,
19
20
  UnknownObject,
21
+ Config,
20
22
  ComponentNames,
21
23
  NestedComponent,
22
- ConfigDerivedData,
23
24
  ComponentDefaults,
24
25
  ComponentConfigFull,
25
26
  VuelessComponentInstance,
@@ -30,6 +31,27 @@ const CVA_KEY_SET = new Set(Object.values(CVA_CONFIG_KEY));
30
31
  const SYSTEM_KEY_SET = new Set(Object.values(SYSTEM_CONFIG_KEY));
31
32
  const TRANSITION_KEY = SYSTEM_CONFIG_KEY.transition;
32
33
 
34
+ interface StaticKeyData {
35
+ keyConfig: NestedComponent;
36
+ extendsKeyConfig: NestedComponent;
37
+ nestedComponent: string;
38
+ mergedNestedConfig: unknown;
39
+ defaultAttrs: UnknownObject;
40
+ }
41
+
42
+ type StaticDerivedData = Record<string, StaticKeyData>;
43
+
44
+ interface ConfigEntry<T> {
45
+ config: ComponentConfigFull<T>;
46
+ cvaCache: Map<string, ReturnType<typeof cva>>;
47
+ staticData: StaticDerivedData;
48
+ }
49
+
50
+ type SharedEntries = WeakMap<object, Map<string, ConfigEntry<unknown>>>;
51
+
52
+ /* Entries shared by instances without config overrides, dropped once `vuelessConfig` is replaced. */
53
+ const sharedEntriesByConfig = new WeakMap<Config, SharedEntries>();
54
+
33
55
  /**
34
56
  * Merging component configs in a given sequence (bigger number = bigger priority):
35
57
  * 1. Default component config
@@ -47,7 +69,6 @@ export function useUI<T>(defaultConfig: T, mutatedProps?: MutatedProps, topLevel
47
69
  const globalConfig = (vuelessConfig.components?.[componentName] || {}) as ComponentConfigFull<T>;
48
70
 
49
71
  const firstClassKey = Object.keys(defaultConfig || {})[0];
50
- const config = ref({}) as Ref<ComponentConfigFull<T>>;
51
72
  const isDev = import.meta.env?.DEV || __VUELESS_DEV__;
52
73
  const isUnstyled = Boolean(vuelessConfig.unstyled);
53
74
 
@@ -63,80 +84,133 @@ export function useUI<T>(defaultConfig: T, mutatedProps?: MutatedProps, topLevel
63
84
  */
64
85
  const reactiveProps = computed(() => ({ ...props }));
65
86
 
66
- /* Cache for CVA resolver functions — only recreated when config changes. */
67
- const cvaCache = new Map<string, ReturnType<typeof cva>>();
87
+ const entry = computed(() => {
88
+ const propsConfig = props.config as ComponentConfigFull<T>;
68
89
 
69
- watch(
70
- () => props.config,
71
- (newVal, oldVal) => {
72
- if (newVal === oldVal) return;
90
+ return isEmptyValue(propsConfig) ? getSharedEntry() : createEntry(propsConfig);
91
+ });
73
92
 
74
- const propsConfig = props.config as ComponentConfigFull<T>;
93
+ const config = computed(() => entry.value.config) as Ref<ComponentConfigFull<T>>;
94
+ const attrsConfig = computed(() => attrs["config"]);
75
95
 
76
- config.value = getMergedConfig({
77
- defaultConfig,
78
- globalConfig,
79
- propsConfig,
80
- unstyled: isUnstyled,
81
- }) as ComponentConfigFull<T>;
96
+ /* Props-independent per-key data; only `mergedNestedConfig` is re-merged for a `config` attr. */
97
+ const staticData = computed(() => {
98
+ const entryData = entry.value.staticData;
82
99
 
83
- /* Invalidate CVA cache when config changes. */
84
- cvaCache.clear();
85
- },
86
- { deep: true, immediate: true },
87
- );
100
+ if (isEmptyValue(attrsConfig.value)) return entryData;
88
101
 
89
- /**
90
- * Pre-computed data per config key that only changes when config changes.
91
- * Avoids recomputing extends configs, nested component info, and merged configs on every prop change.
92
- */
93
- const configDerivedData = computed(() => {
94
- const data: ConfigDerivedData = {};
102
+ const data: StaticDerivedData = {};
95
103
 
96
- for (const key in config.value) {
104
+ for (const key in entryData) {
105
+ const { keyConfig, extendsKeyConfig } = entryData[key];
106
+
107
+ data[key] = {
108
+ ...entryData[key],
109
+ mergedNestedConfig: getMergedConfig({
110
+ defaultConfig: extendsKeyConfig,
111
+ globalConfig: keyConfig,
112
+ propsConfig: attrsConfig.value,
113
+ unstyled: isUnstyled,
114
+ }),
115
+ };
116
+ }
117
+
118
+ return data;
119
+ });
120
+
121
+ function createEntry(propsConfig: ComponentConfigFull<T>): ConfigEntry<T> {
122
+ const mergedConfig = getMergedConfig({
123
+ defaultConfig,
124
+ globalConfig,
125
+ propsConfig,
126
+ unstyled: isUnstyled,
127
+ }) as ComponentConfigFull<T>;
128
+
129
+ return {
130
+ config: mergedConfig,
131
+ cvaCache: new Map(),
132
+ staticData: computeStaticData(mergedConfig, propsConfig),
133
+ };
134
+ }
135
+
136
+ function getSharedEntry(): ConfigEntry<T> {
137
+ if (typeof defaultConfig !== "object" || defaultConfig === null) return createEntry({});
138
+
139
+ let sharedEntries = sharedEntriesByConfig.get(vuelessConfig);
140
+
141
+ if (!sharedEntries) {
142
+ sharedEntries = new WeakMap();
143
+ sharedEntriesByConfig.set(vuelessConfig, sharedEntries);
144
+ }
145
+
146
+ let componentEntries = sharedEntries.get(defaultConfig);
147
+
148
+ if (!componentEntries) {
149
+ componentEntries = new Map();
150
+ sharedEntries.set(defaultConfig, componentEntries);
151
+ }
152
+
153
+ const entryKey = `${componentName}:${isUnstyled}`;
154
+ let sharedEntry = componentEntries.get(entryKey) as ConfigEntry<T> | undefined;
155
+
156
+ if (!sharedEntry) {
157
+ sharedEntry = createEntry({});
158
+ componentEntries.set(entryKey, sharedEntry);
159
+ }
160
+
161
+ return sharedEntry;
162
+ }
163
+
164
+ function computeStaticData(
165
+ mergedConfig: ComponentConfigFull<T>,
166
+ propsConfig: ComponentConfigFull<T>,
167
+ ): StaticDerivedData {
168
+ const data: StaticDerivedData = {};
169
+
170
+ for (const key in mergedConfig) {
97
171
  if (isSystemKey(key)) continue;
98
172
 
99
173
  const keyConfig: NestedComponent =
100
- typeof config.value[key] === "object" ? (config.value[key] as NestedComponent) : {};
174
+ typeof mergedConfig[key] === "object" ? (mergedConfig[key] as NestedComponent) : {};
101
175
 
102
- const extendsKeyConfig = computeExtendsKeyConfig(key);
176
+ const extendsKeyConfig = computeExtendsKeyConfig(mergedConfig, propsConfig, key);
103
177
  const extendsKeyNestedComponent = getNestedComponent(extendsKeyConfig);
104
- const keyNestedComponent = getNestedComponent(config.value[key]);
178
+ const keyNestedComponent = getNestedComponent(mergedConfig[key]);
105
179
  const nestedComponent = extendsKeyNestedComponent || keyNestedComponent || componentName;
106
180
 
107
181
  const mergedNestedConfig = getMergedConfig({
108
182
  defaultConfig: extendsKeyConfig,
109
183
  globalConfig: keyConfig,
110
- propsConfig: attrs["config"] || {},
184
+ propsConfig: {},
111
185
  unstyled: isUnstyled,
112
186
  });
113
187
 
114
- const mergedDefaults: ComponentDefaults = {};
115
-
116
- const defaultAttrs = {
117
- ...(extendsKeyConfig.defaults || {}),
118
- ...(keyConfig.defaults || {}),
119
- };
120
-
121
- for (const defaultKey in defaultAttrs) {
122
- mergedDefaults[defaultKey] =
123
- typeof defaultAttrs[defaultKey] === "object"
124
- ? defaultAttrs[defaultKey][String(props[defaultKey])]
125
- : defaultAttrs[defaultKey];
126
- }
127
-
128
188
  data[key] = {
129
189
  keyConfig,
130
- extendsClasses: computeExtendsClasses(key),
131
190
  extendsKeyConfig,
132
191
  nestedComponent,
133
192
  mergedNestedConfig,
134
- mergedDefaults,
193
+ defaultAttrs: { ...(extendsKeyConfig.defaults || {}), ...(keyConfig.defaults || {}) },
135
194
  };
136
195
  }
137
196
 
138
197
  return data;
139
- });
198
+ }
199
+
200
+ function computeMergedDefaults(defaultAttrs: UnknownObject): ComponentDefaults {
201
+ const mergedDefaults: ComponentDefaults = {};
202
+
203
+ for (const defaultKey in defaultAttrs) {
204
+ const defaultValue = defaultAttrs[defaultKey];
205
+
206
+ mergedDefaults[defaultKey] =
207
+ typeof defaultValue === "object"
208
+ ? (defaultValue as UnknownObject)[String(props[defaultKey])]
209
+ : defaultValue;
210
+ }
211
+
212
+ return mergedDefaults;
213
+ }
140
214
 
141
215
  /**
142
216
  * Compute classes for a given key directly (not as a computed ref).
@@ -152,6 +226,7 @@ export function useUI<T>(defaultConfig: T, mutatedProps?: MutatedProps, topLevel
152
226
  let classes = "";
153
227
 
154
228
  if (typeof value === "object" && isCVA(value)) {
229
+ const { cvaCache } = entry.value;
155
230
  let cvaFn = cvaCache.get(key);
156
231
 
157
232
  if (!cvaFn) {
@@ -200,21 +275,24 @@ export function useUI<T>(defaultConfig: T, mutatedProps?: MutatedProps, topLevel
200
275
  * Merge extends nested component configs.
201
276
  * TODO: Add ability to merge multiple keys in one (now works for merging only 1 first key).
202
277
  */
203
- function computeExtendsKeyConfig(configKey: string): NestedComponent {
204
- const propsConfig = props.config as ComponentConfigFull<T>;
205
- const extendsKeys = getExtendsKeys(config.value[configKey]);
278
+ function computeExtendsKeyConfig(
279
+ mergedConfig: ComponentConfigFull<T>,
280
+ propsConfig: ComponentConfigFull<T>,
281
+ configKey: string,
282
+ ): NestedComponent {
283
+ const extendsKeys = getExtendsKeys(mergedConfig[configKey]);
206
284
 
207
285
  if (!extendsKeys.length) return {};
208
286
 
209
287
  const [firstKey] = extendsKeys;
210
288
 
211
- if (config.value[firstKey] === undefined) {
289
+ if (mergedConfig[firstKey] === undefined) {
212
290
  // eslint-disable-next-line no-console
213
291
  console.warn(`[vueless] Missing ${firstKey} extend key.`);
214
292
  }
215
293
 
216
294
  return getMergedConfig({
217
- defaultConfig: config.value[firstKey] || {},
295
+ defaultConfig: mergedConfig[firstKey] || {},
218
296
  globalConfig: globalConfig[firstKey],
219
297
  propsConfig: propsConfig[firstKey],
220
298
  unstyled: isUnstyled,
@@ -258,7 +336,7 @@ export function useUI<T>(defaultConfig: T, mutatedProps?: MutatedProps, topLevel
258
336
  watch(
259
337
  [config, reactiveProps, mutatedProps || (() => undefined), reactiveAttrs],
260
338
  () => {
261
- const derived = configDerivedData.value;
339
+ const derived = staticData.value;
262
340
 
263
341
  for (const key in attrsRefs) {
264
342
  const data = derived[key];
@@ -281,9 +359,9 @@ export function useUI<T>(defaultConfig: T, mutatedProps?: MutatedProps, topLevel
281
359
 
282
360
  const nextValue: KeyAttrs = {
283
361
  ...commonAttrs,
284
- class: cx([...data.extendsClasses, classes, commonAttrs.class]),
362
+ class: cx([...computeExtendsClasses(key), classes, commonAttrs.class]),
285
363
  config: data.mergedNestedConfig,
286
- ...data.mergedDefaults,
364
+ ...computeMergedDefaults(data.defaultAttrs),
287
365
  };
288
366
 
289
367
  /* Keep the previous ref identity when nothing changed — see attrsSignatures. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vueless",
3
- "version": "1.5.3-beta.5",
3
+ "version": "1.5.4-beta.0",
4
4
  "description": "Vue Styleless UI Component Library, powered by Tailwind CSS.",
5
5
  "author": "Johnny Grid <hello@vueless.com> (https://vueless.com)",
6
6
  "homepage": "https://vueless.com",
@@ -59,7 +59,7 @@
59
59
  "@vue/eslint-config-typescript": "^14.7.0",
60
60
  "@vue/test-utils": "^2.4.8",
61
61
  "@vue/tsconfig": "^0.9.1",
62
- "@vueless/storybook": "^1.8.3",
62
+ "@vueless/storybook": "^1.8.5",
63
63
  "eslint": "^10.2.1",
64
64
  "eslint-plugin-storybook": "^10.3.5",
65
65
  "eslint-plugin-tailwindcss": "^4.0.0-alpha.7",
@@ -1,5 +1,15 @@
1
1
  <script setup lang="ts">
2
- import { computed, useSlots, watch, useId, useTemplateRef, nextTick, ref } from "vue";
2
+ import {
3
+ computed,
4
+ useSlots,
5
+ watch,
6
+ useId,
7
+ useTemplateRef,
8
+ nextTick,
9
+ ref,
10
+ onMounted,
11
+ onBeforeUnmount,
12
+ } from "vue";
3
13
 
4
14
  import { useUI } from "../composables/useUI";
5
15
  import { getDefaults } from "../utils/ui";
@@ -12,6 +22,7 @@ import UButton from "../ui.button/UButton.vue";
12
22
 
13
23
  import defaultConfig from "./config";
14
24
  import { COMPONENT_NAME } from "./constants";
25
+ import { registerModal, unregisterModal, isTopModal } from "./modalStack";
15
26
 
16
27
  import type { Props, Config } from "./types";
17
28
 
@@ -43,6 +54,7 @@ const emit = defineEmits([
43
54
  ]);
44
55
 
45
56
  const elementId = props.id || useId();
57
+ const modalKey = Symbol(COMPONENT_NAME);
46
58
 
47
59
  const slots = useSlots();
48
60
 
@@ -81,21 +93,57 @@ function getFocusableElements() {
81
93
  wrapperRef.value.querySelectorAll(
82
94
  'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
83
95
  ),
84
- );
96
+ ).filter(isFocusableElement);
97
+ }
98
+
99
+ function isFocusableElement(element: Element) {
100
+ if (element.matches(":disabled, [tabindex='-1']") || element.closest("[hidden], [inert]")) {
101
+ return false;
102
+ }
103
+
104
+ let ancestor: Element | null = element;
105
+
106
+ while (ancestor) {
107
+ const style = getComputedStyle(ancestor);
108
+
109
+ if (
110
+ style.display === "none" ||
111
+ style.visibility === "hidden" ||
112
+ style.visibility === "collapse"
113
+ ) {
114
+ return false;
115
+ }
116
+
117
+ ancestor = ancestor.parentElement;
118
+ }
119
+
120
+ return true;
85
121
  }
86
122
 
87
123
  function trapFocus(e: KeyboardEvent) {
88
- if (e.key !== "Tab") return;
124
+ if (e.key !== "Tab" || !isTopModal(modalKey)) return;
89
125
 
90
126
  const focusableElements = getFocusableElements();
91
127
 
92
- if (!focusableElements.length) return;
128
+ if (!focusableElements.length) {
129
+ e.preventDefault();
130
+ wrapperRef.value?.focus();
131
+
132
+ return;
133
+ }
93
134
 
94
135
  const firstElement = focusableElements.at(0) as HTMLElement;
95
136
  const lastElement = focusableElements.at(-1) as HTMLElement;
96
137
 
97
- // Shift+Tab - if focused on first element, move to last
98
- if (e.shiftKey && document.activeElement === firstElement) {
138
+ const activeElement = document.activeElement;
139
+ const isFocusInside = activeElement && wrapperRef.value?.contains(activeElement);
140
+
141
+ const isAtStart = activeElement === firstElement || activeElement === wrapperRef.value;
142
+ const shouldWrapBackward = e.shiftKey && (isAtStart || !isFocusInside);
143
+ const shouldWrapForward = !e.shiftKey && (activeElement === lastElement || !isFocusInside);
144
+
145
+ // Shift+Tab from the wrapper or first control stays inside the modal.
146
+ if (shouldWrapBackward) {
99
147
  e.preventDefault();
100
148
  lastElement.focus();
101
149
 
@@ -103,7 +151,7 @@ function trapFocus(e: KeyboardEvent) {
103
151
  }
104
152
 
105
153
  // Tab - if focused on the last element, move to the first
106
- if (!e.shiftKey && document.activeElement === lastElement) {
154
+ if (shouldWrapForward) {
107
155
  e.preventDefault();
108
156
  firstElement.focus();
109
157
  }
@@ -128,18 +176,22 @@ function onChangeShownModal(newValue: boolean) {
128
176
 
129
177
  function toggleOverflow() {
130
178
  if (isShownModal.value) {
131
- document.body.style.overflow = "hidden";
179
+ registerModal(modalKey, () => wrapperRef.value?.focus());
132
180
  } else {
133
- const element = document.getElementById(`${elementId}`);
134
-
135
- if (element) {
136
- element.style.overflow = "hidden";
137
- }
138
-
139
- document.body.style.overflow = "auto";
181
+ unregisterModal(modalKey);
140
182
  }
141
183
  }
142
184
 
185
+ onMounted(() => {
186
+ if (isShownModal.value) onChangeShownModal(true);
187
+ });
188
+
189
+ onBeforeUnmount(() => {
190
+ document.removeEventListener("keydown", trapFocus);
191
+ document.removeEventListener("keydown", onKeydownEsc);
192
+ unregisterModal(modalKey);
193
+ });
194
+
143
195
  function toggleEventListeners() {
144
196
  if (isShownModal.value) {
145
197
  document.addEventListener("keydown", trapFocus);
@@ -166,9 +218,15 @@ function onClickOutside() {
166
218
  }
167
219
 
168
220
  function onKeydownEsc(e: KeyboardEvent) {
169
- if (e.key !== "Escape" || !props.closeOnEsc) return;
221
+ if (e.key !== "Escape" || !props.closeOnEsc || !isTopModal(modalKey)) return false;
170
222
 
171
223
  closeModal();
224
+
225
+ return true;
226
+ }
227
+
228
+ function onWrapperKeydownEsc(e: KeyboardEvent) {
229
+ if (onKeydownEsc(e)) e.stopPropagation();
172
230
  }
173
231
 
174
232
  function closeModal() {
@@ -235,7 +293,7 @@ const {
235
293
  tabindex="0"
236
294
  v-bind="wrapperAttrs"
237
295
  :data-test="getDataTest()"
238
- @keydown.self.esc="onKeydownEsc"
296
+ @keydown.self.esc="onWrapperKeydownEsc"
239
297
  >
240
298
  <div
241
299
  v-bind="innerWrapperAttrs"
@@ -0,0 +1,34 @@
1
+ import { nextTick } from "vue";
2
+
3
+ // Modal instances share the document's scroll lock and keyboard handling.
4
+ const activeModals = new Map<symbol, () => void>();
5
+ let previousOverflow = "";
6
+
7
+ export function registerModal(id: symbol, focus: () => void): void {
8
+ if (!activeModals.size) previousOverflow = document.body.style.overflow;
9
+
10
+ activeModals.delete(id);
11
+ activeModals.set(id, focus);
12
+ document.body.style.overflow = "hidden";
13
+ }
14
+
15
+ export function unregisterModal(id: symbol): void {
16
+ const wasTopModal = isTopModal(id);
17
+
18
+ if (!activeModals.delete(id)) return;
19
+
20
+ if (!activeModals.size) document.body.style.overflow = previousOverflow;
21
+ else if (wasTopModal) {
22
+ const previousId = Array.from(activeModals.keys()).at(-1);
23
+
24
+ nextTick(() => {
25
+ if (previousId && isTopModal(previousId)) {
26
+ activeModals.get(previousId)?.();
27
+ }
28
+ });
29
+ }
30
+ }
31
+
32
+ export function isTopModal(id: symbol): boolean {
33
+ return Array.from(activeModals.keys()).at(-1) === id;
34
+ }
@@ -95,12 +95,6 @@ const defaultTemplate = `
95
95
 
96
96
  const password = ref("");
97
97
 
98
- function resetBodyOverflow(delay = 500) {
99
- setTimeout(() => {
100
- document.body.style.overflow = "auto";
101
- }, delay);
102
- }
103
-
104
98
  const DefaultTemplate: StoryFn<UModalArgs> = (args: UModalArgs) => ({
105
99
  components: {
106
100
  UModal,
@@ -117,8 +111,6 @@ const DefaultTemplate: StoryFn<UModalArgs> = (args: UModalArgs) => ({
117
111
  setup() {
118
112
  function onClick() {
119
113
  args.modelValue = true;
120
-
121
- resetBodyOverflow();
122
114
  }
123
115
 
124
116
  const slots = getSlotNames(UModal.__name);
@@ -171,6 +163,15 @@ const EnumTemplate: StoryFn<UModalArgs> = (args: UModalArgs, { argTypes }) => ({
171
163
 
172
164
  export const Default = DefaultTemplate.bind({});
173
165
  Default.args = {};
166
+ Default.parameters = {
167
+ docs: {
168
+ description: {
169
+ story:
170
+ // eslint-disable-next-line vue/max-len
171
+ "An initially open modal focuses its wrapper and locks body scrolling. Tab stays inside visible controls, and Escape closes the modal when closeOnEsc is enabled. Listeners are removed on unmount.",
172
+ },
173
+ },
174
+ };
174
175
 
175
176
  export const Description = DefaultTemplate.bind({});
176
177
  Description.args = {
@@ -191,14 +192,10 @@ export const Inner: StoryFn<UModalArgs> = (args: UModalArgs) => ({
191
192
 
192
193
  function openMainModal() {
193
194
  showMainModal.value = true;
194
-
195
- resetBodyOverflow();
196
195
  }
197
196
 
198
197
  function openInnerModal() {
199
198
  showInnerModal.value = true;
200
-
201
- resetBodyOverflow();
202
199
  }
203
200
 
204
201
  return { args, showMainModal, showInnerModal, openMainModal, openInnerModal };
@@ -253,7 +250,9 @@ export const Inner: StoryFn<UModalArgs> = (args: UModalArgs) => ({
253
250
  Inner.parameters = {
254
251
  docs: {
255
252
  description: {
256
- story: "Add extra top margin for modal inside another modal.",
253
+ story:
254
+ // eslint-disable-next-line vue/max-len
255
+ "Add extra top margin for a nested modal. Only the topmost modal handles keyboard navigation. Closing or unmounting it returns focus to the previous modal. Body scrolling stays locked until the last modal closes, then the previous overflow style is restored.",
257
256
  },
258
257
  },
259
258
  };
@@ -1,5 +1,5 @@
1
- import { mount } from "@vue/test-utils";
2
- import { describe, it, expect } from "vitest";
1
+ import { mount, enableAutoUnmount } from "@vue/test-utils";
2
+ import { describe, it, expect, afterEach } from "vitest";
3
3
  import { createRouter, createWebHistory } from "vue-router";
4
4
 
5
5
  import UModal from "../UModal.vue";
@@ -31,6 +31,8 @@ const mountWithRouter = (component: unknown, options: UnknownObject) => {
31
31
  });
32
32
  };
33
33
 
34
+ enableAutoUnmount(afterEach);
35
+
34
36
  describe("UModal", () => {
35
37
  const modelValue = true;
36
38
 
@@ -0,0 +1,197 @@
1
+ import { enableAutoUnmount, mount } from "@vue/test-utils";
2
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
+ import { nextTick } from "vue";
4
+
5
+ import UModal from "../UModal.vue";
6
+
7
+ let previousOverflow: string;
8
+ let previousTabindex: string | null;
9
+
10
+ beforeEach(() => {
11
+ previousOverflow = document.body.style.overflow;
12
+ previousTabindex = document.body.getAttribute("tabindex");
13
+ });
14
+
15
+ enableAutoUnmount((unmount) => {
16
+ afterEach(() => {
17
+ unmount();
18
+ document.body.style.overflow = previousOverflow;
19
+
20
+ if (previousTabindex === null) document.body.removeAttribute("tabindex");
21
+ else document.body.setAttribute("tabindex", previousTabindex);
22
+ });
23
+ });
24
+
25
+ function mountModal(props = {}) {
26
+ return mount(UModal, {
27
+ attachTo: document.body,
28
+ props: { modelValue: true, ...props },
29
+ slots: {
30
+ default: '<input data-test="first-input" /><button data-test="last-button">Last</button>',
31
+ },
32
+ });
33
+ }
34
+
35
+ describe("UModal keyboard and lifecycle", () => {
36
+ it("focuses an initially open modal and handles Escape from its input", async () => {
37
+ const wrapper = mountModal();
38
+
39
+ await nextTick();
40
+ expect(document.activeElement).toBe(wrapper.get('[vl-key="wrapper"]').element);
41
+ await wrapper.get('[data-test="first-input"]').trigger("keydown", { key: "Escape" });
42
+ expect(wrapper.emitted("update:modelValue")).toEqual([[false]]);
43
+ });
44
+
45
+ it("handles wrapper Escape once without also reaching the document handler", async () => {
46
+ const wrapper = mountModal();
47
+
48
+ await nextTick();
49
+ await wrapper.get('[vl-key="wrapper"]').trigger("keydown", { key: "Escape" });
50
+
51
+ expect(wrapper.emitted("update:modelValue")).toEqual([[false]]);
52
+ });
53
+
54
+ it("routes Escape from a background wrapper to the topmost modal", async () => {
55
+ const parent = mountModal();
56
+ const child = mountModal();
57
+
58
+ await parent.get('[vl-key="wrapper"]').trigger("keydown", { key: "Escape" });
59
+
60
+ expect(parent.emitted("update:modelValue")).toBeUndefined();
61
+ expect(child.emitted("update:modelValue")).toEqual([[false]]);
62
+ });
63
+
64
+ it("traps Tab at the last focusable element", async () => {
65
+ const wrapper = mountModal();
66
+
67
+ await nextTick();
68
+ (wrapper.get('[data-test="last-button"]').element as HTMLElement).focus();
69
+ await wrapper.get('[data-test="last-button"]').trigger("keydown", { key: "Tab" });
70
+ expect(document.activeElement).toBe(wrapper.get('[data-test="first-input"]').element);
71
+ });
72
+
73
+ it.each(['style="display: none"', 'style="visibility: hidden"', "hidden", "inert"])(
74
+ "ignores controls inside a hidden or inert ancestor: %s",
75
+ async (attributes) => {
76
+ const wrapper = mount(UModal, {
77
+ attachTo: document.body,
78
+ props: { modelValue: true },
79
+ slots: {
80
+ default:
81
+ `<input data-test="first-input" /><button data-test="last-button">Last</button>` +
82
+ `<div ${attributes}><button>Hidden</button></div>`,
83
+ },
84
+ });
85
+
86
+ await nextTick();
87
+ (wrapper.get('[data-test="last-button"]').element as HTMLElement).focus();
88
+ await wrapper.get('[data-test="last-button"]').trigger("keydown", { key: "Tab" });
89
+ expect(document.activeElement).toBe(wrapper.get('[data-test="first-input"]').element);
90
+ await wrapper
91
+ .get('[data-test="first-input"]')
92
+ .trigger("keydown", { key: "Tab", shiftKey: true });
93
+ expect(document.activeElement).toBe(wrapper.get('[data-test="last-button"]').element);
94
+ },
95
+ );
96
+
97
+ it("keeps focus inside while all controls are disabled", async () => {
98
+ const wrapper = mount(UModal, {
99
+ attachTo: document.body,
100
+ props: { modelValue: true },
101
+ slots: { default: "<input disabled /><button disabled>Saving</button>" },
102
+ });
103
+
104
+ await nextTick();
105
+ const element = wrapper.get('[vl-key="wrapper"]').element;
106
+ const event = new KeyboardEvent("keydown", {
107
+ key: "Tab",
108
+ bubbles: true,
109
+ cancelable: true,
110
+ });
111
+
112
+ element.dispatchEvent(event);
113
+ expect(event.defaultPrevented).toBe(true);
114
+ expect(document.activeElement).toBe(element);
115
+ });
116
+
117
+ it("handles Escape only in the topmost modal and respects disabled Escape", async () => {
118
+ const parent = mountModal();
119
+ const child = mountModal({ closeOnEsc: false });
120
+
121
+ await child.get('[data-test="first-input"]').trigger("keydown", { key: "Escape" });
122
+ expect(parent.emitted("update:modelValue")).toBeUndefined();
123
+ expect(child.emitted("update:modelValue")).toBeUndefined();
124
+ await child.setProps({ closeOnEsc: true });
125
+ await child.get('[data-test="first-input"]').trigger("keydown", { key: "Escape" });
126
+ expect(parent.emitted("update:modelValue")).toBeUndefined();
127
+ expect(child.emitted("update:modelValue")).toEqual([[false]]);
128
+ });
129
+
130
+ it("keeps body scrolling locked until all nested modals have closed", async () => {
131
+ document.body.style.overflow = "scroll";
132
+ const parent = mountModal();
133
+ const child = mountModal();
134
+
135
+ expect(document.body.style.overflow).toBe("hidden");
136
+ await child.setProps({ modelValue: false });
137
+ expect(document.body.style.overflow).toBe("hidden");
138
+ await parent.setProps({ modelValue: false });
139
+ expect(document.body.style.overflow).toBe("scroll");
140
+ });
141
+
142
+ it("removes listeners and releases the scroll lock when unmounted while open", async () => {
143
+ document.body.style.overflow = "auto";
144
+ const wrapper = mountModal();
145
+
146
+ await nextTick();
147
+ wrapper.unmount();
148
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
149
+ expect(wrapper.emitted("update:modelValue")).toBeUndefined();
150
+ expect(document.body.style.overflow).toBe("auto");
151
+ });
152
+ it.each(["close", "unmount"])("returns focus to the parent after child %s", async (action) => {
153
+ const parent = mountModal();
154
+
155
+ await nextTick();
156
+ const child = mountModal();
157
+
158
+ await nextTick();
159
+ (child.get('[data-test="first-input"]').element as HTMLElement).focus();
160
+ if (action === "close") await child.setProps({ modelValue: false });
161
+ else child.unmount();
162
+ await nextTick();
163
+ const wrapper = parent.get('[vl-key="wrapper"]').element;
164
+
165
+ expect(document.activeElement).toBe(wrapper);
166
+ await parent.get('[vl-key="wrapper"]').trigger("keydown", { key: "Tab", shiftKey: true });
167
+ expect(document.activeElement).toBe(parent.get('[data-test="last-button"]').element);
168
+ });
169
+
170
+ it("does not steal focus from the child when a background modal closes", async () => {
171
+ const parent = mountModal();
172
+ const child = mountModal();
173
+
174
+ await nextTick();
175
+ const input = child.get('[data-test="first-input"]').element as HTMLElement;
176
+
177
+ input.focus();
178
+ await parent.setProps({ modelValue: false });
179
+ expect(document.activeElement).toBe(input);
180
+ });
181
+
182
+ it("traps Tab when focus is outside the topmost modal", async () => {
183
+ const modal = mountModal();
184
+
185
+ await nextTick();
186
+ document.body.tabIndex = -1;
187
+ document.body.focus();
188
+ document.dispatchEvent(
189
+ new KeyboardEvent("keydown", {
190
+ key: "Tab",
191
+ bubbles: true,
192
+ cancelable: true,
193
+ }),
194
+ );
195
+ expect(document.activeElement).toBe(modal.get('[data-test="first-input"]').element);
196
+ });
197
+ });
@@ -0,0 +1,47 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { nextTick } from "vue";
3
+
4
+ import { isTopModal, registerModal, unregisterModal } from "../modalStack";
5
+
6
+ const parent = Symbol("parent");
7
+ const child = Symbol("child");
8
+ let previousOverflow: string;
9
+
10
+ beforeEach(() => {
11
+ previousOverflow = document.body.style.overflow;
12
+ });
13
+
14
+ afterEach(() => {
15
+ unregisterModal(parent);
16
+ unregisterModal(child);
17
+ document.body.style.overflow = previousOverflow;
18
+ });
19
+
20
+ describe("modal stack registration", () => {
21
+ it("moves a registered modal to the top and restores focus to the remaining modal", async () => {
22
+ const focusChild = vi.fn();
23
+
24
+ registerModal(parent, vi.fn());
25
+ registerModal(child, focusChild);
26
+ expect(isTopModal(child)).toBe(true);
27
+ registerModal(parent, vi.fn());
28
+ expect(isTopModal(parent)).toBe(true);
29
+ unregisterModal(parent);
30
+ await nextTick();
31
+ expect(isTopModal(child)).toBe(true);
32
+ expect(focusChild).toHaveBeenCalledOnce();
33
+ expect(document.body.style.overflow).toBe("hidden");
34
+ unregisterModal(child);
35
+ expect(document.body.style.overflow).toBe(previousOverflow);
36
+ });
37
+
38
+ it("does not duplicate registrations or overwrite the saved overflow", () => {
39
+ document.body.style.overflow = "scroll";
40
+ registerModal(parent, vi.fn());
41
+ registerModal(parent, vi.fn());
42
+ unregisterModal(parent);
43
+
44
+ expect(isTopModal(parent)).toBe(false);
45
+ expect(document.body.style.overflow).toBe("scroll");
46
+ });
47
+ });
@@ -188,7 +188,7 @@ const visibleColumns = computed(() => {
188
188
  const columnPositions = shallowRef<Map<string, number>>(new Map());
189
189
 
190
190
  const colsCount = computed(() => {
191
- return normalizedColumns.value.length + 1;
191
+ return visibleColumns.value.length + Number(props.selectable);
192
192
  });
193
193
 
194
194
  const isShownActionsHeader = computed(() => {
@@ -1,6 +1,6 @@
1
1
  import { mount } from "@vue/test-utils";
2
2
  import { describe, it, expect } from "vitest";
3
- import { nextTick } from "vue";
3
+ import { h, nextTick } from "vue";
4
4
 
5
5
  import UTable from "../UTable.vue";
6
6
  import UTableRow from "../UTableRow.vue";
@@ -16,6 +16,71 @@ import {
16
16
  import type { Column, Row, Props, ColumnObject } from "../types";
17
17
 
18
18
  describe("UTable.vue", () => {
19
+ it.each([false, true])(
20
+ "matches the empty-state colspan to visible headers when selectable is %s",
21
+ async (selectable) => {
22
+ const component = mountUTable({
23
+ columns: [
24
+ { key: "message", label: "Message" },
25
+ { key: "severity", label: "Severity" },
26
+ { key: "hidden", label: "Hidden", isShown: false },
27
+ ],
28
+ rows: [{ id: 1, message: "Message", severity: "info" }],
29
+ selectable,
30
+ });
31
+
32
+ await component.setProps({ rows: [] });
33
+ const headerCount = component.findAll("thead th").length;
34
+
35
+ expect(headerCount).toBe(2 + Number(selectable));
36
+ expect(component.get("tbody td").attributes("colspan")).toBe(String(headerCount));
37
+ component.unmount();
38
+ },
39
+ );
40
+
41
+ it.each([false, true])(
42
+ "updates full-row spans when columns or selection change: selectable=%s",
43
+ async (selectable) => {
44
+ const component = mountUTable(
45
+ {
46
+ columns: [
47
+ { key: "message", label: "Message" },
48
+ { key: "severity", label: "Severity", isShown: false },
49
+ ],
50
+ rows: [
51
+ { id: 1, message: "First", rowDate: "2026-10-15" },
52
+ { id: 2, message: "Second", rowDate: "2026-10-16" },
53
+ ],
54
+ selectable,
55
+ dateDivider: true,
56
+ },
57
+ {
58
+ slots: {
59
+ "before-header": ({ colsCount }: { colsCount: number }) =>
60
+ h("th", { colspan: colsCount, "data-test": "before-header" }, "Group header"),
61
+ "before-first-row": "Before rows",
62
+ "after-last-row": ({ colsCount }: { colsCount: number }) =>
63
+ h("td", { colspan: colsCount, "data-test": "after-rows" }, "After rows"),
64
+ },
65
+ },
66
+ );
67
+
68
+ function expectSpans(count: number) {
69
+ const spanningCells = component.findAll("[colspan]");
70
+
71
+ expect(spanningCells).toHaveLength(4);
72
+ for (const cell of spanningCells) expect(cell.attributes("colspan")).toBe(String(count));
73
+ }
74
+
75
+ expectSpans(1 + Number(selectable));
76
+ await component.setProps({ columns: ["message", "severity"] });
77
+ expectSpans(2 + Number(selectable));
78
+ await component.setProps({ selectable: !selectable });
79
+ expectSpans(2 + Number(!selectable));
80
+ component.unmount();
81
+ },
82
+ );
83
+
19
84
  const defaultColumns: ColumnObject[] = [
20
85
  { key: "name", label: "Name" },
21
86
  { key: "email", label: "Email" },
@@ -389,6 +454,11 @@ describe("UTable.vue", () => {
389
454
 
390
455
  // Should have at least one spacer row (top or bottom)
391
456
  expect(spacerRows.length).toBeGreaterThanOrEqual(1);
457
+ const headerCount = component.findAll("thead th").length;
458
+
459
+ for (const row of spacerRows) {
460
+ expect(row.get("td").attributes("colspan")).toBe(String(headerCount));
461
+ }
392
462
  });
393
463
 
394
464
  it("Row Height – uses default rowHeight value", () => {