srcdev-nuxt-components 9.6.7 → 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 (152) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +14 -0
  2. package/.claude/skills/component-compliance-checklist.md +18 -1
  3. package/.claude/skills/component-inline-action-button.md +1 -1
  4. package/.claude/skills/component-local-style-override.md +1 -1
  5. package/.claude/skills/component-naming.md +13 -13
  6. package/.claude/skills/components/container-glow.md +2 -2
  7. package/.claude/skills/components/form-field.md +2 -2
  8. package/.claude/skills/components/form-wrapper.md +1 -1
  9. package/.claude/skills/components/glowing-border.md +1 -1
  10. package/.claude/skills/components/input-checkbox-radio.md +19 -3
  11. package/.claude/skills/components/input-checkbox.md +14 -3
  12. package/.claude/skills/components/input-description.md +7 -7
  13. package/.claude/skills/components/input-error.md +3 -3
  14. package/.claude/skills/components/input-label.md +7 -8
  15. package/.claude/skills/components/input-number.md +60 -29
  16. package/.claude/skills/components/input-radio.md +15 -1
  17. package/.claude/skills/components/{input-range-core.md → input-range.md} +53 -18
  18. package/.claude/skills/components/input-select.md +153 -0
  19. package/.claude/skills/components/input-text.md +220 -0
  20. package/.claude/skills/components/input-textarea.md +147 -0
  21. package/.claude/skills/components/select-menu.md +4 -4
  22. package/.claude/skills/components/toggle-switch.md +143 -0
  23. package/.claude/skills/composable-zod-validation.md +1 -1
  24. package/.claude/skills/icon-sets.md +1 -1
  25. package/.claude/skills/icon-sizing.md +1 -1
  26. package/.claude/skills/index.md +10 -10
  27. package/.claude/skills/storybook-add-story.md +1 -1
  28. package/.claude/skills/testing-add-unit-test.md +17 -0
  29. package/.claude/skills/theming-component-token-pattern.md +8 -8
  30. package/.claude/skills/theming-form-geometry-tokens.md +1 -1
  31. package/.vscode/srcdev-component-input-checkbox-radio.code-snippets +0 -1
  32. package/.vscode/srcdev-component-input-range.code-snippets +38 -18
  33. package/.vscode/srcdev-component-input-select.code-snippets +20 -14
  34. package/.vscode/srcdev-component-input-text.code-snippets +46 -40
  35. package/.vscode/srcdev-component-input-textarea.code-snippets +19 -14
  36. package/.vscode/srcdev-component-toggle-switch.code-snippets +19 -29
  37. package/app/assets/styles/setup/04.elements/forms/00.element-defaults.css +2 -2
  38. package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +1 -1
  39. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +2 -2
  40. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +4 -4
  41. package/app/components/02.molecules/progress-tracker/stories/ProgressTracker.stories.ts +4 -4
  42. package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +1 -1
  43. package/app/components/05.forms/form-field/CONSUMER-STYLING.md +2 -2
  44. package/app/components/05.forms/input-button/stories/InputButton.stories.ts +3 -3
  45. package/app/components/05.forms/input-checkbox/CONSUMER-STYLING.md +10 -0
  46. package/app/components/05.forms/input-checkbox/MultipleCheckboxes.vue +18 -4
  47. package/app/components/05.forms/input-checkbox/SingleCheckbox.vue +14 -2
  48. package/app/components/05.forms/input-checkbox/stories/MultipleCheckboxes.stories.ts +146 -37
  49. package/app/components/05.forms/input-checkbox/stories/SingleCheckbox.stories.ts +107 -30
  50. package/app/components/05.forms/input-checkbox/tests/MultipleCheckboxes.spec.ts +38 -4
  51. package/app/components/05.forms/input-checkbox/tests/SingleCheckbox.spec.ts +5 -0
  52. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +10 -3
  53. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +1 -0
  54. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +11 -10
  55. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioField.vue +3 -0
  56. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadio.stories.ts +44 -17
  57. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioButton.stories.ts +164 -58
  58. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioField.stories.ts +94 -21
  59. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadio.spec.ts +14 -3
  60. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioButton.spec.ts +38 -3
  61. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioField.spec.ts +14 -3
  62. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +7 -7
  63. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +6 -6
  64. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +50 -6
  65. package/app/components/05.forms/input-number/InputNumber.vue +39 -27
  66. package/app/components/05.forms/input-number/InputNumberField.vue +29 -16
  67. package/app/components/05.forms/input-number/stories/InputNumber.stories.ts +76 -25
  68. package/app/components/05.forms/input-number/stories/InputNumberField.stories.ts +116 -50
  69. package/app/components/05.forms/input-number/tests/InputNumber.spec.ts +49 -13
  70. package/app/components/05.forms/input-number/tests/InputNumberField.spec.ts +59 -5
  71. package/app/components/05.forms/input-radio/CONSUMER-STYLING.md +11 -0
  72. package/app/components/05.forms/input-radio/MultipleRadiobuttons.vue +14 -3
  73. package/app/components/05.forms/input-radio/stories/MultipleRadiobuttons.stories.ts +138 -38
  74. package/app/components/05.forms/input-radio/tests/MultipleRadioButtons.spec.ts +50 -3
  75. package/app/components/05.forms/input-range/CONSUMER-STYLING.md +68 -26
  76. package/app/components/05.forms/input-range/InputRange.vue +247 -0
  77. package/app/components/05.forms/input-range/{InputRangeDefault.vue → InputRangeField.vue} +41 -18
  78. package/app/components/05.forms/input-range/stories/InputRange.stories.ts +187 -0
  79. package/app/components/05.forms/input-range/stories/InputRangeField.stories.ts +196 -0
  80. package/app/components/05.forms/input-range/tests/{InputRangeCore.spec.ts → InputRange.spec.ts} +39 -15
  81. package/app/components/05.forms/input-range/tests/{InputRangeDefault.spec.ts → InputRangeField.spec.ts} +71 -7
  82. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +80 -10
  83. package/app/components/05.forms/input-select/InputSelect.vue +198 -0
  84. package/app/components/05.forms/input-select/InputSelectField.vue +130 -0
  85. package/app/components/05.forms/input-select/stories/InputSelect.stories.ts +184 -0
  86. package/app/components/05.forms/input-select/stories/InputSelectField.stories.ts +187 -0
  87. package/app/components/05.forms/input-select/tests/{InputSelectCore.spec.ts → InputSelect.spec.ts} +75 -12
  88. package/app/components/05.forms/input-select/tests/InputSelectField.spec.ts +121 -0
  89. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +85 -15
  90. package/app/components/05.forms/input-text/{InputPasswordWithLabel.vue → InputPasswordField.vue} +16 -19
  91. package/app/components/05.forms/input-text/{InputTextCore.vue → InputText.vue} +42 -78
  92. package/app/components/05.forms/input-text/{InputTextAsNumberWithLabel.vue → InputTextAsNumberField.vue} +66 -62
  93. package/app/components/05.forms/input-text/InputTextField.vue +168 -0
  94. package/app/components/05.forms/input-text/stories/InputPasswordField.stories.ts +163 -0
  95. package/app/components/05.forms/input-text/stories/InputText.stories.ts +194 -0
  96. package/app/components/05.forms/input-text/stories/InputTextAsNumberField.stories.ts +182 -0
  97. package/app/components/05.forms/input-text/stories/InputTextField.stories.ts +206 -0
  98. package/app/components/05.forms/input-text/tests/{InputPasswordWithLabel.spec.ts → InputPasswordField.spec.ts} +42 -3
  99. package/app/components/05.forms/input-text/tests/InputText.spec.ts +166 -0
  100. package/app/components/05.forms/input-text/tests/InputTextAsNumberField.spec.ts +135 -0
  101. package/app/components/05.forms/input-text/tests/InputTextField.spec.ts +130 -0
  102. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +94 -12
  103. package/app/components/05.forms/input-textarea/InputTextarea.vue +165 -0
  104. package/app/components/05.forms/input-textarea/InputTextareaField.vue +159 -0
  105. package/app/components/05.forms/input-textarea/stories/InputTextarea.stories.ts +198 -0
  106. package/app/components/05.forms/input-textarea/stories/InputTextareaField.stories.ts +161 -0
  107. package/app/components/05.forms/input-textarea/tests/InputTextarea.spec.ts +149 -0
  108. package/app/components/05.forms/input-textarea/tests/InputTextareaField.spec.ts +122 -0
  109. package/app/components/05.forms/patterns/stories/ContactForm.stories.ts +13 -13
  110. package/app/components/05.forms/patterns/stories/LoginForm.stories.ts +6 -6
  111. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +34 -36
  112. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +71 -50
  113. package/app/components/05.forms/toggle-switch/{ToggleSwitchCore.vue → ToggleSwitch.vue} +58 -73
  114. package/app/components/05.forms/toggle-switch/{ToggleSwitchWithLabel.vue → ToggleSwitchField.vue} +65 -19
  115. package/app/components/05.forms/toggle-switch/stories/ToggleSwitch.stories.ts +148 -0
  116. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchField.stories.ts +176 -0
  117. package/app/components/05.forms/toggle-switch/tests/ToggleSwitch.spec.ts +145 -0
  118. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchField.spec.ts +155 -0
  119. package/package.json +1 -1
  120. package/.claude/skills/components/input-select-core.md +0 -151
  121. package/.claude/skills/components/input-text-core.md +0 -204
  122. package/.claude/skills/components/input-textarea-core.md +0 -154
  123. package/.claude/skills/components/toggle-switch-core.md +0 -169
  124. package/app/components/05.forms/input-range/InputRangeCore.vue +0 -169
  125. package/app/components/05.forms/input-range/stories/InputRangeCore.stories.ts +0 -126
  126. package/app/components/05.forms/input-range/stories/InputRangeDefault.stories.ts +0 -108
  127. package/app/components/05.forms/input-select/InputSelectCore.vue +0 -188
  128. package/app/components/05.forms/input-select/InputSelectWithLabel.vue +0 -118
  129. package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +0 -69
  130. package/app/components/05.forms/input-select/stories/InputSelectWithLabel.stories.ts +0 -115
  131. package/app/components/05.forms/input-select/tests/InputSelectWithLabel.spec.ts +0 -87
  132. package/app/components/05.forms/input-text/InputTextWithLabel.vue +0 -154
  133. package/app/components/05.forms/input-text/stories/InputPasswordWithLabel.stories.ts +0 -368
  134. package/app/components/05.forms/input-text/stories/InputTextAsNumberWithLabel.stories.ts +0 -480
  135. package/app/components/05.forms/input-text/stories/InputTextCore.stories.ts +0 -267
  136. package/app/components/05.forms/input-text/stories/InputTextWithLabel.stories.ts +0 -319
  137. package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +0 -75
  138. package/app/components/05.forms/input-text/tests/InputTextCore.spec.ts +0 -60
  139. package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +0 -71
  140. package/app/components/05.forms/input-textarea/InputTextareaCore.vue +0 -159
  141. package/app/components/05.forms/input-textarea/InputTextareaWithLabel.vue +0 -136
  142. package/app/components/05.forms/input-textarea/stories/InputTextareaCore.stories.ts +0 -247
  143. package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +0 -458
  144. package/app/components/05.forms/input-textarea/tests/InputTextareaCore.spec.ts +0 -119
  145. package/app/components/05.forms/input-textarea/tests/InputTextareaWithLabel.spec.ts +0 -93
  146. package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabelInline.vue +0 -81
  147. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchCore.stories.ts +0 -251
  148. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +0 -251
  149. package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabelInline.stories.ts +0 -283
  150. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchCore.spec.ts +0 -101
  151. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabel.spec.ts +0 -133
  152. package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabelInline.spec.ts +0 -66
