srcdev-nuxt-components 9.4.9 → 9.4.11

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 (178) hide show
  1. package/.claude/commands/migrate-component.md +38 -6
  2. package/.claude/component-ledger/audit.json +1 -1
  3. package/.claude/component-ledger/build.mjs +26 -0
  4. package/.claude/component-ledger/output.html +8 -3
  5. package/.claude/component-ledger/template.html +7 -2
  6. package/.claude/skills/component-dynamic-slots.md +1 -1
  7. package/.claude/skills/components/alert-mask-core.md +84 -0
  8. package/.claude/skills/components/canvas-switcher.md +1 -1
  9. package/.claude/skills/components/carousel-flip.md +112 -168
  10. package/.claude/skills/components/container-glow.md +123 -0
  11. package/.claude/skills/components/dashboard-quad-grid.md +98 -0
  12. package/.claude/skills/components/dashboard-stats-grid.md +103 -0
  13. package/.claude/skills/components/display-theme-switch.md +60 -0
  14. package/.claude/skills/components/glowing-border.md +66 -0
  15. package/.claude/skills/components/input-number-core.md +214 -0
  16. package/.claude/skills/components/input-range-core.md +142 -0
  17. package/.claude/skills/components/input-select-core.md +151 -0
  18. package/.claude/skills/components/input-text-core.md +16 -0
  19. package/.claude/skills/components/input-textarea-core.md +154 -0
  20. package/.claude/skills/components/section-parallax.md +32 -10
  21. package/.claude/skills/components/select-menu.md +195 -0
  22. package/.claude/skills/components/toggle-switch-core.md +150 -0
  23. package/.claude/skills/components/ui-block-decorated.md +56 -0
  24. package/.claude/skills/components/wipe-away-vertical.md +98 -0
  25. package/.claude/skills/icon-sets.md +1 -1
  26. package/.claude/skills/index.md +15 -1
  27. package/.vscode/css-custom-data.json +38 -0
  28. package/.vscode/settings.json +3 -0
  29. package/.vscode/srcdev-component-alert-mask-core.code-snippets +31 -0
  30. package/.vscode/srcdev-component-carousel-flip.code-snippets +41 -0
  31. package/.vscode/srcdev-component-container-glow.code-snippets +33 -0
  32. package/.vscode/srcdev-component-dashboard-quad-grid.code-snippets +36 -0
  33. package/.vscode/srcdev-component-dashboard-stats-grid.code-snippets +35 -0
  34. package/.vscode/srcdev-component-display-theme-switch.code-snippets +27 -0
  35. package/.vscode/srcdev-component-glowing-border.code-snippets +23 -0
  36. package/.vscode/srcdev-component-input-number.code-snippets +65 -0
  37. package/.vscode/srcdev-component-input-range.code-snippets +85 -0
  38. package/.vscode/srcdev-component-input-select.code-snippets +42 -0
  39. package/.vscode/srcdev-component-input-textarea.code-snippets +40 -0
  40. package/.vscode/srcdev-component-section-parallax.code-snippets +27 -0
  41. package/.vscode/srcdev-component-select-menu.code-snippets +65 -0
  42. package/.vscode/srcdev-component-toggle-switch.code-snippets +49 -0
  43. package/.vscode/srcdev-component-ui-block-decorated.code-snippets +24 -0
  44. package/.vscode/srcdev-component-wipe-away-vertical.code-snippets +25 -0
  45. package/README.md +18 -37
  46. package/app/components/{alert-mask → 01.atoms/alert-mask}/AlertMaskCore.vue +16 -17
  47. package/app/components/01.atoms/alert-mask/CONSUMER-STYLING.md +46 -0
  48. package/app/components/01.atoms/alert-mask/stories/AlertMaskCore.stories.ts +79 -0
  49. package/app/components/{alert-mask → 01.atoms/alert-mask}/tests/AlertMaskCore.spec.ts +10 -10
  50. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +86 -0
  51. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +299 -0
  52. package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +175 -0
  53. package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +131 -0
  54. package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +14 -0
  55. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +61 -0
  56. package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +132 -0
  57. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +165 -0
  58. package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +94 -0
  59. package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +3 -0
  60. package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +1 -1
  61. package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +1 -1
  62. package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +1 -1
  63. package/app/components/01.atoms/animations/section-parallax/CONSUMER-STYLING.md +44 -0
  64. package/app/components/{parallax → 01.atoms/animations/section-parallax}/SectionParallax.vue +11 -10
  65. package/app/components/01.atoms/animations/section-parallax/stories/SectionParallax.stories.ts +98 -0
  66. package/app/components/01.atoms/animations/section-parallax/tests/SectionParallax.spec.ts +102 -0
  67. package/app/components/01.atoms/animations/section-parallax/tests/__snapshots__/SectionParallax.spec.ts.snap +9 -0
  68. package/app/components/01.atoms/animations/view-timeline/CONSUMER-STYLING.md +70 -0
  69. package/app/components/01.atoms/animations/view-timeline/WipeAwayVertical.vue +181 -0
  70. package/app/components/01.atoms/animations/view-timeline/stories/WipeAwayVertical.stories.ts +269 -0
  71. package/app/components/01.atoms/animations/view-timeline/tests/WipeAwayVertical.spec.ts +127 -0
  72. package/app/components/01.atoms/canvas-switcher/CONSUMER-STYLING.md +3 -3
  73. package/app/components/01.atoms/canvas-switcher/CanvasSwitcher.vue +2 -2
  74. package/app/components/01.atoms/canvas-switcher/stories/CanvasSwitcher.stories.ts +1 -1
  75. package/app/components/01.atoms/card/CardCore.vue +4 -4
  76. package/app/components/01.atoms/clipped-panel/CONSUMER-STYLING.md +2 -2
  77. package/app/components/01.atoms/clipped-panel/ClippedPanel.vue +2 -2
  78. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/CONSUMER-STYLING.md +42 -0
  79. package/app/components/{layout-grids/LayoutGridA.vue → 01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/DashboardQuadGrid.vue} +23 -13
  80. package/app/components/{layout-grids/stories/LayoutGridA.stories.ts → 01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/stories/DashboardQuadGrid.stories.ts} +151 -23
  81. package/app/components/{layout-grids/tests/LayoutGridA.spec.ts → 01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/tests/DashboardQuadGrid.spec.ts} +25 -25
  82. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/CONSUMER-STYLING.md +41 -0
  83. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/DashboardStatsGrid.vue +133 -0
  84. package/app/components/{layout-grids/stories/LayoutGridB.stories.ts → 01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/stories/DashboardStatsGrid.stories.ts} +24 -24
  85. package/app/components/{layout-grids/tests/LayoutGridB.spec.ts → 01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/tests/DashboardStatsGrid.spec.ts} +23 -23
  86. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +8 -8
  87. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +8 -8
  88. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +6 -6
  89. package/app/components/01.atoms/pop-over/PopOver.vue +6 -6
  90. package/app/components/01.atoms/ui-block-decorated/CONSUMER-STYLING.md +39 -0
  91. package/app/components/01.atoms/ui-block-decorated/UiBlockDecorated.vue +141 -0
  92. package/app/components/01.atoms/ui-block-decorated/stories/UiBlockDecorated.stories.ts +148 -0
  93. package/app/components/01.atoms/ui-block-decorated/tests/UiBlockDecorated.spec.ts +80 -0
  94. package/app/components/01.atoms/ui-block-decorated/tests/__snapshots__/UiBlockDecorated.spec.ts.snap +3 -0
  95. package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +6 -6
  96. package/app/components/02.molecules/display-theme-switch/CONSUMER-STYLING.md +34 -0
  97. package/app/components/02.molecules/display-theme-switch/DisplayThemeSwitch.vue +102 -0
  98. package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +115 -0
  99. package/app/components/02.molecules/display-theme-switch/tests/DisplayThemeSwitch.spec.ts +86 -0
  100. package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +1 -1
  101. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +1 -1
  102. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/CONSUMER-STYLING.md +1 -1
  103. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/DeepExpandingMenuClassic.vue +1 -1
  104. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +168 -0
  105. package/app/components/02.molecules/select-menu/SelectMenu.vue +384 -0
  106. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +359 -0
  107. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +476 -0
  108. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +42 -0
  109. package/app/components/03.organisms/image-galleries/carousel-flip/CONSUMER-STYLING.md +46 -0
  110. package/app/components/{carousels → 03.organisms/image-galleries/carousel-flip}/CarouselFlip.vue +63 -42
  111. package/app/components/{carousels → 03.organisms/image-galleries/carousel-flip}/stories/CarouselFlip.stories.ts +114 -29
  112. package/app/components/{carousels → 03.organisms/image-galleries/carousel-flip}/tests/CarouselFlip.spec.ts +80 -0
  113. package/app/components/05.forms/form-field/FormField.vue +1 -1
  114. package/app/components/05.forms/form-wrapper/FormWrapper.vue +1 -1
  115. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +2 -7
  116. package/app/components/05.forms/input-button/InputButtonCore.vue +1 -5
  117. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +26 -6
  118. package/app/components/05.forms/input-number/InputNumberCore.vue +125 -85
  119. package/app/components/05.forms/input-number/stories/InputNumberCore.stories.ts +92 -0
  120. package/app/components/05.forms/input-number/tests/InputNumberCore.spec.ts +119 -0
  121. package/app/components/05.forms/input-number/variants/InputNumberDefault.vue +30 -22
  122. package/app/components/05.forms/input-number/variants/stories/InputNumberDefault.stories.ts +123 -0
  123. package/app/components/05.forms/input-number/variants/tests/InputNumberDefault.spec.ts +143 -0
  124. package/app/components/05.forms/input-range/CONSUMER-STYLING.md +45 -0
  125. package/app/components/05.forms/input-range/InputRangeCore.vue +79 -143
  126. package/app/components/05.forms/input-range/stories/InputRangeCore.stories.ts +126 -0
  127. package/app/components/05.forms/input-range/tests/InputRangeCore.spec.ts +124 -0
  128. package/app/components/05.forms/input-range/variants/InputRangeDefault.vue +9 -32
  129. package/app/components/05.forms/input-range/variants/stories/InputRangeDefault.stories.ts +108 -0
  130. package/app/components/05.forms/input-range/variants/tests/InputRangeDefault.spec.ts +140 -0
  131. package/app/components/05.forms/input-select/InputSelectCore.vue +23 -30
  132. package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +2 -2
  133. package/app/components/05.forms/input-select/stories/InputSelectWithLabel.stories.ts +115 -0
  134. package/app/components/05.forms/input-select/tests/InputSelectCore.spec.ts +129 -0
  135. package/app/components/05.forms/input-select/variants/InputSelectWithLabel.vue +1 -1
  136. package/app/components/05.forms/input-select/variants/tests/InputSelectWithLabel.spec.ts +87 -0
  137. package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +14 -0
  138. package/app/components/05.forms/input-text/variants/InputTextAsNumberWithLabel.vue +1 -1
  139. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +55 -0
  140. package/app/components/05.forms/input-textarea/InputTextareaCore.vue +24 -16
  141. package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +1 -1
  142. package/app/components/05.forms/input-textarea/tests/InputTextareaCore.spec.ts +119 -0
  143. package/app/components/05.forms/input-textarea/variants/InputTextareaWithLabel.vue +3 -13
  144. package/app/components/05.forms/input-textarea/variants/tests/InputTextareaWithLabel.spec.ts +93 -0
  145. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +220 -0
  146. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +29 -0
  147. package/app/components/05.forms/toggle-switch/ToggleSwitchCore.vue +120 -119
  148. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchCore.stories.ts +1 -1
  149. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +1 -1
  150. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabelInline.stories.ts +2 -2
  151. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchCore.spec.ts +101 -0
  152. package/app/components/05.forms/toggle-switch/variants/ToggleSwitchWithLabel.vue +3 -5
  153. package/app/components/05.forms/toggle-switch/variants/ToggleSwitchWithLabelInline.vue +1 -2
  154. package/app/components/05.forms/toggle-switch/variants/tests/ToggleSwitchWithLabel.spec.ts +77 -0
  155. package/app/components/05.forms/toggle-switch/variants/tests/ToggleSwitchWithLabelInline.spec.ts +66 -0
  156. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +20 -6
  157. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitchCore.vue +12 -10
  158. package/app/types/components/container-glow.d.ts +8 -0
  159. package/app/types/components/index.ts +2 -1
  160. package/app/types/components/select-menu.d.ts +5 -0
  161. package/package.json +3 -2
  162. package/.vscode/srcdev-component-toggle-switch-core.code-snippets +0 -13
  163. package/app/components/05.forms/input-range-fancy/InputRangeFancyCore.vue +0 -426
  164. package/app/components/05.forms/input-range-fancy/InputRangeFancyWithLabel.vue +0 -94
  165. package/app/components/carousels/CarouselBasic.vue +0 -317
  166. package/app/components/carousels/CarouselInfinite.vue +0 -358
  167. package/app/components/container-glow/ContainerGlowCore.vue +0 -296
  168. package/app/components/container-glow/stories/ContainerGlowCore.stories.ts +0 -337
  169. package/app/components/display-theme-switch/DisplayThemeSwitch.vue +0 -189
  170. package/app/components/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +0 -154
  171. package/app/components/glowing-border/GlowingBorder.vue +0 -142
  172. package/app/components/glowing-border/stories/GlowingBorder.stories.ts +0 -141
  173. package/app/components/layout-grids/LayoutGridB.vue +0 -134
  174. package/app/components/test-storybook/TestStorybook.vue +0 -49
  175. package/app/components/test-storybook/stories/TestStorybook.stories.ts +0 -28
  176. package/app/components/ui-block-decorated/UiBlockDecorated.vue +0 -167
  177. package/app/components/view-timeline/WipeAwayVertical.vue +0 -180
  178. package/app/types/components/carousel-basic.d.ts +0 -19
