vueless 1.4.12-beta.2 → 1.4.12-beta.21

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 (109) hide show
  1. package/README.md +1 -1
  2. package/composables/useUI.ts +19 -1
  3. package/index.d.ts +128 -0
  4. package/index.ts +128 -0
  5. package/package.json +10 -9
  6. package/ui.button/config.ts +1 -1
  7. package/ui.button-link/config.ts +1 -1
  8. package/ui.button-toggle/UToggle.vue +6 -4
  9. package/ui.button-toggle/storybook/stories.ts +4 -1
  10. package/ui.button-toggle/tests/UToggle.test-d.ts +48 -0
  11. package/ui.button-toggle/tests/UToggleTypes.vue +55 -0
  12. package/ui.button-toggle/types.ts +18 -6
  13. package/ui.container-accordion/types.ts +5 -2
  14. package/ui.container-collapsible/config.ts +1 -1
  15. package/ui.container-drawer/tests/UDrawer.test.ts +10 -17
  16. package/ui.container-modal/tests/UModal.test.ts +10 -17
  17. package/ui.data-list/UDataList.vue +48 -17
  18. package/ui.data-list/config.ts +2 -2
  19. package/ui.data-list/storybook/stories.ts +5 -2
  20. package/ui.data-list/tests/UDataList.test-d.ts +76 -0
  21. package/ui.data-list/tests/UDataList.test.ts +52 -3
  22. package/ui.data-list/tests/UDataListTypes.vue +110 -0
  23. package/ui.data-list/types.ts +37 -5
  24. package/ui.data-table/UTable.vue +202 -24
  25. package/ui.data-table/UTableRow.vue +4 -3
  26. package/ui.data-table/config.ts +11 -2
  27. package/ui.data-table/storybook/stories.ts +34 -1
  28. package/ui.data-table/tests/UTable.test-d.ts +179 -0
  29. package/ui.data-table/tests/UTable.test.ts +85 -7
  30. package/ui.data-table/tests/UTableEmptyColumnsTypes.vue +27 -0
  31. package/ui.data-table/tests/UTableTypes.vue +193 -0
  32. package/ui.data-table/types.ts +106 -9
  33. package/ui.data-table/utilTable.ts +1 -1
  34. package/ui.dropdown/UDropdown.vue +19 -8
  35. package/ui.dropdown/storybook/stories.ts +4 -1
  36. package/ui.dropdown/tests/UDropdown.test-d.ts +61 -0
  37. package/ui.dropdown/tests/UDropdown.test.ts +8 -1
  38. package/ui.dropdown/tests/UDropdownTypes.vue +111 -0
  39. package/ui.dropdown/types.ts +27 -3
  40. package/ui.dropdown-badge/UDropdownBadge.vue +9 -4
  41. package/ui.dropdown-badge/storybook/stories.ts +6 -1
  42. package/ui.dropdown-badge/tests/UDropdownBadge.test-d.ts +55 -0
  43. package/ui.dropdown-badge/tests/UDropdownBadge.test.ts +8 -1
  44. package/ui.dropdown-badge/tests/UDropdownBadgeTypes.vue +107 -0
  45. package/ui.dropdown-badge/types.ts +17 -3
  46. package/ui.dropdown-button/UDropdownButton.vue +9 -4
  47. package/ui.dropdown-button/storybook/stories.ts +6 -1
  48. package/ui.dropdown-button/tests/UDropdownButton.test-d.ts +57 -0
  49. package/ui.dropdown-button/tests/UDropdownButton.test.ts +8 -1
  50. package/ui.dropdown-button/tests/UDropdownButtonTypes.vue +107 -0
  51. package/ui.dropdown-button/types.ts +17 -3
  52. package/ui.dropdown-link/UDropdownLink.vue +9 -4
  53. package/ui.dropdown-link/config.ts +1 -1
  54. package/ui.dropdown-link/storybook/stories.ts +4 -1
  55. package/ui.dropdown-link/tests/UDropdownLink.test-d.ts +55 -0
  56. package/ui.dropdown-link/tests/UDropdownLink.test.ts +8 -1
  57. package/ui.dropdown-link/tests/UDropdownLinkTypes.vue +107 -0
  58. package/ui.dropdown-link/types.ts +17 -3
  59. package/ui.form-calendar/config.ts +1 -1
  60. package/ui.form-checkbox/config.ts +1 -1
  61. package/ui.form-checkbox/tests/UCheckbox.test.ts +4 -4
  62. package/ui.form-checkbox/types.ts +4 -2
  63. package/ui.form-checkbox-group/UCheckboxGroup.vue +8 -2
  64. package/ui.form-date-picker/UDatePicker.vue +11 -1
  65. package/ui.form-date-picker/tests/UDatePicker.test.ts +47 -0
  66. package/ui.form-input/UInput.vue +1 -0
  67. package/ui.form-input/config.ts +1 -1
  68. package/ui.form-input/tests/UInput.test.ts +16 -0
  69. package/ui.form-input-file/config.ts +1 -1
  70. package/ui.form-listbox/UListbox.vue +29 -12
  71. package/ui.form-listbox/config.ts +1 -1
  72. package/ui.form-listbox/storybook/stories.ts +4 -1
  73. package/ui.form-listbox/tests/UListbox.test-d.ts +63 -0
  74. package/ui.form-listbox/tests/UListboxTypes.vue +107 -0
  75. package/ui.form-listbox/types.ts +34 -2
  76. package/ui.form-radio/config.ts +1 -1
  77. package/ui.form-radio-group/URadioGroup.vue +15 -3
  78. package/ui.form-radio-group/config.ts +8 -3
  79. package/ui.form-radio-group/types.ts +2 -3
  80. package/ui.form-select/USelect.vue +49 -17
  81. package/ui.form-select/config.ts +4 -4
  82. package/ui.form-select/storybook/stories.ts +4 -1
  83. package/ui.form-select/tests/USelect.test-d.ts +76 -0
  84. package/ui.form-select/tests/USelect.test.ts +65 -1
  85. package/ui.form-select/tests/USelectTypes.vue +113 -0
  86. package/ui.form-select/types.ts +55 -4
  87. package/ui.form-switch/USwitch.vue +13 -12
  88. package/ui.form-switch/config.ts +2 -1
  89. package/ui.form-switch/tests/USwitch.test.ts +151 -0
  90. package/ui.form-textarea/config.ts +1 -1
  91. package/ui.image-avatar/types.ts +5 -2
  92. package/ui.image-avatar-group/types.ts +5 -2
  93. package/ui.image-icon/UIcon.vue +3 -2
  94. package/ui.image-icon/types.ts +2 -2
  95. package/ui.navigation-breadcrumbs/types.ts +5 -2
  96. package/ui.navigation-tab/UTab.vue +14 -9
  97. package/ui.navigation-tab/tests/UTab.test.ts +14 -0
  98. package/ui.navigation-tabs/UTabs.vue +158 -6
  99. package/ui.navigation-tabs/config.ts +2 -1
  100. package/ui.navigation-tabs/constants.ts +2 -0
  101. package/ui.navigation-tabs/storybook/stories.ts +12 -1
  102. package/ui.navigation-tabs/tests/UTabs.test-d.ts +50 -0
  103. package/ui.navigation-tabs/tests/UTabs.test.ts +213 -1
  104. package/ui.navigation-tabs/tests/UTabsTypes.vue +65 -0
  105. package/ui.navigation-tabs/types.ts +26 -4
  106. package/ui.skeleton-text/constants.ts +1 -1
  107. package/ui.text-badge/config.ts +1 -1
  108. package/utils/node/loaderIcon.js +1 -1
  109. package/v.tooltip/vTooltip.ts +1 -1
