srcdev-nuxt-components 9.4.10 → 9.4.12
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/.claude/commands/migrate-component.md +18 -0
- package/.claude/component-ledger/audit.json +1 -1
- package/.claude/component-ledger/output.html +1 -1
- package/.claude/skills/components/input-number-core.md +214 -0
- package/.claude/skills/components/input-select-core.md +151 -0
- package/.claude/skills/components/input-text-core.md +16 -0
- package/.claude/skills/components/input-textarea-core.md +154 -0
- package/.claude/skills/components/select-menu.md +195 -0
- package/.claude/skills/components/toggle-switch-core.md +150 -0
- package/.claude/skills/index.md +5 -0
- package/.vscode/srcdev-component-input-number.code-snippets +65 -0
- package/.vscode/srcdev-component-input-select.code-snippets +42 -0
- package/.vscode/srcdev-component-input-textarea.code-snippets +40 -0
- package/.vscode/srcdev-component-select-menu.code-snippets +65 -0
- package/.vscode/srcdev-component-toggle-switch.code-snippets +49 -0
- package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +1 -1
- package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +1 -1
- package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +1 -1
- package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +1 -1
- package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +1 -1
- package/app/components/01.atoms/animations/section-parallax/CONSUMER-STYLING.md +1 -1
- package/app/components/01.atoms/animations/section-parallax/SectionParallax.vue +1 -1
- package/app/components/01.atoms/canvas-switcher/CONSUMER-STYLING.md +2 -2
- package/app/components/01.atoms/canvas-switcher/CanvasSwitcher.vue +2 -2
- package/app/components/01.atoms/card/CardCore.vue +4 -4
- package/app/components/01.atoms/clipped-panel/CONSUMER-STYLING.md +2 -2
- package/app/components/01.atoms/clipped-panel/ClippedPanel.vue +2 -2
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/CONSUMER-STYLING.md +2 -2
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/DashboardQuadGrid.vue +2 -2
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/stories/DashboardQuadGrid.stories.ts +1 -1
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/CONSUMER-STYLING.md +1 -1
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/DashboardStatsGrid.vue +1 -1
- package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +8 -8
- package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +8 -8
- package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +6 -6
- package/app/components/01.atoms/pop-over/PopOver.vue +6 -6
- package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +1 -1
- package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +1 -1
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/CONSUMER-STYLING.md +1 -1
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/DeepExpandingMenuClassic.vue +1 -1
- package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +11 -0
- package/app/components/02.molecules/pricing-card/PricingCard.vue +3 -1
- package/app/components/02.molecules/pricing-card/stories/PricingCard.stories.ts +2 -0
- package/app/components/02.molecules/pricing-card/tests/PricingCard.spec.ts +13 -0
- package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +168 -0
- package/app/components/02.molecules/select-menu/SelectMenu.vue +384 -0
- package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +359 -0
- package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +476 -0
- package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +42 -0
- package/app/components/03.organisms/image-galleries/carousel-flip/CONSUMER-STYLING.md +3 -3
- package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +3 -3
- package/app/components/03.organisms/image-galleries/carousel-flip/stories/CarouselFlip.stories.ts +18 -18
- package/app/components/05.forms/input-button/CONSUMER-STYLING.md +2 -7
- package/app/components/05.forms/input-button/InputButtonCore.vue +1 -5
- package/app/components/05.forms/input-number/CONSUMER-STYLING.md +26 -6
- package/app/components/05.forms/input-number/InputNumberCore.vue +125 -85
- package/app/components/05.forms/input-number/stories/InputNumberCore.stories.ts +92 -0
- package/app/components/05.forms/input-number/tests/InputNumberCore.spec.ts +119 -0
- package/app/components/05.forms/input-number/variants/InputNumberDefault.vue +30 -22
- package/app/components/05.forms/input-number/variants/stories/InputNumberDefault.stories.ts +123 -0
- package/app/components/05.forms/input-number/variants/tests/InputNumberDefault.spec.ts +143 -0
- package/app/components/05.forms/input-range/CONSUMER-STYLING.md +2 -2
- package/app/components/05.forms/input-range/InputRangeCore.vue +2 -2
- package/app/components/05.forms/input-select/InputSelectCore.vue +23 -30
- package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +2 -2
- package/app/components/05.forms/input-select/stories/InputSelectWithLabel.stories.ts +115 -0
- package/app/components/05.forms/input-select/tests/InputSelectCore.spec.ts +129 -0
- package/app/components/05.forms/input-select/variants/InputSelectWithLabel.vue +1 -1
- package/app/components/05.forms/input-select/variants/tests/InputSelectWithLabel.spec.ts +87 -0
- package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +14 -0
- package/app/components/05.forms/input-text/variants/InputTextAsNumberWithLabel.vue +1 -1
- package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +55 -0
- package/app/components/05.forms/input-textarea/InputTextareaCore.vue +24 -16
- package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +1 -1
- package/app/components/05.forms/input-textarea/tests/InputTextareaCore.spec.ts +119 -0
- package/app/components/05.forms/input-textarea/variants/InputTextareaWithLabel.vue +3 -13
- package/app/components/05.forms/input-textarea/variants/tests/InputTextareaWithLabel.spec.ts +93 -0
- package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +95 -10
- package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +29 -0
- package/app/components/05.forms/toggle-switch/ToggleSwitchCore.vue +120 -119
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchCore.stories.ts +1 -1
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +1 -1
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabelInline.stories.ts +2 -2
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchCore.spec.ts +101 -0
- package/app/components/05.forms/toggle-switch/variants/ToggleSwitchWithLabel.vue +3 -5
- package/app/components/05.forms/toggle-switch/variants/ToggleSwitchWithLabelInline.vue +1 -2
- package/app/components/05.forms/toggle-switch/variants/tests/ToggleSwitchWithLabel.spec.ts +77 -0
- package/app/components/05.forms/toggle-switch/variants/tests/ToggleSwitchWithLabelInline.spec.ts +66 -0
- package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +4 -4
- package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitchCore.vue +6 -6
- package/app/types/components/index.ts +1 -0
- package/app/types/components/select-menu.d.ts +5 -0
- package/package.json +1 -1
- package/.vscode/srcdev-component-toggle-switch-core.code-snippets +0 -13
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
2
|
import { ref } from "vue";
|
|
3
3
|
import StorybookComponent from "../InputSelectCore.vue";
|
|
4
|
-
import type { IFormMultipleOptions } from "~/types/forms/types.forms.d";
|
|
4
|
+
import type { IFormMultipleOptions, InputUiVariant } from "~/types/forms/types.forms.d";
|
|
5
5
|
|
|
6
6
|
interface InputSelectCoreStoryArgs {
|
|
7
7
|
id: string;
|
|
@@ -9,7 +9,7 @@ interface InputSelectCoreStoryArgs {
|
|
|
9
9
|
placeholder: string;
|
|
10
10
|
required: boolean;
|
|
11
11
|
fieldHasError: boolean;
|
|
12
|
-
inputVariant:
|
|
12
|
+
inputVariant: InputUiVariant;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export default {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
|
+
import { ref, computed } from "vue";
|
|
3
|
+
import StorybookComponent from "../variants/InputSelectWithLabel.vue";
|
|
4
|
+
import type { FormUiTheme, InputUiVariant, IFormMultipleOptions } from "~/types/forms/types.forms.d";
|
|
5
|
+
|
|
6
|
+
interface InputSelectWithLabelStoryArgs {
|
|
7
|
+
modelValue: string | number;
|
|
8
|
+
name: string;
|
|
9
|
+
label: string;
|
|
10
|
+
placeholder: string;
|
|
11
|
+
errorMessage: string;
|
|
12
|
+
fieldHasError: boolean;
|
|
13
|
+
required: boolean;
|
|
14
|
+
theme: FormUiTheme;
|
|
15
|
+
inputVariant: InputUiVariant;
|
|
16
|
+
styleClassPassthrough: string[];
|
|
17
|
+
useDescriptionSlot: boolean;
|
|
18
|
+
descriptionContent: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default {
|
|
22
|
+
title: "Components/Forms/Input Select/InputSelectWithLabel",
|
|
23
|
+
component: StorybookComponent,
|
|
24
|
+
argTypes: {
|
|
25
|
+
modelValue: { control: "text", table: { category: "Model" } },
|
|
26
|
+
name: { control: "text", table: { category: "Basic" } },
|
|
27
|
+
label: { control: "text", table: { category: "Basic" } },
|
|
28
|
+
placeholder: { control: "text", table: { category: "Basic" } },
|
|
29
|
+
errorMessage: { control: "text", table: { category: "Validation" } },
|
|
30
|
+
fieldHasError: { control: "boolean", table: { category: "Validation" } },
|
|
31
|
+
required: { control: "boolean", table: { category: "Validation" } },
|
|
32
|
+
theme: {
|
|
33
|
+
control: { type: "select" },
|
|
34
|
+
options: ["default", "success", "error", "warning"],
|
|
35
|
+
table: { category: "Styling" },
|
|
36
|
+
},
|
|
37
|
+
inputVariant: {
|
|
38
|
+
control: { type: "select" },
|
|
39
|
+
options: ["normal", "outlined", "underlined"],
|
|
40
|
+
table: { category: "Styling" },
|
|
41
|
+
},
|
|
42
|
+
styleClassPassthrough: { control: "object", table: { category: "Styling" } },
|
|
43
|
+
useDescriptionSlot: { control: "boolean", table: { category: "Slots" } },
|
|
44
|
+
descriptionContent: { control: "text", table: { category: "Slots" } },
|
|
45
|
+
},
|
|
46
|
+
args: {
|
|
47
|
+
modelValue: "",
|
|
48
|
+
name: "colour",
|
|
49
|
+
label: "Favourite colour",
|
|
50
|
+
placeholder: "Choose a colour",
|
|
51
|
+
errorMessage: "",
|
|
52
|
+
fieldHasError: false,
|
|
53
|
+
required: false,
|
|
54
|
+
theme: "default",
|
|
55
|
+
inputVariant: "normal",
|
|
56
|
+
styleClassPassthrough: [],
|
|
57
|
+
useDescriptionSlot: false,
|
|
58
|
+
descriptionContent: "Pick the colour you like best",
|
|
59
|
+
},
|
|
60
|
+
} as Meta<InputSelectWithLabelStoryArgs>;
|
|
61
|
+
|
|
62
|
+
// `args` is Storybook's own reactive object — bind to it directly (`args.x`) rather than
|
|
63
|
+
// destructuring it into local variables/refs, which would snapshot the values once at setup()
|
|
64
|
+
// and stop reflecting later Controls-panel changes. `componentArgs` strips the non-prop
|
|
65
|
+
// slot-toggle args so they don't leak onto the component as unknown attributes.
|
|
66
|
+
const Template: StoryFn<InputSelectWithLabelStoryArgs> = (args) => ({
|
|
67
|
+
components: { StorybookComponent },
|
|
68
|
+
setup() {
|
|
69
|
+
const fieldData = ref<IFormMultipleOptions>({
|
|
70
|
+
data: [
|
|
71
|
+
{ id: "1", name: "red", value: "red", label: "Red" },
|
|
72
|
+
{ id: "2", name: "blue", value: "blue", label: "Blue" },
|
|
73
|
+
{ id: "3", name: "green", value: "green", label: "Green" },
|
|
74
|
+
],
|
|
75
|
+
total: 3,
|
|
76
|
+
skip: 0,
|
|
77
|
+
limit: 10,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const componentArgs = computed(() => {
|
|
81
|
+
const { useDescriptionSlot, descriptionContent, ...rest } = args;
|
|
82
|
+
return rest;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return { args, componentArgs, fieldData };
|
|
86
|
+
},
|
|
87
|
+
template: `
|
|
88
|
+
<div style="margin: 36px; max-width: 320px;">
|
|
89
|
+
<StorybookComponent v-model="args.modelValue" v-model:field-data="fieldData" v-bind="componentArgs">
|
|
90
|
+
<template v-if="args.useDescriptionSlot" #descriptionText>{{ args.descriptionContent }}</template>
|
|
91
|
+
</StorybookComponent>
|
|
92
|
+
</div>
|
|
93
|
+
`,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const Default = Template.bind({});
|
|
97
|
+
|
|
98
|
+
export const WithError = Template.bind({});
|
|
99
|
+
WithError.args = {
|
|
100
|
+
fieldHasError: true,
|
|
101
|
+
errorMessage: "Please choose a colour",
|
|
102
|
+
theme: "error",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const Required = Template.bind({});
|
|
106
|
+
Required.args = { required: true };
|
|
107
|
+
|
|
108
|
+
export const WithDescription = Template.bind({});
|
|
109
|
+
WithDescription.args = { useDescriptionSlot: true };
|
|
110
|
+
|
|
111
|
+
export const Underlined = Template.bind({});
|
|
112
|
+
Underlined.args = { inputVariant: "underlined" };
|
|
113
|
+
|
|
114
|
+
export const Outlined = Template.bind({});
|
|
115
|
+
Outlined.args = { inputVariant: "outlined", useDescriptionSlot: true };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// https://nuxt.com/docs/getting-started/testing#unit-testing
|
|
2
|
+
import type { VueWrapper } from "@vue/test-utils";
|
|
3
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
4
|
+
import ComponentUnderTest from "../InputSelectCore.vue";
|
|
5
|
+
|
|
6
|
+
const initialFieldData = {
|
|
7
|
+
data: [
|
|
8
|
+
{ id: "1", name: "red", value: "red", label: "Red" },
|
|
9
|
+
{ id: "2", name: "blue", value: "blue", label: "Blue" },
|
|
10
|
+
],
|
|
11
|
+
total: 2,
|
|
12
|
+
skip: 0,
|
|
13
|
+
limit: 10,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const initialPropsData = {
|
|
17
|
+
id: "test-select",
|
|
18
|
+
name: "testName",
|
|
19
|
+
modelValue: "red",
|
|
20
|
+
fieldData: initialFieldData,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
|
|
24
|
+
const wrapperFactory = (propsData = {}) => {
|
|
25
|
+
const mockPropsData = { ...initialPropsData, ...propsData };
|
|
26
|
+
|
|
27
|
+
return mountSuspended(ComponentUnderTest, {
|
|
28
|
+
attachTo: document.body,
|
|
29
|
+
props: mockPropsData,
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("InputSelectCore", () => {
|
|
34
|
+
it("mounts without error", async () => {
|
|
35
|
+
wrapper = await wrapperFactory();
|
|
36
|
+
expect(wrapper.vm).toBeTruthy();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("renders an option per fieldData item", async () => {
|
|
40
|
+
wrapper = await wrapperFactory();
|
|
41
|
+
const options = wrapper.findAll("option");
|
|
42
|
+
expect(options.map((o) => o.text())).toEqual(["Red", "Blue"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("binds v-model to the select value", async () => {
|
|
46
|
+
wrapper = await wrapperFactory({ modelValue: "blue" });
|
|
47
|
+
expect(wrapper.find("select").element.value).toBe("blue");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("renders a disabled placeholder option when placeholder is set", async () => {
|
|
51
|
+
wrapper = await wrapperFactory({ placeholder: "Choose a colour", modelValue: "" });
|
|
52
|
+
const placeholderOption = wrapper.find("option.placeholder");
|
|
53
|
+
|
|
54
|
+
expect(placeholderOption.exists()).toBe(true);
|
|
55
|
+
expect(placeholderOption.text()).toBe("Choose a colour");
|
|
56
|
+
expect(placeholderOption.attributes("disabled")).toBeDefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("does not render a placeholder option when placeholder is unset", async () => {
|
|
60
|
+
wrapper = await wrapperFactory();
|
|
61
|
+
expect(wrapper.find("option.placeholder").exists()).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("sets required attribute when required is true", async () => {
|
|
65
|
+
wrapper = await wrapperFactory({ required: true });
|
|
66
|
+
expect(wrapper.find("select").attributes("required")).toBeDefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does not set required attribute by default", async () => {
|
|
70
|
+
wrapper = await wrapperFactory();
|
|
71
|
+
expect(wrapper.find("select").attributes("required")).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("sets data-theme from the theme prop", async () => {
|
|
75
|
+
wrapper = await wrapperFactory({ theme: "error" });
|
|
76
|
+
expect(wrapper.find(".input-select-wrapper").attributes("data-theme")).toBe("error");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("sets data-invalid and aria-invalid when fieldHasError is true", async () => {
|
|
80
|
+
wrapper = await wrapperFactory({ fieldHasError: true });
|
|
81
|
+
expect(wrapper.find(".input-select-wrapper").attributes("data-invalid")).toBe("");
|
|
82
|
+
expect(wrapper.find("select").attributes("aria-invalid")).toBe("true");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("defaults inputVariant to normal", async () => {
|
|
86
|
+
wrapper = await wrapperFactory();
|
|
87
|
+
expect(wrapper.find(".input-select-wrapper").classes()).toContain("normal");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("applies inputVariant as a class on the wrapper", async () => {
|
|
91
|
+
wrapper = await wrapperFactory({ inputVariant: "underlined" });
|
|
92
|
+
expect(wrapper.find(".input-select-wrapper").classes()).toContain("underlined");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("applies styleClassPassthrough to the select", async () => {
|
|
96
|
+
wrapper = await wrapperFactory({ styleClassPassthrough: ["custom-select-class"] });
|
|
97
|
+
expect(wrapper.find("select").classes()).toContain("custom-select-class");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("sets isActive true on focus and false on blur", async () => {
|
|
101
|
+
wrapper = await wrapperFactory();
|
|
102
|
+
const select = wrapper.find("select");
|
|
103
|
+
|
|
104
|
+
await select.trigger("focusin");
|
|
105
|
+
expect(wrapper.emitted("update:isActive")?.at(-1)).toEqual([true]);
|
|
106
|
+
|
|
107
|
+
await select.trigger("focusout");
|
|
108
|
+
expect(wrapper.emitted("update:isActive")?.at(-1)).toEqual([false]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("sets isDirty true when the value changes", async () => {
|
|
112
|
+
wrapper = await wrapperFactory();
|
|
113
|
+
await wrapper.find("select").trigger("change");
|
|
114
|
+
expect(wrapper.emitted("update:isDirty")?.at(-1)).toEqual([true]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("renders an icon for an option that has one, hidden from assistive tech", async () => {
|
|
118
|
+
wrapper = await wrapperFactory({
|
|
119
|
+
fieldData: {
|
|
120
|
+
...initialFieldData,
|
|
121
|
+
data: [{ id: "1", name: "red", value: "red", label: "Red", icon: "mdi:circle" }],
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const icon = wrapper.find(".input-select-core-option-decorator-icon");
|
|
126
|
+
expect(icon.exists()).toBe(true);
|
|
127
|
+
expect(icon.attributes("aria-hidden")).toBe("true");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -117,5 +117,5 @@ const { id, errorId, descriptionId, ariaDescribedby } = useAriaDescribedById(
|
|
|
117
117
|
const modelValue = defineModel<string | number | readonly number[]>({ required: true });
|
|
118
118
|
const isDirty = defineModel<boolean>("isDirty");
|
|
119
119
|
const isActive = defineModel<boolean>("isActive");
|
|
120
|
-
const fieldData = defineModel<IFormMultipleOptions>("fieldData");
|
|
120
|
+
const fieldData = defineModel<IFormMultipleOptions>("fieldData", { required: true });
|
|
121
121
|
</script>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// https://nuxt.com/docs/getting-started/testing#unit-testing
|
|
2
|
+
import type { VueWrapper } from "@vue/test-utils";
|
|
3
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
4
|
+
import ComponentUnderTest from "../InputSelectWithLabel.vue";
|
|
5
|
+
|
|
6
|
+
const initialFieldData = {
|
|
7
|
+
data: [
|
|
8
|
+
{ id: "1", name: "red", value: "red", label: "Red" },
|
|
9
|
+
{ id: "2", name: "blue", value: "blue", label: "Blue" },
|
|
10
|
+
],
|
|
11
|
+
total: 2,
|
|
12
|
+
skip: 0,
|
|
13
|
+
limit: 10,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const initialPropsData = {
|
|
17
|
+
name: "colour",
|
|
18
|
+
label: "Colour",
|
|
19
|
+
errorMessage: "",
|
|
20
|
+
modelValue: "red",
|
|
21
|
+
fieldData: initialFieldData,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
|
|
25
|
+
const wrapperFactory = (propsData = {}, slots = {}) => {
|
|
26
|
+
const mockPropsData = { ...initialPropsData, ...propsData };
|
|
27
|
+
|
|
28
|
+
return mountSuspended(ComponentUnderTest, {
|
|
29
|
+
attachTo: document.body,
|
|
30
|
+
props: mockPropsData,
|
|
31
|
+
slots,
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe("InputSelectWithLabel", () => {
|
|
36
|
+
it("mounts without error", async () => {
|
|
37
|
+
wrapper = await wrapperFactory();
|
|
38
|
+
expect(wrapper.vm).toBeTruthy();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("renders the label text", async () => {
|
|
42
|
+
wrapper = await wrapperFactory({ label: "Favourite colour" });
|
|
43
|
+
expect(wrapper.text()).toContain("Favourite colour");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("renders a native select with an option per fieldData item", async () => {
|
|
47
|
+
wrapper = await wrapperFactory();
|
|
48
|
+
expect(wrapper.find("select").exists()).toBe(true);
|
|
49
|
+
expect(wrapper.findAll("option").map((o) => o.text())).toEqual(["Red", "Blue"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("sets data-invalid and the error class when fieldHasError is true", async () => {
|
|
53
|
+
wrapper = await wrapperFactory({ fieldHasError: true });
|
|
54
|
+
expect(wrapper.find(".input-select-with-label").attributes("data-invalid")).toBe("");
|
|
55
|
+
expect(wrapper.find(".input-select-with-label").classes()).toContain("error");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("renders the error message when fieldHasError is true", async () => {
|
|
59
|
+
wrapper = await wrapperFactory({ fieldHasError: true, errorMessage: "Please choose a colour" });
|
|
60
|
+
expect(wrapper.text()).toContain("Please choose a colour");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("renders the descriptionText slot", async () => {
|
|
64
|
+
wrapper = await wrapperFactory({}, { descriptionText: () => "Pick your favourite" });
|
|
65
|
+
expect(wrapper.text()).toContain("Pick your favourite");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("applies inputVariant as a class on the wrapper", async () => {
|
|
69
|
+
wrapper = await wrapperFactory({ inputVariant: "underlined" });
|
|
70
|
+
expect(wrapper.find(".input-select-with-label").classes()).toContain("underlined");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("applies styleClassPassthrough", async () => {
|
|
74
|
+
wrapper = await wrapperFactory({ styleClassPassthrough: "custom-select" });
|
|
75
|
+
expect(wrapper.find("select").classes()).toContain("custom-select");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("forwards required to the native select", async () => {
|
|
79
|
+
wrapper = await wrapperFactory({ required: true });
|
|
80
|
+
expect(wrapper.find("select").attributes("required")).toBeDefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("applies the error theme to InputSelectCore when fieldHasError is true", async () => {
|
|
84
|
+
wrapper = await wrapperFactory({ fieldHasError: true, theme: "default" });
|
|
85
|
+
expect(wrapper.find(".input-select-wrapper").attributes("data-theme")).toBe("error");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -58,4 +58,18 @@ describe("InputTextAsNumberWithLabel", () => {
|
|
|
58
58
|
|
|
59
59
|
expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([6]);
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
it("forwards the input-text-as-number marker class down to InputTextCore's own input", async () => {
|
|
63
|
+
// Regression test: the marker classes toggled via updateElementClasses() must reach the
|
|
64
|
+
// inner <input> (not just the outer wrapper), or the scoped .input-text-core.input-text-as-number
|
|
65
|
+
// CSS (compact fit-content width, centered text) silently never matches.
|
|
66
|
+
wrapper = await wrapperFactory();
|
|
67
|
+
expect(wrapper.find("input").classes()).toContain("input-text-as-number");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("still forwards a consumer-supplied styleClassPassthrough down to the input", async () => {
|
|
71
|
+
wrapper = await wrapperFactory({ styleClassPassthrough: ["custom-number-class"] });
|
|
72
|
+
expect(wrapper.find("input").classes()).toContain("custom-number-class");
|
|
73
|
+
expect(wrapper.find("input").classes()).toContain("input-text-as-number");
|
|
74
|
+
});
|
|
61
75
|
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# InputTextareaCore — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## Public token API
|
|
4
|
+
|
|
5
|
+
Every colour token below is `var(--input-textarea-*, {default})` — set the `--input-textarea-*`
|
|
6
|
+
name to override just this component; leave it unset and it inherits the shared `--theme-input-*`/
|
|
7
|
+
`--theme-*` token every other themed component also falls back to. See
|
|
8
|
+
`theming-component-token-pattern.md` for why this two-tier shape exists.
|
|
9
|
+
|
|
10
|
+
This shape mirrors `InputTextCore`'s token set (see its own CONSUMER-STYLING.md).
|
|
11
|
+
|
|
12
|
+
| Token | Falls back to | Controls |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| `--input-textarea-surface` | `var(--theme-input-surface)` | Wrapper background |
|
|
15
|
+
| `--input-textarea-border` | `var(--theme-border)` | Border colour (`normal`/`underlined` variant border) |
|
|
16
|
+
| `--input-textarea-border-hover` | `var(--theme-border-focus)` | Wrapper outline colour on mouse hover (`normal` variant only) |
|
|
17
|
+
| `--input-textarea-border-focus` | `var(--theme-border-focus)` | Wrapper outline colour on `:focus-visible` (`normal` variant only) |
|
|
18
|
+
| `--input-textarea-text-color` | `var(--theme-input-text-color-normal)` | Textarea text colour |
|
|
19
|
+
| `--input-textarea-placeholder-color` | `var(--theme-input-placeholder)` | Placeholder text colour |
|
|
20
|
+
|
|
21
|
+
Geometry (`--form-textarea-padding-block`, `--form-textarea-padding-inline`, `--input-font-size`,
|
|
22
|
+
`--input-textarea-line-height`, `--input-padding-inline`, etc.) is shared across every `05.forms`
|
|
23
|
+
component and untouched by this — see `theming-form-geometry-tokens.md`.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Input variant
|
|
28
|
+
|
|
29
|
+
`:input-variant="'normal' | 'outlined' | 'underlined'"` (default `"normal"`) — same prop as
|
|
30
|
+
`InputTextCore`. Only `normal` (bordered box, with hover/focus outline) and `underlined`
|
|
31
|
+
(bottom-border only) have CSS today; `outlined` has no styling in `InputTextCore` either (no
|
|
32
|
+
`&.outlined` rule exists there), so passing it renders with no border/outline at all — a
|
|
33
|
+
pre-existing gap in the type, not specific to `InputTextareaCore`.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Global theming — app-level CSS file
|
|
38
|
+
|
|
39
|
+
```css
|
|
40
|
+
:where(html) {
|
|
41
|
+
--input-textarea-surface: var(--rose-09);
|
|
42
|
+
--input-textarea-border: var(--rose-05);
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Only declare the tokens you want to change — everything else keeps inheriting the shared
|
|
47
|
+
`--theme-input-*`/`--theme-*` tokens.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Per-instance overrides
|
|
52
|
+
|
|
53
|
+
```vue
|
|
54
|
+
<InputTextareaCore id="message" name="message" style="--input-textarea-border: var(--gold-04);" />
|
|
55
|
+
```
|
|
@@ -66,11 +66,9 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
66
66
|
|
|
67
67
|
const slots = useSlots();
|
|
68
68
|
|
|
69
|
-
const
|
|
70
|
-
|
|
69
|
+
const modelValue = defineModel<string | number | readonly string[] | null | undefined>({
|
|
70
|
+
required: true,
|
|
71
71
|
});
|
|
72
|
-
|
|
73
|
-
const modelValue = defineModel<string | number | readonly string[] | null | undefined>();
|
|
74
72
|
const isDirty = defineModel<boolean>("isDirty");
|
|
75
73
|
const isActive = defineModel<boolean>("isActive");
|
|
76
74
|
|
|
@@ -86,31 +84,41 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
|
|
|
86
84
|
<style lang="css">
|
|
87
85
|
@layer components {
|
|
88
86
|
.input-textarea-wrapper {
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
/* Public --input-textarea-* tokens, inline-fallback to the shared --theme-* tokens (see
|
|
88
|
+
theming-component-token-pattern.md) — mirrors InputTextCore's token shape. --_border is
|
|
89
|
+
genuinely reused (both the .normal border and the .underlined border-bottom read it); the
|
|
90
|
+
hover/focus border colours are each used only once, so they're inlined directly rather than
|
|
91
|
+
wrapped in a private var (see the Public token pattern rule in CLAUDE.md's Styling
|
|
92
|
+
Methodology). */
|
|
93
|
+
--_border: var(--input-textarea-border, var(--theme-border));
|
|
94
|
+
|
|
95
|
+
background-color: var(--input-textarea-surface, var(--theme-input-surface));
|
|
96
|
+
padding-inline: var(--input-padding-inline);
|
|
91
97
|
transition: all var(--theme-form-transition-duration) ease-in-out;
|
|
92
98
|
|
|
93
99
|
z-index: 2;
|
|
94
100
|
position: relative;
|
|
95
101
|
|
|
96
102
|
&.normal {
|
|
97
|
-
border: var(--form-element-border-width) solid var(--
|
|
103
|
+
border: var(--form-element-border-width) solid var(--_border);
|
|
98
104
|
border-radius: var(--form-input-border-radius);
|
|
99
105
|
outline: var(--form-element-outline-width) solid transparent;
|
|
100
106
|
|
|
101
107
|
&:has(textarea:focus-visible) {
|
|
102
|
-
outline: var(--form-element-outline-width-focus) solid
|
|
108
|
+
outline: var(--form-element-outline-width-focus) solid
|
|
109
|
+
var(--input-textarea-border-focus, var(--theme-border-focus));
|
|
103
110
|
outline-offset: var(--form-element-outline-offset-focus);
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
&:has(textarea:is(:hover)) {
|
|
107
|
-
outline: var(--form-element-outline-width-focus) solid
|
|
114
|
+
outline: var(--form-element-outline-width-focus) solid
|
|
115
|
+
var(--input-textarea-border-hover, var(--theme-border-focus));
|
|
108
116
|
outline-offset: var(--form-element-outline-offset-focus);
|
|
109
117
|
}
|
|
110
118
|
}
|
|
111
119
|
|
|
112
120
|
&.underlined {
|
|
113
|
-
border-bottom: var(--form-element-border-bottom-width-underlined) solid var(--
|
|
121
|
+
border-bottom: var(--form-element-border-bottom-width-underlined) solid var(--_border);
|
|
114
122
|
}
|
|
115
123
|
|
|
116
124
|
display: flex;
|
|
@@ -129,7 +137,7 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
|
|
|
129
137
|
flex-grow: 1;
|
|
130
138
|
field-sizing: content;
|
|
131
139
|
|
|
132
|
-
color: var(--theme-input-text-color-normal);
|
|
140
|
+
color: var(--input-textarea-text-color, var(--theme-input-text-color-normal));
|
|
133
141
|
font-family: var(--font-family);
|
|
134
142
|
font-size: var(--input-font-size);
|
|
135
143
|
line-height: var(--input-textarea-line-height);
|
|
@@ -139,11 +147,11 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
|
|
|
139
147
|
min-height: 6lh;
|
|
140
148
|
|
|
141
149
|
&::placeholder {
|
|
142
|
-
color: var(--theme-input-placeholder);
|
|
143
|
-
font-size: var(--input-placeholder-font-size);
|
|
144
|
-
font-style:
|
|
145
|
-
line-height:
|
|
146
|
-
font-weight:
|
|
150
|
+
color: var(--input-textarea-placeholder-color, var(--theme-input-placeholder));
|
|
151
|
+
font-size: var(--theme-input-placeholder-font-size);
|
|
152
|
+
font-style: italic;
|
|
153
|
+
line-height: 1;
|
|
154
|
+
font-weight: normal;
|
|
147
155
|
}
|
|
148
156
|
}
|
|
149
157
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
|
-
import { computed } from "vue";
|
|
2
|
+
import { computed, reactive } from "vue";
|
|
3
3
|
import StorybookComponent from "../variants/InputTextareaWithLabel.vue";
|
|
4
4
|
import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms.d";
|
|
5
5
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// https://nuxt.com/docs/getting-started/testing#unit-testing
|
|
2
|
+
import type { VueWrapper } from "@vue/test-utils";
|
|
3
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
4
|
+
import ComponentUnderTest from "../InputTextareaCore.vue";
|
|
5
|
+
|
|
6
|
+
const initialPropsData = {
|
|
7
|
+
id: "test-textarea",
|
|
8
|
+
name: "testName",
|
|
9
|
+
modelValue: "",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
|
|
13
|
+
const wrapperFactory = (propsData = {}, slots = {}) => {
|
|
14
|
+
const mockPropsData = { ...initialPropsData, ...propsData };
|
|
15
|
+
|
|
16
|
+
return mountSuspended(ComponentUnderTest, {
|
|
17
|
+
attachTo: document.body,
|
|
18
|
+
props: mockPropsData,
|
|
19
|
+
slots,
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("InputTextareaCore", () => {
|
|
24
|
+
it("mounts without error", async () => {
|
|
25
|
+
wrapper = await wrapperFactory();
|
|
26
|
+
expect(wrapper.vm).toBeTruthy();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("renders a native textarea", async () => {
|
|
30
|
+
wrapper = await wrapperFactory();
|
|
31
|
+
expect(wrapper.find("textarea").exists()).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("binds v-model to the textarea value", async () => {
|
|
35
|
+
wrapper = await wrapperFactory({ modelValue: "hello world" });
|
|
36
|
+
expect(wrapper.find("textarea").element.value).toBe("hello world");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("forwards placeholder", async () => {
|
|
40
|
+
wrapper = await wrapperFactory({ placeholder: "Enter your message" });
|
|
41
|
+
expect(wrapper.find("textarea").attributes("placeholder")).toBe("Enter your message");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("forwards maxlength, defaulting to 255", async () => {
|
|
45
|
+
wrapper = await wrapperFactory();
|
|
46
|
+
expect(wrapper.find("textarea").attributes("maxlength")).toBe("255");
|
|
47
|
+
|
|
48
|
+
wrapper = await wrapperFactory({ maxlength: 100 });
|
|
49
|
+
expect(wrapper.find("textarea").attributes("maxlength")).toBe("100");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("forwards aria-describedby", async () => {
|
|
53
|
+
wrapper = await wrapperFactory({ ariaDescribedby: "some-description-id" });
|
|
54
|
+
expect(wrapper.find("textarea").attributes("aria-describedby")).toBe("some-description-id");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("sets data-theme from the theme prop", async () => {
|
|
58
|
+
wrapper = await wrapperFactory({ theme: "error" });
|
|
59
|
+
expect(wrapper.find(".input-textarea-wrapper").attributes("data-theme")).toBe("error");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("sets data-invalid and aria-invalid when fieldHasError is true", async () => {
|
|
63
|
+
wrapper = await wrapperFactory({ fieldHasError: true });
|
|
64
|
+
expect(wrapper.find(".input-textarea-wrapper").attributes("data-invalid")).toBe("");
|
|
65
|
+
expect(wrapper.find("textarea").attributes("aria-invalid")).toBe("true");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("defaults inputVariant to normal", async () => {
|
|
69
|
+
wrapper = await wrapperFactory();
|
|
70
|
+
expect(wrapper.find(".input-textarea-wrapper").classes()).toContain("normal");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("applies inputVariant as a class on the wrapper", async () => {
|
|
74
|
+
wrapper = await wrapperFactory({ inputVariant: "underlined" });
|
|
75
|
+
expect(wrapper.find(".input-textarea-wrapper").classes()).toContain("underlined");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("does not render the left/right slot wrappers when the slots are not used", async () => {
|
|
79
|
+
wrapper = await wrapperFactory();
|
|
80
|
+
expect(wrapper.find(".slot.left-slot").exists()).toBe(false);
|
|
81
|
+
expect(wrapper.find(".slot.right-slot").exists()).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("renders the left/right slots when provided", async () => {
|
|
85
|
+
wrapper = await wrapperFactory(
|
|
86
|
+
{},
|
|
87
|
+
{
|
|
88
|
+
left: '<span data-testid="left-slot">📝</span>',
|
|
89
|
+
right: '<span data-testid="right-slot">✨</span>',
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(wrapper.find(".slot.left-slot [data-testid='left-slot']").exists()).toBe(true);
|
|
94
|
+
expect(wrapper.find(".slot.right-slot [data-testid='right-slot']").exists()).toBe(true);
|
|
95
|
+
expect(wrapper.find(".input-textarea-wrapper").classes()).toContain("has-left-slot");
|
|
96
|
+
expect(wrapper.find(".input-textarea-wrapper").classes()).toContain("has-right-slot");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("applies styleClassPassthrough to the textarea", async () => {
|
|
100
|
+
wrapper = await wrapperFactory({ styleClassPassthrough: ["custom-textarea-class"] });
|
|
101
|
+
expect(wrapper.find("textarea").classes()).toContain("custom-textarea-class");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("sets required attribute when required is true", async () => {
|
|
105
|
+
wrapper = await wrapperFactory({ required: true });
|
|
106
|
+
expect(wrapper.find("textarea").attributes("required")).toBeDefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("sets isActive true on focus and false on blur", async () => {
|
|
110
|
+
wrapper = await wrapperFactory();
|
|
111
|
+
const textarea = wrapper.find("textarea");
|
|
112
|
+
|
|
113
|
+
await textarea.trigger("focusin");
|
|
114
|
+
expect(wrapper.emitted("update:isActive")?.at(-1)).toEqual([true]);
|
|
115
|
+
|
|
116
|
+
await textarea.trigger("focusout");
|
|
117
|
+
expect(wrapper.emitted("update:isActive")?.at(-1)).toEqual([false]);
|
|
118
|
+
});
|
|
119
|
+
});
|