vueless 1.4.12-beta.9 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/index.d.ts +128 -0
- package/index.ts +128 -0
- package/package.json +10 -9
- package/ui.button/config.ts +1 -1
- package/ui.button-link/config.ts +1 -1
- package/ui.button-toggle/UToggle.vue +6 -4
- package/ui.button-toggle/storybook/stories.ts +4 -1
- package/ui.button-toggle/tests/UToggle.test-d.ts +48 -0
- package/ui.button-toggle/tests/UToggleTypes.vue +55 -0
- package/ui.button-toggle/types.ts +18 -6
- package/ui.container-accordion/types.ts +5 -2
- package/ui.container-collapsible/config.ts +1 -1
- package/ui.data-list/UDataList.vue +48 -17
- package/ui.data-list/storybook/stories.ts +4 -1
- package/ui.data-list/tests/UDataList.test-d.ts +76 -0
- package/ui.data-list/tests/UDataList.test.ts +50 -1
- package/ui.data-list/tests/UDataListTypes.vue +110 -0
- package/ui.data-list/types.ts +37 -5
- package/ui.data-table/UTable.vue +68 -10
- package/ui.data-table/config.ts +1 -1
- package/ui.data-table/storybook/stories.ts +4 -1
- package/ui.data-table/tests/UTable.test-d.ts +179 -0
- package/ui.data-table/tests/UTable.test.ts +1 -1
- package/ui.data-table/tests/UTableEmptyColumnsTypes.vue +27 -0
- package/ui.data-table/tests/UTableTypes.vue +193 -0
- package/ui.data-table/types.ts +91 -8
- package/ui.data-table/utilTable.ts +1 -1
- package/ui.dropdown/UDropdown.vue +19 -8
- package/ui.dropdown/storybook/stories.ts +4 -1
- package/ui.dropdown/tests/UDropdown.test-d.ts +61 -0
- package/ui.dropdown/tests/UDropdown.test.ts +8 -1
- package/ui.dropdown/tests/UDropdownTypes.vue +111 -0
- package/ui.dropdown/types.ts +27 -3
- package/ui.dropdown-badge/UDropdownBadge.vue +9 -4
- package/ui.dropdown-badge/storybook/stories.ts +6 -1
- package/ui.dropdown-badge/tests/UDropdownBadge.test-d.ts +55 -0
- package/ui.dropdown-badge/tests/UDropdownBadge.test.ts +8 -1
- package/ui.dropdown-badge/tests/UDropdownBadgeTypes.vue +107 -0
- package/ui.dropdown-badge/types.ts +17 -3
- package/ui.dropdown-button/UDropdownButton.vue +9 -4
- package/ui.dropdown-button/storybook/stories.ts +6 -1
- package/ui.dropdown-button/tests/UDropdownButton.test-d.ts +57 -0
- package/ui.dropdown-button/tests/UDropdownButton.test.ts +8 -1
- package/ui.dropdown-button/tests/UDropdownButtonTypes.vue +107 -0
- package/ui.dropdown-button/types.ts +17 -3
- package/ui.dropdown-link/UDropdownLink.vue +9 -4
- package/ui.dropdown-link/config.ts +1 -1
- package/ui.dropdown-link/storybook/stories.ts +4 -1
- package/ui.dropdown-link/tests/UDropdownLink.test-d.ts +55 -0
- package/ui.dropdown-link/tests/UDropdownLink.test.ts +8 -1
- package/ui.dropdown-link/tests/UDropdownLinkTypes.vue +107 -0
- package/ui.dropdown-link/types.ts +17 -3
- package/ui.form-calendar/config.ts +1 -1
- package/ui.form-checkbox/config.ts +1 -1
- package/ui.form-checkbox/tests/UCheckbox.test.ts +4 -4
- package/ui.form-checkbox/types.ts +4 -2
- package/ui.form-checkbox-group/UCheckboxGroup.vue +8 -2
- package/ui.form-date-picker/UDatePicker.vue +11 -1
- package/ui.form-date-picker/tests/UDatePicker.test.ts +47 -0
- package/ui.form-input/config.ts +1 -1
- package/ui.form-input-file/config.ts +1 -1
- package/ui.form-listbox/UListbox.vue +29 -12
- package/ui.form-listbox/config.ts +1 -1
- package/ui.form-listbox/storybook/stories.ts +4 -1
- package/ui.form-listbox/tests/UListbox.test-d.ts +63 -0
- package/ui.form-listbox/tests/UListboxTypes.vue +107 -0
- package/ui.form-listbox/types.ts +34 -2
- package/ui.form-radio/config.ts +1 -1
- package/ui.form-radio-group/URadioGroup.vue +15 -3
- package/ui.form-radio-group/config.ts +8 -3
- package/ui.form-radio-group/types.ts +2 -3
- package/ui.form-select/USelect.vue +49 -17
- package/ui.form-select/config.ts +4 -4
- package/ui.form-select/storybook/stories.ts +4 -1
- package/ui.form-select/tests/USelect.test-d.ts +76 -0
- package/ui.form-select/tests/USelect.test.ts +65 -1
- package/ui.form-select/tests/USelectTypes.vue +113 -0
- package/ui.form-select/types.ts +55 -4
- package/ui.form-switch/USwitch.vue +13 -12
- package/ui.form-switch/config.ts +2 -1
- package/ui.form-switch/tests/USwitch.test.ts +151 -0
- package/ui.form-textarea/config.ts +1 -1
- package/ui.image-avatar/types.ts +5 -2
- package/ui.image-avatar-group/types.ts +5 -2
- package/ui.image-icon/UIcon.vue +3 -2
- package/ui.image-icon/types.ts +2 -2
- package/ui.navigation-breadcrumbs/types.ts +5 -2
- package/ui.navigation-tabs/UTabs.vue +6 -4
- package/ui.navigation-tabs/storybook/stories.ts +4 -1
- package/ui.navigation-tabs/tests/UTabs.test-d.ts +50 -0
- package/ui.navigation-tabs/tests/UTabsTypes.vue +65 -0
- package/ui.navigation-tabs/types.ts +25 -3
- package/ui.skeleton-text/constants.ts +1 -1
- package/ui.text-badge/config.ts +1 -1
- package/utils/node/loaderIcon.js +1 -1
- package/v.tooltip/vTooltip.ts +1 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
Props,
|
|
5
|
+
SelectOption,
|
|
6
|
+
SelectedSlotOption,
|
|
7
|
+
SelectedOptionsBinding,
|
|
8
|
+
USelectSlots,
|
|
9
|
+
} from "../types";
|
|
10
|
+
import type { SlotOption } from "../../ui.form-listbox/types";
|
|
11
|
+
|
|
12
|
+
interface UserOption {
|
|
13
|
+
label: string;
|
|
14
|
+
value: string;
|
|
15
|
+
qty: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type OptionSlot = NonNullable<USelectSlots<UserOption>["option"]>;
|
|
19
|
+
type OptionProps = Parameters<OptionSlot>[0];
|
|
20
|
+
|
|
21
|
+
type SelectedOptionProps = Parameters<NonNullable<USelectSlots<UserOption>["selected-option"]>>[0];
|
|
22
|
+
|
|
23
|
+
describe("USelect types", () => {
|
|
24
|
+
it("infers the caller's option shape in a forwarded option slot", () => {
|
|
25
|
+
expectTypeOf<OptionProps["option"]["label"]>().toEqualTypeOf<string | undefined>();
|
|
26
|
+
expectTypeOf<OptionProps["option"]["qty"]>().toEqualTypeOf<number | undefined>();
|
|
27
|
+
expectTypeOf<OptionProps["index"]>().toEqualTypeOf<number>();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("does not expose undeclared option properties to a slot", () => {
|
|
31
|
+
type HasIndexSignature = string extends keyof OptionProps["option"] ? true : false;
|
|
32
|
+
|
|
33
|
+
expectTypeOf<HasIndexSignature>().toEqualTypeOf<false>();
|
|
34
|
+
expectTypeOf<keyof OptionProps["option"]>().not.toEqualTypeOf<string>();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("reuses UListbox's SlotOption rather than a second dialect", () => {
|
|
38
|
+
/* Stage 3 must not invent its own option-slot shape. */
|
|
39
|
+
expectTypeOf<OptionProps["option"]>().toEqualTypeOf<SlotOption<UserOption>>();
|
|
40
|
+
expectTypeOf<SelectedSlotOption<UserOption>>().toEqualTypeOf<SlotOption<UserOption>>();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("promises nothing on a selected option, because {} reaches it at runtime", () => {
|
|
44
|
+
/* `getCurrentOption` returns `{} as Option` when no option matches `modelValue`
|
|
45
|
+
* (`utilSelect.ts:15`). A runtime probe records that bare `{}` at `selected-option` in all
|
|
46
|
+
* three `multiple` variants, and at `left` / `right` in single mode. */
|
|
47
|
+
expectTypeOf<SelectedOptionProps["option"]["label"]>().toEqualTypeOf<string | undefined>();
|
|
48
|
+
expectTypeOf<SelectedOptionProps["option"]["qty"]>().toEqualTypeOf<number | undefined>();
|
|
49
|
+
|
|
50
|
+
/* The empty object the runtime actually delivers must be assignable. */
|
|
51
|
+
expectTypeOf<Record<string, never>>().toExtend<SelectedOptionProps["option"]>();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("types left / right / selected-options as the option-or-array binding", () => {
|
|
55
|
+
type LeftProps = Parameters<NonNullable<USelectSlots<UserOption>["left"]>>[0];
|
|
56
|
+
type RightProps = Parameters<NonNullable<USelectSlots<UserOption>["right"]>>[0];
|
|
57
|
+
type SelectedOptionsProps = Parameters<
|
|
58
|
+
NonNullable<USelectSlots<UserOption>["selected-options"]>
|
|
59
|
+
>[0];
|
|
60
|
+
|
|
61
|
+
/* All three bind `multiple ? selectedOptions.full : selectedOption`. */
|
|
62
|
+
expectTypeOf<LeftProps["options"]>().toEqualTypeOf<SelectedOptionsBinding<UserOption>>();
|
|
63
|
+
expectTypeOf<RightProps["options"]>().toEqualTypeOf<SelectedOptionsBinding<UserOption>>();
|
|
64
|
+
expectTypeOf<SelectedOptionsProps["options"]>().toEqualTypeOf<
|
|
65
|
+
SelectedOptionsBinding<UserOption>
|
|
66
|
+
>();
|
|
67
|
+
|
|
68
|
+
/* In `multiple` mode a non-matching `modelValue` entry contributes a `{}` element. */
|
|
69
|
+
expectTypeOf<Record<string, never>[]>().toExtend<SelectedOptionsBinding<UserOption>>();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("resolves Props with no type argument", () => {
|
|
73
|
+
expectTypeOf<Props>().toEqualTypeOf<Props<SelectOption>>();
|
|
74
|
+
expectTypeOf<Props<UserOption>["options"]>().toEqualTypeOf<UserOption[] | undefined>();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -2,13 +2,20 @@ import { mount, flushPromises } from "@vue/test-utils";
|
|
|
2
2
|
import { describe, it, expect, vi } from "vitest";
|
|
3
3
|
|
|
4
4
|
import USelect from "../USelect.vue";
|
|
5
|
-
import
|
|
5
|
+
import UListboxComponent from "../../ui.form-listbox/UListbox.vue";
|
|
6
6
|
import UIcon from "../../ui.image-icon/UIcon.vue";
|
|
7
7
|
import ULabel from "../../ui.form-label/ULabel.vue";
|
|
8
8
|
import UBadge from "../../ui.text-badge/UBadge.vue";
|
|
9
9
|
|
|
10
10
|
import type { Props } from "../types";
|
|
11
11
|
|
|
12
|
+
import type { ComponentProps } from "../../types";
|
|
13
|
+
|
|
14
|
+
/* Generic SFCs are typed as a function, which `findComponent` does not accept. */
|
|
15
|
+
const UListbox = UListboxComponent as unknown as new () => {
|
|
16
|
+
$props: ComponentProps<typeof UListboxComponent>;
|
|
17
|
+
};
|
|
18
|
+
|
|
12
19
|
describe("USelect.vue", () => {
|
|
13
20
|
const defaultOptions = [
|
|
14
21
|
{ label: "Option 1", value: "option1" },
|
|
@@ -255,6 +262,63 @@ describe("USelect.vue", () => {
|
|
|
255
262
|
expect(component.getComponent(UListbox).props("searchable")).toBe(true);
|
|
256
263
|
});
|
|
257
264
|
|
|
265
|
+
it("Searchable – closes when the search input blurs to an outside element", async () => {
|
|
266
|
+
const component = mount(USelect, {
|
|
267
|
+
props: {
|
|
268
|
+
searchable: true,
|
|
269
|
+
options: defaultOptions,
|
|
270
|
+
},
|
|
271
|
+
attachTo: document.body,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await component.get("[role='combobox']").trigger("focus");
|
|
275
|
+
await flushPromises();
|
|
276
|
+
|
|
277
|
+
expect(component.emitted("open")).toBeTruthy();
|
|
278
|
+
|
|
279
|
+
const outside = document.createElement("button");
|
|
280
|
+
|
|
281
|
+
document.body.appendChild(outside);
|
|
282
|
+
|
|
283
|
+
await component
|
|
284
|
+
.getComponent(UListbox)
|
|
285
|
+
.get("input")
|
|
286
|
+
.trigger("blur", { relatedTarget: outside });
|
|
287
|
+
await flushPromises();
|
|
288
|
+
|
|
289
|
+
expect(component.emitted("close")).toBeTruthy();
|
|
290
|
+
expect(component.findComponent(UListbox).exists()).toBe(false);
|
|
291
|
+
|
|
292
|
+
outside.remove();
|
|
293
|
+
component.unmount();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("Searchable – stays open when focus moves within its own listbox", async () => {
|
|
297
|
+
const component = mount(USelect, {
|
|
298
|
+
props: {
|
|
299
|
+
searchable: true,
|
|
300
|
+
options: defaultOptions,
|
|
301
|
+
},
|
|
302
|
+
attachTo: document.body,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await component.get("[role='combobox']").trigger("focus");
|
|
306
|
+
await flushPromises();
|
|
307
|
+
|
|
308
|
+
const listboxEl = component.getComponent(UListbox).element as HTMLElement;
|
|
309
|
+
|
|
310
|
+
await component
|
|
311
|
+
.getComponent(UListbox)
|
|
312
|
+
.get("input")
|
|
313
|
+
.trigger("blur", { relatedTarget: listboxEl });
|
|
314
|
+
await flushPromises();
|
|
315
|
+
|
|
316
|
+
expect(component.emitted("close")).toBeFalsy();
|
|
317
|
+
expect(component.findComponent(UListbox).exists()).toBe(true);
|
|
318
|
+
|
|
319
|
+
component.unmount();
|
|
320
|
+
});
|
|
321
|
+
|
|
258
322
|
it("Search v-model – passes search to UListbox and filters", async () => {
|
|
259
323
|
const component = mount(USelect, {
|
|
260
324
|
props: {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<!-- Type-level fixture: checked by `npm run ts:check`, never mounted.
|
|
2
|
+
Proves slot props infer from the `options` prop at a real call site. -->
|
|
3
|
+
<template>
|
|
4
|
+
<USelect :options="options">
|
|
5
|
+
<!-- Forwarded verbatim to `UListbox`, so they inherit its group flattening — a flattened
|
|
6
|
+
member need not carry the parent's members, hence the optional types. -->
|
|
7
|
+
<template #before-option="{ option, index }">
|
|
8
|
+
{{ expectOptionalString(option.label) }}
|
|
9
|
+
{{ expectOptionalNumber(option.qty) }}
|
|
10
|
+
{{ expectNumber(index) }}
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<template #option="{ option, index }">
|
|
14
|
+
{{ expectOptionalString(option.label) }}
|
|
15
|
+
{{ expectNumber(index) }}
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<template #after-option="{ option, selected, index }">
|
|
19
|
+
{{ expectOptionalString(option.label) }}
|
|
20
|
+
{{ expectBoolean(selected) }}
|
|
21
|
+
{{ expectNumber(index) }}
|
|
22
|
+
</template>
|
|
23
|
+
|
|
24
|
+
<template #empty>empty</template>
|
|
25
|
+
</USelect>
|
|
26
|
+
|
|
27
|
+
<!-- Options from a non-literal source still expose the declared option shape. -->
|
|
28
|
+
<USelect :options="typedOptions">
|
|
29
|
+
<template #option="{ option }">{{ expectOptionalString(option.label) }}</template>
|
|
30
|
+
</USelect>
|
|
31
|
+
|
|
32
|
+
<USelect :options="options">
|
|
33
|
+
<template #option="{ option }">
|
|
34
|
+
<!-- @vue-expect-error `nonExistent` is not a key of the inferred option. -->
|
|
35
|
+
{{ option.nonExistent }}
|
|
36
|
+
</template>
|
|
37
|
+
</USelect>
|
|
38
|
+
|
|
39
|
+
<!-- Regression guard: `getCurrentOption` returns `{} as Option` when no option matches
|
|
40
|
+
`modelValue` (`utilSelect.ts:15`). A runtime probe records that bare `{}` arriving at
|
|
41
|
+
`selected-option`, `left` and `right`, and as an array element in `multiple` mode. -->
|
|
42
|
+
<USelect :options="options" model-value="no-such-value">
|
|
43
|
+
<template #selected-option="{ option }">
|
|
44
|
+
<!-- @vue-expect-error the `{}` fallback need not carry `label`. -->
|
|
45
|
+
{{ expectString(option.label) }}
|
|
46
|
+
</template>
|
|
47
|
+
|
|
48
|
+
<template #left="{ options: selected }">
|
|
49
|
+
<!-- @vue-expect-error the binding is `option | option[]`, never an array alone. -->
|
|
50
|
+
{{ expectArray(selected) }}
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<template #right="{ options: selected }">
|
|
54
|
+
{{ expectSelectedBinding(selected) }}
|
|
55
|
+
</template>
|
|
56
|
+
|
|
57
|
+
<template #selected-options="{ options: selected }">
|
|
58
|
+
{{ expectSelectedBinding(selected) }}
|
|
59
|
+
</template>
|
|
60
|
+
</USelect>
|
|
61
|
+
|
|
62
|
+
<USelect :options="options" :model-value="['a']" multiple>
|
|
63
|
+
<template #selected-options="{ options: selected }">
|
|
64
|
+
<!-- @vue-expect-error in `multiple` mode an element may be the `{}` fallback. -->
|
|
65
|
+
{{ Array.isArray(selected) ? expectString(selected[0].label) : "" }}
|
|
66
|
+
</template>
|
|
67
|
+
</USelect>
|
|
68
|
+
</template>
|
|
69
|
+
|
|
70
|
+
<script setup lang="ts">
|
|
71
|
+
import { ref } from "vue";
|
|
72
|
+
|
|
73
|
+
import USelect from "../USelect.vue";
|
|
74
|
+
|
|
75
|
+
import type { SelectedOptionsBinding } from "../types";
|
|
76
|
+
|
|
77
|
+
interface UserOption {
|
|
78
|
+
label: string;
|
|
79
|
+
value: string;
|
|
80
|
+
qty: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const options = [{ label: "a", value: "a", qty: 1 }];
|
|
84
|
+
const typedOptions = ref<UserOption[]>([]);
|
|
85
|
+
|
|
86
|
+
function expectString(value: string) {
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function expectNumber(value: number) {
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function expectBoolean(value: boolean) {
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function expectOptionalString(value: string | undefined) {
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function expectOptionalNumber(value: number | undefined) {
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function expectArray(value: unknown[]) {
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function expectSelectedBinding(value: SelectedOptionsBinding<UserOption>) {
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
</script>
|
package/ui.form-select/types.ts
CHANGED
|
@@ -1,11 +1,62 @@
|
|
|
1
1
|
import defaultConfig from "./config";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
4
|
-
import type { ComponentConfig } from "../types";
|
|
3
|
+
import type { BaseOption, SlotOption } from "../ui.form-listbox/types";
|
|
4
|
+
import type { ComponentConfig, UnknownObject } from "../types";
|
|
5
5
|
|
|
6
6
|
export type Config = typeof defaultConfig;
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/* Any object shape is a valid option, only reserved option keys are type checked. */
|
|
9
|
+
export type SelectOption = BaseOption & (object | UnknownObject);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Shape of a selected option exposed to slots.
|
|
13
|
+
*
|
|
14
|
+
* `getCurrentOption` falls back to `{} as Option` when no option matches (`utilSelect.ts:15`), and
|
|
15
|
+
* that empty object reaches `selected-option`, `left` and `right` verbatim — confirmed by runtime
|
|
16
|
+
* probe. In `multiple` mode it is an element of the array given to `selected-options`. So no member
|
|
17
|
+
* of `TOption` can be promised; `SlotOption` (Stage 2's type) already encodes exactly that.
|
|
18
|
+
*/
|
|
19
|
+
export type SelectedSlotOption<TOption extends SelectOption = SelectOption> = SlotOption<TOption>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What `left`, `right` and `selected-options` receive: `multiple ? selectedOptions.full : selectedOption`.
|
|
23
|
+
* In `multiple` mode a non-matching `modelValue` entry contributes a `{}` element to the array.
|
|
24
|
+
*/
|
|
25
|
+
export type SelectedOptionsBinding<TOption extends SelectOption = SelectOption> =
|
|
26
|
+
| SelectedSlotOption<TOption>
|
|
27
|
+
| SelectedSlotOption<TOption>[];
|
|
28
|
+
|
|
29
|
+
export interface USelectSlots<TOption extends SelectOption = SelectOption> {
|
|
30
|
+
label?: (props: { label: string }) => unknown;
|
|
31
|
+
description?: (props: { description: string }) => unknown;
|
|
32
|
+
error?: (props: { error: string | boolean }) => unknown;
|
|
33
|
+
left?: (props: { iconName: string; options: SelectedOptionsBinding<TOption> }) => unknown;
|
|
34
|
+
right?: (props: { iconName: string; options: SelectedOptionsBinding<TOption> }) => unknown;
|
|
35
|
+
toggle?: (props: { iconName: string; opened: boolean }) => unknown;
|
|
36
|
+
clear?: (props: { iconName: string; clear: (event: MouseEvent) => void }) => unknown;
|
|
37
|
+
"before-toggle"?: () => unknown;
|
|
38
|
+
"after-toggle"?: () => unknown;
|
|
39
|
+
"selected-options"?: (props: { options: SelectedOptionsBinding<TOption> }) => unknown;
|
|
40
|
+
/* All four `selected-option` sites bind a single option, never an array: the `!multiple` branch
|
|
41
|
+
* binds the selected option and each `multiple` variant binds a `selectedOptions.visible` element.
|
|
42
|
+
* A runtime probe records a bare `{}` here in all three variants, hence the optional members. */
|
|
43
|
+
"selected-option"?: (props: {
|
|
44
|
+
label: unknown;
|
|
45
|
+
value: unknown;
|
|
46
|
+
option: SelectedSlotOption<TOption>;
|
|
47
|
+
}) => unknown;
|
|
48
|
+
"selected-counter"?: (props: { count: number }) => unknown;
|
|
49
|
+
"before-option"?: (props: { option: SlotOption<TOption>; index: number }) => unknown;
|
|
50
|
+
option?: (props: { option: SlotOption<TOption>; index: number }) => unknown;
|
|
51
|
+
"after-option"?: (props: {
|
|
52
|
+
option: SlotOption<TOption>;
|
|
53
|
+
selected: boolean;
|
|
54
|
+
index: number;
|
|
55
|
+
}) => unknown;
|
|
56
|
+
empty?: () => unknown;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Props<TOption extends SelectOption = SelectOption> {
|
|
9
60
|
/**
|
|
10
61
|
* Select value.
|
|
11
62
|
*/
|
|
@@ -14,7 +65,7 @@ export interface Props {
|
|
|
14
65
|
/**
|
|
15
66
|
* Select options.
|
|
16
67
|
*/
|
|
17
|
-
options?:
|
|
68
|
+
options?: TOption[];
|
|
18
69
|
|
|
19
70
|
/**
|
|
20
71
|
* Select label.
|
|
@@ -68,16 +68,19 @@ function toggle() {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
function
|
|
77
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Toggles when the visible track is clicked. Clicks forwarded from the label
|
|
73
|
+
* arrive on the hidden checkbox (which already toggles via v-model), so skip
|
|
74
|
+
* those to avoid a double toggle.
|
|
75
|
+
*/
|
|
76
|
+
function onClickWrapper(event: MouseEvent) {
|
|
77
|
+
if ((event.target as HTMLElement)?.tagName !== "INPUT") {
|
|
78
|
+
toggle();
|
|
79
|
+
}
|
|
78
80
|
}
|
|
79
81
|
|
|
80
|
-
function
|
|
82
|
+
function onKeydownToggle(event: Event) {
|
|
83
|
+
event.preventDefault();
|
|
81
84
|
toggle();
|
|
82
85
|
}
|
|
83
86
|
|
|
@@ -119,7 +122,6 @@ const {
|
|
|
119
122
|
:disabled="disabled"
|
|
120
123
|
v-bind="switchLabelAttrs"
|
|
121
124
|
:data-test="getDataTest()"
|
|
122
|
-
@click="onClickToggle"
|
|
123
125
|
>
|
|
124
126
|
<template #label>
|
|
125
127
|
<!--
|
|
@@ -141,9 +143,9 @@ const {
|
|
|
141
143
|
ref="wrapper"
|
|
142
144
|
tabindex="0"
|
|
143
145
|
v-bind="wrapperAttrs"
|
|
144
|
-
@keydown.enter="onKeydownSpace"
|
|
145
|
-
@keydown.space.prevent="onKeydownSpace"
|
|
146
146
|
@click="onClickWrapper"
|
|
147
|
+
@keydown.enter="onKeydownToggle"
|
|
148
|
+
@keydown.space="onKeydownToggle"
|
|
147
149
|
>
|
|
148
150
|
<input
|
|
149
151
|
:id="elementId"
|
|
@@ -154,7 +156,6 @@ const {
|
|
|
154
156
|
:aria-labelledby="inputAriaLabelledBy"
|
|
155
157
|
:aria-label="!hasLabel ? localeMessages.switch : undefined"
|
|
156
158
|
v-bind="inputAttrs"
|
|
157
|
-
@click="onClickToggle"
|
|
158
159
|
/>
|
|
159
160
|
|
|
160
161
|
<span v-bind="circleAttrs">
|
package/ui.form-switch/config.ts
CHANGED
|
@@ -6,7 +6,8 @@ export default /*tw*/ {
|
|
|
6
6
|
},
|
|
7
7
|
wrapper: {
|
|
8
8
|
base: `
|
|
9
|
-
flex items-center p-0.5 relative
|
|
9
|
+
flex items-center p-0.5 relative cursor-pointer transition focus-within:transition
|
|
10
|
+
rounded-full outline-solid outline-medium outline-transparent
|
|
10
11
|
focus-visible:outline focus-visible:outline-medium focus-visible:outline-offset-2 focus-visible:outline-{color}
|
|
11
12
|
`,
|
|
12
13
|
variants: {
|
|
@@ -13,6 +13,7 @@ describe("USwitch.vue", () => {
|
|
|
13
13
|
const modelValue = true;
|
|
14
14
|
|
|
15
15
|
const component = mount(USwitch, {
|
|
16
|
+
attachTo: document.body,
|
|
16
17
|
props: {
|
|
17
18
|
modelValue,
|
|
18
19
|
"onUpdate:modelValue": (e) => component.setProps({ modelValue: e }),
|
|
@@ -28,6 +29,8 @@ describe("USwitch.vue", () => {
|
|
|
28
29
|
await inputElement.trigger("click");
|
|
29
30
|
|
|
30
31
|
expect(component.emitted("update:modelValue")![1][0]).toBe(true);
|
|
32
|
+
|
|
33
|
+
component.unmount();
|
|
31
34
|
});
|
|
32
35
|
|
|
33
36
|
it("Label – passes label to ULabel component", () => {
|
|
@@ -199,6 +202,154 @@ describe("USwitch.vue", () => {
|
|
|
199
202
|
});
|
|
200
203
|
});
|
|
201
204
|
|
|
205
|
+
describe("Toggle behavior", () => {
|
|
206
|
+
it("Label click – plain-text label toggles exactly once", async () => {
|
|
207
|
+
const component = mount(USwitch, {
|
|
208
|
+
attachTo: document.body,
|
|
209
|
+
props: {
|
|
210
|
+
modelValue: false,
|
|
211
|
+
label: "Test Label",
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await component.find("label").trigger("click");
|
|
216
|
+
|
|
217
|
+
const emitted = component.emitted("update:modelValue");
|
|
218
|
+
|
|
219
|
+
expect(emitted).toHaveLength(1);
|
|
220
|
+
expect(emitted![0][0]).toBe(true);
|
|
221
|
+
|
|
222
|
+
component.unmount();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("Label slot click – #label slot content toggles exactly once", async () => {
|
|
226
|
+
const component = mount(USwitch, {
|
|
227
|
+
attachTo: document.body,
|
|
228
|
+
props: {
|
|
229
|
+
modelValue: false,
|
|
230
|
+
},
|
|
231
|
+
slots: {
|
|
232
|
+
label: "Custom Label Content",
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
await component.find("label").trigger("click");
|
|
237
|
+
|
|
238
|
+
const emitted = component.emitted("update:modelValue");
|
|
239
|
+
|
|
240
|
+
expect(emitted).toHaveLength(1);
|
|
241
|
+
expect(emitted![0][0]).toBe(true);
|
|
242
|
+
|
|
243
|
+
component.unmount();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// The track (wrapper) is a sibling of the <label>, outside its `for` forwarding,
|
|
247
|
+
// so its own @click="toggle" is the only path — clicking it must toggle exactly once.
|
|
248
|
+
it("Track click – wrapper toggles exactly once", async () => {
|
|
249
|
+
const component = mount(USwitch, {
|
|
250
|
+
attachTo: document.body,
|
|
251
|
+
props: {
|
|
252
|
+
modelValue: false,
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await component.get("[vl-key='wrapper']").trigger("click");
|
|
257
|
+
|
|
258
|
+
const emitted = component.emitted("update:modelValue");
|
|
259
|
+
|
|
260
|
+
expect(emitted).toHaveLength(1);
|
|
261
|
+
expect(emitted![0][0]).toBe(true);
|
|
262
|
+
|
|
263
|
+
component.unmount();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("Track click – disabled wrapper emits nothing", async () => {
|
|
267
|
+
const component = mount(USwitch, {
|
|
268
|
+
attachTo: document.body,
|
|
269
|
+
props: {
|
|
270
|
+
modelValue: false,
|
|
271
|
+
disabled: true,
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await component.get("[vl-key='wrapper']").trigger("click");
|
|
276
|
+
|
|
277
|
+
expect(component.emitted("update:modelValue")).toBeUndefined();
|
|
278
|
+
|
|
279
|
+
component.unmount();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("Keyboard – Enter toggles exactly once", async () => {
|
|
283
|
+
const component = mount(USwitch, {
|
|
284
|
+
attachTo: document.body,
|
|
285
|
+
props: {
|
|
286
|
+
modelValue: false,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
await component.get("[vl-key='wrapper']").trigger("keydown.enter");
|
|
291
|
+
|
|
292
|
+
const emitted = component.emitted("update:modelValue");
|
|
293
|
+
|
|
294
|
+
expect(emitted).toHaveLength(1);
|
|
295
|
+
expect(emitted![0][0]).toBe(true);
|
|
296
|
+
|
|
297
|
+
component.unmount();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("Keyboard – Space toggles exactly once", async () => {
|
|
301
|
+
const component = mount(USwitch, {
|
|
302
|
+
attachTo: document.body,
|
|
303
|
+
props: {
|
|
304
|
+
modelValue: false,
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
await component.get("[vl-key='wrapper']").trigger("keydown.space");
|
|
309
|
+
|
|
310
|
+
const emitted = component.emitted("update:modelValue");
|
|
311
|
+
|
|
312
|
+
expect(emitted).toHaveLength(1);
|
|
313
|
+
expect(emitted![0][0]).toBe(true);
|
|
314
|
+
|
|
315
|
+
component.unmount();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("Disabled – label click emits nothing", async () => {
|
|
319
|
+
const component = mount(USwitch, {
|
|
320
|
+
attachTo: document.body,
|
|
321
|
+
props: {
|
|
322
|
+
modelValue: false,
|
|
323
|
+
label: "Test Label",
|
|
324
|
+
disabled: true,
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
await component.find("label").trigger("click");
|
|
329
|
+
|
|
330
|
+
expect(component.emitted("update:modelValue")).toBeUndefined();
|
|
331
|
+
|
|
332
|
+
component.unmount();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("Disabled – keyboard emits nothing", async () => {
|
|
336
|
+
const component = mount(USwitch, {
|
|
337
|
+
attachTo: document.body,
|
|
338
|
+
props: {
|
|
339
|
+
modelValue: false,
|
|
340
|
+
disabled: true,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
await component.get("[vl-key='wrapper']").trigger("keydown.enter");
|
|
345
|
+
await component.get("[vl-key='wrapper']").trigger("keydown.space");
|
|
346
|
+
|
|
347
|
+
expect(component.emitted("update:modelValue")).toBeUndefined();
|
|
348
|
+
|
|
349
|
+
component.unmount();
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
202
353
|
describe("Slots", () => {
|
|
203
354
|
it("Label – renders custom content from label slot", () => {
|
|
204
355
|
const customLabelContent = "Custom Label Content";
|
|
@@ -15,7 +15,7 @@ export default /*tw*/ {
|
|
|
15
15
|
wrapper: {
|
|
16
16
|
base: `
|
|
17
17
|
flex px-3 py-2 gap-3 w-full bg-default transition
|
|
18
|
-
rounded-medium border border-solid border-default outline-solid outline-transparent
|
|
18
|
+
rounded-medium border border-solid border-default outline-solid outline-small outline-transparent
|
|
19
19
|
hover:border-lifted hover:focus-within:border-primary focus-within:border-primary
|
|
20
20
|
focus-within:outline focus-within:outline-small focus-within:outline-primary focus-within:transition
|
|
21
21
|
`,
|
package/ui.image-avatar/types.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import defaultConfig from "./config";
|
|
2
2
|
|
|
3
|
-
import type { ComponentConfig } from "../types";
|
|
3
|
+
import type { ComponentConfig, UnknownObject } from "../types";
|
|
4
4
|
|
|
5
5
|
export type Config = typeof defaultConfig;
|
|
6
6
|
|
|
7
|
-
export interface
|
|
7
|
+
export interface BaseChipItem {
|
|
8
8
|
icon?: string;
|
|
9
9
|
color?: Props["color"];
|
|
10
10
|
xPosition?: "left" | "right";
|
|
@@ -12,6 +12,9 @@ export interface ChipItem {
|
|
|
12
12
|
inset?: boolean;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/* Any object shape is a valid chip, only reserved chip keys are type checked. */
|
|
16
|
+
export type ChipItem = BaseChipItem & (object | UnknownObject);
|
|
17
|
+
|
|
15
18
|
export interface Props {
|
|
16
19
|
/**
|
|
17
20
|
* Avatar label (username, nickname, etc.).
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import defaultConfig from "./config";
|
|
2
2
|
|
|
3
|
-
import type { ComponentConfig } from "../types";
|
|
3
|
+
import type { ComponentConfig, UnknownObject } from "../types";
|
|
4
4
|
import type { ChipItem } from "../ui.image-avatar/types";
|
|
5
5
|
|
|
6
6
|
export type Config = typeof defaultConfig;
|
|
7
7
|
|
|
8
|
-
export interface
|
|
8
|
+
export interface BaseAvatarItem {
|
|
9
9
|
src?: string;
|
|
10
10
|
label?: string;
|
|
11
11
|
color?: string;
|
|
@@ -13,6 +13,9 @@ export interface AvatarItem {
|
|
|
13
13
|
chip?: ChipItem;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/* Any object shape is a valid avatar, only reserved avatar keys are type checked. */
|
|
17
|
+
export type AvatarItem = BaseAvatarItem & (object | UnknownObject);
|
|
18
|
+
|
|
16
19
|
export interface Props {
|
|
17
20
|
/**
|
|
18
21
|
* Avatar items.
|
package/ui.image-icon/UIcon.vue
CHANGED
|
@@ -11,13 +11,14 @@ import defaultConfig from "./config";
|
|
|
11
11
|
|
|
12
12
|
import USkeleton from "../ui.skeleton/USkeleton.vue";
|
|
13
13
|
|
|
14
|
-
import type { AsyncComponentLoader, ComponentPublicInstance } from "vue";
|
|
14
|
+
import type { AsyncComponentLoader, ComponentOptions, ComponentPublicInstance } from "vue";
|
|
15
15
|
import type { Props, Config } from "./types";
|
|
16
16
|
|
|
17
17
|
defineOptions({ inheritAttrs: false });
|
|
18
18
|
|
|
19
19
|
const props = withDefaults(defineProps<Props>(), {
|
|
20
20
|
...getDefaults<Props, Config>(defaultConfig, COMPONENT_NAME),
|
|
21
|
+
src: undefined,
|
|
21
22
|
});
|
|
22
23
|
|
|
23
24
|
const emit = defineEmits([
|
|
@@ -53,7 +54,7 @@ const dynamicComponent = computed(() => {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
const name = props.name;
|
|
56
|
-
const src = props.src;
|
|
57
|
+
const src = props.src as ComponentOptions | undefined;
|
|
57
58
|
|
|
58
59
|
/* Edge case */
|
|
59
60
|
if (!src && !name) return "";
|
package/ui.image-icon/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import defaultConfig from "./config";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type { Component, SVGAttributes } from "vue";
|
|
4
4
|
|
|
5
5
|
import type { ComponentConfig } from "../types";
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ export interface Props {
|
|
|
17
17
|
/**
|
|
18
18
|
* Icon source (svg as a vue component).
|
|
19
19
|
*/
|
|
20
|
-
src?:
|
|
20
|
+
src?: Component<SVGAttributes>;
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* Icon color.
|