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
@@ -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 "../InputNumberCore.vue";
5
+
6
+ const initialPropsData = {
7
+ id: "test-number",
8
+ name: "testName",
9
+ min: 0,
10
+ max: 100,
11
+ modelValue: 50,
12
+ };
13
+
14
+ let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
15
+ const wrapperFactory = (propsData = {}, slots = {}) => {
16
+ const mockPropsData = { ...initialPropsData, ...propsData };
17
+
18
+ return mountSuspended(ComponentUnderTest, {
19
+ attachTo: document.body,
20
+ props: mockPropsData,
21
+ slots,
22
+ });
23
+ };
24
+
25
+ describe("InputNumberCore", () => {
26
+ it("mounts without error", async () => {
27
+ wrapper = await wrapperFactory();
28
+ expect(wrapper.vm).toBeTruthy();
29
+ });
30
+
31
+ it("renders a native number input with the given min/max/step", async () => {
32
+ wrapper = await wrapperFactory({ min: 10, max: 90, step: 5 });
33
+ const input = wrapper.find("input");
34
+
35
+ expect(input.attributes("type")).toBe("number");
36
+ expect(input.attributes("min")).toBe("10");
37
+ expect(input.attributes("max")).toBe("90");
38
+ expect(input.attributes("step")).toBe("5");
39
+ });
40
+
41
+ it("defaults step to 1", async () => {
42
+ wrapper = await wrapperFactory();
43
+ expect(wrapper.find("input").attributes("step")).toBe("1");
44
+ });
45
+
46
+ it("binds v-model to the input value", async () => {
47
+ wrapper = await wrapperFactory({ modelValue: 42 });
48
+ expect(wrapper.find("input").element.value).toBe("42");
49
+ });
50
+
51
+ it("forwards placeholder", async () => {
52
+ wrapper = await wrapperFactory({ placeholder: "Enter a quantity" });
53
+ expect(wrapper.find("input").attributes("placeholder")).toBe("Enter a quantity");
54
+ });
55
+
56
+ it("forwards aria-describedby", async () => {
57
+ wrapper = await wrapperFactory({ ariaDescribedby: "some-description-id" });
58
+ expect(wrapper.find("input").attributes("aria-describedby")).toBe("some-description-id");
59
+ });
60
+
61
+ it("sets data-theme from the theme prop", async () => {
62
+ wrapper = await wrapperFactory({ theme: "error" });
63
+ expect(wrapper.find(".input-number-wrapper").attributes("data-theme")).toBe("error");
64
+ });
65
+
66
+ it("sets data-invalid when fieldHasError is true", async () => {
67
+ wrapper = await wrapperFactory({ fieldHasError: true });
68
+ expect(wrapper.find(".input-number-wrapper").attributes("data-invalid")).toBe("");
69
+ });
70
+
71
+ it("does not render the left/right slot wrappers when the slots are not used", async () => {
72
+ wrapper = await wrapperFactory();
73
+ expect(wrapper.find(".slot.left-slot").exists()).toBe(false);
74
+ expect(wrapper.find(".slot.right-slot").exists()).toBe(false);
75
+ expect(wrapper.find(".input-number-wrapper").classes()).not.toContain("has-left-slot");
76
+ expect(wrapper.find(".input-number-wrapper").classes()).not.toContain("has-right-slot");
77
+ });
78
+
79
+ it("renders the left/right slots when provided", async () => {
80
+ wrapper = await wrapperFactory(
81
+ {},
82
+ {
83
+ left: '<button data-testid="left-slot">-</button>',
84
+ right: '<button data-testid="right-slot">+</button>',
85
+ }
86
+ );
87
+
88
+ expect(wrapper.find(".slot.left-slot [data-testid='left-slot']").exists()).toBe(true);
89
+ expect(wrapper.find(".slot.right-slot [data-testid='right-slot']").exists()).toBe(true);
90
+ expect(wrapper.find(".input-number-wrapper").classes()).toContain("has-left-slot");
91
+ expect(wrapper.find(".input-number-wrapper").classes()).toContain("has-right-slot");
92
+ });
93
+
94
+ it("applies the weight class from the weight prop", async () => {
95
+ wrapper = await wrapperFactory({ weight: "wght-700" });
96
+ expect(wrapper.find("input").classes()).toContain("input-number--wght-700");
97
+ });
98
+
99
+ it("applies styleClassPassthrough to the input", async () => {
100
+ wrapper = await wrapperFactory({ styleClassPassthrough: ["custom-number-class"] });
101
+ expect(wrapper.find("input").classes()).toContain("custom-number-class");
102
+ });
103
+
104
+ it("sets required attribute when required is true", async () => {
105
+ wrapper = await wrapperFactory({ required: true });
106
+ expect(wrapper.find("input").attributes("required")).toBeDefined();
107
+ });
108
+
109
+ it("defaults inputVariant to normal", async () => {
110
+ wrapper = await wrapperFactory();
111
+ expect(wrapper.find(".input-number-wrapper").classes()).toContain("normal");
112
+ });
113
+
114
+ it("applies inputVariant as a class on the wrapper", async () => {
115
+ wrapper = await wrapperFactory({ inputVariant: "underlined" });
116
+ expect(wrapper.find(".input-number-wrapper").classes()).toContain("underlined");
117
+ expect(wrapper.find(".input-number-wrapper").classes()).not.toContain("normal");
118
+ });
119
+ });
@@ -3,14 +3,14 @@
3
3
  class="input-number-with-label"
