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.
- package/.claude/skills/component-compliance-checklist.md +12 -1
- package/.claude/skills/component-inline-action-button.md +1 -1
- package/.claude/skills/component-local-style-override.md +1 -1
- package/.claude/skills/component-naming.md +11 -11
- package/.claude/skills/components/container-glow.md +2 -2
- package/.claude/skills/components/form-field.md +2 -2
- package/.claude/skills/components/form-wrapper.md +1 -1
- package/.claude/skills/components/glowing-border.md +1 -1
- package/.claude/skills/components/input-description.md +7 -7
- package/.claude/skills/components/input-error.md +3 -3
- package/.claude/skills/components/input-label.md +7 -8
- package/.claude/skills/components/input-number.md +17 -17
- package/.claude/skills/components/input-select.md +153 -0
- package/.claude/skills/components/input-text.md +220 -0
- package/.claude/skills/components/input-textarea.md +147 -0
- package/.claude/skills/components/select-menu.md +4 -4
- package/.claude/skills/components/toggle-switch.md +143 -0
- package/.claude/skills/composable-zod-validation.md +1 -1
- package/.claude/skills/icon-sets.md +1 -1
- package/.claude/skills/icon-sizing.md +1 -1
- package/.claude/skills/index.md +5 -5
- package/.claude/skills/storybook-add-story.md +1 -1
- package/.claude/skills/theming-component-token-pattern.md +8 -8
- package/.claude/skills/theming-form-geometry-tokens.md +1 -1
- package/.vscode/srcdev-component-input-select.code-snippets +20 -14
- package/.vscode/srcdev-component-input-text.code-snippets +46 -40
- package/.vscode/srcdev-component-input-textarea.code-snippets +19 -14
- package/.vscode/srcdev-component-toggle-switch.code-snippets +19 -29
- package/app/assets/styles/setup/04.elements/forms/00.element-defaults.css +2 -2
- package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +1 -1
- package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +2 -2
- package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +4 -4
- package/app/components/02.molecules/progress-tracker/stories/ProgressTracker.stories.ts +4 -4
- package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +1 -1
- package/app/components/05.forms/form-field/CONSUMER-STYLING.md +2 -2
- package/app/components/05.forms/input-button/stories/InputButton.stories.ts +3 -3
- package/app/components/05.forms/input-description/CONSUMER-STYLING.md +6 -6
- package/app/components/05.forms/input-label/CONSUMER-STYLING.md +5 -5
- package/app/components/05.forms/input-number/CONSUMER-STYLING.md +9 -5
- package/app/components/05.forms/input-number/InputNumber.vue +2 -2
- package/app/components/05.forms/input-select/CONSUMER-STYLING.md +80 -10
- package/app/components/05.forms/input-select/InputSelect.vue +198 -0
- package/app/components/05.forms/input-select/InputSelectField.vue +130 -0
- package/app/components/05.forms/input-select/stories/InputSelect.stories.ts +184 -0
- package/app/components/05.forms/input-select/stories/InputSelectField.stories.ts +187 -0
- package/app/components/05.forms/input-select/tests/{InputSelectCore.spec.ts → InputSelect.spec.ts} +75 -12
- package/app/components/05.forms/input-select/tests/InputSelectField.spec.ts +121 -0
- package/app/components/05.forms/input-text/CONSUMER-STYLING.md +85 -15
- package/app/components/05.forms/input-text/{InputPasswordWithLabel.vue → InputPasswordField.vue} +16 -19
- package/app/components/05.forms/input-text/{InputTextCore.vue → InputText.vue} +42 -78
- package/app/components/05.forms/input-text/{InputTextAsNumberWithLabel.vue → InputTextAsNumberField.vue} +66 -62
- package/app/components/05.forms/input-text/InputTextField.vue +168 -0
- package/app/components/05.forms/input-text/stories/InputPasswordField.stories.ts +163 -0
- package/app/components/05.forms/input-text/stories/InputText.stories.ts +194 -0
- package/app/components/05.forms/input-text/stories/InputTextAsNumberField.stories.ts +182 -0
- package/app/components/05.forms/input-text/stories/InputTextField.stories.ts +206 -0
- package/app/components/05.forms/input-text/tests/{InputPasswordWithLabel.spec.ts → InputPasswordField.spec.ts} +42 -3
- package/app/components/05.forms/input-text/tests/InputText.spec.ts +166 -0
- package/app/components/05.forms/input-text/tests/InputTextAsNumberField.spec.ts +135 -0
- package/app/components/05.forms/input-text/tests/InputTextField.spec.ts +130 -0
- package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +94 -12
- package/app/components/05.forms/input-textarea/InputTextarea.vue +165 -0
- package/app/components/05.forms/input-textarea/InputTextareaField.vue +159 -0
- package/app/components/05.forms/input-textarea/stories/InputTextarea.stories.ts +198 -0
- package/app/components/05.forms/input-textarea/stories/InputTextareaField.stories.ts +161 -0
- package/app/components/05.forms/input-textarea/tests/InputTextarea.spec.ts +149 -0
- package/app/components/05.forms/input-textarea/tests/InputTextareaField.spec.ts +122 -0
- package/app/components/05.forms/patterns/stories/ContactForm.stories.ts +13 -13
- package/app/components/05.forms/patterns/stories/LoginForm.stories.ts +6 -6
- package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +30 -32
- package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +71 -50
- package/app/components/05.forms/toggle-switch/{ToggleSwitchCore.vue → ToggleSwitch.vue} +58 -73
- package/app/components/05.forms/toggle-switch/{ToggleSwitchWithLabel.vue → ToggleSwitchField.vue} +65 -19
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitch.stories.ts +148 -0
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchField.stories.ts +176 -0
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitch.spec.ts +145 -0
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchField.spec.ts +155 -0
- package/package.json +1 -1
- package/.claude/skills/components/input-select-core.md +0 -151
- package/.claude/skills/components/input-text-core.md +0 -204
- package/.claude/skills/components/input-textarea-core.md +0 -154
- package/.claude/skills/components/toggle-switch-core.md +0 -169
- package/app/components/05.forms/input-select/InputSelectCore.vue +0 -188
- package/app/components/05.forms/input-select/InputSelectWithLabel.vue +0 -118
- package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +0 -69
- package/app/components/05.forms/input-select/stories/InputSelectWithLabel.stories.ts +0 -115
- package/app/components/05.forms/input-select/tests/InputSelectWithLabel.spec.ts +0 -87
- package/app/components/05.forms/input-text/InputTextWithLabel.vue +0 -154
- package/app/components/05.forms/input-text/stories/InputPasswordWithLabel.stories.ts +0 -368
- package/app/components/05.forms/input-text/stories/InputTextAsNumberWithLabel.stories.ts +0 -480
- package/app/components/05.forms/input-text/stories/InputTextCore.stories.ts +0 -267
- package/app/components/05.forms/input-text/stories/InputTextWithLabel.stories.ts +0 -319
- package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +0 -75
- package/app/components/05.forms/input-text/tests/InputTextCore.spec.ts +0 -60
- package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +0 -71
- package/app/components/05.forms/input-textarea/InputTextareaCore.vue +0 -159
- package/app/components/05.forms/input-textarea/InputTextareaWithLabel.vue +0 -136
- package/app/components/05.forms/input-textarea/stories/InputTextareaCore.stories.ts +0 -247
- package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +0 -458
- package/app/components/05.forms/input-textarea/tests/InputTextareaCore.spec.ts +0 -119
- package/app/components/05.forms/input-textarea/tests/InputTextareaWithLabel.spec.ts +0 -93
- package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabelInline.vue +0 -81
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchCore.stories.ts +0 -251
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +0 -251
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabelInline.stories.ts +0 -283
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchCore.spec.ts +0 -101
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabel.spec.ts +0 -133
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabelInline.spec.ts +0 -66
|
@@ -1,29 +1,36 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
3
|
-
|
|
2
|
+
<div
|
|
3
|
+
class="toggle-switch"
|
|
4
|
+
:class="elementClasses"
|
|
5
|
+
:data-theme="formUiTheme"
|
|
6
|
+
:data-checked="isChecked ? '' : null"
|
|
7
|
+
:data-invalid="fieldHasError ? '' : null"
|
|
8
|
+
>
|
|
9
|
+
<div class="toggle-switch-wrapper" @click="toggleFromTrack">
|
|
4
10
|
<input
|
|
5
11
|
:id="inputId"
|
|
6
12
|
v-model="modelValue"
|
|
13
|
+
class="toggle-switch-input"
|
|
7
14
|
type="checkbox"
|
|
15
|
+
role="switch"
|
|
8
16
|
:true-value
|
|
9
17
|
:false-value
|
|
10
|
-
:aria-invalid="fieldHasError"
|
|
18
|
+
:aria-invalid="fieldHasError ? 'true' : undefined"
|
|
11
19
|
:aria-describedby="ariaDescribedby || undefined"
|
|
12
20
|
:name
|
|
13
21
|
:required
|
|
14
|
-
:checked="isChecked"
|
|
15
22
|
/>
|
|
16
|
-
<div class="
|
|
17
|
-
<div class="
|
|
18
|
-
<div class="
|
|
23
|
+
<div class="toggle-switch-track" aria-hidden="true">
|
|
24
|
+
<div class="toggle-switch-thumb">
|
|
25
|
+
<div class="toggle-switch-icon toggle-switch-icon-on">
|
|
19
26
|
<slot name="iconOn">
|
|
20
|
-
<Icon name="material-symbols:circle-outline"
|
|
27
|
+
<Icon name="material-symbols:circle-outline" />
|
|
21
28
|
</slot>
|
|
22
29
|
</div>
|
|
23
30
|
|
|
24
|
-
<div class="
|
|
31
|
+
<div class="toggle-switch-icon toggle-switch-icon-off">
|
|
25
32
|
<slot name="iconOff">
|
|
26
|
-
<Icon name="material-symbols:circle-outline"
|
|
33
|
+
<Icon name="material-symbols:circle-outline" />
|
|
27
34
|
</slot>
|
|
28
35
|
</div>
|
|
29
36
|
</div>
|
|
@@ -59,74 +66,51 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
59
66
|
ariaDescribedby: "",
|
|
60
67
|
});
|
|
61
68
|
|
|
62
|
-
const slots = useSlots();
|
|
63
|
-
const useDefaultIcons = () => !slots.iconOn && !slots.iconOff;
|
|
64
|
-
|
|
65
|
-
const formUiTheme = computed(() => {
|
|
66
|
-
return props.fieldHasError ? "error" : props.theme;
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
const roundedRadiusDefault = computed(() => (props.round ? "100vw" : "0.4rem"));
|
|
70
|
-
|
|
71
69
|
const modelValue = defineModel<string | number | boolean>({ required: true });
|
|
72
|
-
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
73
70
|
|
|
71
|
+
const formUiTheme = computed(() => (props.fieldHasError ? "error" : props.theme));
|
|
72
|
+
const roundedRadiusDefault = computed(() => (props.round ? "100vw" : "0.4rem"));
|
|
74
73
|
const inputId = computed(() => `toggle-switch-${props.id}`);
|
|
74
|
+
const isChecked = computed(() => modelValue.value === props.trueValue);
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const wrapperClasses = () => ({
|
|
82
|
-
"use-default-icons": useDefaultIcons(),
|
|
83
|
-
});
|
|
76
|
+
// Clicks on the input itself (including a label click or Space) are handled by v-model.
|
|
77
|
+
const toggleFromTrack = (event: MouseEvent) => {
|
|
78
|
+
if ((event.target as HTMLElement).tagName === "INPUT") return;
|
|
79
|
+
modelValue.value = isChecked.value ? props.falseValue : props.trueValue;
|
|
80
|
+
};
|
|
84
81
|
|
|
85
|
-
const
|
|
86
|
-
checked: isChecked.value,
|
|
87
|
-
}));
|
|
82
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
88
83
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
84
|
+
watch(
|
|
85
|
+
() => props.styleClassPassthrough,
|
|
86
|
+
() => {
|
|
87
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
88
|
+
}
|
|
89
|
+
);
|
|
92
90
|
</script>
|
|
93
91
|
|
|
94
92
|
<style lang="css">
|
|
95
93
|
@layer components {
|
|
96
|
-
.toggle-switch
|
|
97
|
-
.toggle-switch-label {
|
|
98
|
-
display: block;
|
|
99
|
-
}
|
|
100
|
-
|
|
94
|
+
.toggle-switch {
|
|
101
95
|
.toggle-switch-wrapper {
|
|
102
96
|
--_border-radius: var(--toggle-switch-border-radius, v-bind(roundedRadiusDefault));
|
|
103
97
|
|
|
104
98
|
display: flex;
|
|
105
99
|
flex-direction: column;
|
|
106
100
|
|
|
107
|
-
input {
|
|
101
|
+
.toggle-switch-input {
|
|
108
102
|
height: 0;
|
|
109
103
|
width: 0;
|
|
110
104
|
opacity: 0;
|
|
111
105
|
|
|
112
|
-
&:focus-visible {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
outline-offset: var(--form-element-outline-offset-focus);
|
|
116
|
-
}
|
|
106
|
+
&:focus-visible + .toggle-switch-track {
|
|
107
|
+
outline: var(--form-element-outline-width-focus) solid var(--toggle-switch-border-focus, var(--theme-border-focus));
|
|
108
|
+
outline-offset: var(--form-element-outline-offset-focus);
|
|
117
109
|
}
|
|
118
110
|
}
|
|
119
111
|
|
|
120
|
-
.
|
|
121
|
-
/*
|
|
122
|
-
|
|
123
|
-
/* Track width has no direct public token — it's derived from the same geometry tokens
|
|
124
|
-
that size the thumb, so the track always scales with --input-toggle-symbol-size instead
|
|
125
|
-
of clipping/floating it. Breakdown at the default 0.1rem tokens (= 72px total):
|
|
126
|
-
2×symbol-size (6rem) + 2×form-element-border-width (0.2rem, wrapper border) +
|
|
127
|
-
2×wrapper-padding (0.2rem) + 3×symbol-outline-width (0.3rem) +
|
|
128
|
-
3×symbol-border-width (0.3rem) + 2×symbol-margin (0.2rem). Override
|
|
129
|
-
--toggle-switch-track-width directly if you need a value the formula doesn't produce. */
|
|
112
|
+
.toggle-switch-track {
|
|
113
|
+
/* Track width scales with the thumb: 2 thumbs plus every border, outline, padding and margin around them. */
|
|
130
114
|
--_track-width: var(
|
|
131
115
|
--toggle-switch-track-width,
|
|
132
116
|
calc(
|
|
@@ -144,10 +128,8 @@ const toggleSwitchValue = () => {
|
|
|
144
128
|
background-color var(--theme-form-transition-duration) linear,
|
|
145
129
|
border-color var(--theme-form-transition-duration) linear,
|
|
146
130
|
outline var(--theme-form-transition-duration) linear,
|
|
147
|
-
outline-offset var(--theme-form-transition-duration) linear
|
|
148
|
-
outline var(--theme-form-transition-duration) linear;
|
|
131
|
+
outline-offset var(--theme-form-transition-duration) linear;
|
|
149
132
|
|
|
150
|
-
/* UI */
|
|
151
133
|
background-color: var(--toggle-switch-surface, var(--theme-checkbox-symbol-surface));
|
|
152
134
|
border: var(--form-element-border-width) solid var(--toggle-switch-border, var(--theme-border));
|
|
153
135
|
outline: var(--form-element-outline-width) solid transparent;
|
|
@@ -160,18 +142,14 @@ const toggleSwitchValue = () => {
|
|
|
160
142
|
cursor: pointer;
|
|
161
143
|
}
|
|
162
144
|
|
|
163
|
-
.
|
|
164
|
-
/* Geometry */
|
|
165
|
-
|
|
145
|
+
.toggle-switch-thumb {
|
|
166
146
|
display: inline-grid;
|
|
167
147
|
grid-template-areas: "icon";
|
|
168
148
|
place-content: center;
|
|
169
149
|
overflow: hidden;
|
|
170
150
|
translate: 0 0;
|
|
171
|
-
|
|
172
151
|
aspect-ratio: 1/1;
|
|
173
152
|
|
|
174
|
-
/* UI */
|
|
175
153
|
border: var(--input-toggle-symbol-border-width) solid var(--toggle-switch-symbol-border, var(--theme-text));
|
|
176
154
|
outline: var(--input-toggle-symbol-outline-width) solid transparent;
|
|
177
155
|
border-radius: var(--_border-radius);
|
|
@@ -180,7 +158,11 @@ const toggleSwitchValue = () => {
|
|
|
180
158
|
translate 0.4s ease,
|
|
181
159
|
background-color 0.4s linear;
|
|
182
160
|
|
|
183
|
-
|
|
161
|
+
@media (prefers-reduced-motion: reduce) {
|
|
162
|
+
transition: none;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.toggle-switch[data-checked] & {
|
|
184
166
|
background-color: var(--toggle-switch-symbol-surface-on, var(--theme-surface));
|
|
185
167
|
translate: calc(
|
|
186
168
|
var(--input-toggle-symbol-size) + var(--input-toggle-symbol-border-width) +
|
|
@@ -189,9 +171,7 @@ const toggleSwitchValue = () => {
|
|
|
189
171
|
0;
|
|
190
172
|
}
|
|
191
173
|
|
|
192
|
-
.
|
|
193
|
-
/* Geometry */
|
|
194
|
-
|
|
174
|
+
.toggle-switch-icon {
|
|
195
175
|
display: grid;
|
|
196
176
|
grid-area: icon;
|
|
197
177
|
place-content: center;
|
|
@@ -199,7 +179,6 @@ const toggleSwitchValue = () => {
|
|
|
199
179
|
line-height: 0;
|
|
200
180
|
opacity: 0;
|
|
201
181
|
|
|
202
|
-
/* UI */
|
|
203
182
|
width: var(--input-toggle-symbol-size);
|
|
204
183
|
height: var(--input-toggle-symbol-size);
|
|
205
184
|
margin: var(--input-toggle-symbol-margin);
|
|
@@ -207,21 +186,27 @@ const toggleSwitchValue = () => {
|
|
|
207
186
|
opacity var(--theme-form-transition-duration),
|
|
208
187
|
color var(--theme-form-transition-duration);
|
|
209
188
|
|
|
210
|
-
&.icon-on {
|
|
189
|
+
&.toggle-switch-icon-on {
|
|
211
190
|
color: var(--toggle-switch-icon-on-color, var(--theme-on-surface));
|
|
212
|
-
}
|
|
213
191
|
|
|
214
|
-
|
|
215
|
-
|
|
192
|
+
.toggle-switch[data-checked] & {
|
|
193
|
+
opacity: 1;
|
|
194
|
+
}
|
|
216
195
|
}
|
|
217
196
|
|
|
218
|
-
&.
|
|
197
|
+
&.toggle-switch-icon-off {
|
|
198
|
+
color: var(--toggle-switch-icon-off-color, var(--theme-surface));
|
|
219
199
|
opacity: 1;
|
|
200
|
+
|
|
201
|
+
.toggle-switch[data-checked] & {
|
|
202
|
+
opacity: 0;
|
|
203
|
+
}
|
|
220
204
|
}
|
|
221
205
|
}
|
|
222
206
|
}
|
|
223
207
|
}
|
|
224
208
|
}
|
|
209
|
+
|
|
225
210
|
}
|
|
226
211
|
}
|
|
227
212
|
</style>
|
package/app/components/05.forms/toggle-switch/{ToggleSwitchWithLabel.vue → ToggleSwitchField.vue}
RENAMED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
2
|
+
<div
|
|
3
|
+
class="toggle-switch-field"
|
|
4
|
+
:class="elementClasses"
|
|
5
|
+
:data-theme="formUiTheme"
|
|
6
|
+
:data-layout="layout"
|
|
7
|
+
:data-invalid="fieldHasError ? '' : null"
|
|
8
|
+
>
|
|
3
9
|
<InputLabel
|
|
4
10
|
:id="toggleSwitchId"
|
|
5
11
|
indicator="none"
|
|
6
12
|
input-variant="normal"
|
|
7
13
|
:field-has-error
|
|
8
|
-
:style-class-passthrough="['
|
|
14
|
+
:style-class-passthrough="['toggle-switch-label', 'input-text-label', labelWeightClass]"
|
|
9
15
|
>
|
|
10
16
|
<template #textLabel>{{ label }}</template>
|
|
11
17
|
</InputLabel>
|
|
@@ -22,7 +28,8 @@
|
|
|
22
28
|
<slot name="descriptionText"></slot>
|
|
23
29
|
</template>
|
|
24
30
|
</InputDescription>
|
|
25
|
-
|
|
31
|
+
|
|
32
|
+
<ToggleSwitch
|
|
26
33
|
:id
|
|
27
34
|
v-model="modelValue"
|
|
28
35
|
:name
|
|
@@ -41,17 +48,20 @@
|
|
|
41
48
|
<template v-if="slots.iconOff" #iconOff>
|
|
42
49
|
<slot name="iconOff"></slot>
|
|
43
50
|
</template>
|
|
44
|
-
</
|
|
51
|
+
</ToggleSwitch>
|
|
52
|
+
|
|
45
53
|
<InputError :id="errorId" :error-message :show-error="fieldHasError" :is-detached="true" />
|
|
46
54
|
</div>
|
|
47
55
|
</template>
|
|
48
56
|
|
|
49
57
|
<script setup lang="ts">
|
|
50
|
-
import type { FormUiTheme } from "~/types/forms/types.forms";
|
|
58
|
+
import type { FormUiTheme, LabelWeight } from "~/types/forms/types.forms";
|
|
51
59
|
|
|
52
60
|
interface Props {
|
|
53
61
|
name: string;
|
|
54
62
|
label: string;
|
|
63
|
+
layout?: "stacked" | "inline";
|
|
64
|
+
labelWeight?: LabelWeight;
|
|
55
65
|
required?: boolean;
|
|
56
66
|
errorMessage?: object | string;
|
|
57
67
|
fieldHasError?: boolean;
|
|
@@ -63,6 +73,8 @@ interface Props {
|
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
const props = withDefaults(defineProps<Props>(), {
|
|
76
|
+
layout: "stacked",
|
|
77
|
+
labelWeight: undefined,
|
|
66
78
|
required: false,
|
|
67
79
|
errorMessage: "",
|
|
68
80
|
fieldHasError: false,
|
|
@@ -75,21 +87,24 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
75
87
|
|
|
76
88
|
const slots = useSlots();
|
|
77
89
|
|
|
78
|
-
const formUiTheme = computed(() =>
|
|
79
|
-
return props.fieldHasError ? "error" : props.theme;
|
|
80
|
-
});
|
|
90
|
+
const formUiTheme = computed(() => (props.fieldHasError ? "error" : props.theme));
|
|
81
91
|
|
|
82
|
-
const
|
|
92
|
+
const labelWeightClassMap: Record<LabelWeight, string> = {
|
|
93
|
+
bold: "body-normal-bold",
|
|
94
|
+
"semi-bold": "body-normal-semibold",
|
|
95
|
+
normal: "body-normal",
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const labelWeightClass = computed(
|
|
99
|
+
() => labelWeightClassMap[props.labelWeight ?? (props.layout === "inline" ? "normal" : "bold")]
|
|
100
|
+
);
|
|
83
101
|
|
|
84
|
-
|
|
102
|
+
const id = useId();
|
|
85
103
|
const toggleSwitchId = computed(() => `toggle-switch-${id}`);
|
|
86
104
|
const descriptionId = computed(() => `${id}-description`);
|
|
87
105
|
const errorId = computed(() => `${id}-error-message`);
|
|
88
106
|
|
|
89
|
-
// A plain function,
|
|
90
|
-
// its first answer if a description slot is added or removed after mount (CLAUDE.md pitfall #25).
|
|
91
|
-
// Lists both ids when there's a description and an error, so the description is still announced
|
|
92
|
-
// while the field is invalid.
|
|
107
|
+
// A plain function, not a computed: slots aren't reactive (CLAUDE.md pitfall #25).
|
|
93
108
|
const ariaDescribedby = () => {
|
|
94
109
|
const ids: string[] = [];
|
|
95
110
|
if (slots.descriptionText || slots.descriptionHtml) ids.push(descriptionId.value);
|
|
@@ -98,15 +113,46 @@ const ariaDescribedby = () => {
|
|
|
98
113
|
};
|
|
99
114
|
|
|
100
115
|
const modelValue = defineModel<string | number | boolean>({ required: true });
|
|
101
|
-
|
|
116
|
+
|
|
117
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
118
|
+
|
|
119
|
+
watch(
|
|
120
|
+
() => props.styleClassPassthrough,
|
|
121
|
+
() => {
|
|
122
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
123
|
+
}
|
|
124
|
+
);
|
|
102
125
|
</script>
|
|
103
126
|
|
|
104
127
|
<style lang="css">
|
|
105
128
|
@layer components {
|
|
106
|
-
.toggle-switch-
|
|
107
|
-
|
|
108
|
-
|
|
129
|
+
.toggle-switch-field {
|
|
130
|
+
min-inline-size: 0;
|
|
131
|
+
overflow-wrap: anywhere;
|
|
132
|
+
|
|
133
|
+
.toggle-switch-label {
|
|
134
|
+
display: block;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
&[data-layout="inline"] {
|
|
138
|
+
display: grid;
|
|
139
|
+
grid-template-columns: minmax(0, max-content) auto;
|
|
140
|
+
align-items: center;
|
|
141
|
+
justify-content: start;
|
|
142
|
+
column-gap: var(--toggle-switch-field-inline-gap, 1.2rem);
|
|
143
|
+
|
|
144
|
+
> * {
|
|
145
|
+
grid-column: 1 / -1;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
> .toggle-switch-label {
|
|
149
|
+
grid-area: 1 / 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
> .toggle-switch {
|
|
153
|
+
grid-area: 1 / 2;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
109
156
|
}
|
|
110
157
|
}
|
|
111
|
-
}
|
|
112
158
|
</style>
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
2
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
import ToggleSwitch from "../ToggleSwitch.vue";
|
|
4
|
+
import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
|
|
5
|
+
import type { FormUiTheme } from "~/types/forms/types.forms.d";
|
|
6
|
+
import type { MediaCanvas } from "~/types/components";
|
|
7
|
+
|
|
8
|
+
interface StoryArgs {
|
|
9
|
+
modelValue: string | number | boolean;
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
required: boolean;
|
|
13
|
+
fieldHasError: boolean;
|
|
14
|
+
trueValue: string | number | boolean;
|
|
15
|
+
falseValue: string | number | boolean;
|
|
16
|
+
theme: FormUiTheme;
|
|
17
|
+
round: boolean;
|
|
18
|
+
styleClassPassthrough: string[];
|
|
19
|
+
useCustomIcons: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const meta: Meta<StoryArgs> = {
|
|
23
|
+
title: "Components/Forms/Toggle Switch/ToggleSwitch",
|
|
24
|
+
component: ToggleSwitch,
|
|
25
|
+
argTypes: {
|
|
26
|
+
modelValue: { control: "boolean", table: { category: "Model" } },
|
|
27
|
+
id: { control: "text", table: { category: "Basic" } },
|
|
28
|
+
name: { control: "text", table: { category: "Basic" } },
|
|
29
|
+
required: { control: "boolean", table: { category: "Validation" } },
|
|
30
|
+
fieldHasError: { control: "boolean", table: { category: "Validation" } },
|
|
31
|
+
trueValue: { control: "text", table: { category: "Values" } },
|
|
32
|
+
falseValue: { control: "text", table: { category: "Values" } },
|
|
33
|
+
theme: {
|
|
34
|
+
control: { type: "select" },
|
|
35
|
+
options: ["default", "success", "error", "warning"],
|
|
36
|
+
table: { category: "Styling" },
|
|
37
|
+
},
|
|
38
|
+
round: { control: "boolean", table: { category: "Styling" } },
|
|
39
|
+
styleClassPassthrough: { control: "object", table: { category: "Styling" } },
|
|
40
|
+
useCustomIcons: {
|
|
41
|
+
control: "boolean",
|
|
42
|
+
description: "Story-only: fills the iconOn/iconOff slots",
|
|
43
|
+
table: { category: "Slots" },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
args: {
|
|
47
|
+
modelValue: false,
|
|
48
|
+
id: "notifications",
|
|
49
|
+
name: "notifications",
|
|
50
|
+
required: false,
|
|
51
|
+
fieldHasError: false,
|
|
52
|
+
trueValue: true,
|
|
53
|
+
falseValue: false,
|
|
54
|
+
theme: "default",
|
|
55
|
+
round: true,
|
|
56
|
+
styleClassPassthrough: [],
|
|
57
|
+
useCustomIcons: false,
|
|
58
|
+
},
|
|
59
|
+
decorators: [
|
|
60
|
+
(story, context) => ({
|
|
61
|
+
components: { story, CanvasSwitcher },
|
|
62
|
+
setup() {
|
|
63
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
|
|
64
|
+
return { canvasName };
|
|
65
|
+
},
|
|
66
|
+
template: `
|
|
67
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
68
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
69
|
+
</div>
|
|
70
|
+
<div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
|
|
71
|
+
<story />
|
|
72
|
+
</div>
|
|
73
|
+
`,
|
|
74
|
+
}),
|
|
75
|
+
],
|
|
76
|
+
parameters: {
|
|
77
|
+
layout: "fullscreen",
|
|
78
|
+
docs: {
|
|
79
|
+
description: {
|
|
80
|
+
component:
|
|
81
|
+
"Bare on/off switch: a visually hidden native checkbox with `role=\"switch\"` and a decorative track and thumb. No label (use `ToggleSwitchField`, or give it your own `<label for=\"toggle-switch-{id}\">`). `v-model` toggles between `trueValue` and `falseValue`; `iconOn`/`iconOff` slots replace the thumb icons. Clicking the track or the input, a label click and Space all toggle it once.",
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export default meta;
|
|
88
|
+
type Story = StoryObj<StoryArgs>;
|
|
89
|
+
|
|
90
|
+
const render = (args: StoryArgs) => ({
|
|
91
|
+
components: { ToggleSwitch },
|
|
92
|
+
setup() {
|
|
93
|
+
const componentArgs = computed(() => {
|
|
94
|
+
const { modelValue: _modelValue, useCustomIcons: _useCustomIcons, ...rest } = args;
|
|
95
|
+
return rest;
|
|
96
|
+
});
|
|
97
|
+
return { args, componentArgs };
|
|
98
|
+
},
|
|
99
|
+
template: `
|
|
100
|
+
<label :for="'toggle-switch-' + args.id" style="display: block; margin-block-end: 0.8rem;">Email notifications</label>
|
|
101
|
+
<ToggleSwitch v-model="args.modelValue" v-bind="componentArgs">
|
|
102
|
+
<template v-if="args.useCustomIcons" #iconOn><Icon name="mdi:check" /></template>
|
|
103
|
+
<template v-if="args.useCustomIcons" #iconOff><Icon name="mdi:close" /></template>
|
|
104
|
+
</ToggleSwitch>
|
|
105
|
+
<p style="margin-block-start: 1.6rem; font-family: monospace;">modelValue: {{ JSON.stringify(args.modelValue) }}</p>
|
|
106
|
+
`,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export const Default: Story = { render };
|
|
110
|
+
|
|
111
|
+
export const WithCustomIcons: Story = { args: { useCustomIcons: true }, render };
|
|
112
|
+
|
|
113
|
+
export const ErrorState: Story = { args: { fieldHasError: true }, render };
|
|
114
|
+
|
|
115
|
+
export const SquareShape: Story = {
|
|
116
|
+
args: { round: false },
|
|
117
|
+
render,
|
|
118
|
+
parameters: { docs: { description: { story: "`round: false` gives a rounded-square track and thumb." } } },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const StringValues: Story = {
|
|
122
|
+
args: { modelValue: "off", trueValue: "on", falseValue: "off" },
|
|
123
|
+
render,
|
|
124
|
+
parameters: { docs: { description: { story: "`v-model` toggles between the strings `\"on\"` and `\"off\"`." } } },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const StressTest: Story = {
|
|
128
|
+
name: "Stress Test (Worst-Case Data)",
|
|
129
|
+
args: { modelValue: "neither", trueValue: 1, falseValue: 0, fieldHasError: true, useCustomIcons: true },
|
|
130
|
+
render: (args: StoryArgs) => ({
|
|
131
|
+
...render(args),
|
|
132
|
+
template: `
|
|
133
|
+
<div style="--input-toggle-symbol-size: 4.8rem;">${render(args).template}</div>
|
|
134
|
+
<div style="--input-toggle-symbol-size: 1.2rem; margin-block-start: 1.6rem;">
|
|
135
|
+
<ToggleSwitch id="tiny" v-model="args.modelValue" name="tiny" :true-value="1" :false-value="0" />
|
|
136
|
+
</div>
|
|
137
|
+
`,
|
|
138
|
+
}),
|
|
139
|
+
parameters: {
|
|
140
|
+
initialCanvas: "mobileCanvas",
|
|
141
|
+
docs: {
|
|
142
|
+
description: {
|
|
143
|
+
story:
|
|
144
|
+
"A model value that is neither `trueValue` nor `falseValue` (shows as off; the first click turns it on), numeric values, the error state and custom icons, at an oversized and a tiny thumb size. Check at every canvas width: the track stays inside the dashed outline and scales with the thumb, the icons stay centred, and both switches share one value.",
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
};
|