srcdev-nuxt-components 9.6.9 → 9.6.11
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/rules/consumer/ids-from-useid.md +4 -2
- package/.claude/skills/component-compliance-checklist.md +24 -2
- package/.claude/skills/component-inline-action-button.md +5 -4
- package/.claude/skills/component-naming.md +8 -2
- package/.claude/skills/components/{alert-mask-core.md → alert-mask.md} +38 -23
- package/.claude/skills/components/alert-masked-content.md +3 -3
- package/.claude/skills/components/container-glow.md +1 -1
- package/.claude/skills/components/display-theme-switch.md +8 -8
- package/.claude/skills/components/input-button.md +26 -7
- package/.claude/skills/components/input-checkbox-radio.md +2 -0
- package/.claude/skills/components/input-description.md +5 -0
- package/.claude/skills/components/input-error.md +9 -2
- package/.claude/skills/components/input-label.md +8 -2
- package/.claude/skills/components/input-number.md +2 -0
- package/.claude/skills/components/input-otp.md +2 -0
- package/.claude/skills/components/input-range.md +2 -0
- package/.claude/skills/components/input-select.md +2 -0
- package/.claude/skills/components/input-text.md +2 -0
- package/.claude/skills/components/input-textarea.md +2 -0
- package/.claude/skills/components/pending-effect.md +5 -4
- package/.claude/skills/components/{triple-toggle-switch.md → segmented-control.md} +34 -19
- package/.claude/skills/components/services-card.md +1 -1
- package/.claude/skills/components/tabbed-content.md +1 -1
- package/.claude/skills/components/toggle-switch.md +2 -0
- package/.claude/skills/css-nesting-conventions.md +22 -1
- package/.claude/skills/index.md +5 -5
- package/.claude/skills/storybook-add-story.md +3 -1
- package/.claude/skills/theming-component-token-pattern.md +24 -0
- package/.claude/skills/theming-form-geometry-tokens.md +1 -1
- package/.claude/skills/theming-partial-override.md +1 -1
- package/.vscode/srcdev-component-alert-mask.code-snippets +41 -0
- package/.vscode/{srcdev-component-triple-toggle-switch.code-snippets → srcdev-component-segmented-control.code-snippets} +6 -7
- package/app/assets/styles/setup/03.theming/_theme-slots.css +1 -1
- package/app/assets/styles/setup/04.elements/forms/03.generic-input-geometry.css +4 -0
- package/app/components/01.atoms/alert-mask/AlertMask.vue +194 -0
- package/app/components/01.atoms/alert-mask/CONSUMER-STYLING.md +57 -42
- package/app/components/01.atoms/alert-mask/stories/AlertMask.stories.ts +198 -0
- package/app/components/01.atoms/alert-mask/tests/AlertMask.spec.ts +233 -0
- package/app/components/02.molecules/alert-masked-content/AlertMaskedContent.vue +2 -2
- package/app/components/02.molecules/alert-masked-content/CONSUMER-STYLING.md +4 -4
- package/app/components/02.molecules/alert-masked-content/stories/AlertMaskedContent.stories.ts +60 -0
- package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +12 -12
- package/app/components/02.molecules/cookie-consent-banner/CONSUMER-STYLING.md +3 -3
- package/app/components/02.molecules/cookie-consent-banner/CookieConsentBanner.vue +1 -1
- package/app/components/02.molecules/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +1 -1
- package/app/components/02.molecules/display-theme-switch/CONSUMER-STYLING.md +9 -9
- package/app/components/02.molecules/display-theme-switch/DisplayThemeSwitch.vue +16 -16
- package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +53 -1
- package/app/components/02.molecules/display-theme-switch/tests/DisplayThemeSwitch.spec.ts +8 -8
- package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +4 -4
- package/app/components/02.molecules/input-copy/InputCopy.vue +2 -2
- package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +2 -2
- package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +4 -4
- package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +2 -2
- package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +2 -2
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +6 -6
- package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +8 -0
- package/app/components/05.forms/form-errors/InputError.vue +43 -37
- package/app/components/05.forms/form-errors/stories/InputError.stories.ts +79 -1
- package/app/components/05.forms/form-errors/tests/InputError.spec.ts +29 -1
- package/app/components/05.forms/input-button/CONSUMER-STYLING.md +80 -47
- package/app/components/05.forms/input-button/InputButton.vue +52 -100
- package/app/components/05.forms/input-button/playwright/input-button.playwright.ts +1 -1
- package/app/components/05.forms/input-button/stories/InputButton.stories.ts +145 -8
- package/app/components/05.forms/input-button/tests/InputButton.spec.ts +76 -46
- package/app/components/05.forms/input-button/tests/__snapshots__/InputButton.spec.ts.snap +17 -17
- package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +9 -1
- package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +8 -0
- package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +2 -2
- package/app/components/05.forms/input-description/CONSUMER-STYLING.md +14 -2
- package/app/components/05.forms/input-description/InputDescription.vue +2 -1
- package/app/components/05.forms/input-description/stories/InputDescription.stories.ts +70 -4
- package/app/components/05.forms/input-description/tests/InputDescription.spec.ts +2 -1
- package/app/components/05.forms/input-label/CONSUMER-STYLING.md +10 -2
- package/app/components/05.forms/input-label/InputLabel.vue +1 -0
- package/app/components/05.forms/input-label/stories/InputLabel.stories.ts +68 -2
- package/app/components/05.forms/input-label/tests/InputLabel.spec.ts +13 -3
- package/app/components/05.forms/input-number/CONSUMER-STYLING.md +7 -1
- package/app/components/05.forms/input-number/InputNumber.vue +4 -5
- package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +7 -1
- package/app/components/05.forms/input-otp/InputOtp.vue +2 -2
- package/app/components/05.forms/input-range/InputRange.vue +1 -1
- package/app/components/05.forms/input-select/CONSUMER-STYLING.md +7 -1
- package/app/components/05.forms/input-select/InputSelect.vue +14 -5
- package/app/components/05.forms/input-text/CONSUMER-STYLING.md +7 -1
- package/app/components/05.forms/input-text/InputText.vue +4 -4
- package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +7 -1
- package/app/components/05.forms/input-textarea/InputTextarea.vue +2 -3
- package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +4 -4
- package/app/components/05.forms/pending-effect/CONSUMER-STYLING.md +7 -3
- package/app/components/05.forms/pending-effect/PendingEffect.vue +3 -3
- package/app/components/05.forms/pending-effect/tests/PendingEffect.spec.ts +1 -1
- package/app/components/05.forms/segmented-control/CONSUMER-STYLING.md +125 -0
- package/app/components/05.forms/segmented-control/SegmentedControl.vue +231 -0
- package/app/components/05.forms/segmented-control/stories/SegmentedControl.stories.ts +215 -0
- package/app/components/05.forms/segmented-control/tests/SegmentedControl.spec.ts +202 -0
- package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +7 -0
- package/app/components/05.forms/toggle-switch/ToggleSwitch.vue +2 -0
- package/app/types/components/index.ts +1 -1
- package/package.json +1 -1
- package/.vscode/srcdev-component-alert-mask-core.code-snippets +0 -31
- package/app/components/01.atoms/alert-mask/AlertMaskCore.vue +0 -166
- package/app/components/01.atoms/alert-mask/stories/AlertMaskCore.stories.ts +0 -105
- package/app/components/01.atoms/alert-mask/tests/AlertMaskCore.spec.ts +0 -342
- package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +0 -99
- package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +0 -251
- package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitch.stories.ts +0 -104
- package/app/components/05.forms/triple-toggle-switch/tests/TripleToggleSwitch.spec.ts +0 -138
- /package/app/types/components/{alert-mask-core.d.ts → alert-mask.d.ts} +0 -0
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
# TripleToggleSwitch — Consumer Styling Guide
|
|
2
|
-
|
|
3
|
-
> **Renamed 2026-09-27**: `TripleToggleSwitchCore` → `TripleToggleSwitch`. The root class was
|
|
4
|
-
> already `.triple-toggle-switch`, and no token names changed.
|
|
5
|
-
|
|
6
|
-
## Public token API
|
|
7
|
-
|
|
8
|
-
Colour tokens fall back to the shared `--theme-*` tokens every other themed component uses, so
|
|
9
|
-
leave them unset to follow the site theme. See `theming-component-token-pattern.md`.
|
|
10
|
-
|
|
11
|
-
### Wrapper
|
|
12
|
-
|
|
13
|
-
| Token | Default | Controls |
|
|
14
|
-
|---|---|---|
|
|
15
|
-
| `--triple-toggle-switch-surface` | `var(--theme-input-surface)` | Wrapper background |
|
|
16
|
-
| `--triple-toggle-switch-border` | `var(--theme-border)` | Wrapper border colour |
|
|
17
|
-
| `--triple-toggle-switch-ring-focus` | `var(--theme-ring)` | Wrapper outline colour while a radio has `:focus-visible` |
|
|
18
|
-
| `--triple-toggle-switch-padding` | `0.6rem` | Wrapper padding |
|
|
19
|
-
| `--triple-toggle-switch-gap` | `1rem` | Gap between the three options (also drives the marker's position) |
|
|
20
|
-
|
|
21
|
-
### Selected-option marker
|
|
22
|
-
|
|
23
|
-
| Token | Default | Controls |
|
|
24
|
-
|---|---|---|
|
|
25
|
-
| `--triple-toggle-switch-marker-surface` | `var(--theme-input-surface)` | Marker background when the selected value isn't `system`/`light`/`dark` |
|
|
26
|
-
| `--triple-toggle-switch-marker-border` | `var(--slate-10)` | Marker border colour |
|
|
27
|
-
| `--triple-toggle-switch-marker-gradient-system` | green `radial-gradient(...)` | Marker `background-image` when `system` is selected |
|
|
28
|
-
| `--triple-toggle-switch-marker-gradient-light` | red/orange `radial-gradient(...)` | Marker `background-image` when `light` is selected |
|
|
29
|
-
| `--triple-toggle-switch-marker-gradient-dark` | black/grey `radial-gradient(...)` | Marker `background-image` when `dark` is selected |
|
|
30
|
-
|
|
31
|
-
> **Changed 2026-09-27**: the three marker gradients were hardcoded literals with no override hook.
|
|
32
|
-
> They're now public tokens; the defaults are unchanged. Any `<image>` value works
|
|
33
|
-
> (`linear-gradient(...)`, `url(...)`, `none`).
|
|
34
|
-
|
|
35
|
-
### Options
|
|
36
|
-
|
|
37
|
-
| Token | Default | Controls |
|
|
38
|
-
|---|---|---|
|
|
39
|
-
| `--triple-toggle-switch-option-padding` | `0.5rem` | Padding inside each option circle |
|
|
40
|
-
| `--triple-toggle-switch-icon-size` | `2rem` | Icon `font-size` (and the hidden radio's hit area) |
|
|
41
|
-
| `--triple-toggle-switch-option-border` | `#00000025` | Each option's resting border colour |
|
|
42
|
-
| `--triple-toggle-switch-option-border-hover` | `var(--slate-10)` | Option outline colour on icon hover |
|
|
43
|
-
| `--triple-toggle-switch-option-ring-focus` | `var(--theme-ring)` | Option outline colour on `:focus-visible` |
|
|
44
|
-
| `--triple-toggle-switch-option-icon-color` | `var(--slate-10)` | Icon colour, resting |
|
|
45
|
-
| `--triple-toggle-switch-option-icon-color-active` | `var(--slate-00)` | Icon colour, selected |
|
|
46
|
-
|
|
47
|
-
Also read (shared, not component-specific): `--form-element-border-width`,
|
|
48
|
-
`--form-element-outline-width`, `--theme-form-transition-duration`. See
|
|
49
|
-
`theming-form-geometry-tokens.md`.
|
|
50
|
-
|
|
51
|
-
Private tokens (not public API): `--_form-items-gap`, `--_scheme-icon-font-size` (each used twice,
|
|
52
|
-
fed by the public gap/icon-size tokens) and `--_select-scheme-group-background-color`/
|
|
53
|
-
`--_select-scheme-group-background-image` (swapped per selected value).
|
|
54
|
-
|
|
55
|
-
## State hooks
|
|
56
|
-
|
|
57
|
-
- **Selected value**: the marker gradients key off `:has(input[value="system" | "light" | "dark"]:checked)`
|
|
58
|
-
on the root. These three values are hardcoded, a legacy of the one real consumer
|
|
59
|
-
(`DisplayThemeSwitch`). Any other value shows `--triple-toggle-switch-marker-surface` instead.
|
|
60
|
-
- **Option icon classes**: each `.option-icon` carries the option's `id` as a class (e.g.
|
|
61
|
-
`.option-icon.light`) plus `.active` when selected.
|
|
62
|
-
- **Marker**: `.selected-option-marker` gains `.show` roughly 250ms after mount (it's positioned
|
|
63
|
-
from a measured option width, so it stays hidden until measured).
|
|
64
|
-
- **Theme**: `data-theme` on the root, from the `theme` prop.
|
|
65
|
-
- **Inner classes**: `.triple-toggle-switch-wrapper`, `.selected-option-marker-wrapper`,
|
|
66
|
-
`.selected-option-marker`, `.option-group-wrapper` (the `role="radiogroup"`), `.option-group`
|
|
67
|
-
(each `<label>`), `.option-icon`, `.option-input`.
|
|
68
|
-
|
|
69
|
-
## Motion
|
|
70
|
-
|
|
71
|
-
The marker slides between options over the `stepAnimationDuration` prop (default `250ms`). Under
|
|
72
|
-
`prefers-reduced-motion: reduce` the slide transition is removed and the marker jumps.
|
|
73
|
-
|
|
74
|
-
## Global theming
|
|
75
|
-
|
|
76
|
-
```css
|
|
77
|
-
:where(html) {
|
|
78
|
-
--triple-toggle-switch-surface: var(--rose-09);
|
|
79
|
-
--triple-toggle-switch-option-ring-focus: var(--rose-04);
|
|
80
|
-
}
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
## Local overrides
|
|
84
|
-
|
|
85
|
-
Set the tokens above on an element you own (a page or section class, or a class added with
|
|
86
|
-
`:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
|
|
87
|
-
`@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
|
|
88
|
-
`<style scoped>`, tokens set on your element still work, but selectors that reach inside the
|
|
89
|
-
component need `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
|
|
90
|
-
|
|
91
|
-
**Caveat:** when used through `DisplayThemeSwitch`, that wrapper re-declares the four sizing tokens
|
|
92
|
-
(`--triple-toggle-switch-gap`, `-padding`, `-option-padding`, `-icon-size`) on the root element, so
|
|
93
|
-
setting them on an ancestor has no effect there. Set them on the component itself (via
|
|
94
|
-
`style` or a passthrough class) instead. Used directly, `TripleToggleSwitch` re-declares nothing.
|
|
95
|
-
|
|
96
|
-
## Class passthrough
|
|
97
|
-
|
|
98
|
-
`style-class-passthrough` adds classes to the root `.triple-toggle-switch` element, alongside
|
|
99
|
-
`data-theme`. `DisplayThemeSwitch` uses it for its `colour-scheme-select` and `small` classes.
|
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<div class="triple-toggle-switch" :class="[elementClasses]" :data-theme="theme">
|
|
3
|
-
<div class="triple-toggle-switch-wrapper">
|
|
4
|
-
<div class="selected-option-marker-wrapper">
|
|
5
|
-
<div class="selected-option-marker" :class="[{ show: showMarker }]"></div>
|
|
6
|
-
</div>
|
|
7
|
-
<div class="option-group-wrapper" role="radiogroup" :aria-label="ariaLabel || undefined">
|
|
8
|
-
<label
|
|
9
|
-
v-for="option in fieldData.data"
|
|
10
|
-
:key="option.id"
|
|
11
|
-
ref="optionGroup"
|
|
12
|
-
:for="option.id"
|
|
13
|
-
class="option-group"
|
|
14
|
-
>
|
|
15
|
-
<span class="sr-only">{{ option.label }}</span>
|
|
16
|
-
<Icon
|
|
17
|
-
v-if="option.icon"
|
|
18
|
-
:name="option.icon"
|
|
19
|
-
class="option-icon"
|
|
20
|
-
:class="[option.id, { active: modelValue === option.value }]"
|
|
21
|
-
/>
|
|
22
|
-
<input
|
|
23
|
-
:id="option.id"
|
|
24
|
-
v-model="modelValue"
|
|
25
|
-
type="radio"
|
|
26
|
-
:name="name"
|
|
27
|
-
class="option-input"
|
|
28
|
-
:value="option.value"
|
|
29
|
-
/>
|
|
30
|
-
</label>
|
|
31
|
-
</div>
|
|
32
|
-
</div>
|
|
33
|
-
</div>
|
|
34
|
-
</template>
|
|
35
|
-
|
|
36
|
-
<script setup lang="ts">
|
|
37
|
-
import type { FormUiTheme, IFormMultipleOptions } from "~/types/forms/types.forms";
|
|
38
|
-
|
|
39
|
-
interface Props {
|
|
40
|
-
name?: string;
|
|
41
|
-
ariaLabel?: string;
|
|
42
|
-
theme?: FormUiTheme;
|
|
43
|
-
stepAnimationDuration?: string;
|
|
44
|
-
styleClassPassthrough?: string | string[];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const props = withDefaults(defineProps<Props>(), {
|
|
48
|
-
name: "triple-toggle-switch",
|
|
49
|
-
ariaLabel: "",
|
|
50
|
-
theme: "default",
|
|
51
|
-
stepAnimationDuration: "250ms",
|
|
52
|
-
styleClassPassthrough: () => [],
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
const modelValue = defineModel<string | number | boolean>({ required: true });
|
|
56
|
-
const fieldData = defineModel<IFormMultipleOptions>("fieldData", { required: true });
|
|
57
|
-
|
|
58
|
-
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
59
|
-
|
|
60
|
-
const { stepAnimationDuration } = toRefs(props);
|
|
61
|
-
|
|
62
|
-
const optionGroupRefs = useTemplateRef<HTMLLabelElement[]>("optionGroup");
|
|
63
|
-
|
|
64
|
-
const iconWidth = ref("0px");
|
|
65
|
-
const showMarker = ref(false);
|
|
66
|
-
|
|
67
|
-
const selectedOptionIndex = computed(() => {
|
|
68
|
-
if (!fieldData.value?.data) return 0;
|
|
69
|
-
const index = fieldData.value.data.findIndex((option) => option.value === modelValue.value);
|
|
70
|
-
return Math.max(0, index);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
const setupDefaults = async () => {
|
|
74
|
-
await nextTick();
|
|
75
|
-
|
|
76
|
-
const firstOption = optionGroupRefs.value?.[0];
|
|
77
|
-
if (firstOption) {
|
|
78
|
-
iconWidth.value = `${firstOption.getBoundingClientRect().width}px`;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
await new Promise((resolve) => {
|
|
82
|
-
requestAnimationFrame(() => {
|
|
83
|
-
setTimeout(resolve, 250);
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
showMarker.value = true;
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
onMounted(() => {
|
|
91
|
-
setupDefaults();
|
|
92
|
-
});
|
|
93
|
-
</script>
|
|
94
|
-
|
|
95
|
-
<style lang="css">
|
|
96
|
-
@layer components {
|
|
97
|
-
.triple-toggle-switch {
|
|
98
|
-
--_form-items-gap: var(--triple-toggle-switch-gap, 1rem);
|
|
99
|
-
--_select-scheme-group-background-color: var(--triple-toggle-switch-marker-surface, var(--theme-input-surface));
|
|
100
|
-
--_select-scheme-group-background-image: none;
|
|
101
|
-
--_scheme-icon-font-size: var(--triple-toggle-switch-icon-size, 2rem);
|
|
102
|
-
|
|
103
|
-
&:has(input[value="system"]:checked) {
|
|
104
|
-
--_select-scheme-group-background-color: transparent;
|
|
105
|
-
--_select-scheme-group-background-image: var(
|
|
106
|
-
--triple-toggle-switch-marker-gradient-system,
|
|
107
|
-
radial-gradient(circle, rgb(66, 180, 58) 0%, rgb(17, 199, 0) 27%, rgb(8, 117, 3) 100%)
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
&:has(input[value="light"]:checked) {
|
|
112
|
-
--_select-scheme-group-background-color: transparent;
|
|
113
|
-
--_select-scheme-group-background-image: var(
|
|
114
|
-
--triple-toggle-switch-marker-gradient-light,
|
|
115
|
-
radial-gradient(circle, rgba(180, 58, 91, 1) 0%, rgba(253, 29, 29, 1) 27%, rgba(252, 176, 69, 1) 100%)
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
&:has(input[value="dark"]:checked) {
|
|
120
|
-
--_select-scheme-group-background-color: transparent;
|
|
121
|
-
--_select-scheme-group-background-image: var(
|
|
122
|
-
--triple-toggle-switch-marker-gradient-dark,
|
|
123
|
-
radial-gradient(circle, rgb(50, 20, 25) 0%, rgb(0, 0, 0) 27%, rgb(100, 100, 100) 100%)
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
.triple-toggle-switch-wrapper {
|
|
128
|
-
display: inline-grid;
|
|
129
|
-
grid-template-areas: "select-stack";
|
|
130
|
-
width: fit-content;
|
|
131
|
-
|
|
132
|
-
background-color: var(--triple-toggle-switch-surface, var(--theme-input-surface));
|
|
133
|
-
border: var(--form-element-border-width) solid var(--triple-toggle-switch-border, var(--theme-border));
|
|
134
|
-
outline: var(--form-element-outline-width) solid transparent;
|
|
135
|
-
border-radius: 100vw;
|
|
136
|
-
padding: var(--triple-toggle-switch-padding, 0.6rem);
|
|
137
|
-
|
|
138
|
-
transition: all var(--theme-form-transition-duration) ease-in-out;
|
|
139
|
-
|
|
140
|
-
&:has(input:focus-visible) {
|
|
141
|
-
outline: var(--form-element-outline-width) solid var(--triple-toggle-switch-ring-focus, var(--theme-ring));
|
|
142
|
-
outline-offset: 0.2rem;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
.selected-option-marker-wrapper {
|
|
146
|
-
grid-area: select-stack;
|
|
147
|
-
z-index: 1;
|
|
148
|
-
display: flex;
|
|
149
|
-
align-items: center;
|
|
150
|
-
position: relative;
|
|
151
|
-
|
|
152
|
-
.selected-option-marker {
|
|
153
|
-
aspect-ratio: 1;
|
|
154
|
-
width: v-bind(iconWidth);
|
|
155
|
-
transition: all v-bind(stepAnimationDuration) ease-in-out;
|
|
156
|
-
background-color: var(--_select-scheme-group-background-color);
|
|
157
|
-
background-image: var(--_select-scheme-group-background-image);
|
|
158
|
-
border: var(--form-element-border-width) solid var(--triple-toggle-switch-marker-border, var(--slate-10));
|
|
159
|
-
border-radius: 50%;
|
|
160
|
-
|
|
161
|
-
position: absolute;
|
|
162
|
-
left: calc(
|
|
163
|
-
v-bind(selectedOptionIndex) * v-bind(iconWidth) + (var(--_form-items-gap) * v-bind(selectedOptionIndex))
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
opacity: 0;
|
|
167
|
-
|
|
168
|
-
&.show {
|
|
169
|
-
opacity: 1;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
@media (prefers-reduced-motion: reduce) {
|
|
173
|
-
transition: none;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
.option-group-wrapper {
|
|
179
|
-
display: grid;
|
|
180
|
-
grid-area: select-stack;
|
|
181
|
-
grid-template-columns: repeat(3, 1fr);
|
|
182
|
-
align-items: center;
|
|
183
|
-
width: fit-content;
|
|
184
|
-
z-index: 2;
|
|
185
|
-
gap: var(--_form-items-gap);
|
|
186
|
-
position: relative;
|
|
187
|
-
|
|
188
|
-
.option-group {
|
|
189
|
-
display: grid;
|
|
190
|
-
grid-template-areas: "icon-stack";
|
|
191
|
-
place-items: center;
|
|
192
|
-
background: transparent;
|
|
193
|
-
border: var(--form-element-border-width) solid var(--triple-toggle-switch-option-border, #00000025);
|
|
194
|
-
outline: var(--form-element-outline-width) solid transparent;
|
|
195
|
-
border-radius: 50%;
|
|
196
|
-
padding: var(--triple-toggle-switch-option-padding, 0.5rem);
|
|
197
|
-
overflow: hidden;
|
|
198
|
-
|
|
199
|
-
transition: all calc(var(--theme-form-transition-duration) / 3);
|
|
200
|
-
|
|
201
|
-
&:has(.option-icon:hover) {
|
|
202
|
-
outline: var(--form-element-outline-width) solid
|
|
203
|
-
var(--triple-toggle-switch-option-border-hover, var(--slate-10));
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
&:has(input:focus-visible) {
|
|
207
|
-
outline: var(--form-element-outline-width) solid
|
|
208
|
-
var(--triple-toggle-switch-option-ring-focus, var(--theme-ring));
|
|
209
|
-
outline-offset: 0.2rem;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
.option-icon {
|
|
213
|
-
grid-area: icon-stack;
|
|
214
|
-
display: block;
|
|
215
|
-
font-size: var(--_scheme-icon-font-size);
|
|
216
|
-
width: 1em;
|
|
217
|
-
height: 1em;
|
|
218
|
-
color: var(--triple-toggle-switch-option-icon-color, var(--slate-10));
|
|
219
|
-
transition: color calc(v-bind(stepAnimationDuration) / 2) ease-in-out;
|
|
220
|
-
|
|
221
|
-
&.active {
|
|
222
|
-
color: var(--triple-toggle-switch-option-icon-color-active, var(--slate-00));
|
|
223
|
-
transition-delay: calc(v-bind(stepAnimationDuration) / 2);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
@media (prefers-reduced-motion: reduce) {
|
|
227
|
-
transition: none;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
&:hover {
|
|
231
|
-
cursor: pointer;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
.option-input {
|
|
236
|
-
grid-area: icon-stack;
|
|
237
|
-
appearance: none;
|
|
238
|
-
opacity: 0;
|
|
239
|
-
width: var(--_scheme-icon-font-size);
|
|
240
|
-
height: var(--_scheme-icon-font-size);
|
|
241
|
-
|
|
242
|
-
&:hover {
|
|
243
|
-
cursor: pointer;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
</style>
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
|
-
import { ref } from "vue";
|
|
3
|
-
import StorybookComponent from "../TripleToggleSwitch.vue";
|
|
4
|
-
import type { IFormMultipleOptions } from "~/types/forms/types.forms.d";
|
|
5
|
-
|
|
6
|
-
interface TripleToggleSwitchStoryArgs {
|
|
7
|
-
name: string;
|
|
8
|
-
ariaLabel: string;
|
|
9
|
-
stepAnimationDuration: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
// TripleToggleSwitch keys its marker gradients off three option *values*: "system", "light",
|
|
13
|
-
// "dark" (see CONSUMER-STYLING.md). This story uses exactly those values so each gradient fires;
|
|
14
|
-
// any other value falls back to --triple-toggle-switch-marker-surface. DisplayThemeSwitch is the
|
|
15
|
-
// real-world consumer and adds the useSettingsStore wiring on top.
|
|
16
|
-
export default {
|
|
17
|
-
title: "Components/Forms/Triple Toggle Switch/TripleToggleSwitch",
|
|
18
|
-
component: StorybookComponent,
|
|
19
|
-
argTypes: {
|
|
20
|
-
name: { control: "text", table: { category: "Basic" } },
|
|
21
|
-
ariaLabel: {
|
|
22
|
-
control: "text",
|
|
23
|
-
description: "Accessible name for the radiogroup",
|
|
24
|
-
table: { category: "Accessibility" },
|
|
25
|
-
},
|
|
26
|
-
stepAnimationDuration: {
|
|
27
|
-
control: "text",
|
|
28
|
-
description: "CSS transition duration for the marker sliding between options",
|
|
29
|
-
table: { category: "Styling" },
|
|
30
|
-
},
|
|
31
|
-
},
|
|
32
|
-
args: {
|
|
33
|
-
name: "colour-scheme",
|
|
34
|
-
ariaLabel: "Colour scheme",
|
|
35
|
-
stepAnimationDuration: "250ms",
|
|
36
|
-
},
|
|
37
|
-
} as Meta<TripleToggleSwitchStoryArgs>;
|
|
38
|
-
|
|
39
|
-
const buildOptions = (): IFormMultipleOptions => ({
|
|
40
|
-
data: [
|
|
41
|
-
{ id: "system", name: "colour-scheme", value: "system", label: "System", icon: "material-symbols:night-sight-auto-sharp" },
|
|
42
|
-
{ id: "light", name: "colour-scheme", value: "light", label: "Light", icon: "radix-icons:sun" },
|
|
43
|
-
{ id: "dark", name: "colour-scheme", value: "dark", label: "Dark", icon: "radix-icons:moon" },
|
|
44
|
-
],
|
|
45
|
-
total: 3,
|
|
46
|
-
skip: 0,
|
|
47
|
-
limit: 3,
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
const Template: StoryFn<TripleToggleSwitchStoryArgs> = (args) => ({
|
|
51
|
-
components: { StorybookComponent },
|
|
52
|
-
setup() {
|
|
53
|
-
const modelValue = ref("system");
|
|
54
|
-
const fieldData = ref<IFormMultipleOptions>(buildOptions());
|
|
55
|
-
return { args, modelValue, fieldData };
|
|
56
|
-
},
|
|
57
|
-
template: `
|
|
58
|
-
<div style="margin: 36px;">
|
|
59
|
-
<StorybookComponent
|
|
60
|
-
v-model="modelValue"
|
|
61
|
-
v-model:field-data="fieldData"
|
|
62
|
-
:name="args.name"
|
|
63
|
-
:aria-label="args.ariaLabel"
|
|
64
|
-
:step-animation-duration="args.stepAnimationDuration"
|
|
65
|
-
/>
|
|
66
|
-
<div style="margin-top: 1.6rem; font-size: 1.4rem;">Current value: {{ modelValue }}</div>
|
|
67
|
-
</div>
|
|
68
|
-
`,
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
export const Default = Template.bind({});
|
|
72
|
-
|
|
73
|
-
const CustomTokensTemplate: StoryFn<TripleToggleSwitchStoryArgs> = (args) => ({
|
|
74
|
-
components: { StorybookComponent },
|
|
75
|
-
setup() {
|
|
76
|
-
const modelValue = ref("light");
|
|
77
|
-
const fieldData = ref<IFormMultipleOptions>(buildOptions());
|
|
78
|
-
return { args, modelValue, fieldData };
|
|
79
|
-
},
|
|
80
|
-
template: `
|
|
81
|
-
<div
|
|
82
|
-
style="
|
|
83
|
-
margin: 36px;
|
|
84
|
-
--triple-toggle-switch-gap: 0.4rem;
|
|
85
|
-
--triple-toggle-switch-padding: 0.4rem;
|
|
86
|
-
--triple-toggle-switch-icon-size: 1.6rem;
|
|
87
|
-
--triple-toggle-switch-marker-gradient-system: linear-gradient(135deg, #0ea5e9, #6366f1);
|
|
88
|
-
--triple-toggle-switch-marker-gradient-light: linear-gradient(135deg, #facc15, #f97316);
|
|
89
|
-
--triple-toggle-switch-marker-gradient-dark: linear-gradient(135deg, #1e293b, #475569);
|
|
90
|
-
"
|
|
91
|
-
>
|
|
92
|
-
<StorybookComponent
|
|
93
|
-
v-model="modelValue"
|
|
94
|
-
v-model:field-data="fieldData"
|
|
95
|
-
:name="args.name"
|
|
96
|
-
:aria-label="args.ariaLabel"
|
|
97
|
-
:step-animation-duration="args.stepAnimationDuration"
|
|
98
|
-
/>
|
|
99
|
-
<div style="margin-top: 1.6rem; font-size: 1.4rem;">Current value: {{ modelValue }}</div>
|
|
100
|
-
</div>
|
|
101
|
-
`,
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
export const CustomTokens = CustomTokensTemplate.bind({});
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
-
import { nextTick } from "vue";
|
|
4
|
-
import TripleToggleSwitch from "../TripleToggleSwitch.vue";
|
|
5
|
-
import type { IFormMultipleOptions } from "~/types/forms/types.forms";
|
|
6
|
-
|
|
7
|
-
const buildOptions = (): IFormMultipleOptions => ({
|
|
8
|
-
data: [
|
|
9
|
-
{ id: "system", name: "scheme", value: "system", label: "System", icon: "radix-icons:gear" },
|
|
10
|
-
{ id: "light", name: "scheme", value: "light", label: "Light", icon: "radix-icons:sun" },
|
|
11
|
-
{ id: "dark", name: "scheme", value: "dark", label: "Dark", icon: "radix-icons:moon" },
|
|
12
|
-
],
|
|
13
|
-
total: 3,
|
|
14
|
-
skip: 0,
|
|
15
|
-
limit: 3,
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
const mountSwitch = (props: Record<string, unknown> = {}) =>
|
|
19
|
-
mountSuspended(TripleToggleSwitch, {
|
|
20
|
-
props: {
|
|
21
|
-
modelValue: "system",
|
|
22
|
-
fieldData: buildOptions(),
|
|
23
|
-
...props,
|
|
24
|
-
},
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
interface TripleToggleSwitchInstance {
|
|
28
|
-
selectedOptionIndex: number;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
describe("TripleToggleSwitch", () => {
|
|
32
|
-
it("mounts without error", async () => {
|
|
33
|
-
const wrapper = await mountSwitch();
|
|
34
|
-
expect(wrapper.vm).toBeTruthy();
|
|
35
|
-
expect(wrapper.find(".triple-toggle-switch").exists()).toBe(true);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("renders one radio per option with matching id, value and shared name", async () => {
|
|
39
|
-
const wrapper = await mountSwitch({ name: "colour-scheme" });
|
|
40
|
-
const inputs = wrapper.findAll("input.option-input");
|
|
41
|
-
expect(inputs).toHaveLength(3);
|
|
42
|
-
expect(inputs.map((i) => i.attributes("id"))).toEqual(["system", "light", "dark"]);
|
|
43
|
-
expect(inputs.map((i) => (i.element as HTMLInputElement).value)).toEqual(["system", "light", "dark"]);
|
|
44
|
-
inputs.forEach((i) => {
|
|
45
|
-
expect(i.attributes("type")).toBe("radio");
|
|
46
|
-
expect(i.attributes("name")).toBe("colour-scheme");
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("uses the default name when none is passed", async () => {
|
|
51
|
-
const wrapper = await mountSwitch();
|
|
52
|
-
expect(wrapper.find("input.option-input").attributes("name")).toBe("triple-toggle-switch");
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("links each label to its radio and renders the label text for screen readers", async () => {
|
|
56
|
-
const wrapper = await mountSwitch();
|
|
57
|
-
const labels = wrapper.findAll("label.option-group");
|
|
58
|
-
expect(labels.map((l) => l.attributes("for"))).toEqual(["system", "light", "dark"]);
|
|
59
|
-
expect(wrapper.findAll(".option-group .sr-only").map((s) => s.text())).toEqual(["System", "Light", "Dark"]);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("exposes the options as a radiogroup", async () => {
|
|
63
|
-
const wrapper = await mountSwitch();
|
|
64
|
-
const group = wrapper.find(".option-group-wrapper");
|
|
65
|
-
expect(group.attributes("role")).toBe("radiogroup");
|
|
66
|
-
expect(group.attributes("aria-label")).toBeUndefined();
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("applies ariaLabel to the radiogroup", async () => {
|
|
70
|
-
const wrapper = await mountSwitch({ ariaLabel: "Colour scheme" });
|
|
71
|
-
expect(wrapper.find(".option-group-wrapper").attributes("aria-label")).toBe("Colour scheme");
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("checks the radio matching modelValue", async () => {
|
|
75
|
-
const wrapper = await mountSwitch({ modelValue: "light" });
|
|
76
|
-
const checked = wrapper.findAll("input.option-input").map((i) => (i.element as HTMLInputElement).checked);
|
|
77
|
-
expect(checked).toEqual([false, true, false]);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("emits update:modelValue when another option is chosen", async () => {
|
|
81
|
-
const wrapper = await mountSwitch();
|
|
82
|
-
await wrapper.findAll("input.option-input")[2]!.setValue(true);
|
|
83
|
-
expect(wrapper.emitted("update:modelValue")?.[0]).toEqual(["dark"]);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("marks only the selected option's icon as active", async () => {
|
|
87
|
-
const wrapper = await mountSwitch({ modelValue: "dark" });
|
|
88
|
-
const icons = wrapper.findAll(".option-icon");
|
|
89
|
-
expect(icons).toHaveLength(3);
|
|
90
|
-
expect(icons.map((i) => i.classes().includes("active"))).toEqual([false, false, true]);
|
|
91
|
-
expect(icons[0]!.classes()).toContain("system");
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("updates the active icon when modelValue changes", async () => {
|
|
95
|
-
const wrapper = await mountSwitch();
|
|
96
|
-
await wrapper.setProps({ modelValue: "light" });
|
|
97
|
-
const icons = wrapper.findAll(".option-icon");
|
|
98
|
-
expect(icons[1]!.classes()).toContain("active");
|
|
99
|
-
expect(icons[0]!.classes()).not.toContain("active");
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("omits the icon when an option has none", async () => {
|
|
103
|
-
const options = buildOptions();
|
|
104
|
-
delete options.data[1]!.icon;
|
|
105
|
-
const wrapper = await mountSwitch({ fieldData: options });
|
|
106
|
-
expect(wrapper.findAll(".option-icon")).toHaveLength(2);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("computes the selected option index, falling back to 0 for an unknown value", async () => {
|
|
110
|
-
const wrapper = await mountSwitch({ modelValue: "dark" });
|
|
111
|
-
const vm = wrapper.vm as unknown as TripleToggleSwitchInstance;
|
|
112
|
-
expect(vm.selectedOptionIndex).toBe(2);
|
|
113
|
-
await wrapper.setProps({ modelValue: "unknown" });
|
|
114
|
-
expect(vm.selectedOptionIndex).toBe(0);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it("shows the marker only after the mount delay", async () => {
|
|
118
|
-
const wrapper = await mountSwitch();
|
|
119
|
-
const marker = wrapper.find(".selected-option-marker");
|
|
120
|
-
expect(marker.classes()).not.toContain("show");
|
|
121
|
-
vi.advanceTimersByTime(300);
|
|
122
|
-
await nextTick();
|
|
123
|
-
await nextTick();
|
|
124
|
-
expect(wrapper.find(".selected-option-marker").classes()).toContain("show");
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("applies the theme as data-theme", async () => {
|
|
128
|
-
const wrapper = await mountSwitch({ theme: "success" });
|
|
129
|
-
expect(wrapper.find(".triple-toggle-switch").attributes("data-theme")).toBe("success");
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("applies styleClassPassthrough classes to the root", async () => {
|
|
133
|
-
const wrapper = await mountSwitch({ styleClassPassthrough: ["small", "extra"] });
|
|
134
|
-
const root = wrapper.find(".triple-toggle-switch");
|
|
135
|
-
expect(root.classes()).toContain("small");
|
|
136
|
-
expect(root.classes()).toContain("extra");
|
|
137
|
-
});
|
|
138
|
-
});
|
|
File without changes
|