srcdev-nuxt-components 9.6.8 → 9.6.9

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 (108) hide show
  1. package/.claude/skills/component-compliance-checklist.md +12 -1
  2. package/.claude/skills/component-inline-action-button.md +1 -1
  3. package/.claude/skills/component-local-style-override.md +1 -1
  4. package/.claude/skills/component-naming.md +11 -11
  5. package/.claude/skills/components/container-glow.md +2 -2
  6. package/.claude/skills/components/form-field.md +2 -2
  7. package/.claude/skills/components/form-wrapper.md +1 -1
  8. package/.claude/skills/components/glowing-border.md +1 -1
  9. package/.claude/skills/components/input-description.md +7 -7
  10. package/.claude/skills/components/input-error.md +3 -3
  11. package/.claude/skills/components/input-label.md +7 -8
  12. package/.claude/skills/components/input-number.md +17 -17
  13. package/.claude/skills/components/input-select.md +153 -0
  14. package/.claude/skills/components/input-text.md +220 -0
  15. package/.claude/skills/components/input-textarea.md +147 -0
  16. package/.claude/skills/components/select-menu.md +4 -4
  17. package/.claude/skills/components/toggle-switch.md +143 -0
  18. package/.claude/skills/composable-zod-validation.md +1 -1
  19. package/.claude/skills/icon-sets.md +1 -1
  20. package/.claude/skills/icon-sizing.md +1 -1
  21. package/.claude/skills/index.md +5 -5
  22. package/.claude/skills/storybook-add-story.md +1 -1
  23. package/.claude/skills/theming-component-token-pattern.md +8 -8
  24. package/.claude/skills/theming-form-geometry-tokens.md +1 -1
  25. package/.vscode/srcdev-component-input-select.code-snippets +20 -14
  26. package/.vscode/srcdev-component-input-text.code-snippets +46 -40
  27. package/.vscode/srcdev-component-input-textarea.code-snippets +19 -14
  28. package/.vscode/srcdev-component-toggle-switch.code-snippets +19 -29
  29. package/app/assets/styles/setup/04.elements/forms/00.element-defaults.css +2 -2
  30. package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +1 -1
  31. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +2 -2
  32. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +4 -4
  33. package/app/components/02.molecules/progress-tracker/stories/ProgressTracker.stories.ts +4 -4
  34. package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +1 -1
  35. package/app/components/05.forms/form-field/CONSUMER-STYLING.md +2 -2
  36. package/app/components/05.forms/input-button/stories/InputButton.stories.ts +3 -3
  37. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +6 -6
  38. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +5 -5
  39. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +9 -5
  40. package/app/components/05.forms/input-number/InputNumber.vue +2 -2
  41. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +80 -10
  42. package/app/components/05.forms/input-select/InputSelect.vue +198 -0
  43. package/app/components/05.forms/input-select/InputSelectField.vue +130 -0
  44. package/app/components/05.forms/input-select/stories/InputSelect.stories.ts +184 -0
  45. package/app/components/05.forms/input-select/stories/InputSelectField.stories.ts +187 -0
  46. package/app/components/05.forms/input-select/tests/{InputSelectCore.spec.ts → InputSelect.spec.ts} +75 -12
  47. package/app/components/05.forms/input-select/tests/InputSelectField.spec.ts +121 -0
  48. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +85 -15
  49. package/app/components/05.forms/input-text/{InputPasswordWithLabel.vue → InputPasswordField.vue} +16 -19
  50. package/app/components/05.forms/input-text/{InputTextCore.vue → InputText.vue} +42 -78
  51. package/app/components/05.forms/input-text/{InputTextAsNumberWithLabel.vue → InputTextAsNumberField.vue} +66 -62
  52. package/app/components/05.forms/input-text/InputTextField.vue +168 -0
  53. package/app/components/05.forms/input-text/stories/InputPasswordField.stories.ts +163 -0
  54. package/app/components/05.forms/input-text/stories/InputText.stories.ts +194 -0
  55. package/app/components/05.forms/input-text/stories/InputTextAsNumberField.stories.ts +182 -0
  56. package/app/components/05.forms/input-text/stories/InputTextField.stories.ts +206 -0
  57. package/app/components/05.forms/input-text/tests/{InputPasswordWithLabel.spec.ts → InputPasswordField.spec.ts} +42 -3
  58. package/app/components/05.forms/input-text/tests/InputText.spec.ts +166 -0
  59. package/app/components/05.forms/input-text/tests/InputTextAsNumberField.spec.ts +135 -0
  60. package/app/components/05.forms/input-text/tests/InputTextField.spec.ts +130 -0
  61. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +94 -12
  62. package/app/components/05.forms/input-textarea/InputTextarea.vue +165 -0
  63. package/app/components/05.forms/input-textarea/InputTextareaField.vue +159 -0
  64. package/app/components/05.forms/input-textarea/stories/InputTextarea.stories.ts +198 -0
  65. package/app/components/05.forms/input-textarea/stories/InputTextareaField.stories.ts +161 -0
  66. package/app/components/05.forms/input-textarea/tests/InputTextarea.spec.ts +149 -0
  67. package/app/components/05.forms/input-textarea/tests/InputTextareaField.spec.ts +122 -0
  68. package/app/components/05.forms/patterns/stories/ContactForm.stories.ts +13 -13
  69. package/app/components/05.forms/patterns/stories/LoginForm.stories.ts +6 -6
  70. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +30 -32
  71. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +71 -50
  72. package/app/components/05.forms/toggle-switch/{ToggleSwitchCore.vue → ToggleSwitch.vue} +58 -73
  73. package/app/components/05.forms/toggle-switch/{ToggleSwitchWithLabel.vue → ToggleSwitchField.vue} +65 -19
  74. package/app/components/05.forms/toggle-switch/stories/ToggleSwitch.stories.ts +148 -0
  75. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchField.stories.ts +176 -0
  76. package/app/components/05.forms/toggle-switch/tests/ToggleSwitch.spec.ts +145 -0
  77. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchField.spec.ts +155 -0
  78. package/package.json +1 -1
  79. package/.claude/skills/components/input-select-core.md +0 -151
  80. package/.claude/skills/components/input-text-core.md +0 -204
  81. package/.claude/skills/components/input-textarea-core.md +0 -154
  82. package/.claude/skills/components/toggle-switch-core.md +0 -169
  83. package/app/components/05.forms/input-select/InputSelectCore.vue +0 -188
  84. package/app/components/05.forms/input-select/InputSelectWithLabel.vue +0 -118
  85. package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +0 -69
  86. package/app/components/05.forms/input-select/stories/InputSelectWithLabel.stories.ts +0 -115
  87. package/app/components/05.forms/input-select/tests/InputSelectWithLabel.spec.ts +0 -87
  88. package/app/components/05.forms/input-text/InputTextWithLabel.vue +0 -154
  89. package/app/components/05.forms/input-text/stories/InputPasswordWithLabel.stories.ts +0 -368
  90. package/app/components/05.forms/input-text/stories/InputTextAsNumberWithLabel.stories.ts +0 -480
  91. package/app/components/05.forms/input-text/stories/InputTextCore.stories.ts +0 -267
  92. package/app/components/05.forms/input-text/stories/InputTextWithLabel.stories.ts +0 -319
  93. package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +0 -75
  94. package/app/components/05.forms/input-text/tests/InputTextCore.spec.ts +0 -60
  95. package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +0 -71
  96. package/app/components/05.forms/input-textarea/InputTextareaCore.vue +0 -159
  97. package/app/components/05.forms/input-textarea/InputTextareaWithLabel.vue +0 -136
  98. package/app/components/05.forms/input-textarea/stories/InputTextareaCore.stories.ts +0 -247
  99. package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +0 -458
  100. package/app/components/05.forms/input-textarea/tests/InputTextareaCore.spec.ts +0 -119
  101. package/app/components/05.forms/input-textarea/tests/InputTextareaWithLabel.spec.ts +0 -93
  102. package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabelInline.vue +0 -81
  103. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchCore.stories.ts +0 -251
  104. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +0 -251
  105. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabelInline.stories.ts +0 -283
  106. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchCore.spec.ts +0 -101
  107. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabel.spec.ts +0 -133
  108. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabelInline.spec.ts +0 -66