4
4
  :data-theme="theme"
5
5
  :data-invalid="fieldHasError ? '' : null"
6
- :class="[elementClasses, `theme-${theme}`, { error: fieldHasError }]"
6
+ :class="[elementClasses, inputVariant, `theme-${theme}`, { error: fieldHasError }]"
7
7
  >
8
8
  <InputLabel
9
9
  :id
10
10
  :for="id"
11
11
  :theme
12
12
  :name
13
- input-variant="normal"
13
+ :input-variant
14
14
  :field-has-error
15
15
  :style-class-passthrough="['input-number-label', 'body-normal-bold']"
16
16
  >
@@ -39,9 +39,11 @@
39
39
  :min
40
40
  :max
41
41
  :step
42
+ :placeholder
42
43
  :theme
43
44
  :required
44
45
  :weight
46
+ :input-variant
45
47
  :field-has-error
46
48
  :style-class-passthrough
47
49
  :aria-describedby
@@ -51,7 +53,7 @@
51
53
  type="button"
52
54
  :readonly="Number(modelValue) <= min"
53
55
  :is-pending="false"
54
- button-text="Step down"
56
+ :button-text="stepDownLabel"
55
57
  :theme="theme"
56
58
  variant="inline"
57
59
  @click.stop.prevent="updateValue(-step, Number(modelValue) > min)"
@@ -66,7 +68,7 @@
66
68
  type="button"
67
69
  :readonly="Number(modelValue) >= max"
68
70
  :is-pending="false"
69
- button-text="Step up"
71
+ :button-text="stepUpLabel"
70
72
  :theme="theme"
71
73
  variant="inline"
72
74
  @click.stop.prevent="updateValue(step, Number(modelValue) < max)"
@@ -82,7 +84,7 @@
82
84
  </template>
83
85
 
84
86
  <script setup lang="ts">
85
- import type { FormWeight } from "~/types/forms/types.forms";
87
+ import type { FormUiTheme, FormWeight, InputUiVariant } from "~/types/forms/types.forms";
86
88
 
