srcdev-nuxt-components 9.4.10 → 9.4.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/migrate-component.md +18 -0
- package/.claude/component-ledger/audit.json +1 -1
- package/.claude/component-ledger/output.html +1 -1
- package/.claude/skills/components/input-number-core.md +214 -0
- package/.claude/skills/components/input-select-core.md +151 -0
- package/.claude/skills/components/input-text-core.md +16 -0
- package/.claude/skills/components/input-textarea-core.md +154 -0
- package/.claude/skills/components/select-menu.md +195 -0
- package/.claude/skills/components/toggle-switch-core.md +150 -0
- package/.claude/skills/index.md +5 -0
- package/.vscode/srcdev-component-input-number.code-snippets +65 -0
- package/.vscode/srcdev-component-input-select.code-snippets +42 -0
- package/.vscode/srcdev-component-input-textarea.code-snippets +40 -0
- package/.vscode/srcdev-component-select-menu.code-snippets +65 -0
- package/.vscode/srcdev-component-toggle-switch.code-snippets +49 -0
- package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +1 -1
- package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +1 -1
- package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +1 -1
- package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +1 -1
- package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +1 -1
- package/app/components/01.atoms/animations/section-parallax/CONSUMER-STYLING.md +1 -1
- package/app/components/01.atoms/animations/section-parallax/SectionParallax.vue +1 -1
- package/app/components/01.atoms/canvas-switcher/CONSUMER-STYLING.md +2 -2
- package/app/components/01.atoms/canvas-switcher/CanvasSwitcher.vue +2 -2
- package/app/components/01.atoms/card/CardCore.vue +4 -4
- package/app/components/01.atoms/clipped-panel/CONSUMER-STYLING.md +2 -2
- package/app/components/01.atoms/clipped-panel/ClippedPanel.vue +2 -2
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/CONSUMER-STYLING.md +2 -2
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/DashboardQuadGrid.vue +2 -2
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/stories/DashboardQuadGrid.stories.ts +1 -1
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/CONSUMER-STYLING.md +1 -1
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/DashboardStatsGrid.vue +1 -1
- package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +8 -8
- package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +8 -8
- package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +6 -6
- package/app/components/01.atoms/pop-over/PopOver.vue +6 -6
- package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +1 -1
- package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +1 -1
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/CONSUMER-STYLING.md +1 -1
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/DeepExpandingMenuClassic.vue +1 -1
- package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +11 -0
- package/app/components/02.molecules/pricing-card/PricingCard.vue +3 -1
- package/app/components/02.molecules/pricing-card/stories/PricingCard.stories.ts +2 -0
- package/app/components/02.molecules/pricing-card/tests/PricingCard.spec.ts +13 -0
- package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +168 -0
- package/app/components/02.molecules/select-menu/SelectMenu.vue +384 -0
- package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +359 -0
- package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +476 -0
- package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +42 -0
- package/app/components/03.organisms/image-galleries/carousel-flip/CONSUMER-STYLING.md +3 -3
- package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +3 -3
- package/app/components/03.organisms/image-galleries/carousel-flip/stories/CarouselFlip.stories.ts +18 -18
- package/app/components/05.forms/input-button/CONSUMER-STYLING.md +2 -7
- package/app/components/05.forms/input-button/InputButtonCore.vue +1 -5
- package/app/components/05.forms/input-number/CONSUMER-STYLING.md +26 -6
- package/app/components/05.forms/input-number/InputNumberCore.vue +125 -85
- package/app/components/05.forms/input-number/stories/InputNumberCore.stories.ts +92 -0
- package/app/components/05.forms/input-number/tests/InputNumberCore.spec.ts +119 -0
- package/app/components/05.forms/input-number/variants/InputNumberDefault.vue +30 -22
- package/app/components/05.forms/input-number/variants/stories/InputNumberDefault.stories.ts +123 -0
- package/app/components/05.forms/input-number/variants/tests/InputNumberDefault.spec.ts +143 -0
- package/app/components/05.forms/input-range/CONSUMER-STYLING.md +2 -2
- package/app/components/05.forms/input-range/InputRangeCore.vue +2 -2
- package/app/components/05.forms/input-select/InputSelectCore.vue +23 -30
- package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +2 -2
- package/app/components/05.forms/input-select/stories/InputSelectWithLabel.stories.ts +115 -0
- package/app/components/05.forms/input-select/tests/InputSelectCore.spec.ts +129 -0
- package/app/components/05.forms/input-select/variants/InputSelectWithLabel.vue +1 -1
- package/app/components/05.forms/input-select/variants/tests/InputSelectWithLabel.spec.ts +87 -0
- package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +14 -0
- package/app/components/05.forms/input-text/variants/InputTextAsNumberWithLabel.vue +1 -1
- package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +55 -0
- package/app/components/05.forms/input-textarea/InputTextareaCore.vue +24 -16
- package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +1 -1
- package/app/components/05.forms/input-textarea/tests/InputTextareaCore.spec.ts +119 -0
- package/app/components/05.forms/input-textarea/variants/InputTextareaWithLabel.vue +3 -13
- package/app/components/05.forms/input-textarea/variants/tests/InputTextareaWithLabel.spec.ts +93 -0
- package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +95 -10
- package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +29 -0
- package/app/components/05.forms/toggle-switch/ToggleSwitchCore.vue +120 -119
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchCore.stories.ts +1 -1
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +1 -1
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabelInline.stories.ts +2 -2
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchCore.spec.ts +101 -0
- package/app/components/05.forms/toggle-switch/variants/ToggleSwitchWithLabel.vue +3 -5
- package/app/components/05.forms/toggle-switch/variants/ToggleSwitchWithLabelInline.vue +1 -2
- package/app/components/05.forms/toggle-switch/variants/tests/ToggleSwitchWithLabel.spec.ts +77 -0
- package/app/components/05.forms/toggle-switch/variants/tests/ToggleSwitchWithLabelInline.spec.ts +66 -0
- package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +4 -4
- package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitchCore.vue +6 -6
- package/app/types/components/index.ts +1 -0
- package/app/types/components/select-menu.d.ts +5 -0
- package/package.json +1 -1
- package/.vscode/srcdev-component-toggle-switch-core.code-snippets +0 -13
|
@@ -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
|
|
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="
|
|
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="
|
|
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?:
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
141
|
+
.input-number-with-label {
|
|
142
|
+
.input-number-label {
|
|
143
|
+
display: block;
|
|
144
|
+
margin-block: 0.8rem;
|
|
137
145
|
|
|
138
|
-
|
|
139
|
-
|
|
146
|
+
&:hover {
|
|
147
|
+
cursor: pointer;
|
|
148
|
+
}
|
|
140
149
|
}
|
|
141
|
-
}
|
|
142
150
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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` | `
|
|
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` | `
|
|
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,
|
|
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,
|
|
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=""
|
|
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?:
|
|
52
|
-
inputVariant?:
|
|
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"
|
|
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. --
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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(--
|
|
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
|
|
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(--
|
|
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(--
|
|
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 {
|