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
@@ -1,4 +1,4 @@
1
- <script setup lang="ts">
1
+ <script setup lang="ts" generic="TOption extends ListboxOption">
2
2
  import { watch, computed, useId, ref, useTemplateRef, nextTick } from "vue";
3
3
  import { isEqual } from "lodash-es";
4
4
 
@@ -18,12 +18,20 @@ import usePointer from "./usePointer";
18
18
  import defaultConfig from "./config";
19
19
  import { COMPONENT_NAME } from "./constants";
20
20
 
21
- import type { Option, Props, Config, SelectedValue } from "./types";
21
+ import type {
22
+ Option,
23
+ Props,
24
+ Config,
25
+ SelectedValue,
26
+ ListboxOption,
27
+ SlotOption,
28
+ UListboxSlots,
29
+ } from "./types";
22
30
 
23
31
  defineOptions({ inheritAttrs: false });
24
32
 
25
- const props = withDefaults(defineProps<Props>(), {
26
- ...getDefaults<Props, Config>(defaultConfig, COMPONENT_NAME),
33
+ const props = withDefaults(defineProps<Props<TOption>>(), {
34
+ ...getDefaults<Props<TOption>, Config>(defaultConfig, COMPONENT_NAME),
27
35
  modelValue: "",
28
36
  options: () => [],
29
37
  });
@@ -65,6 +73,8 @@ const emit = defineEmits([
65
73
  "update:search",
66
74
  ]);
67
75
 
76
+ defineSlots<UListboxSlots<TOption>>();
77
+
68
78
  const wrapperRef = useTemplateRef<HTMLDivElement>("wrapper");
69
79
  const listboxInputRef = useTemplateRef<{ input: HTMLInputElement }>("listbox-input");
70
80
  const optionsRef = useTemplateRef<HTMLLIElement[]>("option");
@@ -75,8 +85,11 @@ const wrapperMaxHeight = ref("");
75
85
 
76
86
  const localSearch = ref(props.search ?? "");
77
87
 
88
+ /* Indexable view of the options, `labelKey` / `valueKey` lookups need an index signature. */
89
+ const localOptions = computed(() => props.options as Option[]);
90
+
78
91
  const { pointer, pointerDirty, pointerSet, pointerBackward, pointerForward, pointerReset } =
79
- usePointer(props.options, optionsRef, wrapperRef);
92
+ usePointer(localOptions.value, optionsRef, wrapperRef);
80
93
 
81
94
  const elementId = props.id || useId();
82
95
 
@@ -123,10 +136,13 @@ const getOptionAriaSelected = (option: Option) => {
123
136
  return isSelectedOption(option);
124
137
  };
125
138
 
139
+ /* `filterGroups` flattens group members, which need not carry `TOption`'s members. */
140
+ const getSlotOption = (option: Option) => option as SlotOption<TOption>;
141
+
126
142
  const filteredOptions = computed(() => {
127
143
  const normalizedSearch = searchModel.value.toLowerCase().trim();
128
144
 
129
- let options = [...props.options];
145
+ let options = [...localOptions.value];
130
146
 
131
147
  options = props.groupValueKey
132
148
  ? filterGroups(
@@ -324,9 +340,9 @@ function optionHighlight(index: number, option: Option) {
324
340
  }
325
341
 
326
342
  function addPointerElement(keyCode?: string) {
327
- if (props.options.length > 0) {
328
- select(props.options[pointer.value], keyCode);
329
- onClickOption(props.options[pointer.value]);
343
+ if (localOptions.value.length > 0) {
344
+ select(localOptions.value[pointer.value], keyCode);
345
+ onClickOption(localOptions.value[pointer.value]);
330
346
  }
331
347
 
332
348
  pointerReset();
@@ -496,14 +512,14 @@ const {
496
512
  @binding {object} option
497
513
  @binding {number} index
498
514
  -->
499
- <slot name="before-option" :option="option" :index="index" />
515
+ <slot name="before-option" :option="getSlotOption(option)" :index="index" />
500
516
 
501
517
  <!--
502
518
  @slot Use it to add something instead of the option.
503
519
  @binding {object} option
504
520
  @binding {number} index
505
521
  -->
506
- <slot name="option" :option="option" :index="index">
522
+ <slot name="option" :option="getSlotOption(option)" :index="index">
507
523
  <span
508
524
  :style="getMarginForSubCategory(option.level)"
509
525
  v-bind="optionContentAttrs"
@@ -520,7 +536,7 @@ const {
520
536
  -->
521
537
  <slot
522
538
  name="after-option"
523
- :option="option"
539
+ :option="getSlotOption(option)"
524
540
  :selected="isSelectedOption(option)"
525
541
  :index="index"
526
542
  >
@@ -580,6 +596,7 @@ const {
580
596
  <UButton
581
597
  round
582
598
  square
599
+ tabindex="-1"
583
600
  v-bind="addOptionButtonAttrs"
584
601
  :data-test="getDataTest('add-button')"
585
602
  @click="onClickAddOption"
@@ -15,7 +15,7 @@ export default /*tw*/ {
15
15
  listboxInput: {
16
16
  base: "{UInputSearch} sticky top-0 pt-1 bg-default z-10",
17
17
  searchInput: {
18
- wrapper: "pl-2 pr-1 rounded-none border-0 border-b focus-within:outline-0 border-default!",
18
+ wrapper: "pl-2 pr-1 rounded-none border-0 border-b focus-within:outline-0 outline-none! border-default!",
19
19
  },
20
20
  },
21
21
  selectIcon: {
@@ -6,7 +6,7 @@ import {
6
6
  getDocsDescription,
7
7
  } from "../../utils/storybook";
8
8
 
9
- import UListbox from "../UListbox.vue";
9
+ import UListboxComponent from "../UListbox.vue";
10
10
  import URow from "../../ui.container-row/URow.vue";
11
11
  import UCol from "../../ui.container-col/UCol.vue";
12
12
  import UAvatar from "../../ui.image-avatar/UAvatar.vue";
@@ -23,6 +23,9 @@ import emilyDavis from "../../ui.form-select/storybook/assets/images/emily-davis
23
23
  import alexJohnson from "../../ui.form-select/storybook/assets/images/alex-johnson.png";
24
24
  import patMorgan from "../../ui.form-select/storybook/assets/images/pat-morgan.png";
25
25
 
26
+ /* Generic SFCs are typed as a function, which drops the compiler-generated `__name`. */
27
+ const UListbox = UListboxComponent as typeof UListboxComponent & { __name: string };
28
+
26
29
  interface DefaultUListboxArgs extends Props {
27
30
  slotTemplate?: string;
28
31
  }
@@ -0,0 +1,63 @@
1
+ import { describe, it, expectTypeOf } from "vitest";
2
+
3
+ import type { ListboxOption, Props, SlotOption, UListboxSlots } from "../types";
4
+
5
+ interface UserOption {
6
+ label: string;
7
+ value: string;
8
+ qty: number;
9
+ }
10
+
11
+ type OptionSlot = NonNullable<UListboxSlots<UserOption>["option"]>;
12
+ type OptionProps = Parameters<OptionSlot>[0];
13
+
14
+ describe("UListbox types", () => {
15
+ it("infers the caller's option shape in an option slot", () => {
16
+ expectTypeOf<OptionProps["option"]["label"]>().toEqualTypeOf<string | undefined>();
17
+ expectTypeOf<OptionProps["option"]["qty"]>().toEqualTypeOf<number | undefined>();
18
+ expectTypeOf<OptionProps["index"]>().toEqualTypeOf<number>();
19
+ });
20
+
21
+ it("does not expose undeclared option properties to a slot", () => {
22
+ /* An index signature would make any key legal, so `option.nonExistent` must not resolve. */
23
+ type HasIndexSignature = string extends keyof OptionProps["option"] ? true : false;
24
+
25
+ expectTypeOf<HasIndexSignature>().toEqualTypeOf<false>();
26
+ expectTypeOf<keyof OptionProps["option"]>().not.toEqualTypeOf<string>();
27
+ });
28
+
29
+ it("keeps the reserved option keys available", () => {
30
+ expectTypeOf<OptionProps["option"]>().toHaveProperty("groupLabel");
31
+ expectTypeOf<OptionProps["option"]>().toHaveProperty("isSubGroup");
32
+ expectTypeOf<OptionProps["option"]>().toHaveProperty("level");
33
+ });
34
+
35
+ it("does not promise the caller's members on any option slot", () => {
36
+ /* `filterGroups` concats the `groupValueKey` array into the flat list the slots iterate, and
37
+ * those members are typed `Option`, not `TOption`. A runtime probe with grouped options whose
38
+ * members lack the parent's members confirms all three slots receive them verbatim. */
39
+ type BeforeOption = Parameters<NonNullable<UListboxSlots<UserOption>["before-option"]>>[0];
40
+ type AfterOption = Parameters<NonNullable<UListboxSlots<UserOption>["after-option"]>>[0];
41
+
42
+ expectTypeOf<BeforeOption["option"]["label"]>().toEqualTypeOf<string | undefined>();
43
+ expectTypeOf<OptionProps["option"]["label"]>().toEqualTypeOf<string | undefined>();
44
+ expectTypeOf<AfterOption["option"]["label"]>().toEqualTypeOf<string | undefined>();
45
+ expectTypeOf<AfterOption["selected"]>().toEqualTypeOf<boolean>();
46
+
47
+ /* The real flattened-member shape recorded at these slots must be assignable to all of them. */
48
+ type GroupMember = { label: string; value: string };
49
+
50
+ expectTypeOf<GroupMember>().toExtend<BeforeOption["option"]>();
51
+ expectTypeOf<GroupMember>().toExtend<OptionProps["option"]>();
52
+ expectTypeOf<GroupMember>().toExtend<AfterOption["option"]>();
53
+ });
54
+
55
+ it("resolves Props with no type argument", () => {
56
+ expectTypeOf<Props>().toEqualTypeOf<Props<ListboxOption>>();
57
+ expectTypeOf<Props<UserOption>["options"]>().toEqualTypeOf<UserOption[] | undefined>();
58
+ });
59
+
60
+ it("exposes SlotOption as the shared slot option shape", () => {
61
+ expectTypeOf<SlotOption<UserOption>>().toEqualTypeOf<OptionProps["option"]>();
62
+ });
63
+ });
@@ -0,0 +1,107 @@
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
+ <UListbox :options="options">
5
+ <!-- With `groupValueKey` set, `filterGroups` flattens group members into the same list the
6
+ slots iterate, and a member need not carry the parent's members — so they are optional. -->
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
+ </UListbox>
26
+
27
+ <!-- Options from a non-literal source still expose the declared option shape. -->
28
+ <UListbox :options="typedOptions">
29
+ <template #option="{ option }">{{ expectOptionalString(option.label) }}</template>
30
+ </UListbox>
31
+
32
+ <UListbox :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
+ </UListbox>
38
+
39
+ <!-- Regression guard: an option slot must never promise the caller's members as present.
40
+ `filterGroups` concats the `groupValueKey` array into the flat list, and those members are
41
+ typed `Option`, not `TOption` — a runtime probe confirms the slots receive them verbatim. -->
42
+ <UListbox
43
+ :options="groupedOptions"
44
+ group-label-key="groupName"
45
+ group-value-key="children"
46
+ label-key="label"
47
+ >
48
+ <template #option="{ option }">
49
+ <!-- @vue-expect-error a flattened group member need not carry `label`. -->
50
+ {{ expectString(option.label) }}
51
+ </template>
52
+ <template #before-option="{ option }">
53
+ <!-- @vue-expect-error a flattened group member need not carry `qty`. -->
54
+ {{ expectNumber(option.qty) }}
55
+ </template>
56
+ <template #after-option="{ option }">
57
+ <!-- @vue-expect-error a flattened group member need not carry `groupName`. -->
58
+ {{ expectString(option.groupName) }}
59
+ </template>
60
+ </UListbox>
61
+ </template>
62
+
63
+ <script setup lang="ts">
64
+ import { ref } from "vue";
65
+
66
+ import UListbox from "../UListbox.vue";
67
+
68
+ interface UserOption {
69
+ label: string;
70
+ value: string;
71
+ qty: number;
72
+ }
73
+
74
+ const options = [{ label: "a", value: "a", qty: 1 }];
75
+ const typedOptions = ref<UserOption[]>([]);
76
+
77
+ /* A group whose members lack the parent's members — exactly what `filterGroups` flattens.
78
+ The parent declares `label`/`qty`/`groupName`; the flattened child carries none of them. */
79
+ const groupedOptions = [
80
+ {
81
+ groupName: "Group A",
82
+ label: "Group A",
83
+ qty: 1,
84
+ children: [{ value: "c1", title: "Child" }],
85
+ },
86
+ ];
87
+
88
+ function expectString(value: string) {
89
+ return value;
90
+ }
91
+
92
+ function expectNumber(value: number) {
93
+ return value;
94
+ }
95
+
96
+ function expectBoolean(value: boolean) {
97
+ return value;
98
+ }
99
+
100
+ function expectOptionalString(value: string | undefined) {
101
+ return value;
102
+ }
103
+
104
+ function expectOptionalNumber(value: number | undefined) {
105
+ return value;
106
+ }
107
+ </script>
@@ -16,9 +16,41 @@ export interface Option extends BaseOption {
16
16
  [key: string]: string | number | boolean | UnknownObject | Option[] | OnClickOption | undefined;
17
17
  }
18
18
 
19
+ /* Any object shape is a valid option, only reserved option keys are type checked. */
20
+ export type ListboxOption = BaseOption & (object | UnknownObject);
21
+
19
22
  export type SelectedValue = string | number | UnknownObject;
20
23
 
21
- export interface Props {
24
+ /* Drops `Option`'s `[key: string]` index signature so slot options only expose known keys. */
25
+ type KnownKeys<T> = {
26
+ [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K];
27
+ };
28
+
29
+ /**
30
+ * Option shape exposed to slots: the caller's option plus the reserved option keys.
31
+ *
32
+ * `TOption`'s own keys are optional. With `groupValueKey` set, `filterGroups` flattens the
33
+ * nested `groupValueKey` array into the same list the slots iterate, and those group members
34
+ * are typed `Option`, not `TOption` — so a flattened child need not carry the parent's members.
35
+ * `BaseOption`'s keys are optional already, so nothing is promised that can be absent.
36
+ */
37
+ export type SlotOption<TOption extends ListboxOption = ListboxOption> = Partial<
38
+ KnownKeys<TOption>
39
+ > &
40
+ BaseOption;
41
+
42
+ export interface UListboxSlots<TOption extends ListboxOption = ListboxOption> {
43
+ "before-option"?: (props: { option: SlotOption<TOption>; index: number }) => unknown;
44
+ option?: (props: { option: SlotOption<TOption>; index: number }) => unknown;
45
+ "after-option"?: (props: {
46
+ option: SlotOption<TOption>;
47
+ selected: boolean;
48
+ index: number;
49
+ }) => unknown;
50
+ empty?: () => unknown;
51
+ }
52
+
53
+ export interface Props<TOption extends ListboxOption = ListboxOption> {
22
54
  /**
23
55
  * Selected item.
24
56
  */
@@ -32,7 +64,7 @@ export interface Props {
32
64
  /**
33
65
  * List options.
34
66
  */
35
- options?: Option[];
67
+ options?: TOption[];
36
68
 
37
69
  /**
38
70
  * Shows input to search value in a list.
@@ -3,7 +3,7 @@ export default /*tw*/ {
3
3
  radio: {
4
4
  base: `
5
5
  bg-default cursor-pointer transition
6
- border border-solid border-default rounded-full outline-solid outline-transparent
6
+ border border-solid border-default rounded-full outline-solid outline-medium outline-transparent
7
7
  appearance-none p-0 print:color-adjust-exact inline-block align-middle bg-origin-border select-none shrink-0
8
8
  hover:border-lifted hover:checked:border-{color}
9
9
  active:border-{color} active:bg-{color}/15
@@ -10,7 +10,7 @@ import URadio from "../ui.form-radio/URadio.vue";
10
10
  import defaultConfig from "./config";
11
11
  import { COMPONENT_NAME } from "./constants";
12
12
 
13
- import type { Props, SetRadioGroupSelectedItem, Config } from "./types";
13
+ import type { Props, BaseOption, SetRadioGroupSelectedItem, Config } from "./types";
14
14
 
15
15
  defineOptions({ inheritAttrs: false });
16
16
 
@@ -31,6 +31,11 @@ const emit = defineEmits([
31
31
 
32
32
  const listRef = useTemplateRef<HTMLDivElement>("list");
33
33
 
34
+ /* Indexable view of the options for `labelKey` / `valueKey` lookups. */
35
+ const keyedOptions = computed(
36
+ () => props.options as (BaseOption & Record<string, BaseOption["value"]>)[],
37
+ );
38
+
34
39
  const selectedItem = computed({
35
40
  get: () => props.modelValue,
36
41
  set: (value) => emit("update:modelValue", value),
@@ -57,7 +62,14 @@ defineExpose({
57
62
  * Get element / nested component attributes for each config token ✨
58
63
  * Applies: `class`, `config`, redefined default `props` and dev `vl-...` attributes.
59
64
  */
60
- const { getDataTest, groupLabelAttrs, listAttrs, groupRadioAttrs } = useUI<Config>(defaultConfig);
65
+ const mutatedProps = computed(() => ({
66
+ label: Boolean(props.label),
67
+ }));
68
+
69
+ const { getDataTest, groupLabelAttrs, listAttrs, groupRadioAttrs } = useUI<Config>(
70
+ defaultConfig,
71
+ mutatedProps,
72
+ );
61
73
  </script>
62
74
 
63
75
  <template>
@@ -99,7 +111,7 @@ const { getDataTest, groupLabelAttrs, listAttrs, groupRadioAttrs } = useUI<Confi
99
111
  <!-- @slot Use it to add URadio directly. -->
100
112
  <slot>
101
113
  <URadio
102
- v-for="(option, index) in options"
114
+ v-for="(option, index) in keyedOptions"
103
115
  :key="index"
104
116
  :model-value="selectedItem"
105
117
  :value="option[valueKey]"
@@ -5,11 +5,16 @@ export default /*tw*/ {
5
5
  base: "flex flex-col",
6
6
  variants: {
7
7
  size: {
8
- sm: "gap-1.5 mt-1",
9
- md: "gap-2 mt-1.5",
10
- lg: "gap-2.5 mt-2",
8
+ sm: "gap-2",
9
+ md: "gap-2",
10
+ lg: "gap-2.5",
11
11
  },
12
12
  },
13
+ compoundVariants: [
14
+ { size: "sm", label: true, class: "mt-1" },
15
+ { size: "md", label: true, class: "mt-1.5" },
16
+ { size: "lg", label: true, class: "mt-2" },
17
+ ],
13
18
  },
14
19
  defaults: {
15
20
  color: "primary",
@@ -10,9 +10,8 @@ export interface BaseOption {
10
10
  description?: string;
11
11
  }
12
12
 
13
- export interface Option extends BaseOption {
14
- [key: string]: string | number | boolean | UnknownArray | UnknownObject | undefined;
15
- }
13
+ /* Any object shape is a valid option, only reserved option keys are type checked. */
14
+ export type Option = BaseOption & (object | UnknownObject);
16
15
 
17
16
  export type SetRadioGroupSelectedItem =
18
17
  | ((value: string | number | boolean | UnknownArray | UnknownObject) => void)
@@ -1,4 +1,4 @@
1
- <script setup lang="ts">
1
+ <script setup lang="ts" generic="TOption extends SelectOption">
2
2
  import { ref, computed, nextTick, watch, useSlots, onMounted, useId, useTemplateRef } from "vue";
3
3
 
4
4
  import UIcon from "../ui.image-icon/UIcon.vue";
@@ -19,12 +19,13 @@ import defaultConfig from "./config";
19
19
  import { COMPONENT_NAME, DIRECTION, KEYS, MULTIPLE_VARIANTS } from "./constants";
20
20
 
21
21
  import type { Option, Config as UListboxConfig } from "../ui.form-listbox/types";
22
- import type { Props, Config } from "./types";
23
- import type { KeyAttrsWithConfig } from "../types";
22
+ import type { Props, Config, SelectOption, USelectSlots, SelectedSlotOption } from "./types";
23
+ import type { ComponentPublicInstance } from "vue";
24
+ import type { ComponentExposed, KeyAttrsWithConfig } from "../types";
24
25
 
25
26
  defineOptions({ inheritAttrs: false });
26
27
 
27
- const props = withDefaults(defineProps<Props>(), {
28
+ const props = withDefaults(defineProps<Props<TOption>>(), {
28
29
  ...getDefaults<Props, Config>(defaultConfig, COMPONENT_NAME),
29
30
  options: () => [],
30
31
  modelValue: "",
@@ -97,12 +98,17 @@ const emit = defineEmits([
97
98
  "change",
98
99
  ]);
99
100
 
101
+ defineSlots<USelectSlots<TOption>>();
102
+
100
103
  const slots = useSlots();
101
104
 
102
105
  const isOpen = ref(false);
103
106
  const preferredOpenDirection = ref(DIRECTION.bottom);
104
107
 
105
- const listboxRef = useTemplateRef<InstanceType<typeof UListbox>>("listbox");
108
+ /* `UListbox` is generic, so its type is a function — `InstanceType` does not apply. */
109
+ const listboxRef = useTemplateRef<ComponentExposed<typeof UListbox> & ComponentPublicInstance>(
110
+ "listbox",
111
+ );
106
112
  const wrapperRef = useTemplateRef<HTMLDivElement>("wrapper");
107
113
  const labelComponentRef = useTemplateRef<InstanceType<typeof ULabel>>("labelComponent");
108
114
  const leftSlotWrapperRef = useTemplateRef<HTMLDivElement>("leftSlotWrapper");
@@ -135,7 +141,7 @@ const dropdownValue = computed({
135
141
  emit("update:modelValue", value);
136
142
  emit("change", { value, options: props.options });
137
143
 
138
- if (!props.multiple && props.closeOnSelect) deactivate();
144
+ if (props.closeOnSelect) deactivate();
139
145
  },
140
146
  });
141
147
 
@@ -151,16 +157,24 @@ const isMultipleBadgeVariant = computed(
151
157
  () => props.multiple && props.multipleVariant === MULTIPLE_VARIANTS.badge,
152
158
  );
153
159
 
160
+ /* Indexable view of the options — `labelKey` / `valueKey` lookups need an index signature. */
161
+ const listboxOptions = computed(() => props.options as Option[]);
162
+
163
+ /* Same array, keeping `TOption` so the forwarded `UListbox` option slots infer the caller's shape. */
164
+ const typedListboxOptions = computed(() => props.options);
165
+
154
166
  const localValue = computed<Option | Option[]>(() => {
155
167
  if (!props.multiple) {
156
168
  const [singleValue] = Array.isArray(props.modelValue) ? props.modelValue : [props.modelValue];
157
169
 
158
- return getCurrentOption(props.options, singleValue, props.valueKey, props.groupValueKey);
170
+ return getCurrentOption(listboxOptions.value, singleValue, props.valueKey, props.groupValueKey);
159
171
  }
160
172
 
161
173
  return props.modelValue && Array.isArray(props.modelValue)
162
174
  ? (props.modelValue
163
- .map((value) => getCurrentOption(props.options, value, props.valueKey, props.groupValueKey))
175
+ .map((value) =>
176
+ getCurrentOption(listboxOptions.value, value, props.valueKey, props.groupValueKey),
177
+ )
164
178
  .filter(Boolean) as Option[])
165
179
  : [];
166
180
  });
@@ -179,6 +193,21 @@ const selectedOptions = computed(() => {
179
193
  };
180
194
  });
181
195
 
196
+ /* Slot-facing views of the values above. The internals keep `Option` because `labelKey` /
197
+ * `valueKey` are runtime strings and need its index signature; slots get the caller's shape.
198
+ * The narrowing cannot over-promise: `SelectedSlotOption` makes every member optional, which is
199
+ * exactly what the runtime delivers — a probe records a bare `{}` at `selected-option`, `left` and
200
+ * `right` when no option matches `modelValue`, and a `{}` array element in `multiple` mode. */
201
+ const slotSelectedOption = computed(() => selectedOption.value as SelectedSlotOption<TOption>);
202
+
203
+ const slotSelectedOptions = computed(
204
+ () => selectedOptions.value.full as SelectedSlotOption<TOption>[],
205
+ );
206
+
207
+ const slotVisibleOptions = computed(
208
+ () => selectedOptions.value.visible as SelectedSlotOption<TOption>[],
209
+ );
210
+
182
211
  const selectedOptionsLabel = computed(() => {
183
212
  return {
184
213
  full: selectedOptions.value.full.map((item) => item[props.labelKey]).join(", "),
@@ -556,7 +585,7 @@ const {
556
585
 
557
586
  <div
558
587
  ref="wrapper"
559
- :tabindex="searchable || disabled ? -1 : 0"
588
+ :tabindex="disabled ? -1 : searchable && isOpen ? -1 : 0"
560
589
  role="combobox"
561
590
  :aria-expanded="ariaExpanded"
562
591
  aria-haspopup="listbox"
@@ -589,7 +618,7 @@ const {
589
618
  <slot
590
619
  name="right"
591
620
  :icon-name="rightIcon"
592
- :options="multiple ? selectedOptions.full : selectedOption"
621
+ :options="multiple ? slotSelectedOptions : slotSelectedOption"
593
622
  >
594
623
  <UIcon v-if="rightIcon" :name="rightIcon" v-bind="rightIconAttrs" />
595
624
  </slot>
@@ -669,7 +698,10 @@ const {
669
698
  @binding {array} options
670
699
  @binding {object} options
671
700
  -->
672
- <slot name="selected-options" :options="multiple ? selectedOptions.full : selectedOption">
701
+ <slot
702
+ name="selected-options"
703
+ :options="multiple ? slotSelectedOptions : slotSelectedOption"
704
+ >
673
705
  <span v-if="!multiple" v-bind="selectedLabelsAttrs" @click="toggle" @mousedown.prevent>
674
706
  <!--
675
707
  @slot Use it to customize selected option.
@@ -681,7 +713,7 @@ const {
681
713
  name="selected-option"
682
714
  :label="selectedOption[labelKey]"
683
715
  :value="selectedOption[valueKey]"
684
- :option="localValue"
716
+ :option="slotSelectedOption"
685
717
  >
686
718
  <div
687
719
  :title="(selectedOption[labelKey] || '') as string"
@@ -705,7 +737,7 @@ const {
705
737
  name="selected-option"
706
738
  :label="option[labelKey]"
707
739
  :value="option[valueKey]"
708
- :option="option"
740
+ :option="slotVisibleOptions[index]"
709
741
  >
710
742
  {{
711
743
  option[labelKey] +
@@ -746,7 +778,7 @@ const {
746
778
  name="selected-option"
747
779
  :label="option[labelKey]"
748
780
  :value="option[valueKey]"
749
- :option="option"
781
+ :option="slotVisibleOptions[index]"
750
782
  >
751
783
  {{ option[labelKey] }}
752
784
  </slot>
@@ -799,7 +831,7 @@ const {
799
831
  name="selected-option"
800
832
  :label="option[labelKey]"
801
833
  :value="option[valueKey]"
802
- :option="option"
834
+ :option="slotVisibleOptions[index]"
803
835
  >
804
836
  {{ option[labelKey] }}
805
837
  </slot>
@@ -858,7 +890,7 @@ const {
858
890
  :searchable="searchable"
859
891
  :options-limit="optionsLimit"
860
892
  :multiple="multiple"
861
- :options="options"
893
+ :options="typedListboxOptions"
862
894
  :disabled="disabled"
863
895
  :size="size"
864
896
  :debounce="debounce"
@@ -926,7 +958,7 @@ const {
926
958
  <slot
927
959
  name="left"
928
960
  :icon-name="leftIcon"
929
- :options="multiple ? selectedOptions.full : selectedOption"
961
+ :options="multiple ? slotSelectedOptions : slotSelectedOption"
930
962
  >
931
963
  <UIcon v-if="leftIcon" :name="leftIcon" v-bind="leftIconAttrs" />
932
964
  </slot>
@@ -11,10 +11,10 @@ export default /*tw*/ {
11
11
  },
12
12
  wrapper: {
13
13
  base: `
14
- flex flex-row-reverse justify-between w-full min-h-full box-border relative
15
- rounded-medium border border-solid border-default bg-default outline-solid outline-transparent
16
- hover:border-lifted hover:transition hover:focus-within:border-primary focus-within:border-primary
17
- focus-within:outline focus-within:outline-small focus-within:outline-primary focus-within:transition
14
+ flex flex-row-reverse justify-between w-full min-h-full box-border relative transition
15
+ rounded-medium border border-solid border-default bg-default outline-solid outline-small outline-transparent
16
+ hover:border-lifted hover:focus-within:border-primary focus-within:border-primary
17
+ focus-within:outline focus-within:outline-small focus-within:outline-primary
18
18
  `,
19
19
  variants: {
20
20
  error: {
@@ -7,7 +7,7 @@ import {
7
7
  getDocsDescription,
8
8
  } from "../../utils/storybook";
9
9
 
10
- import USelect from "../../ui.form-select/USelect.vue";
10
+ import USelectComponent from "../../ui.form-select/USelect.vue";
11
11
  import URow from "../../ui.container-row/URow.vue";
12
12
  import UCol from "../../ui.container-col/UCol.vue";
13
13
  import UBadge from "../../ui.text-badge/UBadge.vue";
@@ -25,6 +25,9 @@ import patMorgan from "./assets/images/pat-morgan.png";
25
25
  import type { Meta, StoryFn } from "@storybook/vue3-vite";
26
26
  import type { Props } from "../types";
27
27
 
28
+ /* Generic SFCs are typed as a function, which drops the compiler-generated `__name`. */
29
+ const USelect = USelectComponent as typeof USelectComponent & { __name: string };
30
+
28
31
  interface USelectArgs extends Props {
29
32
  slotTemplate?: string;
30
33
  enum: "size" | "openDirection" | "labelAlign";