87
89
  interface Props {
88
90
  name: string;
@@ -94,9 +96,12 @@ interface Props {
94
96
  errorMessage: object | string;
95
97
  fieldHasError?: boolean;
96
98
  required?: boolean;
97
- theme?: "default" | "success" | "error" | "warning";
99
+ theme?: FormUiTheme;
98
100
  weight?: FormWeight;
101
+ inputVariant?: InputUiVariant;
99
102
  styleClassPassthrough?: string | string[];
103
+ stepDownLabel?: string;
104
+ stepUpLabel?: string;
100
105
  }
101
106
 
102
107
  const props = withDefaults(defineProps<Props>(), {
@@ -106,45 +111,48 @@ const props = withDefaults(defineProps<Props>(), {
106
111
  required: false,
107
112
  theme: "default",
108
113
  weight: "normal",
114
+ inputVariant: "normal",
109
115
  styleClassPassthrough: () => [],
116
+ stepDownLabel: "Step down",
117
+ stepUpLabel: "Step up",
110
118
  });
111
119
 
112
120
  const slots = useSlots();
113
- const { elementClasses, updateElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
121
+ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
114
122
 
115
123
  const { id, errorId, descriptionId, ariaDescribedby } = useAriaDescribedById(
116
124
  props.name,
117
125
  toRef(props, "fieldHasError"),
118
126
  slots
119
127
  );
120
- const modelValue = defineModel<number | readonly number[]>();
128
+ const modelValue = defineModel<number | readonly number[]>({
129
+ required: true,
130
+ });
121
131
 
122
132
  const updateValue = (step: number, withinRangeLimit: boolean) => {
123
133
  if (withinRangeLimit) {
124
134
  modelValue.value = (Number(modelValue.value) + step) as number;
125
135
  }
126
136
  };
127
-
128
- updateElementClasses(["has-left-button", "has-right-button"]);
129
137
  </script>
130
138
 
131
139
  <style lang="css">
132
140
  @layer components {
133
- .input-number-with-label {
134
- .input-number-label {
135
- display: block;
136
- margin-block: 0.8rem;
141
+ .input-number-with-label {
142
+ .input-number-label {
143
+ display: block;
144
+ margin-block: 0.8rem;
137
145
 
138
- &:hover {
139
- cursor: pointer;
146
+ &:hover {
147
+ cursor: pointer;
148
+ }
140
149
  }
141
- }
142
150
 
143
- .label-description {
144
- font-family: var(--font-family);
145
- font-size: 1.6rem;
146
- margin-top: 1.2rem;
151
+ .label-description {
152
+ font-family: var(--font-family);
153
+ font-size: 1.6rem;
154
+ margin-top: 1.2rem;
155
+ }
147
156
  }
148
157
  }
149
- }
150
158
  </style>
@@ -0,0 +1,123 @@
1
+ import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
+ import StorybookComponent from "../InputNumberDefault.vue";
3
+ import type { FormUiTheme } from "~/types/forms/types.forms.d";
4
+
5
+ interface InputNumberDefaultStoryArgs {
6
+ modelValue: number;
7
+ name: string;
8
+ label: string;
9
+ min: number;
10
+ max: number;
11
+ step: number;
12
+ placeholder: string;
13
+ errorMessage: string;
14
+ fieldHasError: boolean;
15
+ required: boolean;
16
+ theme: FormUiTheme;
17
+ inputVariant: string;
18
+ stepDownLabel: string;
19
+ stepUpLabel: string;
20
+ useStepButtons: boolean;
21
+ }
22
+
23
+ export default {
24
+ title: "Components/Forms/Input Number/InputNumberDefault",
25
+ component: StorybookComponent,
26
+ argTypes: {
27
+ modelValue: { control: "number", table: { category: "Model" } },
28
+ name: { control: "text", table: { category: "Basic" } },
29
+ label: { control: "text", table: { category: "Basic" } },
30
+ min: { control: "number", table: { category: "Basic" } },
31
+ max: { control: "number", table: { category: "Basic" } },
32
+ step: { control: "number", table: { category: "Basic" } },
33
+ placeholder: { control: "text", table: { category: "Basic" } },
34
+ errorMessage: { control: "text", table: { category: "Validation" } },
35
+ fieldHasError: { control: "boolean", table: { category: "Validation" } },
36
+ required: { control: "boolean", table: { category: "Validation" } },
37
+ theme: {
38
+ control: { type: "select" },
39
+ options: ["default", "success", "error", "warning"],
40
+ table: { category: "Styling" },
41
+ },
42
+ inputVariant: {
43
+ control: { type: "select" },
44
+ options: ["normal", "outlined", "underlined"],
45
+ table: { category: "Styling" },
46
+ },
47
+ stepDownLabel: { control: "text", description: "Localisation override for the step-down button", table: { category: "Accessibility" } },
48
+ stepUpLabel: { control: "text", description: "Localisation override for the step-up button", table: { category: "Accessibility" } },
49
+ useStepButtons: { control: "boolean", description: "Render +/- step buttons via left/right slots", table: { category: "Slots" } },
50
+ },
51
+ args: {
52
+ modelValue: 5,
53
+ name: "quantity",
54
+ label: "Quantity",
55
+ min: 0,
56
+ max: 10,
57
+ step: 1,
58
+ placeholder: "",
59
+ errorMessage: "",
60
+ fieldHasError: false,
61
+ required: false,
62
+ theme: "default",
63
+ inputVariant: "normal",
64
+ stepDownLabel: "Step down",
65
+ stepUpLabel: "Step up",
66
+ useStepButtons: false,
67
+ },
68
+ } as Meta<InputNumberDefaultStoryArgs>;
69
+
70
+ const Template: StoryFn<InputNumberDefaultStoryArgs> = (args) => ({
71
+ components: { StorybookComponent },
72
+ setup() {
73
+ return { args };
74
+ },
75
+ template: `
76
+ <div style="margin: 36px; max-width: 320px;">
77
+ <StorybookComponent
78
+ v-model="args.modelValue"
79
+ :name="args.name"
80
+ :label="args.label"
81
+ :min="args.min"
82
+ :max="args.max"
83
+ :step="args.step"
84
+ :placeholder="args.placeholder"
85
+ :error-message="args.errorMessage"
86
+ :field-has-error="args.fieldHasError"
87
+ :required="args.required"
88
+ :theme="args.theme"
89
+ :input-variant="args.inputVariant"
90
+ :step-down-label="args.stepDownLabel"
91
+ :step-up-label="args.stepUpLabel"
92
+ >
93
+ <template v-if="args.useStepButtons" #left><span>−</span></template>
94
+ <template v-if="args.useStepButtons" #right><span>+</span></template>
95
+ </StorybookComponent>
96
+ </div>
97
+ `,
98
+ });
99
+
100
+ export const Default = Template.bind({});
101
+
102
+ export const WithStepButtons = Template.bind({});
103
+ WithStepButtons.args = { useStepButtons: true };
104
+
105
+ export const WithError = Template.bind({});
106
+ WithError.args = {
107
+ fieldHasError: true,
108
+ errorMessage: "Please enter a valid quantity",
109
+ theme: "error",
110
+ };
111
+
112
+ export const Required = Template.bind({});
113
+ Required.args = { required: true };
114
+
115
+ export const LocalisedLabels = Template.bind({});
116
+ LocalisedLabels.args = {
117
+ useStepButtons: true,
118
+ stepDownLabel: "Diminuer",
119
+ stepUpLabel: "Augmenter",
120
+ };
121
+
122
+ export const Underlined = Template.bind({});
123
+ Underlined.args = { inputVariant: "underlined", useStepButtons: true };
@@ -0,0 +1,143 @@
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 "../InputNumberDefault.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("InputNumberDefault", () => {
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: "Quantity" });
34
+ expect(wrapper.text()).toContain("Quantity");
35
+ });
36
+
37
+ it("renders a number input", async () => {
38
+ wrapper = await wrapperFactory();
39
+ expect(wrapper.find('input[type="number"]').exists()).toBe(true);
40
+ });
41
+
42
+ it("forwards placeholder to the input", async () => {
43
+ wrapper = await wrapperFactory({ placeholder: "Enter a quantity" });
44
+ expect(wrapper.find("input").attributes("placeholder")).toBe("Enter a quantity");
45
+ });
46
+
47
+ it("sets data-invalid and the error class when fieldHasError is true", async () => {
48
+ wrapper = await wrapperFactory({ fieldHasError: true });
49
+ expect(wrapper.find(".input-number-with-label").attributes("data-invalid")).toBe("");
50
+ expect(wrapper.find(".input-number-with-label").classes()).toContain("error");
51
+ });
52
+
53
+ it("does not render step buttons when the left/right slots are not used", async () => {
54
+ wrapper = await wrapperFactory();
55
+ expect(wrapper.findAllComponents({ name: "InputButtonCore" })).toHaveLength(0);
56
+ });
57
+
58
+ it("renders step buttons with default labels when the left/right slots are used", async () => {
59
+ wrapper = await wrapperFactory(
60
+ {},
61
+ {
62
+ left: '<span data-testid="left-icon">-</span>',
63
+ right: '<span data-testid="right-icon">+</span>',
64
+ }
65
+ );
66
+
67
+ expect(wrapper.text()).toContain("Step down");
68
+ expect(wrapper.text()).toContain("Step up");
69
+ });
70
+
71
+ it("overrides step button labels via stepDownLabel/stepUpLabel props", async () => {
72
+ wrapper = await wrapperFactory(
73
+ { stepDownLabel: "Decrease", stepUpLabel: "Increase" },
74
+ {
75
+ left: '<span>-</span>',
76
+ right: '<span>+</span>',
77
+ }
78
+ );
79
+
80
+ expect(wrapper.text()).toContain("Decrease");
81
+ expect(wrapper.text()).toContain("Increase");
82
+ });
83
+
84
+ it("increments the model value when the step-up button is clicked", async () => {
85
+ wrapper = await wrapperFactory({ modelValue: 50, step: 5 }, { right: '<span>+</span>' });
86
+
87
+ const buttons = wrapper.findAll("button");
88
+ const stepUpButton = buttons.find(
89
+ (btn) => btn.text().includes("+") || btn.attributes("aria-label")?.includes("Step up")
90
+ );
91
+ await stepUpButton?.trigger("click");
92
+
93
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([55]);
94
+ });
95
+
96
+ it("decrements the model value when the step-down button is clicked", async () => {
97
+ wrapper = await wrapperFactory({ modelValue: 50, step: 5 }, { left: '<span>-</span>' });
98
+
99
+ const buttons = wrapper.findAll("button");
100
+ const stepDownButton = buttons.find(
101
+ (btn) => btn.text().includes("-") || btn.attributes("aria-label")?.includes("Step down")
102
+ );
103
+ await stepDownButton?.trigger("click");
104
+
105
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([45]);
106
+ });
107
+
108
+ it("does not step below min", async () => {
109
+ wrapper = await wrapperFactory({ modelValue: 0, min: 0, step: 5 }, { left: '<span>-</span>' });
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({ modelValue: 100, max: 100, step: 5 }, { right: '<span>+</span>' });
120
+
121
+ const buttons = wrapper.findAll("button");
122
+ const stepUpButton = buttons.find((btn) => btn.text().includes("+"));
123
+ await stepUpButton?.trigger("click");
124
+
125
+ expect(wrapper.emitted("update:modelValue")).toBeUndefined();
126
+ });
127
+
128
+ it("renders the error message when fieldHasError is true", async () => {
129
+ wrapper = await wrapperFactory({ fieldHasError: true, errorMessage: "This field is required" });
130
+ expect(wrapper.text()).toContain("This field is required");
131
+ });
132
+
133
+ it("applies styleClassPassthrough", async () => {
134
+ wrapper = await wrapperFactory({ styleClassPassthrough: "custom-number" });
135
+ expect(wrapper.find(".input-number-with-label").classes()).toContain("custom-number");
136
+ });
137
+
138
+ it("forwards inputVariant to the wrapper, InputLabel, and InputNumberCore", async () => {
139
+ wrapper = await wrapperFactory({ inputVariant: "underlined" });
140
+ expect(wrapper.find(".input-number-with-label").classes()).toContain("underlined");
141
+ expect(wrapper.find(".input-number-wrapper").classes()).toContain("underlined");
142
+ });
143
+ });
@@ -5,11 +5,11 @@
5
5
  | Token | Default | Controls |
