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
|
@@ -42,7 +42,6 @@
|
|
|
42
42
|
:maxlength
|
|
43
43
|
:name
|
|
44
44
|
:placeholder
|
|
45
|
-
:label
|
|
46
45
|
:field-has-error
|
|
47
46
|
:required
|
|
48
47
|
:style-class-passthrough
|
|
@@ -106,24 +105,15 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
106
105
|
|
|
107
106
|
const slots = useSlots();
|
|
108
107
|
|
|
109
|
-
const FormUiTheme = computed(() => {
|
|
110
|
-
return props.fieldHasError ? "error" : props.theme;
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// const id = `${props.name}-${useId()}`;
|
|
114
|
-
// const errorId = `${id}-error-message`;
|
|
115
|
-
// const ariaDescribedby = computed(() => {
|
|
116
|
-
// const ariaDescribedbyId = slots.descriptionText || slots.descriptionHtml ? `${id}-description` : undefined;
|
|
117
|
-
// return props.fieldHasError ? errorId : ariaDescribedbyId;
|
|
118
|
-
// });
|
|
119
|
-
|
|
120
108
|
const { id, errorId, descriptionId, ariaDescribedby } = useAriaDescribedById(
|
|
121
109
|
props.name,
|
|
122
110
|
toRef(props, "fieldHasError"),
|
|
123
111
|
slots
|
|
124
112
|
);
|
|
125
113
|
|
|
126
|
-
const modelValue = defineModel<string | number | readonly string[] | null | undefined>(
|
|
114
|
+
const modelValue = defineModel<string | number | readonly string[] | null | undefined>({
|
|
115
|
+
required: true,
|
|
116
|
+
});
|
|
127
117
|
const isActive = ref<boolean>(false);
|
|
128
118
|
const isDirty = ref<boolean>(false);
|
|
129
119
|
|
|
@@ -0,0 +1,93 @@
|
|
|
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 "../InputTextareaWithLabel.vue";
|
|
5
|
+
|
|
6
|
+
const initialPropsData = {
|
|
7
|
+
name: "message",
|
|
8
|
+
label: "Message",
|
|
9
|
+
errorMessage: "",
|
|
10
|
+
modelValue: "",
|
|
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
|
+
describe("InputTextareaWithLabel", () => {
|
|
25
|
+
it("mounts without error", async () => {
|
|
26
|
+
wrapper = await wrapperFactory();
|
|
27
|
+
expect(wrapper.vm).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("renders the label text", async () => {
|
|
31
|
+
wrapper = await wrapperFactory({ label: "Feedback" });
|
|
32
|
+
expect(wrapper.text()).toContain("Feedback");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("renders a native textarea", async () => {
|
|
36
|
+
wrapper = await wrapperFactory();
|
|
37
|
+
expect(wrapper.find("textarea").exists()).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("does not leak a label attribute onto InputTextareaCore's wrapper", async () => {
|
|
41
|
+
// Regression test: InputTextareaCore has no `label` prop (matches InputTextCore), so passing
|
|
42
|
+
// it down would fall through as a meaningless raw `label` HTML attribute on the wrapper div.
|
|
43
|
+
wrapper = await wrapperFactory({ label: "Feedback" });
|
|
44
|
+
expect(wrapper.find(".input-textarea-wrapper").attributes("label")).toBeUndefined();
|
|
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-textarea-with-label").attributes("data-invalid")).toBe("");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("renders the error message when fieldHasError is true", async () => {
|
|
53
|
+
wrapper = await wrapperFactory({ fieldHasError: true, errorMessage: "This field is required" });
|
|
54
|
+
expect(wrapper.text()).toContain("This field is required");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("renders the descriptionText slot", async () => {
|
|
58
|
+
wrapper = await wrapperFactory(
|
|
59
|
+
{},
|
|
60
|
+
{ descriptionText: () => "Please provide more detail" }
|
|
61
|
+
);
|
|
62
|
+
expect(wrapper.text()).toContain("Please provide more detail");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("renders the left/right slots when provided", async () => {
|
|
66
|
+
wrapper = await wrapperFactory(
|
|
67
|
+
{},
|
|
68
|
+
{
|
|
69
|
+
left: '<span data-testid="left-slot">📝</span>',
|
|
70
|
+
right: '<span data-testid="right-slot">✨</span>',
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(wrapper.find('[data-testid="left-slot"]').exists()).toBe(true);
|
|
75
|
+
expect(wrapper.find('[data-testid="right-slot"]').exists()).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("sets isDirty true once the value becomes non-empty", async () => {
|
|
79
|
+
wrapper = await wrapperFactory({ modelValue: "hello" });
|
|
80
|
+
const vm = wrapper.vm as unknown as { isDirty: boolean };
|
|
81
|
+
expect(vm.isDirty).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("applies inputVariant as a class on the wrapper", async () => {
|
|
85
|
+
wrapper = await wrapperFactory({ inputVariant: "underlined" });
|
|
86
|
+
expect(wrapper.find(".input-textarea-with-label").classes()).toContain("underlined");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("applies styleClassPassthrough", async () => {
|
|
90
|
+
wrapper = await wrapperFactory({ styleClassPassthrough: "custom-textarea" });
|
|
91
|
+
expect(wrapper.find(".input-textarea-with-label").classes()).toContain("custom-textarea");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -2,10 +2,14 @@ import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
|
2
2
|
import { reactive } from "vue";
|
|
3
3
|
import InputTextWithLabel from "../../input-text/variants/InputTextWithLabel.vue";
|
|
4
4
|
import InputRangeDefault from "../../input-range/variants/InputRangeDefault.vue";
|
|
5
|
+
import InputNumberDefault from "../../input-number/variants/InputNumberDefault.vue";
|
|
6
|
+
import InputTextareaWithLabel from "../../input-textarea/variants/InputTextareaWithLabel.vue";
|
|
7
|
+
import InputSelectWithLabel from "../../input-select/variants/InputSelectWithLabel.vue";
|
|
8
|
+
import ToggleSwitchWithLabel from "../../toggle-switch/variants/ToggleSwitchWithLabel.vue";
|
|
5
9
|
import InputButtonCore from "../../input-button/InputButtonCore.vue";
|
|
6
10
|
import FormField from "../../form-field/FormField.vue";
|
|
7
11
|
import HeroText from "../../../01.atoms/text-blocks/hero-text/HeroText.vue";
|
|
8
|
-
import type { InputUiVariant } from "~/types/forms/types.forms.d";
|
|
12
|
+
import type { InputUiVariant, IFormMultipleOptions } from "~/types/forms/types.forms.d";
|
|
9
13
|
|
|
10
14
|
interface MigratedFieldsFormStoryArgs {
|
|
11
15
|
inputVariant: InputUiVariant;
|
|
@@ -13,9 +17,11 @@ interface MigratedFieldsFormStoryArgs {
|
|
|
13
17
|
|
|
14
18
|
// Living reference, not a component of its own — one field per 05.forms component group that
|
|
15
19
|
// currently scores 5/5 in the Component Ledger (.claude/component-ledger/audit.json: tier folder,
|
|
16
|
-
// tests, story, skill doc, CONSUMER-STYLING.md, VS Code snippet). As of 2026-09-
|
|
17
|
-
// InputTextCore (via InputTextWithLabel)
|
|
18
|
-
//
|
|
20
|
+
// tests, story, skill doc, CONSUMER-STYLING.md, VS Code snippet). As of 2026-09-23 that's
|
|
21
|
+
// InputTextCore (via InputTextWithLabel), InputRangeCore (via InputRangeDefault), InputNumberCore
|
|
22
|
+
// (via InputNumberDefault), InputTextareaCore (via InputTextareaWithLabel), InputSelectCore
|
|
23
|
+
// (via InputSelectWithLabel), and ToggleSwitchCore (via ToggleSwitchWithLabel) — every other
|
|
24
|
+
// 05.forms component (checkbox, radio, ...) is still mid-migration.
|
|
19
25
|
// Add a field here each time /migrate-component brings another 05.forms component up to 5/5, so
|
|
20
26
|
// this story doubles as a visible migration-progress tracker rather than living only in the
|
|
21
27
|
// ledger's HTML output. No validation wiring (useZodValidation/zod) here — that's the consuming
|
|
@@ -39,6 +45,10 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
|
|
|
39
45
|
components: {
|
|
40
46
|
InputTextWithLabel,
|
|
41
47
|
InputRangeDefault,
|
|
48
|
+
InputNumberDefault,
|
|
49
|
+
InputTextareaWithLabel,
|
|
50
|
+
InputSelectWithLabel,
|
|
51
|
+
ToggleSwitchWithLabel,
|
|
42
52
|
InputButtonCore,
|
|
43
53
|
FormField,
|
|
44
54
|
HeroText,
|
|
@@ -47,28 +57,53 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
|
|
|
47
57
|
const state = reactive({
|
|
48
58
|
fullName: "",
|
|
49
59
|
budget: 50,
|
|
60
|
+
quantity: 1,
|
|
61
|
+
notes: "",
|
|
62
|
+
colour: "",
|
|
63
|
+
subscribe: false,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const colourOptions = reactive<IFormMultipleOptions>({
|
|
67
|
+
data: [
|
|
68
|
+
{ id: "1", name: "red", value: "red", label: "Red" },
|
|
69
|
+
{ id: "2", name: "blue", value: "blue", label: "Blue" },
|
|
70
|
+
{ id: "3", name: "green", value: "green", label: "Green" },
|
|
71
|
+
],
|
|
72
|
+
total: 3,
|
|
73
|
+
skip: 0,
|
|
74
|
+
limit: 10,
|
|
50
75
|
});
|
|
51
76
|
|
|
52
77
|
// Demo-only "validation" — a real consuming app would wire this through something like
|
|
53
78
|
// useZodValidation instead (see this file's top comment). Kept deliberately simple so the
|
|
54
|
-
// Continue button has an obvious, reliable way to trigger
|
|
55
|
-
// exercising InputTextWithLabel/InputRangeDefault
|
|
79
|
+
// Continue button has an obvious, reliable way to trigger each field's error state for
|
|
80
|
+
// exercising InputTextWithLabel/InputRangeDefault/InputNumberDefault/InputTextareaWithLabel/
|
|
81
|
+
// InputSelectWithLabel's error UI in Storybook.
|
|
56
82
|
const errors = reactive({
|
|
57
83
|
fullName: "",
|
|
58
84
|
budget: "",
|
|
85
|
+
quantity: "",
|
|
86
|
+
notes: "",
|
|
87
|
+
colour: "",
|
|
59
88
|
});
|
|
60
89
|
|
|
61
90
|
const validate = () => {
|
|
62
91
|
errors.fullName = state.fullName.trim() ? "" : "Full name is required";
|
|
63
92
|
errors.budget = state.budget >= 100 ? "" : "Budget must be at least £100";
|
|
93
|
+
errors.quantity = state.quantity >= 1 ? "" : "Quantity must be at least 1";
|
|
94
|
+
errors.notes = state.notes.trim() ? "" : "Notes are required";
|
|
95
|
+
errors.colour = state.colour ? "" : "Please choose a colour";
|
|
64
96
|
};
|
|
65
97
|
|
|
66
98
|
const clearErrors = () => {
|
|
67
99
|
errors.fullName = "";
|
|
68
100
|
errors.budget = "";
|
|
101
|
+
errors.quantity = "";
|
|
102
|
+
errors.notes = "";
|
|
103
|
+
errors.colour = "";
|
|
69
104
|
};
|
|
70
105
|
|
|
71
|
-
return { args, state, errors, validate, clearErrors };
|
|
106
|
+
return { args, state, errors, validate, clearErrors, colourOptions };
|
|
72
107
|
},
|
|
73
108
|
template: `
|
|
74
109
|
<div style="margin: 36px; max-width: 480px;">
|
|
@@ -78,9 +113,10 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
|
|
|
78
113
|
:text-content="[{ text: 'Migrated fields', styleClass: 'normal' }]"
|
|
79
114
|
:style-class-passthrough="['mbe-20']"
|
|
80
115
|
/>
|
|
81
|
-
<p style="margin: 0 0 2rem 0; color:
|
|
116
|
+
<p style="margin: 0 0 2rem 0; color: #475569; font-size: 1.4rem;">
|
|
82
117
|
One field per 05.forms component that's fully migrated (5/5 on the Component Ledger).
|
|
83
|
-
Click Continue with an empty name
|
|
118
|
+
Click Continue with an empty name, a budget under £100, a quantity under 1, empty notes,
|
|
119
|
+
or no colour chosen to see the error states.
|
|
84
120
|
</p>
|
|
85
121
|
|
|
86
122
|
<form novalidate @submit.prevent="validate">
|
|
@@ -114,6 +150,51 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
|
|
|
114
150
|
</InputRangeDefault>
|
|
115
151
|
</FormField>
|
|
116
152
|
|
|
153
|
+
<FormField width="wide" :has-gutter="false">
|
|
154
|
+
<InputNumberDefault
|
|
155
|
+
v-model="state.quantity"
|
|
156
|
+
name="quantity"
|
|
157
|
+
label="Quantity"
|
|
158
|
+
:min="1"
|
|
159
|
+
:max="10"
|
|
160
|
+
:error-message="errors.quantity"
|
|
161
|
+
:field-has-error="!!errors.quantity"
|
|
162
|
+
:input-variant="args.inputVariant"
|
|
163
|
+
>
|
|
164
|
+
<template #left><span aria-hidden="true">−</span></template>
|
|
165
|
+
<template #right><span aria-hidden="true">+</span></template>
|
|
166
|
+
</InputNumberDefault>
|
|
167
|
+
</FormField>
|
|
168
|
+
|
|
169
|
+
<FormField width="wide" :has-gutter="false">
|
|
170
|
+
<InputTextareaWithLabel
|
|
171
|
+
v-model="state.notes"
|
|
172
|
+
name="notes"
|
|
173
|
+
label="Notes"
|
|
174
|
+
placeholder="Anything else we should know?"
|
|
175
|
+
:error-message="errors.notes"
|
|
176
|
+
:field-has-error="!!errors.notes"
|
|
177
|
+
:input-variant="args.inputVariant"
|
|
178
|
+
/>
|
|
179
|
+
</FormField>
|
|
180
|
+
|
|
181
|
+
<FormField width="wide" :has-gutter="false">
|
|
182
|
+
<InputSelectWithLabel
|
|
183
|
+
v-model="state.colour"
|
|
184
|
+
v-model:field-data="colourOptions"
|
|
185
|
+
name="colour"
|
|
186
|
+
label="Favourite colour"
|
|
187
|
+
placeholder="Choose a colour"
|
|
188
|
+
:error-message="errors.colour"
|
|
189
|
+
:field-has-error="!!errors.colour"
|
|
190
|
+
:input-variant="args.inputVariant"
|
|
191
|
+
/>
|
|
192
|
+
</FormField>
|
|
193
|
+
|
|
194
|
+
<FormField width="wide" :has-gutter="false">
|
|
195
|
+
<ToggleSwitchWithLabel v-model="state.subscribe" name="subscribe" label="Subscribe to updates" />
|
|
196
|
+
</FormField>
|
|
197
|
+
|
|
117
198
|
<FormField width="wide" :has-gutter="false">
|
|
118
199
|
<div style="display: flex; gap: 1.2rem;">
|
|
119
200
|
<InputButtonCore type="submit" variant="primary" button-text="Continue" />
|
|
@@ -123,10 +204,14 @@ const Template: StoryFn<MigratedFieldsFormStoryArgs> = (args) => ({
|
|
|
123
204
|
</form>
|
|
124
205
|
|
|
125
206
|
<div
|
|
126
|
-
style="margin-top: 2rem; padding: 1.6rem; border-radius: 0.8rem; background:
|
|
207
|
+
style="margin-top: 2rem; padding: 1.6rem; border-radius: 0.8rem; background: #f8fafc; font-family: monospace; font-size: 1.3rem;"
|
|
127
208
|
>
|
|
128
209
|
<div>fullName: {{ state.fullName || '""' }}</div>
|
|
129
210
|
<div>budget: £{{ state.budget }}</div>
|
|
211
|
+
<div>quantity: {{ state.quantity }}</div>
|
|
212
|
+
<div>notes: {{ state.notes || '""' }}</div>
|
|
213
|
+
<div>colour: {{ state.colour || '""' }}</div>
|
|
214
|
+
<div>subscribe: {{ state.subscribe }}</div>
|
|
130
215
|
</div>
|
|
131
216
|
</div>
|
|
132
217
|
`,
|
|
@@ -23,6 +23,13 @@ token-rename pass, not a redesign.
|
|
|
23
23
|
Geometry (`--input-toggle-*`, `--form-element-*`) is shared across every `05.forms` component and
|
|
24
24
|
untouched by this — see `theming-form-geometry-tokens.md`.
|
|
25
25
|
|
|
26
|
+
### Shape (`round` prop)
|
|
27
|
+
|
|
28
|
+
`--toggle-switch-border-radius` controls the track and thumb's shared `border-radius`. Left unset,
|
|
29
|
+
it defaults to `100vw` (pill shape) when the `round` prop is `true` (the default), or `0.4rem`
|
|
30
|
+
(a rounded-square shape) when `round` is `false` — see the `SquareShape` story. Set the token
|
|
31
|
+
directly to override with an exact value the two-state default doesn't produce.
|
|
32
|
+
|
|
26
33
|
### Track width
|
|
27
34
|
|
|
28
35
|
`--toggle-switch-track-width` overrides the track's width directly. Left unset, the track derives
|
|
@@ -64,3 +71,25 @@ fixed width independent of symbol size).
|
|
|
64
71
|
```vue
|
|
65
72
|
<ToggleSwitchCore id="notifications" name="notifications" style="--toggle-switch-symbol-surface-on: var(--gold-06);" />
|
|
66
73
|
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Migration notes (2026-09-23)
|
|
78
|
+
|
|
79
|
+
- **`round` prop was previously dead** — `ToggleSwitchWithLabel`/`ToggleSwitchWithLabelInline`
|
|
80
|
+
both accepted and forwarded it, but `ToggleSwitchCore` never declared the prop or read it, so
|
|
81
|
+
the Storybook `SquareShape` story rendered identically to `Default`. Fixed by adding `round` to
|
|
82
|
+
`ToggleSwitchCore` and wiring it to the new `--toggle-switch-border-radius` token (see Shape
|
|
83
|
+
above).
|
|
84
|
+
- **`fieldHasError` didn't visually theme the component** — `ToggleSwitchCore` and
|
|
85
|
+
`ToggleSwitchWithLabel` both computed an error-aware theme value but bound `data-theme` to the
|
|
86
|
+
raw `theme` prop instead, so setting `fieldHasError` never applied this library's
|
|
87
|
+
`[data-theme="error"]` theme-token overrides (`_error.css`). Fixed by binding `data-theme` to the
|
|
88
|
+
computed value.
|
|
89
|
+
- Custom `iconOn`/`iconOff` slot content could render as a non-square box (e.g. a 16×16 SVG
|
|
90
|
+
rendering as ~16×20), throwing off centering inside the thumb — fixed by adding
|
|
91
|
+
`line-height: 0` to `.symbol-icon`, which collapses inline leading for any slotted content.
|
|
92
|
+
- Two dead, unread `--_transition-duration` private locals were removed from
|
|
93
|
+
`ToggleSwitchWithLabel`/`ToggleSwitchWithLabelInline` — nothing in either file's CSS or
|
|
94
|
+
`ToggleSwitchCore`'s ever read them (the actual transition durations are hardcoded `0.4s`
|
|
95
|
+
literals in `ToggleSwitchCore`).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="toggle-switch-core" :class="elementClasses" :data-theme="
|
|
3
|
-
<div class="toggle-switch-wrapper" :class="wrapperClasses"
|
|
2
|
+
<div class="toggle-switch-core" :class="elementClasses" :data-theme="formUiTheme">
|
|
3
|
+
<div class="toggle-switch-wrapper" :class="wrapperClasses" @click="toggleSwitchValue">
|
|
4
4
|
<input
|
|
5
5
|
:id="inputId"
|
|
6
6
|
v-model="modelValue"
|
|
@@ -44,6 +44,7 @@ interface Props {
|
|
|
44
44
|
falseValue?: string | number | boolean;
|
|
45
45
|
styleClassPassthrough?: string | string[];
|
|
46
46
|
theme?: FormUiTheme;
|
|
47
|
+
round?: boolean;
|
|
47
48
|
ariaDescribedby?: string;
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -54,17 +55,20 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
54
55
|
falseValue: false,
|
|
55
56
|
styleClassPassthrough: () => [],
|
|
56
57
|
theme: "default",
|
|
58
|
+
round: true,
|
|
57
59
|
ariaDescribedby: "",
|
|
58
60
|
});
|
|
59
61
|
|
|
60
62
|
const slots = useSlots();
|
|
61
63
|
const useDefaultIcons = computed(() => !slots.iconOn && !slots.iconOff);
|
|
62
64
|
|
|
63
|
-
const
|
|
65
|
+
const formUiTheme = computed(() => {
|
|
64
66
|
return props.fieldHasError ? "error" : props.theme;
|
|
65
67
|
});
|
|
66
68
|
|
|
67
|
-
const
|
|
69
|
+
const roundedRadiusDefault = computed(() => (props.round ? "100vw" : "0.4rem"));
|
|
70
|
+
|
|
71
|
+
const modelValue = defineModel<string | number | boolean>({ required: true });
|
|
68
72
|
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
69
73
|
|
|
70
74
|
const inputId = computed(() => `toggle-switch-${props.id}`);
|
|
@@ -90,156 +94,153 @@ const toggleSwitchValue = () => {
|
|
|
90
94
|
|
|
91
95
|
<style lang="css">
|
|
92
96
|
@layer components {
|
|
93
|
-
.toggle-switch-core {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
.toggle-switch-core {
|
|
98
|
+
.toggle-switch-label {
|
|
99
|
+
display: block;
|
|
100
|
+
}
|
|
97
101
|
|
|
98
|
-
|
|
99
|
-
|
|
102
|
+
.toggle-switch-wrapper {
|
|
103
|
+
/* --_icon-font-size, --_switch-padding, --_toggle-symbol-border-width are the only locals
|
|
100
104
|
actually read anywhere below — --_icon-on-opacity, --_icon-off-opacity,
|
|
101
105
|
--_symbol-background-color, --_symbol-margin-inline-start, --_symbol-checked-offset were
|
|
102
106
|
declared here but never referenced by any property in this file; removed as dead code
|
|
103
107
|
rather than migrated, since migrating an unread token would just create a new unread
|
|
104
108
|
public one. */
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
109
|
+
--_icon-font-size: 2.4rem;
|
|
110
|
+
--_switch-padding: 0.2rem;
|
|
111
|
+
--_toggle-symbol-border-width: 0.1rem;
|
|
108
112
|
|
|
109
|
-
|
|
113
|
+
/* Public --toggle-switch-* tokens, inline-fallback to the shared --theme-* tokens (see
|
|
110
114
|
theming-component-token-pattern.md) — overriding one here doesn't touch every other
|
|
111
115
|
themed input/control that also reads --theme-checkbox-symbol-surface/--theme-border. */
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
116
|
+
--_surface: var(--toggle-switch-surface, var(--theme-checkbox-symbol-surface));
|
|
117
|
+
--_surface-hover: var(--toggle-switch-surface-hover, var(--theme-surface-subtle));
|
|
118
|
+
--_border: var(--toggle-switch-border, var(--theme-border));
|
|
119
|
+
--_border-focus: var(--toggle-switch-border-focus, var(--theme-border-focus));
|
|
120
|
+
--_border-radius: var(--toggle-switch-border-radius, v-bind(roundedRadiusDefault));
|
|
121
|
+
|
|
122
|
+
display: flex;
|
|
123
|
+
flex-direction: column;
|
|
124
|
+
|
|
125
|
+
input {
|
|
126
|
+
height: 0;
|
|
127
|
+
width: 0;
|
|
128
|
+
opacity: 0;
|
|
129
|
+
|
|
130
|
+
&:focus-visible {
|
|
131
|
+
+ .symbol-wrapper {
|
|
132
|
+
outline: var(--form-element-outline-width-focus) solid var(--_border-focus);
|
|
133
|
+
outline-offset: var(--form-element-outline-offset-focus);
|
|
134
|
+
}
|
|
129
135
|
}
|
|
130
136
|
}
|
|
131
|
-
}
|
|
132
137
|
|
|
133
|
-
|
|
134
|
-
|
|
138
|
+
.symbol-wrapper {
|
|
139
|
+
/* Geometry */
|
|
135
140
|
|
|
136
|
-
|
|
141
|
+
/* Track width has no direct public token — it's derived from the same geometry tokens
|
|
137
142
|
that size the thumb, so the track always scales with --input-toggle-symbol-size instead
|
|
138
143
|
of clipping/floating it. Breakdown at the default 0.1rem tokens (= 72px total):
|
|
139
144
|
2×symbol-size (6rem) + 2×form-element-border-width (0.2rem, wrapper border) +
|
|
140
145
|
2×wrapper-padding (0.2rem) + 3×symbol-outline-width (0.3rem) +
|
|
141
146
|
3×symbol-border-width (0.3rem) + 2×symbol-margin (0.2rem). Override
|
|
142
147
|
--toggle-switch-track-width directly if you need a value the formula doesn't produce. */
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
/* UI */
|
|
164
|
-
background-color: var(--_surface);
|
|
165
|
-
border: var(--form-element-border-width) solid var(--_border);
|
|
166
|
-
outline: var(--form-element-outline-width) solid transparent;
|
|
167
|
-
border-radius: 100vw;
|
|
168
|
-
width: var(--_track-width);
|
|
169
|
-
padding: calc(var(--input-toggle-wrapper-padding) + var(--input-toggle-symbol-outline-width));
|
|
170
|
-
|
|
171
|
-
&:hover {
|
|
172
|
-
background-color: var(--_surface-hover);
|
|
173
|
-
cursor: pointer;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
.symbol {
|
|
177
|
-
/* Geometry */
|
|
178
|
-
|
|
179
|
-
display: inline-grid;
|
|
180
|
-
grid-template-areas: "icon";
|
|
181
|
-
place-content: center;
|
|
182
|
-
/* position: absolute; */
|
|
183
|
-
overflow: hidden;
|
|
184
|
-
translate: 0 0;
|
|
185
|
-
|
|
186
|
-
aspect-ratio: 1/1;
|
|
148
|
+
--_track-width: var(
|
|
149
|
+
--toggle-switch-track-width,
|
|
150
|
+
calc(
|
|
151
|
+
(2 * var(--input-toggle-symbol-size)) + (2 * var(--form-element-border-width)) +
|
|
152
|
+
(2 * var(--input-toggle-wrapper-padding)) + (3 * var(--input-toggle-symbol-outline-width)) +
|
|
153
|
+
(3 * var(--input-toggle-symbol-border-width)) + (2 * var(--input-toggle-symbol-margin))
|
|
154
|
+
)
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
display: inline-flex;
|
|
158
|
+
align-items: center;
|
|
159
|
+
justify-content: start;
|
|
160
|
+
position: relative;
|
|
161
|
+
transition:
|
|
162
|
+
background-color var(--theme-form-transition-duration) linear,
|
|
163
|
+
border-color var(--theme-form-transition-duration) linear,
|
|
164
|
+
outline var(--theme-form-transition-duration) linear,
|
|
165
|
+
outline-offset var(--theme-form-transition-duration) linear,
|
|
166
|
+
outline var(--theme-form-transition-duration) linear;
|
|
187
167
|
|
|
188
168
|
/* UI */
|
|
189
|
-
|
|
190
|
-
border: var(--
|
|
191
|
-
outline: var(--
|
|
192
|
-
border-radius:
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
background-color: var(--toggle-switch-symbol-surface-on, var(--theme-surface));
|
|
200
|
-
/* left: calc(
|
|
201
|
-
100% - var(--input-toggle-symbol-size) - var(--_switch-padding) - (2 * var(--_toggle-symbol-border-width))
|
|
202
|
-
); */
|
|
203
|
-
/* translate: calc(var(--input-toggle-symbol-size) / 2) 0; */
|
|
204
|
-
/* translate: 1.6rem 0; */
|
|
205
|
-
translate: calc(
|
|
206
|
-
var(--input-toggle-symbol-size) + var(--input-toggle-symbol-border-width) +
|
|
207
|
-
var(--input-toggle-symbol-outline-width)
|
|
208
|
-
)
|
|
209
|
-
0;
|
|
169
|
+
background-color: var(--_surface);
|
|
170
|
+
border: var(--form-element-border-width) solid var(--_border);
|
|
171
|
+
outline: var(--form-element-outline-width) solid transparent;
|
|
172
|
+
border-radius: var(--_border-radius);
|
|
173
|
+
width: var(--_track-width);
|
|
174
|
+
padding: calc(var(--input-toggle-wrapper-padding) + var(--input-toggle-symbol-outline-width));
|
|
175
|
+
|
|
176
|
+
&:hover {
|
|
177
|
+
background-color: var(--_surface-hover);
|
|
178
|
+
cursor: pointer;
|
|
210
179
|
}
|
|
211
180
|
|
|
212
|
-
.symbol
|
|
181
|
+
.symbol {
|
|
213
182
|
/* Geometry */
|
|
214
183
|
|
|
215
|
-
display: grid;
|
|
216
|
-
grid-
|
|
184
|
+
display: inline-grid;
|
|
185
|
+
grid-template-areas: "icon";
|
|
217
186
|
place-content: center;
|
|
218
|
-
|
|
187
|
+
overflow: hidden;
|
|
188
|
+
translate: 0 0;
|
|
189
|
+
|
|
190
|
+
aspect-ratio: 1/1;
|
|
219
191
|
|
|
220
192
|
/* UI */
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
193
|
+
/* padding: calc(calc(var(--input-toggle-symbol-size) - var(--_icon-font-size)) / 2); */
|
|
194
|
+
border: var(--input-toggle-symbol-border-width) solid var(--toggle-switch-symbol-border, var(--theme-text));
|
|
195
|
+
outline: var(--input-toggle-symbol-outline-width) solid transparent;
|
|
196
|
+
border-radius: var(--_border-radius);
|
|
197
|
+
background-color: var(--toggle-switch-symbol-surface-off, var(--theme-on-surface));
|
|
224
198
|
transition:
|
|
225
|
-
|
|
226
|
-
color
|
|
227
|
-
|
|
228
|
-
&.
|
|
229
|
-
color: var(--toggle-switch-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
199
|
+
translate 0.4s ease,
|
|
200
|
+
background-color 0.4s linear;
|
|
201
|
+
|
|
202
|
+
&.checked {
|
|
203
|
+
background-color: var(--toggle-switch-symbol-surface-on, var(--theme-surface));
|
|
204
|
+
translate: calc(
|
|
205
|
+
var(--input-toggle-symbol-size) + var(--input-toggle-symbol-border-width) +
|
|
206
|
+
var(--input-toggle-symbol-outline-width)
|
|
207
|
+
)
|
|
208
|
+
0;
|
|
234
209
|
}
|
|
235
210
|
|
|
236
|
-
|
|
237
|
-
|
|
211
|
+
.symbol-icon {
|
|
212
|
+
/* Geometry */
|
|
213
|
+
|
|
214
|
+
display: grid;
|
|
215
|
+
grid-area: icon;
|
|
216
|
+
place-content: center;
|
|
217
|
+
/* Removes line-box leading so slotted icon content sizes to its own intrinsic height. */
|
|
218
|
+
line-height: 0;
|
|
219
|
+
opacity: 0;
|
|
220
|
+
|
|
221
|
+
/* UI */
|
|
222
|
+
width: var(--input-toggle-symbol-size);
|
|
223
|
+
height: var(--input-toggle-symbol-size);
|
|
224
|
+
margin: var(--input-toggle-symbol-margin);
|
|
225
|
+
transition:
|
|
226
|
+
opacity var(--theme-form-transition-duration),
|
|
227
|
+
color var(--theme-form-transition-duration);
|
|
228
|
+
|
|
229
|
+
&.icon-on {
|
|
230
|
+
color: var(--toggle-switch-icon-on-color, var(--theme-on-surface));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
&.icon-off {
|
|
234
|
+
color: var(--toggle-switch-icon-off-color, var(--theme-surface));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
&.active {
|
|
238
|
+
opacity: 1;
|
|
239
|
+
}
|
|
238
240
|
}
|
|
239
241
|
}
|
|
240
242
|
}
|
|
241
243
|
}
|
|
242
244
|
}
|
|
243
245
|
}
|
|
244
|
-
}
|
|
245
246
|
</style>
|
|
@@ -75,7 +75,7 @@ export default {
|
|
|
75
75
|
// Styling
|
|
76
76
|
theme: {
|
|
77
77
|
control: { type: "select" },
|
|
78
|
-
options: ["
|
|
78
|
+
options: ["default", "success", "error", "warning"],
|
|
79
79
|
description: "Toggle switch theme",
|
|
80
80
|
table: {
|
|
81
81
|
category: "Styling",
|