@@ -0,0 +1,108 @@
1
+ import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
+ import StorybookComponent from "../InputRangeDefault.vue";
3
+ import type { FormUiTheme } from "~/types/forms/types.forms.d";
4
+
5
+ interface InputRangeDefaultStoryArgs {
6
+ modelValue: number;
7
+ name: string;
8
+ label: string;
9
+ min: number;
10
+ max: number;
11
+ step: number;
12
+ errorMessage: string;
13
+ fieldHasError: boolean;
14
+ required: boolean;
15
+ theme: FormUiTheme;
16
+ stepDownLabel: string;
17
+ stepUpLabel: string;
18
+ useStepButtons: boolean;
19
+ }
20
+
21
+ export default {
22
+ title: "Components/Forms/Input Range/InputRangeDefault",
23
+ component: StorybookComponent,
24
+ argTypes: {
25
+ modelValue: { control: "number", table: { category: "Model" } },
26
+ name: { control: "text", table: { category: "Basic" } },
27
+ label: { control: "text", table: { category: "Basic" } },
28
+ min: { control: "number", table: { category: "Basic" } },
29
+ max: { control: "number", table: { category: "Basic" } },
30
+ step: { control: "number", table: { category: "Basic" } },
31
+ errorMessage: { control: "text", table: { category: "Validation" } },
32
+ fieldHasError: { control: "boolean", table: { category: "Validation" } },
33
+ required: { control: "boolean", table: { category: "Validation" } },
34
+ theme: {
35
+ control: { type: "select" },
36
+ options: ["default", "success", "error", "warning"],
37
+ table: { category: "Styling" },
38
+ },
39
+ stepDownLabel: { control: "text", description: "Localisation override for the step-down button", table: { category: "Accessibility" } },
40
+ stepUpLabel: { control: "text", description: "Localisation override for the step-up button", table: { category: "Accessibility" } },
41
+ useStepButtons: { control: "boolean", description: "Render +/- step buttons via left/right slots", table: { category: "Slots" } },
42
+ },
43
+ args: {
44
+ modelValue: 50,
45
+ name: "priceRange",
46
+ label: "Price range",
47
+ min: 0,
48
+ max: 100,
49
+ step: 1,
50
+ errorMessage: "",
51
+ fieldHasError: false,
52
+ required: false,
53
+ theme: "default",
54
+ stepDownLabel: "Step down",
55
+ stepUpLabel: "Step up",
56
+ useStepButtons: false,
57
+ },
58
+ } as Meta<InputRangeDefaultStoryArgs>;
59
+
60
+ const Template: StoryFn<InputRangeDefaultStoryArgs> = (args) => ({
61
+ components: { StorybookComponent },
62
+ setup() {
63
+ return { args };
64
+ },
65
+ template: `
66
+ <div style="margin: 36px; max-width: 320px;">
67
+ <StorybookComponent
68
+ v-model="args.modelValue"
69
+ :name="args.name"
70
+ :label="args.label"
71
+ :min="args.min"
72
+ :max="args.max"
73
+ :step="args.step"
74
+ :error-message="args.errorMessage"
75
+ :field-has-error="args.fieldHasError"
76
+ :required="args.required"
77
+ :theme="args.theme"
78
+ :step-down-label="args.stepDownLabel"
79
+ :step-up-label="args.stepUpLabel"
80
+ >
81
+ <template v-if="args.useStepButtons" #left><span>−</span></template>
82
+ <template v-if="args.useStepButtons" #right><span>+</span></template>
83
+ </StorybookComponent>
84
+ </div>
85
+ `,
86
+ });
87
+
88
+ export const Default = Template.bind({});
89
+
90
+ export const WithStepButtons = Template.bind({});
91
+ WithStepButtons.args = { useStepButtons: true };
92
+
93
+ export const WithError = Template.bind({});
94
+ WithError.args = {
95
+ fieldHasError: true,
96
+ errorMessage: "Please choose a value within the allowed range",
97
+ theme: "error",
98
+ };
99
+
100
+ export const Required = Template.bind({});
101
+ Required.args = { required: true };
102
+
103
+ export const LocalisedLabels = Template.bind({});
104
+ LocalisedLabels.args = {
105
+ useStepButtons: true,
106
+ stepDownLabel: "Diminuer",
107
+ stepUpLabel: "Augmenter",
108
+ };
@@ -0,0 +1,140 @@
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 "../InputRangeDefault.vue";
5
+
6
+ const initialPropsData = {
7
+ name: "testName",
8
+ label: "Test label",
9
+ min: 0,
10
+ max: 100,
11
+ errorMessage: "",
12
+ modelValue: 50,
13
+ };
14
+
15
+ let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
16
+ const wrapperFactory = (propsData = {}, slots = {}) => {
17
+ const mockPropsData = { ...initialPropsData, ...propsData };
18
+
19
+ return mountSuspended(ComponentUnderTest, {
20
+ attachTo: document.body,
21
+ props: mockPropsData,
22
+ slots,
23
+ });
24
+ };
25
+
26
+ describe("InputRangeDefault", () => {
27
+ it("mounts without error", async () => {
28
+ wrapper = await wrapperFactory();
29
+ expect(wrapper.vm).toBeTruthy();
30
+ });
31
+
32
+ it("renders the label text", async () => {
33
+ wrapper = await wrapperFactory({ label: "Price range" });
34
+ expect(wrapper.text()).toContain("Price range");
35
+ });
36
+
37
+ it("renders a range input", async () => {
38
+ wrapper = await wrapperFactory();
39
+ expect(wrapper.find('input[type="range"]').exists()).toBe(true);
40
+ });
41
+
42
+ it("sets data-invalid and the error class when fieldHasError is true", async () => {
43
+ wrapper = await wrapperFactory({ fieldHasError: true });
44
+ expect(wrapper.find(".input-range-with-label").attributes("data-invalid")).toBe("");
45
+ expect(wrapper.find(".input-range-with-label").classes()).toContain("error");
46
+ });
47
+
48
+ it("does not render step buttons when the left/right slots are not used", async () => {
49
+ wrapper = await wrapperFactory();
50
+ expect(wrapper.findAllComponents({ name: "InputButtonCore" })).toHaveLength(0);
51
+ });
52
+
53
+ it("renders step buttons with default labels when the left/right slots are used", async () => {
54
+ wrapper = await wrapperFactory(
55
+ {},
56
+ {
57
+ left: '<span data-testid="left-icon">-</span>',
58
+ right: '<span data-testid="right-icon">+</span>',
59
+ }
60
+ );
61
+
62
+ expect(wrapper.text()).toContain("Step down");
63
+ expect(wrapper.text()).toContain("Step up");
64
+ });
65
+
66
+ it("overrides step button labels via stepDownLabel/stepUpLabel props", async () => {
67
+ wrapper = await wrapperFactory(
68
+ { stepDownLabel: "Decrease", stepUpLabel: "Increase" },
69
+ {
70
+ left: '<span>-</span>',
71
+ right: '<span>+</span>',
72
+ }
73
+ );
74
+
75
+ expect(wrapper.text()).toContain("Decrease");
76
+ expect(wrapper.text()).toContain("Increase");
77
+ });
78
+
79
+ it("increments the model value when the step-up button is clicked", async () => {
80
+ wrapper = await wrapperFactory(
81
+ { modelValue: 50, step: 5 },
82
+ { right: '<span>+</span>' }
83
+ );
84
+
85
+ const buttons = wrapper.findAll("button");
86
+ const stepUpButton = buttons.find((btn) => btn.text().includes("+") || btn.attributes("aria-label")?.includes("Step up"));
87
+ await stepUpButton?.trigger("click");
88
+
89
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([55]);
90
+ });
91
+
92
+ it("decrements the model value when the step-down button is clicked", async () => {
93
+ wrapper = await wrapperFactory(
94
+ { modelValue: 50, step: 5 },
95
+ { left: '<span>-</span>' }
96
+ );
97
+
98
+ const buttons = wrapper.findAll("button");
99
+ const stepDownButton = buttons.find((btn) => btn.text().includes("-") || btn.attributes("aria-label")?.includes("Step down"));
100
+ await stepDownButton?.trigger("click");
101
+
102
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([45]);
103
+ });
104
+
105
+ it("does not step below min", async () => {
106
+ wrapper = await wrapperFactory(
107
+ { modelValue: 0, min: 0, step: 5 },
108
+ { left: '<span>-</span>' }
109
+ );
110
+
111
+ const buttons = wrapper.findAll("button");
112
+ const stepDownButton = buttons.find((btn) => btn.text().includes("-"));
113
+ await stepDownButton?.trigger("click");
114
+
115
+ expect(wrapper.emitted("update:modelValue")).toBeUndefined();
116
+ });
117
+
118
+ it("does not step above max", async () => {
119
+ wrapper = await wrapperFactory(
120
+ { modelValue: 100, max: 100, step: 5 },
121
+ { right: '<span>+</span>' }
122
+ );
123
+
124
+ const buttons = wrapper.findAll("button");
125
+ const stepUpButton = buttons.find((btn) => btn.text().includes("+"));
126
+ await stepUpButton?.trigger("click");
127
+
128
+ expect(wrapper.emitted("update:modelValue")).toBeUndefined();
129
+ });
130
+
131
+ it("renders the error message when fieldHasError is true", async () => {
132
+ wrapper = await wrapperFactory({ fieldHasError: true, errorMessage: "This field is required" });
133
+ expect(wrapper.text()).toContain("This field is required");
134
+ });
135
+
136
+ it("applies styleClassPassthrough", async () => {
137
+ wrapper = await wrapperFactory({ styleClassPassthrough: "custom-range" });
138
+ expect(wrapper.find(".input-range-with-label").classes()).toContain("custom-range");
139
+ });
140
+ });
@@ -9,12 +9,16 @@
9
9
  :id