6
6
  |---|---|---|
7
7
  | `--input-range-accent-colour` | `var(--theme-accent)` | Native `accent-color` of the range input (track/thumb tint) |
8
- | `--input-range-marker-background-colour` | `light-dark(hsl(0, 29%, 3%), hsl(0, 0%, 92%))` | Background colour of each `markers` slot dot |
8
+ | `--input-range-marker-background-colour` | `hsl(0, 29%, 3%)` | Background colour of each `markers` slot dot |
9
9
  | `--input-range-marker-padding` | `0.5rem` | Padding of each marker dot |
10
10
  | `--input-range-marker-border-radius` | `50%` | Corner radius of each marker dot |
11
11
  | `--input-range-marker-outline-width` | `0.1rem` | Marker dot outline width |
12
- | `--input-range-marker-outline-colour` | `light-dark(var(--slate-04), var(--slate-06))` | Marker dot outline colour |
12
+ | `--input-range-marker-outline-colour` | `var(--slate-04)` | Marker dot outline colour |
13
13
  | `--input-range-marker-icon-size` | `2rem` | Font size of the marker icon (`.marker-icon`) |
14
14
  | `--input-range-marker-icon-colour` | `var(--input-range-accent-colour, var(--theme-accent))` | Marker icon colour |
15
15
  | `--input-range-datalist-font-size` | `1.4rem` | Font size of the `datalist` slot's option labels |
@@ -103,12 +103,12 @@ const modelValue = defineModel<number>({
103
103
  z-index: 2;
104
104
 
105
105
  .marker {
106
- background-color: var(--input-range-marker-background-colour, light-dark(hsl(0, 29%, 3%), hsl(0, 0%, 92%)));
106
+ background-color: var(--input-range-marker-background-colour, hsl(0, 29%, 3%));
107
107
  padding: var(--input-range-marker-padding, 0.5rem);
108
108
  border-radius: var(--input-range-marker-border-radius, 50%);
109
109
  overflow: hidden;
110
110
  outline: var(--input-range-marker-outline-width, 0.1rem) solid
111
- var(--input-range-marker-outline-colour, light-dark(var(--slate-04), var(--slate-06)));
111
+ var(--input-range-marker-outline-colour, var(--slate-04));
112
112
 
113
113
  &:hover {
114
114
  cursor: pointer;
@@ -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 {