@@ -1,11 +1,25 @@
1
1
  import defaultConfig from "./config";
2
2
 
3
- import type { Option } from "../ui.form-listbox/types";
3
+ import type { ListboxOption, SlotOption } from "../ui.form-listbox/types";
4
4
  import type { ComponentConfig, UnknownObject } from "../types";
5
5
 
6
6
  export type Config = typeof defaultConfig;
7
7
 
8
- export interface Props {
8
+ /* Option slots are forwarded through `UDropdown` to `UListbox`, inheriting its group flattening —
9
+ a flattened member need not carry the parent's members, so `SlotOption` is `Partial`. */
10
+ export interface UDropdownLinkSlots<TOption extends ListboxOption = ListboxOption> {
11
+ /* `label` is `UDropdown`'s `displayLabel`, a join of `option[labelKey]` lookups — `undefined`
12
+ when the label cannot be resolved. */
13
+ default?: (props: { label: string | undefined; opened: boolean }) => unknown;
14
+ left?: (props: { opened: boolean }) => unknown;
15
+ toggle?: (props: { opened: boolean }) => unknown;
16
+ "before-option"?: (props: { option: SlotOption<TOption>; index: number }) => unknown;
17
+ option?: (props: { option: SlotOption<TOption>; index: number }) => unknown;
18
+ "after-option"?: (props: { option: SlotOption<TOption>; index: number }) => unknown;
19
+ empty?: () => unknown;
20
+ }
21
+
22
+ export interface Props<TOption extends ListboxOption = ListboxOption> {
9
23
  /**
10
24
  * Selected link.
11
25
  */
@@ -24,7 +38,7 @@ export interface Props {
24
38
  /**
25
39
  * Options list.
26
40
  */
27
- options?: Option[];
41
+ options?: TOption[];
28
42
 
29
43
  /**
30
44
  * Label key in the item object of options.
@@ -56,7 +56,7 @@ export default /*tw*/ {
56
56
  timepicker: "mt-2 pl-1 pt-3 text-medium flex items-stretch justify-between gap-2 border-t border-muted",
57
57
  timepickerLabel: "w-full self-center",
58
58
  timepickerInputWrapper: `
59
- flex items-center rounded-medium border border-solid border-default outline-solid outline-transparent
59
+ flex items-center rounded-medium border border-solid border-default outline-solid outline-small outline-transparent
60
60
  hover:focus-within:border-primary focus-within:border-primary
61
61
  focus-within:outline focus-within:outline-small focus-within:outline-primary
62
62
  `,
@@ -12,7 +12,7 @@ export default /*tw*/ {
12
12
  },
13
13
  checkbox: {
14
14
  base: `
15
- {>checkboxBase} ml-0 bg-default border border-solid border-default outline-solid outline-transparent
15
+ {>checkboxBase} ml-0 bg-default border border-solid border-default outline-solid outline-medium outline-transparent
16
16
  appearance-none p-0 print:color-adjust-exact inline-block align-middle bg-origin-border select-none shrink-0
17
17
  hover:border-lifted
18
18
  active:border-{color} active:bg-{color}/15
@@ -33,7 +33,7 @@ describe("UCheckbox.vue", () => {
33
33
  it("Value – returns correct value type when checkbox is checked", async () => {
34
34
  const testValues = ["string-value", 42, true, { id: 1, name: "test" }, [1, 2, 3]];
35
35
 
36
- testValues.forEach(async (testValue) => {
36
+ for (const testValue of testValues) {
37
37
  const component = mount(UCheckbox, {
38
38
  props: {
39
39
  value: testValue,
@@ -48,7 +48,7 @@ describe("UCheckbox.vue", () => {
48
48
  const emittedValues = component.emitted("update:modelValue");
49
49
 
50
50
  expect(emittedValues![0][0]).toEqual([testValue]);
51
- });
51
+ }
52
52
  });
53
53
 
54
54
  it("TrueValue and FalseValue – returns correct values when checkbox is checked and unchecked", async () => {
@@ -59,7 +59,7 @@ describe("UCheckbox.vue", () => {
59
59
  { trueValue: true, falseValue: false },
60
60
  ];
61
61
 
62
- testValuePairs.forEach(async ({ trueValue, falseValue }) => {
62
+ for (const { trueValue, falseValue } of testValuePairs) {
63
63
  const component = mount(UCheckbox, {
64
64
  props: {
65
65
  trueValue,
@@ -81,7 +81,7 @@ describe("UCheckbox.vue", () => {
81
81
  emittedValues = component.emitted("update:modelValue");
82
82
 
83
83
  expect(emittedValues![1][0]).toEqual(falseValue);
84
- });
84
+ }
85
85
  });
86
86
 
87
87
  it("Partial – displays correct icon when checkbox is partially checked", () => {
@@ -4,16 +4,18 @@ import type { ComponentConfig, UnknownObject, UnknownArray } from "../types";
4
4
 
5
5
  export type Config = typeof defaultConfig;
6
6
 
7
- export interface UCheckboxOption {
7
+ export interface BaseCheckboxOption {
8
8
  value?: string | number | boolean | UnknownObject | UnknownArray;
9
9
  falseValue?: string | number | boolean | UnknownObject | UnknownArray;
10
10
  trueValue?: string | number | boolean | UnknownObject | UnknownArray;
11
11
  label?: string;
12
12
  icon?: string;
13
13
  description?: string;
14
- [key: string]: string | number | boolean | UnknownObject | UnknownArray | undefined;
15
14
  }
16
15
 
16
+ /* Any object shape is a valid option, only reserved option keys are type checked. */
17
+ export type UCheckboxOption = BaseCheckboxOption & (object | UnknownObject);
18
+
17
19
  export interface Props {
18
20
  /**
19
21
  * Checkbox value.
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { provide, ref, watch, useTemplateRef } from "vue";
2
+ import { computed, provide, ref, watch, useTemplateRef } from "vue";
3
3
  import { isEqual } from "lodash-es";
4
4
 
5
5
  import { useUI } from "../composables/useUI";
@@ -12,6 +12,7 @@ import { COMPONENT_NAME } from "./constants";
12
12
  import defaultConfig from "./config";
13
13
 
14
14
  import type { UnknownObject } from "../types";
15
+ import type { BaseCheckboxOption } from "../ui.form-checkbox/types";
15
16
  import type { Props, Config } from "./types";
16
17
 
17
18
  defineOptions({ inheritAttrs: false });
@@ -35,6 +36,11 @@ const listRef = useTemplateRef<HTMLDivElement>("list");
35
36
 
36
37
  const checkedItems = ref([] as UnknownObject[]);
37
38
 
39
+ /* Indexable view of the options for `labelKey` / `valueKey` lookups. */
40
+ const keyedOptions = computed(
41
+ () => props.options as (BaseCheckboxOption & Record<string, BaseCheckboxOption["value"]>)[],
42
+ );
43
+
38
44
  provide<(value: UnknownObject[]) => void>(
39
45
  "setCheckboxGroupCheckedItems",
40
46
  (value: UnknownObject[]) => {
@@ -116,7 +122,7 @@ const { getDataTest, groupLabelAttrs, groupCheckboxAttrs, listAttrs } =
116
122
  <!-- @slot Use it to add UCheckbox directly. -->
117
123
  <slot>
118
124
  <UCheckbox
119
- v-for="(option, index) in options"
125
+ v-for="(option, index) in keyedOptions"
120
126
  :key="index"
121
127
  :model-value="modelValue"
122
128
  :value="option[valueKey]"
@@ -119,6 +119,16 @@ function deactivate() {
119
119
  customView.value = View.Day;
120
120
  }
121
121
 
122
+ function deactivateOnBlur(event: FocusEvent) {
123
+ const related = event.relatedTarget as HTMLElement | null;
124
+
125
+ if (related && wrapperRef.value?.contains(related)) {
126
+ return;
127
+ }
128
+
129
+ deactivate();
130
+ }
131
+
122
132
  function onUserFormatDateChange(value: string) {
123
133
  userFormatDate.value = value.trim();
124
134
  }
@@ -279,7 +289,7 @@ watchEffect(() => {
279
289
  </script>
280
290
 
281
291
  <template>
282
- <div v-bind="wrapperAttrs" ref="wrapper" :data-test="getDataTest()">
292
+ <div v-bind="wrapperAttrs" ref="wrapper" :data-test="getDataTest()" @focusout="deactivateOnBlur">
283
293
  <UInput
284
294
  :id="elementId"
285
295
  :key="String(userFormatDate)"
@@ -356,6 +356,53 @@ describe("UDatePicker.vue", () => {
356
356
  expect(calendar.isVisible()).toBe(true);
357
357
  });
358
358
 
359
+ it("Calendar – hides when focus leaves the whole component", async () => {
360
+ const component = mount(UDatePicker, {
361
+ props: {
362
+ modelValue: new Date(),
363
+ },
364
+ attachTo: document.body,
365
+ });
366
+
367
+ const input = component.getComponent(UInput).find("input");
368
+ const calendar = component.findComponent({ name: "UCalendar" });
369
+
370
+ await input.trigger("focus");
371
+ expect(calendar.isVisible()).toBe(true);
372
+
373
+ const outsideElement = document.createElement("button");
374
+
375
+ document.body.appendChild(outsideElement);
376
+
377
+ await component.trigger("focusout", { relatedTarget: outsideElement });
378
+
379
+ expect(calendar.isVisible()).toBe(false);
380
+
381
+ outsideElement.remove();
382
+ component.unmount();
383
+ });
384
+
385
+ it("Calendar – stays open when focus moves inside the component", async () => {
386
+ const component = mount(UDatePicker, {
387
+ props: {
388
+ modelValue: new Date(),
389
+ },
390
+ attachTo: document.body,
391
+ });
392
+
393
+ const input = component.getComponent(UInput).find("input");
394
+ const calendar = component.findComponent({ name: "UCalendar" });
395
+
396
+ await input.trigger("focus");
397
+ expect(calendar.isVisible()).toBe(true);
398
+
399
+ await component.trigger("focusout", { relatedTarget: calendar.element });
400
+
401
+ expect(calendar.isVisible()).toBe(true);
402
+
403
+ component.unmount();
404
+ });
405
+
359
406
  it("Calendar – hides when escape key is pressed on calendar", async () => {
360
407
  const component = mount(UDatePicker, {
361
408
  props: {
@@ -281,6 +281,7 @@ const {
281
281
  :align="labelAlign"
282
282
  centred
283
283
  v-bind="inputLabelAttrs"
284
+ :data-test="getDataTest('label')"
284
285
  >
285
286
  <template #label>
286
287
  <!--
@@ -12,7 +12,7 @@ export default /*tw*/ {
12
12
  wrapper: {
13
13
  base: `
14
14
  flex gap-3 w-full px-3 relative bg-default transition
15
- border border-solid rounded-medium border-default outline-solid outline-transparent
15
+ border border-solid rounded-medium border-default outline-solid outline-small outline-transparent
16
16
  hover:border-lifted hover:focus-within:border-primary focus-within:border-primary
17
17
  focus-within:outline focus-within:outline-small focus-within:outline-primary focus-within:transition
18
18
  `,
@@ -302,6 +302,22 @@ describe("UInput.vue", () => {
302
302
 
303
303
  expect(component.get("input").attributes("data-test")).toBe(dataTestValue);
304
304
  });
305
+
306
+ it("Data Test – forwards data-test to the label so the error block is targetable", () => {
307
+ const dataTestValue = "test-input";
308
+
309
+ const component = mount(UInput, {
310
+ props: {
311
+ dataTest: dataTestValue,
312
+ error: "This is an error",
313
+ },
314
+ });
315
+
316
+ const errorElement = component.find(`[data-test="${dataTestValue}-label-error"]`);
317
+
318
+ expect(errorElement.exists()).toBe(true);
319
+ expect(errorElement.text()).toBe("This is an error");
320
+ });
305
321
  });
306
322
 
307
323
  describe("Slots", () => {
@@ -3,7 +3,7 @@ export default /*tw*/ {
3
3
  dropzone: {
4
4
  base: `
5
5
  p-3 size-auto w-full bg-default transition
6
- rounded-medium border border-solid border-default outline-solid outline-transparent
6
+ rounded-medium border border-solid border-default outline-solid outline-small outline-transparent
7
7
  hover:border-lifted hover:focus-within:border-primary focus-within:border-primary
8
8
  focus-within:outline focus-within:outline-small focus-within:outline-primary
9
9
  `,
@@ -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