@@ -0,0 +1,176 @@
1
+ import { computed, ref } from "vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import ToggleSwitchField from "../ToggleSwitchField.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
5
+ import type { FormUiTheme, LabelWeight } from "~/types/forms/types.forms.d";
6
+ import type { MediaCanvas } from "~/types/components";
7
+
8
+ interface StoryArgs {
9
+ modelValue: string | number | boolean;
10
+ name: string;
11
+ label: string;
12
+ layout: "stacked" | "inline";
13
+ labelWeight: LabelWeight | undefined;
14
+ required: boolean;
15
+ errorMessage: string;
16
+ fieldHasError: boolean;
17
+ trueValue: string | number | boolean;
18
+ falseValue: string | number | boolean;
19
+ theme: FormUiTheme;
20
+ round: boolean;
21
+ styleClassPassthrough: string[];
22
+ descriptionText: string;
23
+ useCustomIcons: boolean;
24
+ }
25
+
26
+ const meta: Meta<StoryArgs> = {
27
+ title: "Components/Forms/Toggle Switch/ToggleSwitchField",
28
+ component: ToggleSwitchField,
29
+ argTypes: {
30
+ modelValue: { control: "boolean", table: { category: "Model" } },
31
+ name: { control: "text", table: { category: "Basic" } },
32
+ label: { control: "text", table: { category: "Basic" } },
33
+ layout: { control: { type: "select" }, options: ["stacked", "inline"], table: { category: "Layout" } },
34
+ labelWeight: {
35
+ control: { type: "select" },
36
+ options: [undefined, "normal", "semi-bold", "bold"],
37
+ description: "Defaults to bold when stacked, normal when inline",
38
+ table: { category: "Layout" },
39
+ },
40
+ required: { control: "boolean", table: { category: "Validation" } },
41
+ errorMessage: { control: "text", table: { category: "Validation" } },
42
+ fieldHasError: { control: "boolean", table: { category: "Validation" } },
43
+ trueValue: { control: "text", table: { category: "Values" } },
44
+ falseValue: { control: "text", table: { category: "Values" } },
45
+ theme: {
46
+ control: { type: "select" },
47
+ options: ["default", "success", "error", "warning"],
48
+ table: { category: "Styling" },
49
+ },
50
+ round: { control: "boolean", table: { category: "Styling" } },
51
+ styleClassPassthrough: { control: "object", table: { category: "Styling" } },
52
+ descriptionText: { control: "text", description: "Story-only: fills the descriptionText slot", table: { category: "Slots" } },
53
+ useCustomIcons: {
54
+ control: "boolean",
55
+ description: "Story-only: fills the iconOn/iconOff slots",
56
+ table: { category: "Slots" },
57
+ },
58
+ },
59
+ args: {
60
+ modelValue: false,
61
+ name: "notifications",
62
+ label: "Email notifications",
63
+ layout: "stacked",
64
+ labelWeight: undefined,
65
+ required: false,
66
+ errorMessage: "",
67
+ fieldHasError: false,
68
+ trueValue: true,
69
+ falseValue: false,
70
+ theme: "default",
71
+ round: true,
72
+ styleClassPassthrough: [],
73
+ descriptionText: "",
74
+ useCustomIcons: false,
75
+ },
76
+ decorators: [
77
+ (story, context) => ({
78
+ components: { story, CanvasSwitcher },
79
+ setup() {
80
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
81
+ return { canvasName };
82
+ },
83
+ template: `
84
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
85
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
86
+ </div>
87
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
88
+ <story />
89
+ </div>
90
+ `,
91
+ }),
92
+ ],
93
+ parameters: {
94
+ layout: "fullscreen",
95
+ docs: {
96
+ description: {
97
+ component:
98
+ "Labelled switch: `InputLabel`, an optional description, `ToggleSwitch` and an error strip, linked through `aria-describedby`. `layout=\"stacked\"` (default) puts the label above the switch; `layout=\"inline\"` puts them on one row with the description and error below. `labelWeight` defaults to bold when stacked and normal when inline. `style-class-passthrough` lands on the `.toggle-switch-field` root only.",
99
+ },
100
+ },
101
+ },
102
+ };
103
+
104
+ export default meta;
105
+ type Story = StoryObj<StoryArgs>;
106
+
107
+ const render = (args: StoryArgs) => ({
108
+ components: { ToggleSwitchField },
109
+ setup() {
110
+ const componentArgs = computed(() => {
111
+ const { modelValue: _modelValue, descriptionText: _descriptionText, useCustomIcons: _useCustomIcons, ...rest } = args;
112
+ return rest;
113
+ });
114
+ return { args, componentArgs };
115
+ },
116
+ template: `
117
+ <ToggleSwitchField v-model="args.modelValue" v-bind="componentArgs">
118
+ <template v-if="args.descriptionText" #descriptionText>{{ args.descriptionText }}</template>
119
+ <template v-if="args.useCustomIcons" #iconOn><Icon name="mdi:check" /></template>
120
+ <template v-if="args.useCustomIcons" #iconOff><Icon name="mdi:close" /></template>
121
+ </ToggleSwitchField>
122
+ `,
123
+ });
124
+
125
+ export const Default: Story = { render };
126
+
127
+ export const WithDescription: Story = { args: { descriptionText: "At most one email a week." }, render };
128
+
129
+ export const Inline: Story = {
130
+ args: { layout: "inline", label: "Email me appointment reminders", descriptionText: "Sent the day before." },
131
+ render,
132
+ parameters: {
133
+ docs: {
134
+ description: {
135
+ story: "`layout=\"inline\"`: label and switch on one row, description and error below. Replaces `ToggleSwitchWithLabelInline`.",
136
+ },
137
+ },
138
+ },
139
+ };
140
+
141
+ export const WithCustomIcons: Story = { args: { useCustomIcons: true }, render };
142
+
143
+ export const ErrorState: Story = {
144
+ args: { fieldHasError: true, errorMessage: "Please accept to continue", required: true },
145
+ render,
146
+ };
147
+
148
+ export const InlineError: Story = {
149
+ args: { layout: "inline", fieldHasError: true, errorMessage: "Please accept to continue" },
150
+ render,
151
+ };
152
+
153
+ export const StringValues: Story = { args: { modelValue: "off", trueValue: "on", falseValue: "off" }, render };
154
+
155
+ export const StressTest: Story = {
156
+ name: "Stress Test (Worst-Case Data)",
157
+ args: {
158
+ layout: "inline",
159
+ label:
160
+ "🔔 Benachrichtigungenüberterminänderungenundsonderangeboteperemailerhalten https://example.com/a/very/long/unbroken/url <b>not bold</b> إشعارات",
161
+ descriptionText: "يرجى تفعيل الإشعارات Sie erhalten höchstens eine Nachricht pro Woche, jederzeit abbestellbar.",
162
+ errorMessage: "BittebestätigenSiedieEinstellungbevorSiefortfahren",
163
+ fieldHasError: true,
164
+ useCustomIcons: true,
165
+ },
166
+ render,
167
+ parameters: {
168
+ initialCanvas: "mobileCanvas",
169
+ docs: {
170
+ description: {
171
+ story:
172
+ "The inline layout with long unbroken German, URL, emoji, HTML-like and right-to-left copy in the label, description and error. Check at every canvas width: the label wraps beside the switch without pushing it out of the dashed outline, the switch keeps its size, and markup renders as text. Switch `layout` to stacked to check that too.",
173
+ },
174
+ },
175
+ },
176
+ };
@@ -0,0 +1,145 @@
1
+ // https://nuxt.com/docs/getting-started/testing#unit-testing
2
+ import { nextTick } from "vue";
3
+ import type { VueWrapper } from "@vue/test-utils";
4
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
5
+ import ComponentUnderTest from "../ToggleSwitch.vue";
6
+
7
+ const initialPropsData = {
8
+ id: "test-toggle",
9
+ name: "testName",
10
+ modelValue: false,
11
+ };
12
+
13
+ let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
14
+ const wrapperFactory = (propsData = {}, slots = {}) => {
15
+ const mockPropsData = { ...initialPropsData, ...propsData };
16
+
17
+ return mountSuspended(ComponentUnderTest, {
18
+ attachTo: document.body,
19
+ props: mockPropsData,
20
+ slots,
21
+ });
22
+ };
23
+
24
+ afterEach(() => {
25
+ wrapper?.unmount();
26
+ vi.restoreAllMocks();
27
+ });
28
+
29
+ describe("ToggleSwitch", () => {
30
+ it("mounts without error", async () => {
31
+ wrapper = await wrapperFactory();
32
+ expect(wrapper.vm).toBeTruthy();
33
+ });
34
+
35
+ it("renders a checkbox input with the switch role", async () => {
36
+ wrapper = await wrapperFactory();
37
+ const input = wrapper.find("input.toggle-switch-input");
38
+
39
+ expect(input.attributes("type")).toBe("checkbox");
40
+ expect(input.attributes("role")).toBe("switch");
41
+ expect(input.attributes("id")).toBe("toggle-switch-test-toggle");
42
+ });
43
+
44
+ it("binds v-model to the checkbox checked state and data-checked", async () => {
45
+ wrapper = await wrapperFactory({ modelValue: true });
46
+
47
+ expect(wrapper.find<HTMLInputElement>("input").element.checked).toBe(true);
48
+ expect(wrapper.attributes("data-checked")).toBe("");
49
+
50
+ await wrapper.setProps({ modelValue: false });
51
+ expect(wrapper.attributes("data-checked")).toBeUndefined();
52
+ });
53
+
54
+ it("toggles modelValue when the track is clicked", async () => {
55
+ wrapper = await wrapperFactory({ modelValue: false });
56
+ await wrapper.find(".toggle-switch-track").trigger("click");
57
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([true]);
58
+ });
59
+
60
+ it("toggles once, through v-model, when the input itself is clicked", async () => {
61
+ wrapper = await wrapperFactory({ modelValue: false });
62
+ await wrapper.find("input").trigger("click");
63
+
64
+ expect(wrapper.emitted("update:modelValue")).toEqual([[true]]);
65
+ });
66
+
67
+ it("toggles between custom trueValue/falseValue", async () => {
68
+ wrapper = await wrapperFactory({ modelValue: "no", trueValue: "yes", falseValue: "no" });
69
+ await wrapper.find(".toggle-switch-track").trigger("click");
70
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["yes"]);
71
+ });
72
+
73
+ it("sets required attribute when required is true", async () => {
74
+ wrapper = await wrapperFactory({ required: true });
75
+ expect(wrapper.find("input").attributes("required")).toBeDefined();
76
+ });
77
+
78
+ it("does not set required attribute by default", async () => {
79
+ wrapper = await wrapperFactory();
80
+ expect(wrapper.find("input").attributes("required")).toBeUndefined();
81
+ });
82
+
83
+ it("sets data-theme from the theme prop", async () => {
84
+ wrapper = await wrapperFactory({ theme: "success" });
85
+ expect(wrapper.attributes("data-theme")).toBe("success");
86
+ });
87
+
88
+ it("switches to the error theme and sets data-invalid and aria-invalid when fieldHasError is true", async () => {
89
+ wrapper = await wrapperFactory({ fieldHasError: true, theme: "success" });
90
+
91
+ expect(wrapper.attributes("data-theme")).toBe("error");
92
+ expect(wrapper.attributes("data-invalid")).toBe("");
93
+ expect(wrapper.find("input").attributes("aria-invalid")).toBe("true");
94
+ });
95
+
96
+ it("omits aria-invalid and aria-describedby by default", async () => {
97
+ wrapper = await wrapperFactory();
98
+ expect(wrapper.find("input").attributes("aria-invalid")).toBeUndefined();
99
+ expect(wrapper.find("input").attributes("aria-describedby")).toBeUndefined();
100
+ });
101
+
102
+ it("hides the decorative track from assistive tech", async () => {
103
+ wrapper = await wrapperFactory();
104
+ expect(wrapper.find(".toggle-switch-track").attributes("aria-hidden")).toBe("true");
105
+ });
106
+
107
+ it("applies styleClassPassthrough to the root element and reacts to changes", async () => {
108
+ wrapper = await wrapperFactory({ styleClassPassthrough: ["custom-toggle"] });
109
+ expect(wrapper.classes()).toContain("custom-toggle");
110
+
111
+ await wrapper.setProps({ styleClassPassthrough: ["other-toggle"] });
112
+ await nextTick();
113
+ expect(wrapper.classes()).toContain("other-toggle");
114
+ expect(wrapper.classes()).not.toContain("custom-toggle");
115
+ });
116
+
117
+ it("renders default icons when no icon slots are provided", async () => {
118
+ wrapper = await wrapperFactory();
119
+ expect(wrapper.findAll(".toggle-switch-icon")).toHaveLength(2);
120
+ });
121
+
122
+ it("renders custom icon slot content instead of the default icon", async () => {
123
+ wrapper = await wrapperFactory(
124
+ {},
125
+ {
126
+ iconOn: '<span data-testid="custom-on">on</span>',
127
+ iconOff: '<span data-testid="custom-off">off</span>',
128
+ }
129
+ );
130
+
131
+ expect(wrapper.find(".toggle-switch-icon-on [data-testid='custom-on']").exists()).toBe(true);
132
+ expect(wrapper.find(".toggle-switch-icon-off [data-testid='custom-off']").exists()).toBe(true);
133
+ });
134
+
135
+ it("consumes round as a declared prop instead of leaking it as a DOM attribute", async () => {
136
+ wrapper = await wrapperFactory({ round: false });
137
+ expect(wrapper.props("round")).toBe(false);
138
+ expect(wrapper.attributes("round")).toBeUndefined();
139
+ });
140
+
141
+ it("defaults round to true", async () => {
142
+ wrapper = await wrapperFactory();
143
+ expect(wrapper.props("round")).toBe(true);
144
+ });
145
+ });
@@ -0,0 +1,155 @@
1
+ // https://nuxt.com/docs/getting-started/testing#unit-testing
2
+ import { defineComponent, nextTick, ref } from "vue";
3
+ import type { VueWrapper } from "@vue/test-utils";
4
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
5
+ import ComponentUnderTest from "../ToggleSwitchField.vue";
6
+
7
+ const initialPropsData = {
8
+ name: "notifications",
9
+ label: "Enable notifications",
10
+ modelValue: false,
11
+ };
12
+
13
+ let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
14
+ const wrapperFactory = (propsData = {}, slots = {}) => {
15
+ const mockPropsData = { ...initialPropsData, ...propsData };
16
+
17
+ return mountSuspended(ComponentUnderTest, {
18
+ attachTo: document.body,
19
+ props: mockPropsData,
20
+ slots,
21
+ });
22
+ };
23
+
24
+ afterEach(() => {
25
+ wrapper?.unmount();
26
+ vi.restoreAllMocks();
27
+ });
28
+
29
+ describe("ToggleSwitchField", () => {
30
+ it("mounts without error", async () => {
31
+ wrapper = await wrapperFactory();
32
+ expect(wrapper.vm).toBeTruthy();
33
+ });
34
+
35
+ it("renders .toggle-switch-field as the root with the stacked layout by default", async () => {
36
+ wrapper = await wrapperFactory();
37
+ expect(wrapper.classes()).toContain("toggle-switch-field");
38
+ expect(wrapper.attributes("data-layout")).toBe("stacked");
39
+ });
40
+
41
+ it("renders the label text, linked to the switch", async () => {
42
+ wrapper = await wrapperFactory();
43
+ const label = wrapper.find("label");
44
+
45
+ expect(label.text()).toContain("Enable notifications");
46
+ expect(label.classes()).toContain("toggle-switch-label");
47
+ expect(label.attributes("for")).toBe(wrapper.find("input[type='checkbox']").attributes("id"));
48
+ });
49
+
50
+ it("uses a bold label when stacked and a normal one when inline, unless labelWeight is set", async () => {
51
+ wrapper = await wrapperFactory();
52
+ expect(wrapper.find("label").classes()).toContain("body-normal-bold");
53
+
54
+ await wrapper.setProps({ layout: "inline" });
55
+ expect(wrapper.attributes("data-layout")).toBe("inline");
56
+ expect(wrapper.find("label").classes()).toContain("body-normal");
57
+
58
+ await wrapper.setProps({ labelWeight: "semi-bold" });
59
+ expect(wrapper.find("label").classes()).toContain("body-normal-semibold");
60
+ });
61
+
62
+ it("keeps the label, description, switch, error DOM order in both layouts", async () => {
63
+ for (const layout of ["stacked", "inline"]) {
64
+ wrapper = await wrapperFactory(
65
+ { layout, fieldHasError: true, errorMessage: "Required" },
66
+ { descriptionText: () => "Sends at most one email a week." }
67
+ );
68
+ const html = wrapper.html();
69
+ const order = ["<label", "Sends at most one email", "<input", "Required"].map((marker) => html.indexOf(marker));
70
+
71
+ expect(order).toEqual([...order].sort((a, b) => a - b));
72
+ wrapper.unmount();
73
+ }
74
+ });
75
+
76
+ it("renders the error message and sets data-invalid when fieldHasError is true", async () => {
77
+ wrapper = await wrapperFactory({ fieldHasError: true, errorMessage: "Please choose" });
78
+
79
+ expect(wrapper.text()).toContain("Please choose");
80
+ expect(wrapper.attributes("data-invalid")).toBe("");
81
+ expect(wrapper.attributes("data-theme")).toBe("error");
82
+ expect(wrapper.find(".toggle-switch").attributes("data-theme")).toBe("error");
83
+ });
84
+
85
+ it("renders descriptionText and descriptionHtml through InputDescription", async () => {
86
+ wrapper = await wrapperFactory({}, { descriptionText: () => "Plain hint" });
87
+ expect(wrapper.find(".toggle-switch-description").text()).toContain("Plain hint");
88
+ wrapper.unmount();
89
+
90
+ wrapper = await wrapperFactory({}, { descriptionHtml: () => "Rich hint" });
91
+ expect(wrapper.find(".toggle-switch-description").text()).toContain("Rich hint");
92
+ });
93
+
94
+ it("renders no description element without a description slot", async () => {
95
+ wrapper = await wrapperFactory();
96
+ expect(wrapper.find(".toggle-switch-description").exists()).toBe(false);
97
+ });
98
+
99
+ it("forwards required to the underlying input", async () => {
100
+ wrapper = await wrapperFactory({ required: true });
101
+ expect(wrapper.find("input").attributes("required")).toBeDefined();
102
+ });
103
+
104
+ it("applies styleClassPassthrough to its root only and reacts to changes", async () => {
105
+ wrapper = await wrapperFactory({ styleClassPassthrough: ["custom-toggle"] });
106
+ expect(wrapper.classes()).toContain("custom-toggle");
107
+ expect(wrapper.find(".toggle-switch").classes()).not.toContain("custom-toggle");
108
+
109
+ await wrapper.setProps({ styleClassPassthrough: ["other-toggle"] });
110
+ await nextTick();
111
+ expect(wrapper.classes()).toContain("other-toggle");
112
+ });
113
+
114
+ it("forwards round to ToggleSwitch without leaking it as a DOM attribute", async () => {
115
+ wrapper = await wrapperFactory({ round: false });
116
+ const toggle = wrapper.findComponent({ name: "ToggleSwitch" });
117
+
118
+ expect(toggle.props("round")).toBe(false);
119
+ expect(wrapper.find(".toggle-switch").attributes("round")).toBeUndefined();
120
+ });
121
+
122
+ it("points aria-describedby at the description once it appears after mount", async () => {
123
+ const Host = defineComponent({
124
+ components: { ComponentUnderTest },
125
+ setup() {
126
+ return { modelValue: ref(false), showDescription: ref(false) };
127
+ },
128
+ template: `
129
+ <ComponentUnderTest v-model="modelValue" name="notifications" label="Enable notifications">
130
+ <template v-if="showDescription" #descriptionText>Sends at most one email a week.</template>
131
+ </ComponentUnderTest>
132
+ `,
133
+ });
134
+ const host = await mountSuspended(Host);
135
+ const input = () => host.find("input[type='checkbox']");
136
+ expect(input().attributes("aria-describedby")).toBeFalsy();
137
+ (host.vm as unknown as { showDescription: boolean }).showDescription = true;
138
+ await nextTick();
139
+ expect(input().attributes("aria-describedby")).toMatch(/-description$/);
140
+ host.unmount();
141
+ });
142
+
143
+ it("points aria-describedby at both the description and the error when both are present", async () => {
144
+ wrapper = await wrapperFactory(
145
+ { fieldHasError: true, errorMessage: "Required" },
146
+ { descriptionText: () => "Sends at most one email a week." }
147
+ );
148
+ const ids = (wrapper.find("input[type='checkbox']").attributes("aria-describedby") ?? "").split(" ");
149
+
150
+ expect(ids).toHaveLength(2);
151
+ expect(ids[0]).toMatch(/-description$/);
152
+ expect(ids[1]).toMatch(/-error-message$/);
153
+ expect(wrapper.find(`[id="${ids[0]}"]`).exists()).toBe(true);
154
+ });
155
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.6.8",
4
+ "version": "9.6.9",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",
@@ -1,151 +0,0 @@
1
- # InputSelectCore Component
2
-
3
- ## Overview
4
-
5
- `InputSelectCore` is the low-level native `<select>` primitive for the `05.forms` select family.
6
- It renders a native select with a data-driven list of `<option>`s (each optionally decorated with
7
- an icon), an optional placeholder option, and adopts the modern CSS Customizable Select API
8
- (`appearance: base-select`) for a fully styleable open-picker state where supported, with a
9
- graceful CSS fallback (see "Browser support" below). It has no label, description, or
10
- error-message rendering of its own — that's composed by the `InputSelectWithLabel` variant below.
11
-
12
- Most consumers should reach for **InputSelectWithLabel** rather than `InputSelectCore` directly.
13
-
14
- ---
15
-
16
- ## Props reference
17
-
18
- > **Hyphenation rule**: Vue's ESLint config enforces `vue/attribute-hyphenation`. Always write camelCase prop names hyphenated in templates: `:field-has-error`, `:style-class-passthrough`, `:aria-describedby`.
19
-
20
- | Prop (template form) | Type | Default | Notes |
21
- |------|------|---------|-------|
22
- | `:id` | `string` | (required) | Applied to the native select. |
23
- | `:name` | `string` | (required) | Applied to the native select. |
24
- | `:placeholder` | `string` | `""` | Renders a disabled placeholder `<option>` (selected when `modelValue` is empty). No option rendered at all when unset. |
25
- | `:title` | `string` | `"Please select an option"` | Native `title` attribute (tooltip). |
26
- | `:required` | `boolean` | `false` | Native `required`. |
27
- | `:theme` | `FormUiTheme` | `"default"` | Sets `data-theme` on the wrapper. |
28
- | `:input-variant` | `InputUiVariant` | `"normal"` | Adds the variant name as a class on the wrapper — `"normal"` (bordered box) and `"underlined"` (bottom-border only) have CSS; `"outlined"` doesn't (see CONSUMER-STYLING.md). Same prop/behaviour as `InputTextCore`. |
29
- | `:field-has-error` | `boolean` | `false` | Drives `data-invalid` on the wrapper and `aria-invalid` on the select. |
30
- | `:style-class-passthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the select element. |
31
- | `:aria-describedby` | `string` | `""` | Forwarded to the native select. |
32
-
33
- ### v-models
34
-
35
- - `v-model` — `string | number | readonly number[]`, **required** (`defineModel({ required: true })`, satisfying `vue/require-default-prop` — see `project_ledger_eslint_issues_column` in memory).
36
- - `v-model:field-data` — `IFormMultipleOptions` (`{ data: IOptionsConfig[]; total; skip; limit }`), **required**. The `data` array drives the rendered `<option>`s. The template dereferences `fieldData.data` unconditionally, so a consumer that doesn't bind `v-model:field-data` will crash on render — `required: true` documents that constraint (it's a dev-time warning only, not a runtime guard).
37
- - `v-model:is-dirty` — `boolean`. Set to `true` on the native `change` event (never reset to `false` by this component).
38
- - `v-model:is-active` — `boolean`. Tracks native focus/blur (`focusin`/`focusout`), toggling `.active` on the wrapper.
39
-
40
- ---
41
-
42
- ## CSS token API
43
-
44
- See [CONSUMER-STYLING.md](../../app/components/05.forms/input-select/CONSUMER-STYLING.md) for the
45
- full table with defaults.
46
-
47
- **Common tokens:**
48
- - `--input-select-surface` / `-surface-hover` — wrapper/select background and option-row hover background, fall back to `--theme-input-surface`/`-surface-hover`
49
- - `--input-select-border` / `-border-hover` / `-border-focus` — border colour resting, mouse-hover outline, and `:focus-visible` outline (also the open-picker's own border/outline), fall back to `--theme-border`/`--theme-border-focus`
50
-
51
- ---
52
-
53
- ## Browser support: `appearance: base-select`
54
-
55
- The open-picker styling (`&::picker(select)`, `:open`/`:not(:open)` states, the transition/opacity
56
- `@starting-style` reveal) relies on the CSS Customizable Select API
57
- (`appearance: base-select`/`::picker(select)`), a very new feature not yet supported everywhere.
58
- The component degrades gracefully in non-supporting browsers rather than breaking: `appearance:
59
- none` is set first (unconditionally), then overridden by `appearance: base-select` only where the
60
- browser recognizes that value — an unsupported value is simply ignored by the CSS parser, so the
61
- select falls back to the browser's native (unstyled) dropdown instead of losing functionality.
62
- Per pitfall #19's general lesson (checking WebKit support specifically for load-bearing use of a
63
- very new CSS feature, since Chrome's device-emulation mode can't catch a WebKit-only gap): this
64
- fallback is CSS-only degradation, not a functional break like `ExpandingPanel`'s `::details-content`
65
- case was, so it's lower risk — but the open-picker's custom border/outline/transition styling
66
- should still be spot-checked on a real Safari session before treating it as visually final.
67
-
68
- ---
69
-
70
- ## Fixed 2026-09-22: dead functionality and prop-shape bugs
71
-
72
- `InputSelectCore` migrated to full compliance from a `variants:true`, `eslint_issues:true`,
73
- score-2/5 state (no tests, no skill doc, no snippet).
74
-
75
- - **`required` prop was declared but never bound to the native `<select>`** — native HTML
76
- `required` validation was completely inert regardless of the prop's value. Fixed by adding
77
- `:required` to the select.
78
- - **`styleClassPassthrough` prop was declared but never consumed** — no `useStyleClassPassthrough`
79
- call existed anywhere in the script, and the select's `class` was a hardcoded static string.
80
- The prop was entirely dead; a consumer passing it had no effect. Fixed by wiring up
81
- `useStyleClassPassthrough` and binding `elementClasses` alongside the static class.
82
- - **`isDirty`/`isActive` models were declared and read in the wrapper's CSS class bindings
83
- (`{ dirty: isDirty }`, `{ active: isActive }`), but nothing ever set them** — no `focusin`/
84
- `focusout`/`change` handlers existed at all, unlike `InputTextareaCore`'s `updateFocus`
85
- pattern. The `.dirty`/`.active` CSS states could never trigger. Fixed by adding
86
- `@focusin`/`@focusout` (toggling `isActive`) and `@change` (setting `isDirty = true`) directly
87
- on the select.
88
- - **The placeholder `<option>` used `readonly`**, which is not a valid/supported HTML attribute
89
- on `<option>` — a silent no-op, leaving the placeholder fully selectable as a real answer
90
- (defeating its purpose). Fixed by switching to `disabled`, the correct native pattern for a
91
- non-selectable placeholder option.
92
- - **`theme`/`inputVariant` were typed as inline unions** (`"default" | "success" | "error" |
93
- "warning"` / `"normal" | "outlined" | "underlined"`) instead of importing the shared
94
- `FormUiTheme`/`InputUiVariant` types — aligned with the rest of `05.forms`.
95
- - **`fieldData`'s `defineModel` used an untyped `defineModel("fieldData") as Ref<T>` cast**
96
- instead of the proper generic `defineModel<T>("fieldData")` call, and had no default — the
97
- `as` cast also hid the prop from ESLint's `vue/require-default-prop` detection entirely (it
98
- didn't fire on `InputSelectCore.vue`, only on `InputSelectWithLabel.vue`'s correctly-typed
99
- equivalent). Fixed by using the proper generic form with `{ required: true }` on both files —
100
- see the `v-model:field-data` note above for why `required` is the meaningful fix here (the
101
- template would otherwise crash on `fieldData.data` with no data bound).
102
- - **Dead commented-out CSS** (~8 lines, a "legacy support - eg, Safari" `::after` arrow
103
- fallback that was never active) and a **redundant duplicate declaration**
104
- (`&.underlined { background-color: var(--_surface); }`, re-setting a value the unscoped base
105
- rule already applied unconditionally) were removed.
106
- - **CSS token cleanup**: `--_surface`, `--_surface-hover`, `--_border-hover`, and
107
- `--_outline-color` were each used at exactly one point with no composition or state-swap
108
- behind them (`--_outline-color` was a bare alias for `--_border-focus`, per pitfall #20) — all
109
- four inlined directly to their public `var(--input-select-*, {default})` form at their point of
110
- use. `--_border` (reused across `.normal`/`.underlined`/open-picker) and `--_border-focus`
111
- (used directly once, plus now the open-picker's outline too) correctly stayed private.
112
-
113
- ---
114
-
115
- ## Variants
116
-
117
- ### InputSelectWithLabel
118
-
119
- `InputSelectWithLabel` (`InputSelectWithLabel.vue`) composes `InputSelectCore` with
120
- `InputLabel`, `InputDescription` (positioned before or after the field depending on
121
- `inputVariant === "outlined"`, matching `InputTextWithLabel`'s exact placement rule), and
122
- `InputError`.
123
-
124
- **Additional props over InputSelectCore:**
125
-
126
- | Prop (template form) | Type | Default | Notes |
127
- |------|------|---------|-------|
128
- | `:data-testid` | `string` | `"input-select-with-label"` | Sets `data-testid` on the wrapper — used for the wrapper element specifically (`:data-testid` shorthand resolves to the camelCase `dataTestid` prop, same convention as `:aria-describedby`). |
129
- | `:label` | `string` | (required) | Rendered via `InputLabel`. |
130
- | `:error-message` | `object \| string` | (required) | Rendered via `InputError` when `field-has-error` is true. |
131
-
132
- **Slots**: `descriptionHtml`, `descriptionText` (both forwarded to `InputDescription`).
133
-
134
- ```vue
135
- <InputSelectWithLabel
136
- name="colour"
137
- label="Favourite colour"
138
- v-model="colour"
139
- v-model:field-data="colourOptions"
140
- placeholder="Choose a colour"
141
- :error-message="errors.colour"
142
- :field-has-error="!!errors.colour"
143
- >
144
- <template #descriptionText>Pick the colour you like best</template>
145
- </InputSelectWithLabel>
146
- ```
147
-
148
- `v-model` and `v-model:field-data` are both **required** — same as `InputSelectCore` above. Note
149
- this wrapper passes `:theme="FormUiTheme"` (a local `computed`, not the raw `theme` prop) down to
150
- `InputSelectCore`, so the theme automatically switches to `"error"` whenever `fieldHasError` is
151
- true, regardless of the `theme` prop's own value.