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.
Files changed (94) hide show
  1. package/.claude/commands/migrate-component.md +18 -0
  2. package/.claude/component-ledger/audit.json +1 -1
  3. package/.claude/component-ledger/output.html +1 -1
  4. package/.claude/skills/components/input-number-core.md +214 -0
  5. package/.claude/skills/components/input-select-core.md +151 -0
  6. package/.claude/skills/components/input-text-core.md +16 -0
  7. package/.claude/skills/components/input-textarea-core.md +154 -0
  8. package/.claude/skills/components/select-menu.md +195 -0
  9. package/.claude/skills/components/toggle-switch-core.md +150 -0
  10. package/.claude/skills/index.md +5 -0
  11. package/.vscode/srcdev-component-input-number.code-snippets +65 -0
  12. package/.vscode/srcdev-component-input-select.code-snippets +42 -0
  13. package/.vscode/srcdev-component-input-textarea.code-snippets +40 -0
  14. package/.vscode/srcdev-component-select-menu.code-snippets +65 -0
  15. package/.vscode/srcdev-component-toggle-switch.code-snippets +49 -0
  16. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +1 -1
  17. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +1 -1
  18. package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +1 -1
  19. package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +1 -1
  20. package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +1 -1
  21. package/app/components/01.atoms/animations/section-parallax/CONSUMER-STYLING.md +1 -1
  22. package/app/components/01.atoms/animations/section-parallax/SectionParallax.vue +1 -1
  23. package/app/components/01.atoms/canvas-switcher/CONSUMER-STYLING.md +2 -2
  24. package/app/components/01.atoms/canvas-switcher/CanvasSwitcher.vue +2 -2
  25. package/app/components/01.atoms/card/CardCore.vue +4 -4
  26. package/app/components/01.atoms/clipped-panel/CONSUMER-STYLING.md +2 -2
  27. package/app/components/01.atoms/clipped-panel/ClippedPanel.vue +2 -2
  28. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/CONSUMER-STYLING.md +2 -2
  29. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/DashboardQuadGrid.vue +2 -2
  30. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/stories/DashboardQuadGrid.stories.ts +1 -1
  31. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/CONSUMER-STYLING.md +1 -1
  32. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/DashboardStatsGrid.vue +1 -1
  33. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +8 -8
  34. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +8 -8
  35. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +6 -6
  36. package/app/components/01.atoms/pop-over/PopOver.vue +6 -6
  37. package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +1 -1
  38. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +1 -1
  39. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/CONSUMER-STYLING.md +1 -1
  40. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/DeepExpandingMenuClassic.vue +1 -1
  41. package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +11 -0
  42. package/app/components/02.molecules/pricing-card/PricingCard.vue +3 -1
  43. package/app/components/02.molecules/pricing-card/stories/PricingCard.stories.ts +2 -0
  44. package/app/components/02.molecules/pricing-card/tests/PricingCard.spec.ts +13 -0
  45. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +168 -0
  46. package/app/components/02.molecules/select-menu/SelectMenu.vue +384 -0
  47. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +359 -0
  48. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +476 -0
  49. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +42 -0
  50. package/app/components/03.organisms/image-galleries/carousel-flip/CONSUMER-STYLING.md +3 -3
  51. package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +3 -3
  52. package/app/components/03.organisms/image-galleries/carousel-flip/stories/CarouselFlip.stories.ts +18 -18
  53. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +2 -7
  54. package/app/components/05.forms/input-button/InputButtonCore.vue +1 -5
  55. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +26 -6
  56. package/app/components/05.forms/input-number/InputNumberCore.vue +125 -85
  57. package/app/components/05.forms/input-number/stories/InputNumberCore.stories.ts +92 -0
  58. package/app/components/05.forms/input-number/tests/InputNumberCore.spec.ts +119 -0
  59. package/app/components/05.forms/input-number/variants/InputNumberDefault.vue +30 -22
  60. package/app/components/05.forms/input-number/variants/stories/InputNumberDefault.stories.ts +123 -0
  61. package/app/components/05.forms/input-number/variants/tests/InputNumberDefault.spec.ts +143 -0
  62. package/app/components/05.forms/input-range/CONSUMER-STYLING.md +2 -2
  63. package/app/components/05.forms/input-range/InputRangeCore.vue +2 -2
  64. package/app/components/05.forms/input-select/InputSelectCore.vue +23 -30
  65. package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +2 -2
  66. package/app/components/05.forms/input-select/stories/InputSelectWithLabel.stories.ts +115 -0
  67. package/app/components/05.forms/input-select/tests/InputSelectCore.spec.ts +129 -0
  68. package/app/components/05.forms/input-select/variants/InputSelectWithLabel.vue +1 -1
  69. package/app/components/05.forms/input-select/variants/tests/InputSelectWithLabel.spec.ts +87 -0
  70. package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +14 -0
  71. package/app/components/05.forms/input-text/variants/InputTextAsNumberWithLabel.vue +1 -1
  72. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +55 -0
  73. package/app/components/05.forms/input-textarea/InputTextareaCore.vue +24 -16
  74. package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +1 -1
  75. package/app/components/05.forms/input-textarea/tests/InputTextareaCore.spec.ts +119 -0
  76. package/app/components/05.forms/input-textarea/variants/InputTextareaWithLabel.vue +3 -13
  77. package/app/components/05.forms/input-textarea/variants/tests/InputTextareaWithLabel.spec.ts +93 -0
  78. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +95 -10
  79. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +29 -0
  80. package/app/components/05.forms/toggle-switch/ToggleSwitchCore.vue +120 -119
  81. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchCore.stories.ts +1 -1
  82. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +1 -1
  83. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabelInline.stories.ts +2 -2
  84. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchCore.spec.ts +101 -0
  85. package/app/components/05.forms/toggle-switch/variants/ToggleSwitchWithLabel.vue +3 -5
  86. package/app/components/05.forms/toggle-switch/variants/ToggleSwitchWithLabelInline.vue +1 -2
  87. package/app/components/05.forms/toggle-switch/variants/tests/ToggleSwitchWithLabel.spec.ts +77 -0
  88. package/app/components/05.forms/toggle-switch/variants/tests/ToggleSwitchWithLabelInline.spec.ts +66 -0
  89. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +4 -4
  90. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitchCore.vue +6 -6
  91. package/app/types/components/index.ts +1 -0
  92. package/app/types/components/select-menu.d.ts +5 -0
  93. package/package.json +1 -1
  94. 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: "normal" | "outlined" | "underlined";
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
  });
@@ -45,7 +45,7 @@
45
45
  :error-message
46
46
  :field-has-error
47
47
  :required
48
- :style-class-passthrough
48
+ :style-class-passthrough="elementClasses"
49
49
  :theme
50
50
  inputmode="numeric"
51
51
  :aria-describedby
@@ -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 FormUiTheme = computed(() => {
70
- return props.fieldHasError ? "error" : props.theme;
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
- background-color: var(--theme-input-surface);
90
- padding-inline: var(--element-decorator-padding-inline);
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(--theme-border);
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 var(--theme-border-focus);
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 var(--theme-border-focus);
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(--theme-border);
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: var(--input-placeholder-font-style);
145
- line-height: var(--input-placeholder-line-height);
146
- font-weight: var(--input-placeholder-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
+ });