10
10
  v-model="modelValue"
11
11
  :aria-invalid="fieldHasError ? 'true' : undefined"
12
- class="input-select-core"
12
+ :class="['input-select-core', elementClasses]"
13
13
  :name
14
14
  :title
15
+ :required
15
16
  :aria-describedby="ariaDescribedby"
17
+ @focusin="isActive = true"
18
+ @focusout="isActive = false"
19
+ @change="isDirty = true"
16
20
  >
17
- <option v-if="placeholder" value="" readonly :selected="!modelValue" class="input-select-core-option placeholder">
21
+ <option v-if="placeholder" value="" disabled :selected="!modelValue" class="input-select-core-option placeholder">
18
22
  {{ placeholder }}
19
23
  </option>
20
24
  <option
@@ -38,7 +42,7 @@
38
42
  </template>
39
43
 
40
44
  <script setup lang="ts">
41
- import type { IFormMultipleOptions } from "~/types/forms/types.forms";
45
+ import type { IFormMultipleOptions, FormUiTheme, InputUiVariant } from "~/types/forms/types.forms";
42
46
 
43
47
  interface Props {
44
48
  id: string;
@@ -48,12 +52,12 @@ interface Props {
48
52
  required?: boolean;
49
53
  fieldHasError?: boolean;
50
54
  styleClassPassthrough?: string | string[];
51
- theme?: "default" | "success" | "error" | "warning";
52
- inputVariant?: "normal" | "outlined" | "underlined";
55
+ theme?: FormUiTheme;
56
+ inputVariant?: InputUiVariant;
53
57
  ariaDescribedby?: string;
54
58
  }
55
59
 
56
- withDefaults(defineProps<Props>(), {
60
+ const props = withDefaults(defineProps<Props>(), {
57
61
  placeholder: "",
58
62
  title: "Please select an option",
59
63
  required: false,
@@ -67,7 +71,9 @@ withDefaults(defineProps<Props>(), {
67
71
  const modelValue = defineModel<string | number | readonly number[]>({ required: true });
68
72
  const isDirty = defineModel<boolean>("isDirty");
69
73
  const isActive = defineModel<boolean>("isActive");
70
- const fieldData = defineModel("fieldData") as Ref<IFormMultipleOptions>;
74
+ const fieldData = defineModel<IFormMultipleOptions>("fieldData", { required: true });
75
+
76
+ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
71
77
  </script>
72
78
 
73
79
  <style lang="css">
@@ -75,22 +81,18 @@ const fieldData = defineModel("fieldData") as Ref<IFormMultipleOptions>;
75
81
  .input-select-wrapper {
76
82
  /* Public --input-select-* tokens, inline-fallback to the shared --theme-* tokens (see
77
83
  theming-component-token-pattern.md) — overriding one here doesn't touch every other themed
78
- input/control that also reads --theme-input-surface/--theme-border. --_surface etc. are
79
- private locals purely so the rest of this rule can reuse the resolved value without
80
- repeating the fallback chain at every property — they are not themselves an override point,
81
- see CONSUMER-STYLING.md. --_outline-color stays private/computed: it's always just an alias
82
- for --_border-focus with no independent meaning of its own. */
83
- --_surface: var(--input-select-surface, var(--theme-input-surface));
84
- --_surface-hover: var(--input-select-surface-hover, var(--theme-input-surface-hover));
84
+ input/control that also reads --theme-input-surface/--theme-border. --_border is the one
85
+ genuinely reused value (border, underlined border-bottom, open-picker border); the rest of
86
+ this component's tokens are used at exactly one point each, so they're inlined directly
87
+ rather than wrapped in a private var with no composition or state-swap behind it (see
88
+ pitfall #20 in CLAUDE.md). */
85
89
  --_border: var(--input-select-border, var(--theme-border));
86
90
  /* Split so hover (mouse) and :focus-visible (keyboard/assistive) can diverge — both default to
87
91
  the same --theme-border-focus today, identical appearance to before this token existed, but
88
92
  each now has its own override point. */
89
- --_border-hover: var(--input-select-border-hover, var(--theme-border-focus));
90
93
  --_border-focus: var(--input-select-border-focus, var(--theme-border-focus));
91
- --_outline-color: var(--_border-focus);
92
94
 
93
- background-color: var(--_surface);
95
+ background-color: var(--input-select-surface, var(--theme-input-surface));
94
96
  overflow: hidden;
95
97
 
96
98
  z-index: 2;
@@ -105,11 +107,11 @@ const fieldData = defineModel("fieldData") as Ref<IFormMultipleOptions>;
105
107
 
106
108
  &.underlined {
107
109
  border-bottom: var(--form-element-border-bottom-width-underlined) solid var(--_border);
108
- background-color: var(--_surface);
109
110
  }
110
111
 
111
112
  &:has(:hover) {
112
- outline: var(--form-element-outline-width-focus) solid var(--_border-hover);
113
+ outline: var(--form-element-outline-width-focus) solid
114
+ var(--input-select-border-hover, var(--theme-border-focus));
113
115
  outline-offset: var(--form-element-outline-offset-focus);
114
116
  }
115
117
 
@@ -124,15 +126,6 @@ const fieldData = defineModel("fieldData") as Ref<IFormMultipleOptions>;
124
126
  display: flex;
125
127
  align-items: center;
126
128
 
127
- /* For legacy support - eg, Safari */
128
- /* &::after {
129
- content: '';
130
- width: 0.8em;
131
- height: 0.5em;
132
- background-color: var(--_border);
133
- clip-path: polygon(100% 0%, 0 0%, 50% 100%);
134
- } */
135
-
136
129
  /* Start modern Select CSS */
137
130
  &,
138
131
  &::picker(select) {
@@ -153,7 +146,7 @@ const fieldData = defineModel("fieldData") as Ref<IFormMultipleOptions>;
153
146
  &:open::picker(select) {
154
147
  opacity: 1;
155
148
  border: var(--form-element-border-width) solid var(--_border);
156
- outline: var(--form-element-outline-width) solid var(--_outline-color);
149
+ outline: var(--form-element-outline-width) solid var(--_border-focus);
157
150
 
158
151
  @starting-style {
159
152
  opacity: 0;
@@ -180,7 +173,7 @@ const fieldData = defineModel("fieldData") as Ref<IFormMultipleOptions>;
180
173
  transition: all var(--theme-form-transition-duration) ease-in-out;
181
174
 
182
175
  &:hover {
183
- background-color: var(--_surface-hover);
176
+ background-color: var(--input-select-surface-hover, var(--theme-input-surface-hover));
184
177
  }
185
178
 
186
179
  .input-select-core-option-decorator-icon {
@@ -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>