vueless 1.5.3 → 1.5.4-beta.1
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/composables/tests/useUI.test.ts +141 -2
- package/composables/useUI.ts +134 -56
- package/package.json +2 -2
- package/ui.container-modal/UModal.vue +75 -17
- package/ui.container-modal/modalStack.ts +34 -0
- package/ui.container-modal/storybook/stories.ts +12 -13
- package/ui.container-modal/tests/UModal.test.ts +4 -2
- package/ui.container-modal/tests/UModalLifecycle.test.ts +197 -0
- package/ui.container-modal/tests/modalStack.test.ts +47 -0
- package/ui.data-table/UTable.vue +1 -1
- package/ui.data-table/tests/UTable.test.ts +71 -1
- package/ui.form-calendar/config.ts +1 -1
- package/ui.form-date-picker-range/UDatePickerRangePeriodMenu.vue +1 -1
- package/ui.form-date-picker-range/config.ts +2 -2
- package/ui.image-icon/UIcon.vue +1 -1
- package/ui.image-icon/tests/UIcon.test.ts +20 -0
- package/ui.image-icon/tests/assets/test-icon-multipath-filled.svg +1 -0
- package/ui.image-icon/tests/assets/test-icon-multipath-outline.svg +1 -0
|
@@ -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
|
|
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);
|
package/composables/useUI.ts
CHANGED
|
@@ -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
|
-
|
|
67
|
-
|
|
87
|
+
const entry = computed(() => {
|
|
88
|
+
const propsConfig = props.config as ComponentConfigFull<T>;
|
|
68
89
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
(newVal, oldVal) => {
|
|
72
|
-
if (newVal === oldVal) return;
|
|
90
|
+
return isEmptyValue(propsConfig) ? getSharedEntry() : createEntry(propsConfig);
|
|
91
|
+
});
|
|
73
92
|
|
|
74
|
-
|
|
93
|
+
const config = computed(() => entry.value.config) as Ref<ComponentConfigFull<T>>;
|
|
94
|
+
const attrsConfig = computed(() => attrs["config"]);
|
|
75
95
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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(
|
|
204
|
-
|
|
205
|
-
|
|
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 (
|
|
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:
|
|
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 =
|
|
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([...
|
|
362
|
+
class: cx([...computeExtendsClasses(key), classes, commonAttrs.class]),
|
|
285
363
|
config: data.mergedNestedConfig,
|
|
286
|
-
...data.
|
|
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
|
+
"version": "1.5.4-beta.1",
|
|
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.
|
|
62
|
+
"@vueless/storybook": "^1.8.6",
|
|
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 {
|
|
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)
|
|
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
|
-
|
|
98
|
-
|
|
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 (
|
|
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
|
-
|
|
179
|
+
registerModal(modalKey, () => wrapperRef.value?.focus());
|
|
132
180
|
} else {
|
|
133
|
-
|
|
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="
|
|
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:
|
|
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
|
+
});
|
package/ui.data-table/UTable.vue
CHANGED
|
@@ -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
|
|
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", () => {
|
|
@@ -30,7 +30,7 @@ export default /*tw*/ {
|
|
|
30
30
|
anotherMonthDate: "text-muted",
|
|
31
31
|
activeDate: "bg-primary/10",
|
|
32
32
|
selectedDate: "",
|
|
33
|
-
currentDate: "
|
|
33
|
+
currentDate: "",
|
|
34
34
|
day: "{UButton} size-9 w-full",
|
|
35
35
|
currentDay: "{>day} {>currentDate}",
|
|
36
36
|
dayInRange: "{>day} {>dateInRange}",
|
|
@@ -299,7 +299,7 @@ function getDatePeriodState(date: DatePeriodRange) {
|
|
|
299
299
|
v-else-if="getDatePeriodState(date).isCurrentDate"
|
|
300
300
|
size="sm"
|
|
301
301
|
color="primary"
|
|
302
|
-
variant="
|
|
302
|
+
variant="ghost"
|
|
303
303
|
:disabled="isDatePeriodOutOfRange(date)"
|
|
304
304
|
v-bind="attrs.periodDateCurrentAttrs.value"
|
|
305
305
|
:label="String(date.title)"
|
|
@@ -83,8 +83,8 @@ export default /*tw*/ {
|
|
|
83
83
|
},
|
|
84
84
|
periodDate: "w-full",
|
|
85
85
|
periodDateSelected: "{>periodDate}",
|
|
86
|
-
periodDateCurrent: "{>periodDate}
|
|
87
|
-
periodDateCurrentSelected: "{>periodDate} {>
|
|
86
|
+
periodDateCurrent: "{>periodDate}",
|
|
87
|
+
periodDateCurrentSelected: "{>periodDate} {>periodDateCurrent} {>periodDateSelected}",
|
|
88
88
|
customRangeButton: {
|
|
89
89
|
base: "{>periodButton}",
|
|
90
90
|
variants: {
|
package/ui.image-icon/UIcon.vue
CHANGED
|
@@ -2,6 +2,8 @@ import { flushPromises, mount } from "@vue/test-utils";
|
|
|
2
2
|
import { describe, it, expect } from "vitest";
|
|
3
3
|
|
|
4
4
|
import testIcon from "./assets/test-icon.svg?component";
|
|
5
|
+
import multipathOutlineIcon from "./assets/test-icon-multipath-outline.svg?component";
|
|
6
|
+
import multipathFilledIcon from "./assets/test-icon-multipath-filled.svg?component";
|
|
5
7
|
|
|
6
8
|
import UIcon from "../UIcon.vue";
|
|
7
9
|
import type { Props } from "../types";
|
|
@@ -34,6 +36,24 @@ describe("UIcon.vue", () => {
|
|
|
34
36
|
expect(component.get("[vl-key='icon']")).toBeTruthy();
|
|
35
37
|
});
|
|
36
38
|
|
|
39
|
+
it("Src – re-renders multi-path icon when src changes", async () => {
|
|
40
|
+
const component = mount(UIcon, {
|
|
41
|
+
props: {
|
|
42
|
+
src: multipathOutlineIcon,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await flushPromises();
|
|
47
|
+
|
|
48
|
+
expect(component.find("#outline").exists()).toBe(true);
|
|
49
|
+
|
|
50
|
+
await component.setProps({ src: multipathFilledIcon });
|
|
51
|
+
|
|
52
|
+
expect(component.findComponent(multipathFilledIcon!).exists()).toBe(true);
|
|
53
|
+
expect(component.find("#outline").exists()).toBe(false);
|
|
54
|
+
expect(component.find("#filled").exists()).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
37
57
|
it("Color – applies correct color classes", () => {
|
|
38
58
|
const name = "check";
|
|
39
59
|
const colors = [
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M1 1h1"/><path d="M2 2h1"/><path d="M3 3h1"/><path d="M4 4h1"/><path d="M5 5h1"/><path id="filled" d="M6 6h1"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M1 1h1"/><path d="M2 2h1"/><path d="M3 3h1"/><path d="M4 4h1"/><path d="M5 5h1"/><path id="outline" d="M6 6h1"/></svg>
|