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.
Files changed (97) hide show
  1. package/README.md +1 -1
  2. package/index.d.ts +128 -0
  3. package/index.ts +128 -0
  4. package/package.json +10 -9
  5. package/ui.button/config.ts +1 -1
  6. package/ui.button-link/config.ts +1 -1
  7. package/ui.button-toggle/UToggle.vue +6 -4
  8. package/ui.button-toggle/storybook/stories.ts +4 -1
  9. package/ui.button-toggle/tests/UToggle.test-d.ts +48 -0
  10. package/ui.button-toggle/tests/UToggleTypes.vue +55 -0
  11. package/ui.button-toggle/types.ts +18 -6
  12. package/ui.container-accordion/types.ts +5 -2
  13. package/ui.container-collapsible/config.ts +1 -1
  14. package/ui.data-list/UDataList.vue +48 -17
  15. package/ui.data-list/storybook/stories.ts +4 -1
  16. package/ui.data-list/tests/UDataList.test-d.ts +76 -0
  17. package/ui.data-list/tests/UDataList.test.ts +50 -1
  18. package/ui.data-list/tests/UDataListTypes.vue +110 -0
  19. package/ui.data-list/types.ts +37 -5
  20. package/ui.data-table/UTable.vue +68 -10
  21. package/ui.data-table/config.ts +1 -1
  22. package/ui.data-table/storybook/stories.ts +4 -1
  23. package/ui.data-table/tests/UTable.test-d.ts +179 -0
  24. package/ui.data-table/tests/UTable.test.ts +1 -1
  25. package/ui.data-table/tests/UTableEmptyColumnsTypes.vue +27 -0
  26. package/ui.data-table/tests/UTableTypes.vue +193 -0
  27. package/ui.data-table/types.ts +91 -8
  28. package/ui.data-table/utilTable.ts +1 -1
  29. package/ui.dropdown/UDropdown.vue +19 -8
  30. package/ui.dropdown/storybook/stories.ts +4 -1
  31. package/ui.dropdown/tests/UDropdown.test-d.ts +61 -0
  32. package/ui.dropdown/tests/UDropdown.test.ts +8 -1
  33. package/ui.dropdown/tests/UDropdownTypes.vue +111 -0
  34. package/ui.dropdown/types.ts +27 -3
  35. package/ui.dropdown-badge/UDropdownBadge.vue +9 -4
  36. package/ui.dropdown-badge/storybook/stories.ts +6 -1
  37. package/ui.dropdown-badge/tests/UDropdownBadge.test-d.ts +55 -0
  38. package/ui.dropdown-badge/tests/UDropdownBadge.test.ts +8 -1
  39. package/ui.dropdown-badge/tests/UDropdownBadgeTypes.vue +107 -0
  40. package/ui.dropdown-badge/types.ts +17 -3
  41. package/ui.dropdown-button/UDropdownButton.vue +9 -4
  42. package/ui.dropdown-button/storybook/stories.ts +6 -1
  43. package/ui.dropdown-button/tests/UDropdownButton.test-d.ts +57 -0
  44. package/ui.dropdown-button/tests/UDropdownButton.test.ts +8 -1
  45. package/ui.dropdown-button/tests/UDropdownButtonTypes.vue +107 -0
  46. package/ui.dropdown-button/types.ts +17 -3
  47. package/ui.dropdown-link/UDropdownLink.vue +9 -4
  48. package/ui.dropdown-link/config.ts +1 -1
  49. package/ui.dropdown-link/storybook/stories.ts +4 -1
  50. package/ui.dropdown-link/tests/UDropdownLink.test-d.ts +55 -0
  51. package/ui.dropdown-link/tests/UDropdownLink.test.ts +8 -1
  52. package/ui.dropdown-link/tests/UDropdownLinkTypes.vue +107 -0
  53. package/ui.dropdown-link/types.ts +17 -3
  54. package/ui.form-calendar/config.ts +1 -1
  55. package/ui.form-checkbox/config.ts +1 -1
  56. package/ui.form-checkbox/tests/UCheckbox.test.ts +4 -4
  57. package/ui.form-checkbox/types.ts +4 -2
  58. package/ui.form-checkbox-group/UCheckboxGroup.vue +8 -2
  59. package/ui.form-date-picker/UDatePicker.vue +11 -1
  60. package/ui.form-date-picker/tests/UDatePicker.test.ts +47 -0
  61. package/ui.form-input/config.ts +1 -1
  62. package/ui.form-input-file/config.ts +1 -1
  63. package/ui.form-listbox/UListbox.vue +29 -12
  64. package/ui.form-listbox/config.ts +1 -1
  65. package/ui.form-listbox/storybook/stories.ts +4 -1
  66. package/ui.form-listbox/tests/UListbox.test-d.ts +63 -0
  67. package/ui.form-listbox/tests/UListboxTypes.vue +107 -0
  68. package/ui.form-listbox/types.ts +34 -2
  69. package/ui.form-radio/config.ts +1 -1
  70. package/ui.form-radio-group/URadioGroup.vue +15 -3
  71. package/ui.form-radio-group/config.ts +8 -3
  72. package/ui.form-radio-group/types.ts +2 -3
  73. package/ui.form-select/USelect.vue +49 -17
  74. package/ui.form-select/config.ts +4 -4
  75. package/ui.form-select/storybook/stories.ts +4 -1
  76. package/ui.form-select/tests/USelect.test-d.ts +76 -0
  77. package/ui.form-select/tests/USelect.test.ts +65 -1
  78. package/ui.form-select/tests/USelectTypes.vue +113 -0
  79. package/ui.form-select/types.ts +55 -4
  80. package/ui.form-switch/USwitch.vue +13 -12
  81. package/ui.form-switch/config.ts +2 -1
  82. package/ui.form-switch/tests/USwitch.test.ts +151 -0
  83. package/ui.form-textarea/config.ts +1 -1
  84. package/ui.image-avatar/types.ts +5 -2
  85. package/ui.image-avatar-group/types.ts +5 -2
  86. package/ui.image-icon/UIcon.vue +3 -2
  87. package/ui.image-icon/types.ts +2 -2
  88. package/ui.navigation-breadcrumbs/types.ts +5 -2
  89. package/ui.navigation-tabs/UTabs.vue +6 -4
  90. package/ui.navigation-tabs/storybook/stories.ts +4 -1
  91. package/ui.navigation-tabs/tests/UTabs.test-d.ts +50 -0
  92. package/ui.navigation-tabs/tests/UTabsTypes.vue +65 -0
  93. package/ui.navigation-tabs/types.ts +25 -3
  94. package/ui.skeleton-text/constants.ts +1 -1
  95. package/ui.text-badge/config.ts +1 -1
  96. package/utils/node/loaderIcon.js +1 -1
  97. 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 UListbox from "../../ui.form-listbox/UListbox.vue";
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>
@@ -1,11 +1,62 @@
1
1
  import defaultConfig from "./config";
2
2
 
3
- import type { Option } from "../ui.form-listbox/types";
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
- export interface Props {
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?: Option[];
68
+ options?: TOption[];
18
69
 
19
70
  /**
20
71
  * Select label.
@@ -68,16 +68,19 @@ function toggle() {
68
68
  }
69
69
  }
70
70
 
71
- function onClickToggle(event: Event) {
72
- event.stopPropagation();
73
- toggle();
74
- }
75
-
76
- function onKeydownSpace() {
77
- toggle();
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 onClickWrapper() {
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">
@@ -6,7 +6,8 @@ export default /*tw*/ {
6
6
  },
7
7
  wrapper: {
8
8
  base: `
9
- flex items-center p-0.5 relative rounded-full outline-solid outline-transparent cursor-pointer transition focus-within:transition
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
  `,
@@ -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 ChipItem {
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 AvatarItem {
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.
@@ -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 "";
@@ -1,6 +1,6 @@
1
1
  import defaultConfig from "./config";
2
2
 
3
- import type { DefineComponent, SVGAttributes } from "vue";
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?: DefineComponent<SVGAttributes>;
20
+ src?: Component<SVGAttributes>;
21
21
 
22
22
  /**
23
23
  * Icon color.