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.
- package/README.md +1 -1
- package/composables/useUI.ts +19 -1
- package/index.d.ts +128 -0
- package/index.ts +128 -0
- package/package.json +10 -9
- package/ui.button/config.ts +1 -1
- package/ui.button-link/config.ts +1 -1
- package/ui.button-toggle/UToggle.vue +6 -4
- package/ui.button-toggle/storybook/stories.ts +4 -1
- package/ui.button-toggle/tests/UToggle.test-d.ts +48 -0
- package/ui.button-toggle/tests/UToggleTypes.vue +55 -0
- package/ui.button-toggle/types.ts +18 -6
- package/ui.container-accordion/types.ts +5 -2
- package/ui.container-collapsible/config.ts +1 -1
- package/ui.container-drawer/tests/UDrawer.test.ts +10 -17
- package/ui.container-modal/tests/UModal.test.ts +10 -17
- package/ui.data-list/UDataList.vue +48 -17
- package/ui.data-list/config.ts +2 -2
- package/ui.data-list/storybook/stories.ts +5 -2
- package/ui.data-list/tests/UDataList.test-d.ts +76 -0
- package/ui.data-list/tests/UDataList.test.ts +52 -3
- package/ui.data-list/tests/UDataListTypes.vue +110 -0
- package/ui.data-list/types.ts +37 -5
- package/ui.data-table/UTable.vue +202 -24
- package/ui.data-table/UTableRow.vue +4 -3
- package/ui.data-table/config.ts +11 -2
- package/ui.data-table/storybook/stories.ts +34 -1
- package/ui.data-table/tests/UTable.test-d.ts +179 -0
- package/ui.data-table/tests/UTable.test.ts +85 -7
- package/ui.data-table/tests/UTableEmptyColumnsTypes.vue +27 -0
- package/ui.data-table/tests/UTableTypes.vue +193 -0
- package/ui.data-table/types.ts +106 -9
- package/ui.data-table/utilTable.ts +1 -1
- package/ui.dropdown/UDropdown.vue +19 -8
- package/ui.dropdown/storybook/stories.ts +4 -1
- package/ui.dropdown/tests/UDropdown.test-d.ts +61 -0
- package/ui.dropdown/tests/UDropdown.test.ts +8 -1
- package/ui.dropdown/tests/UDropdownTypes.vue +111 -0
- package/ui.dropdown/types.ts +27 -3
- package/ui.dropdown-badge/UDropdownBadge.vue +9 -4
- package/ui.dropdown-badge/storybook/stories.ts +6 -1
- package/ui.dropdown-badge/tests/UDropdownBadge.test-d.ts +55 -0
- package/ui.dropdown-badge/tests/UDropdownBadge.test.ts +8 -1
- package/ui.dropdown-badge/tests/UDropdownBadgeTypes.vue +107 -0
- package/ui.dropdown-badge/types.ts +17 -3
- package/ui.dropdown-button/UDropdownButton.vue +9 -4
- package/ui.dropdown-button/storybook/stories.ts +6 -1
- package/ui.dropdown-button/tests/UDropdownButton.test-d.ts +57 -0
- package/ui.dropdown-button/tests/UDropdownButton.test.ts +8 -1
- package/ui.dropdown-button/tests/UDropdownButtonTypes.vue +107 -0
- package/ui.dropdown-button/types.ts +17 -3
- package/ui.dropdown-link/UDropdownLink.vue +9 -4
- package/ui.dropdown-link/config.ts +1 -1
- package/ui.dropdown-link/storybook/stories.ts +4 -1
- package/ui.dropdown-link/tests/UDropdownLink.test-d.ts +55 -0
- package/ui.dropdown-link/tests/UDropdownLink.test.ts +8 -1
- package/ui.dropdown-link/tests/UDropdownLinkTypes.vue +107 -0
- package/ui.dropdown-link/types.ts +17 -3
- package/ui.form-calendar/config.ts +1 -1
- package/ui.form-checkbox/config.ts +1 -1
- package/ui.form-checkbox/tests/UCheckbox.test.ts +4 -4
- package/ui.form-checkbox/types.ts +4 -2
- package/ui.form-checkbox-group/UCheckboxGroup.vue +8 -2
- package/ui.form-date-picker/UDatePicker.vue +11 -1
- package/ui.form-date-picker/tests/UDatePicker.test.ts +47 -0
- package/ui.form-input/UInput.vue +1 -0
- package/ui.form-input/config.ts +1 -1
- package/ui.form-input/tests/UInput.test.ts +16 -0
- package/ui.form-input-file/config.ts +1 -1
- package/ui.form-listbox/UListbox.vue +29 -12
- package/ui.form-listbox/config.ts +1 -1
- package/ui.form-listbox/storybook/stories.ts +4 -1
- package/ui.form-listbox/tests/UListbox.test-d.ts +63 -0
- package/ui.form-listbox/tests/UListboxTypes.vue +107 -0
- package/ui.form-listbox/types.ts +34 -2
- package/ui.form-radio/config.ts +1 -1
- package/ui.form-radio-group/URadioGroup.vue +15 -3
- package/ui.form-radio-group/config.ts +8 -3
- package/ui.form-radio-group/types.ts +2 -3
- package/ui.form-select/USelect.vue +49 -17
- package/ui.form-select/config.ts +4 -4
- package/ui.form-select/storybook/stories.ts +4 -1
- package/ui.form-select/tests/USelect.test-d.ts +76 -0
- package/ui.form-select/tests/USelect.test.ts +65 -1
- package/ui.form-select/tests/USelectTypes.vue +113 -0
- package/ui.form-select/types.ts +55 -4
- package/ui.form-switch/USwitch.vue +13 -12
- package/ui.form-switch/config.ts +2 -1
- package/ui.form-switch/tests/USwitch.test.ts +151 -0
- package/ui.form-textarea/config.ts +1 -1
- package/ui.image-avatar/types.ts +5 -2
- package/ui.image-avatar-group/types.ts +5 -2
- package/ui.image-icon/UIcon.vue +3 -2
- package/ui.image-icon/types.ts +2 -2
- package/ui.navigation-breadcrumbs/types.ts +5 -2
- package/ui.navigation-tab/UTab.vue +14 -9
- package/ui.navigation-tab/tests/UTab.test.ts +14 -0
- package/ui.navigation-tabs/UTabs.vue +158 -6
- package/ui.navigation-tabs/config.ts +2 -1
- package/ui.navigation-tabs/constants.ts +2 -0
- package/ui.navigation-tabs/storybook/stories.ts +12 -1
- package/ui.navigation-tabs/tests/UTabs.test-d.ts +50 -0
- package/ui.navigation-tabs/tests/UTabs.test.ts +213 -1
- package/ui.navigation-tabs/tests/UTabsTypes.vue +65 -0
- package/ui.navigation-tabs/types.ts +26 -4
- package/ui.skeleton-text/constants.ts +1 -1
- package/ui.text-badge/config.ts +1 -1
- package/utils/node/loaderIcon.js +1 -1
- package/v.tooltip/vTooltip.ts +1 -1
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import defaultConfig from "./config";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
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
|
-
|
|
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?:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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: {
|
package/ui.form-input/UInput.vue
CHANGED
package/ui.form-input/config.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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(
|
|
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 = [...
|
|
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 (
|
|
328
|
-
select(
|
|
329
|
-
onClickOption(
|
|
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
|
|
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>
|
package/ui.form-listbox/types.ts
CHANGED
|
@@ -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
|
-
|
|
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?:
|
|
67
|
+
options?: TOption[];
|
|
36
68
|
|
|
37
69
|
/**
|
|
38
70
|
* Shows input to search value in a list.
|
package/ui.form-radio/config.ts
CHANGED
|
@@ -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
|