@@ -1,29 +1,36 @@
1
1
  <template>
2
- <div class="toggle-switch-core" :class="elementClasses" :data-theme="formUiTheme">
3
- <div class="toggle-switch-wrapper" :class="wrapperClasses()" @click="toggleSwitchValue">
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="symbol-wrapper">
17
- <div class="symbol" :class="symbolClasses">
18
- <div class="symbol-icon icon-on" :class="{ active: isChecked }">
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" class="icon" />
27
+ <Icon name="material-symbols:circle-outline" />
21
28
  </slot>
22
29
  </div>
23
30
 
24
- <div class="symbol-icon icon-off" :class="{ active: !isChecked }">
31
+ <div class="toggle-switch-icon toggle-switch-icon-off">
25
32
  <slot name="iconOff">
26
- <Icon name="material-symbols:circle-outline" class="icon" />
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
- const isChecked = computed(() => {
77
- return modelValue.value === props.trueValue;
78
- });
79
-
80
- // Optimized class computeds to reduce template reactivity
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 symbolClasses = computed(() => ({
86
- checked: isChecked.value,
87
- }));
82
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
88
83
 
89
- const toggleSwitchValue = () => {
90
- modelValue.value = modelValue.value === props.trueValue ? props.falseValue : props.trueValue;
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-core {
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
- + .symbol-wrapper {
114
- outline: var(--form-element-outline-width-focus) solid var(--toggle-switch-border-focus, var(--theme-border-focus));
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
- .symbol-wrapper {
121
- /* Geometry */
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
- .symbol {
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
- &.checked {
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
- .symbol-icon {
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
- &.icon-off {
215
- color: var(--toggle-switch-icon-off-color, var(--theme-surface));
192
+ .toggle-switch[data-checked] & {
193
+ opacity: 1;
194
+ }
216
195
  }
217
196
 
218
- &.active {
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>
@@ -1,11 +1,17 @@
1
1
  <template>
2
- <div class="toggle-switch-with-label" :class="[elementClasses]" :data-theme="formUiTheme">
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="['input-switch-label', 'input-text-label', 'body-normal-bold']"
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
- <ToggleSwitchCore
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
- </ToggleSwitchCore>
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 id = useId();
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
- // Performance-optimized computed properties to avoid template string interpolation
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, like useAriaDescribedById's: slots aren't reactive, so a computed would keep
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
- const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
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-with-label {
107
- .toggle-switch-label {
108
- display: block;
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
+ };