srcdev-nuxt-components 9.6.10 → 9.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/consumer/ids-from-useid.md +4 -2
- package/.claude/skills/component-compliance-checklist.md +14 -3
- package/.claude/skills/component-inline-action-button.md +5 -4
- package/.claude/skills/component-naming.md +2 -1
- 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/form-field.md +15 -0
- package/.claude/skills/components/form-fieldset.md +18 -2
- 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/marquee-scroller.md +51 -14
- 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/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-form-field.code-snippets +7 -6
- package/.vscode/srcdev-component-form-fieldset.code-snippets +12 -12
- package/.vscode/srcdev-component-marquee-scroller.code-snippets +31 -28
- 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/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +77 -54
- package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +103 -91
- package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +132 -3
- package/app/components/01.atoms/animations/marquee-scroller/tests/MarqueeScroller.spec.ts +190 -102
- package/app/components/01.atoms/animations/marquee-scroller/tests/__snapshots__/MarqueeScroller.spec.ts.snap +17 -13
- 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/form-field/CONSUMER-STYLING.md +7 -0
- package/app/components/05.forms/form-field/stories/FormField.stories.ts +116 -2
- package/app/components/05.forms/form-fieldset/CONSUMER-STYLING.md +11 -1
- package/app/components/05.forms/form-fieldset/FormFieldset.vue +4 -2
- package/app/components/05.forms/form-fieldset/stories/FormFieldset.stories.ts +68 -4
- package/app/components/05.forms/form-fieldset/tests/FormFieldset.spec.ts +10 -0
- 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 +8 -0
- 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
|
@@ -38,16 +38,16 @@ describe("AlertMaskedContent", () => {
|
|
|
38
38
|
expect(wrapper.find(".alert-masked-content").attributes("data-theme")).toBe("success");
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
// ───
|
|
41
|
+
// ─── AlertMask ────────────────────────────────────────────────────────
|
|
42
42
|
|
|
43
|
-
it("renders
|
|
43
|
+
it("renders AlertMask", async () => {
|
|
44
44
|
const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "info" } });
|
|
45
|
-
expect(wrapper.find(".alert-mask
|
|
45
|
+
expect(wrapper.find(".alert-mask").exists()).toBe(true);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
it("renders AlertContentInner inside
|
|
48
|
+
it("renders AlertContentInner inside AlertMask", async () => {
|
|
49
49
|
const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "info" } });
|
|
50
|
-
expect(wrapper.find(".alert-mask
|
|
50
|
+
expect(wrapper.find(".alert-mask .alert-content-inner").exists()).toBe(true);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
// ─── Icon ─────────────────────────────────────────────────────────────────
|
|
@@ -131,15 +131,15 @@ describe("AlertMaskedContent", () => {
|
|
|
131
131
|
|
|
132
132
|
// ─── Default maskConfig ───────────────────────────────────────────────────
|
|
133
133
|
|
|
134
|
-
it("passes the resolved maskConfig to
|
|
134
|
+
it("passes the resolved maskConfig to AlertMask", async () => {
|
|
135
135
|
const wrapper = await mountSuspended(AlertMaskedContent, { props: { theme: "info" } });
|
|
136
136
|
const contentEl = wrapper.find(".alert-mask-content");
|
|
137
137
|
const style = (contentEl.element as HTMLElement).style;
|
|
138
|
-
// Default borderLeft: 6 → --_inset-
|
|
139
|
-
expect(style.getPropertyValue("--_inset-
|
|
140
|
-
expect(style.getPropertyValue("--_inset-
|
|
141
|
-
expect(style.getPropertyValue("--_inset-
|
|
142
|
-
expect(style.getPropertyValue("--_inset-
|
|
138
|
+
// Default borderLeft: 6 → --_inset-left: 6px
|
|
139
|
+
expect(style.getPropertyValue("--_inset-left")).toBe("6px");
|
|
140
|
+
expect(style.getPropertyValue("--_inset-top")).toBe("1px");
|
|
141
|
+
expect(style.getPropertyValue("--_inset-right")).toBe("1px");
|
|
142
|
+
expect(style.getPropertyValue("--_inset-bottom")).toBe("1px");
|
|
143
143
|
});
|
|
144
144
|
|
|
145
145
|
it("routes the default mask colours through public tokens", async () => {
|
|
@@ -168,7 +168,7 @@ describe("AlertMaskedContent", () => {
|
|
|
168
168
|
});
|
|
169
169
|
const contentEl = wrapper.find(".alert-mask-content");
|
|
170
170
|
const style = (contentEl.element as HTMLElement).style;
|
|
171
|
-
expect(style.getPropertyValue("--_inset-
|
|
171
|
+
expect(style.getPropertyValue("--_inset-left")).toBe("12px");
|
|
172
172
|
});
|
|
173
173
|
|
|
174
174
|
// ─── showIcon / actions (added 2026-09-27) ──────────────────────────────────
|
|
@@ -9,7 +9,7 @@ adds: position, motion and the two buttons.
|
|
|
9
9
|
> **Changed 2026-10-15**: the closed state class `.closed` is now `.cookie-consent-banner-closed`,
|
|
10
10
|
> and the buttons are `InputButton`s (`variant="inline"`, so the banner's tokens still style them):
|
|
11
11
|
> each button is `.cookie-consent-banner-accept.input-button` / `.cookie-consent-banner-reject.input-button`
|
|
12
|
-
> with the label in an inner `.button-text`. The button tokens below are unchanged.
|
|
12
|
+
> with the label in an inner `.input-button-text` (`.button-text` until 2026-10-17). The button tokens below are unchanged.
|
|
13
13
|
>
|
|
14
14
|
> **Changed 2026-09-27**: rebuilt on `AlertContent`. The banner's own panel tokens
|
|
15
15
|
> (`--cookie-consent-banner-padding`, `-gap`, `-border-radius`, `-border`, `-accent-border-width`,
|
|
@@ -47,7 +47,7 @@ adds: position, motion and the two buttons.
|
|
|
47
47
|
|
|
48
48
|
The gap between the buttons and their alignment (right by default) are `AlertContent`'s
|
|
49
49
|
`--alert-content-actions-gap` and `--alert-content-actions-justify`. Long labels wrap inside the
|
|
50
|
-
buttons (`InputButton`'s `.button-text`), and the buttons wrap onto their own lines when the row is full.
|
|
50
|
+
buttons (`InputButton`'s `.input-button-text`), and the buttons wrap onto their own lines when the row is full.
|
|
51
51
|
The label keeps the banner's font size and case: `InputButton`'s `--button-font-size` and
|
|
52
52
|
`--button-text-transform` don't apply here.
|
|
53
53
|
|
|
@@ -63,7 +63,7 @@ each resolving a public token once for reuse.
|
|
|
63
63
|
- Classes: `.cookie-consent-banner` > `.cookie-consent-banner-inner` (the `role="region"`) >
|
|
64
64
|
`.alert-content` (see its guide for the inner structure); the buttons are
|
|
65
65
|
`InputButton`s with `.cookie-consent-banner-reject` and `.cookie-consent-banner-accept` (plus
|
|
66
|
-
`.input-button
|
|
66
|
+
`.input-button[data-variant="inline"]`) inside `.alert-content-actions`.
|
|
67
67
|
- `data-test-id`: `cookie-consent-banner`, `cookie-consent-banner-reject`, `cookie-consent-banner-accept`,
|
|
68
68
|
plus `AlertContent`'s `alert-icon`, `alert-title`, `alert-content`, `alert-actions`.
|
|
69
69
|
|
|
@@ -118,7 +118,7 @@ describe("CookieConsentBanner", () => {
|
|
|
118
118
|
expect(button.tagName).toBe("BUTTON");
|
|
119
119
|
expect(button.getAttribute("type")).toBe("button");
|
|
120
120
|
expect(button.classList).toContain("input-button");
|
|
121
|
-
expect(button.
|
|
121
|
+
expect(button.dataset.variant).toBe("inline");
|
|
122
122
|
}
|
|
123
123
|
});
|
|
124
124
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# DisplayThemeSwitch — Consumer Styling Guide
|
|
2
2
|
|
|
3
|
-
## No tokens of its own — delegates entirely to
|
|
3
|
+
## No tokens of its own — delegates entirely to SegmentedControl
|
|
4
4
|
|
|
5
|
-
`DisplayThemeSwitch` is a thin wrapper around `
|
|
6
|
-
`app/components/05.forms/
|
|
5
|
+
`DisplayThemeSwitch` is a thin wrapper around `SegmentedControl` (see
|
|
6
|
+
`app/components/05.forms/segmented-control/CONSUMER-STYLING.md`) and declares no
|
|
7
7
|
`--display-theme-switch-*` tokens of its own. Every colour token documented there
|
|
8
|
-
(`--
|
|
8
|
+
(`--segmented-control-surface`, `--segmented-control-border`, `--segmented-control-marker-surface`,
|
|
9
9
|
etc.) applies unchanged, since `DisplayThemeSwitch` renders that component directly with no
|
|
10
10
|
intermediate styling layer.
|
|
11
11
|
|
|
@@ -20,14 +20,14 @@ tight header/nav slot:
|
|
|
20
20
|
<DisplayThemeSwitch style-class-passthrough="small" />
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
This sets `
|
|
23
|
+
This sets `SegmentedControl`'s public `--segmented-control-gap`/`-padding`/
|
|
24
24
|
`-option-padding`/`-icon-size` tokens to smaller values (`0.2rem`/`0.2rem`/`0.2rem`/`1.6rem` vs the
|
|
25
|
-
default `0.4rem`/`0.4rem`/`0.4rem`/`2rem` — both already more compact than `
|
|
25
|
+
default `0.4rem`/`0.4rem`/`0.4rem`/`2rem` — both already more compact than `SegmentedControl`'s
|
|
26
26
|
own defaults of `1rem`/`0.6rem`/`0.5rem`/`2rem`). Override any of the four directly for a custom
|
|
27
27
|
size instead of using `small`:
|
|
28
28
|
|
|
29
29
|
```vue
|
|
30
|
-
<DisplayThemeSwitch style="--
|
|
30
|
+
<DisplayThemeSwitch style="--segmented-control-icon-size: 2.4rem;" />
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
---
|
|
@@ -40,8 +40,8 @@ the block **unlayered** (no `@layer` wrapper) so it beats the library's `@layer
|
|
|
40
40
|
own file uses `<style scoped>`, selectors that reach inside the component need `:deep()`. Patterns
|
|
41
41
|
and examples: `.claude/skills/component-local-style-override.md`.
|
|
42
42
|
|
|
43
|
-
**Caveat:** the colour tokens (`--
|
|
44
|
-
ancestor, but the four sizing tokens (`--
|
|
43
|
+
**Caveat:** the colour tokens (`--segmented-control-surface`, `-border`, ...) work from any
|
|
44
|
+
ancestor, but the four sizing tokens (`--segmented-control-gap`, `-padding`, `-option-padding`,
|
|
45
45
|
`-icon-size`) are declared on this component's own root (that's how its default and `small` sizes
|
|
46
46
|
work), so an ancestor value never reaches the toggle. Set those on the root itself: a class from
|
|
47
47
|
`style-class-passthrough`, or inline `style` as shown above.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<ClientOnly>
|
|
3
|
-
<
|
|
3
|
+
<SegmentedControl
|
|
4
4
|
v-model="colorModeVal"
|
|
5
5
|
v-model:field-data="sampleFieldData"
|
|
6
6
|
:aria-label="ariaLabel"
|
|
@@ -77,27 +77,27 @@ watch(colorModeVal, (val) => {
|
|
|
77
77
|
.colour-scheme-select {
|
|
78
78
|
/* Everything DisplayThemeSwitch used to redeclare here beyond sizing (wrapper background/border/
|
|
79
79
|
focus ring, marker border, option border/outline/focus/hover, option-icon colours, and the
|
|
80
|
-
system/light/dark marker gradients) was a byte-for-byte duplicate of
|
|
80
|
+
system/light/dark marker gradients) was a byte-for-byte duplicate of SegmentedControl's
|
|
81
81
|
own defaults for the exact same selectors — a no-op at best, and for the ones that bypassed
|
|
82
|
-
|
|
82
|
+
SegmentedControl's public --segmented-control-* tokens with a hardcoded literal
|
|
83
83
|
(background-color, border, focus outline, marker border, option border/hover/focus, icon
|
|
84
|
-
colour) an active bug: a consumer setting e.g. --
|
|
84
|
+
colour) an active bug: a consumer setting e.g. --segmented-control-surface globally would
|
|
85
85
|
have been silently overridden back to the hardcoded default by this block's higher
|
|
86
|
-
specificity. Removed 2026-09-20 —
|
|
87
|
-
genuine "small" sizing variant remains, now driving
|
|
86
|
+
specificity. Removed 2026-09-20 — SegmentedControl already owns all of that. Only the
|
|
87
|
+
genuine "small" sizing variant remains, now driving SegmentedControl's own public
|
|
88
88
|
sizing tokens instead of reaching into its private --_form-* and --_scheme-icon-font-size
|
|
89
|
-
locals directly (see
|
|
90
|
-
&.
|
|
91
|
-
--
|
|
92
|
-
--
|
|
93
|
-
--
|
|
94
|
-
--
|
|
89
|
+
locals directly (see SegmentedControl's CONSUMER-STYLING.md). */
|
|
90
|
+
&.segmented-control {
|
|
91
|
+
--segmented-control-gap: 0.4rem;
|
|
92
|
+
--segmented-control-padding: 0.4rem;
|
|
93
|
+
--segmented-control-option-padding: 0.4rem;
|
|
94
|
+
--segmented-control-icon-size: 2rem;
|
|
95
95
|
|
|
96
96
|
&.small {
|
|
97
|
-
--
|
|
98
|
-
--
|
|
99
|
-
--
|
|
100
|
-
--
|
|
97
|
+
--segmented-control-gap: 0.2rem;
|
|
98
|
+
--segmented-control-padding: 0.2rem;
|
|
99
|
+
--segmented-control-option-padding: 0.2rem;
|
|
100
|
+
--segmented-control-icon-size: 1.6rem;
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
}
|
package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { ref } from "vue";
|
|
2
|
+
import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
|
|
3
|
+
import type { MediaCanvas } from "~/types/components";
|
|
1
4
|
import DisplayThemeSwitch from "../DisplayThemeSwitch.vue";
|
|
2
5
|
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
6
|
|
|
@@ -50,7 +53,7 @@ const meta: Meta<typeof DisplayThemeSwitch> = {
|
|
|
50
53
|
docs: {
|
|
51
54
|
description: {
|
|
52
55
|
component:
|
|
53
|
-
"A system/light/dark theme switch wrapping
|
|
56
|
+
"A system/light/dark theme switch wrapping SegmentedControl, wired to useSettingsStore. Colour and sizing are entirely delegated to SegmentedControl's public --segmented-control-* tokens — see CONSUMER-STYLING.md.",
|
|
54
57
|
},
|
|
55
58
|
},
|
|
56
59
|
},
|
|
@@ -118,3 +121,52 @@ export const InNavigation: Story = {
|
|
|
118
121
|
},
|
|
119
122
|
},
|
|
120
123
|
};
|
|
124
|
+
|
|
125
|
+
export const StressTest: Story = {
|
|
126
|
+
name: "Stress Test (Worst-Case Data)",
|
|
127
|
+
args: {
|
|
128
|
+
ariaLabel: "Farbschema für die gesamte Website auswählen <b>not bold</b>",
|
|
129
|
+
systemLabel: "Systemeinstellung des Betriebssystems übernehmen",
|
|
130
|
+
lightLabel: "🌞",
|
|
131
|
+
darkLabel: "داكن",
|
|
132
|
+
lightIcon: "",
|
|
133
|
+
},
|
|
134
|
+
decorators: [
|
|
135
|
+
(story, context) => ({
|
|
136
|
+
components: { story, CanvasSwitcher },
|
|
137
|
+
setup() {
|
|
138
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
|
|
139
|
+
return { canvasName };
|
|
140
|
+
},
|
|
141
|
+
template: `
|
|
142
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
143
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
144
|
+
</div>
|
|
145
|
+
<div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
|
|
146
|
+
<story />
|
|
147
|
+
</div>
|
|
148
|
+
`,
|
|
149
|
+
}),
|
|
150
|
+
],
|
|
151
|
+
render: (args) => ({
|
|
152
|
+
components: { DisplayThemeSwitch },
|
|
153
|
+
setup() {
|
|
154
|
+
return { args };
|
|
155
|
+
},
|
|
156
|
+
template: `
|
|
157
|
+
<div style="display: grid; gap: 1.6rem; justify-items: start;">
|
|
158
|
+
<DisplayThemeSwitch v-bind="args" />
|
|
159
|
+
<DisplayThemeSwitch v-bind="args" style-class-passthrough="small" />
|
|
160
|
+
<div dir="rtl"><DisplayThemeSwitch v-bind="args" /></div>
|
|
161
|
+
</div>
|
|
162
|
+
`,
|
|
163
|
+
}),
|
|
164
|
+
parameters: {
|
|
165
|
+
docs: {
|
|
166
|
+
description: {
|
|
167
|
+
story:
|
|
168
|
+
"Long, HTML-like, emoji and right-to-left labels (screen-reader text only), an empty light icon, the small size and a right-to-left container, all bound to the one site colour scheme. Check at every canvas width: the switch stays inside the dashed outline, the empty-icon option still renders as a clickable circle, the marker follows the scheme in every copy, and it slides the right way in right-to-left.",
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
};
|
|
@@ -25,17 +25,17 @@ describe("DisplayThemeSwitch", () => {
|
|
|
25
25
|
expect(wrapper.vm).toBeTruthy();
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
it("renders
|
|
28
|
+
it("renders SegmentedControl with the colour-scheme-select class", async () => {
|
|
29
29
|
const wrapper = await mountSuspended(DisplayThemeSwitch);
|
|
30
30
|
await nextTick();
|
|
31
31
|
expect(wrapper.find(".colour-scheme-select").exists()).toBe(true);
|
|
32
|
-
expect(wrapper.find(".
|
|
32
|
+
expect(wrapper.find(".segmented-control").exists()).toBe(true);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
it("renders default option labels", async () => {
|
|
36
36
|
const wrapper = await mountSuspended(DisplayThemeSwitch);
|
|
37
37
|
await nextTick();
|
|
38
|
-
const labels = wrapper.findAll(".option
|
|
38
|
+
const labels = wrapper.findAll(".segmented-control-option .sr-only").map((el) => el.text());
|
|
39
39
|
expect(labels).toEqual(["System", "Light", "Dark"]);
|
|
40
40
|
});
|
|
41
41
|
|
|
@@ -48,7 +48,7 @@ describe("DisplayThemeSwitch", () => {
|
|
|
48
48
|
},
|
|
49
49
|
});
|
|
50
50
|
await nextTick();
|
|
51
|
-
const labels = wrapper.findAll(".option
|
|
51
|
+
const labels = wrapper.findAll(".segmented-control-option .sr-only").map((el) => el.text());
|
|
52
52
|
expect(labels).toEqual(["Auto", "Day", "Night"]);
|
|
53
53
|
});
|
|
54
54
|
|
|
@@ -61,8 +61,8 @@ describe("DisplayThemeSwitch", () => {
|
|
|
61
61
|
},
|
|
62
62
|
});
|
|
63
63
|
await nextTick();
|
|
64
|
-
const icons = wrapper.findAll(".option-icon").map((el) => el.
|
|
65
|
-
expect(icons.some((
|
|
64
|
+
const icons = wrapper.findAll(".segmented-control-option-icon").map((el) => el.html());
|
|
65
|
+
expect(icons.some((html) => html.includes("custom:system") || html.includes("custom-system"))).toBe(true);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
it("names the radiogroup with ariaLabel", async () => {
|
|
@@ -79,14 +79,14 @@ describe("DisplayThemeSwitch", () => {
|
|
|
79
79
|
props: { styleClassPassthrough: "small" },
|
|
80
80
|
});
|
|
81
81
|
await nextTick();
|
|
82
|
-
expect(wrapper.find(".
|
|
82
|
+
expect(wrapper.find(".segmented-control").classes()).toContain("small");
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
it("calls setColourScheme when the selected option changes", async () => {
|
|
86
86
|
const wrapper = await mountSuspended(DisplayThemeSwitch);
|
|
87
87
|
await nextTick();
|
|
88
88
|
|
|
89
|
-
const lightInput = wrapper.findAll("input.option-input")[1];
|
|
89
|
+
const lightInput = wrapper.findAll("input.segmented-control-option-input")[1];
|
|
90
90
|
await lightInput?.setValue(true);
|
|
91
91
|
await nextTick();
|
|
92
92
|
|
|
@@ -17,7 +17,7 @@ style it rather than `InputButton`'s variant colours), with optional description
|
|
|
17
17
|
> `--input-copy-gap`, `--input-copy-focus-ring`, `--input-copy-description-line-clamp`.
|
|
18
18
|
> - The input's focus outline used the border-radius token as its colour; it's now a real
|
|
19
19
|
> `:focus-visible` ring from `--input-copy-focus-ring`.
|
|
20
|
-
> - The button label is `InputButton`'s `.button-text` (was `.input-copy__button-text`), and it
|
|
20
|
+
> - The button label is `InputButton`'s `.input-button-text` (was `.input-copy__button-text`, then `.button-text` until 2026-10-17), and it
|
|
21
21
|
> hides below 48rem of the **component's** width (a container query), not a 480px viewport.
|
|
22
22
|
|
|
23
23
|
## Public token API
|
|
@@ -70,10 +70,10 @@ shared by the input and the button.
|
|
|
70
70
|
`copiedIcon`.
|
|
71
71
|
- `disabled` on the button when `isDisabled` is set.
|
|
72
72
|
- Classes: `.input-copy` > `.input-copy__wrapper` > `.input-copy__input` and
|
|
73
|
-
`.input-copy__button` (also `.input-button
|
|
74
|
-
`.button-text`); then `.input-copy__description` and a visually hidden `role="status"` span.
|
|
73
|
+
`.input-copy__button` (also `.input-button[data-variant="inline"]`, containing `.input-copy__icon` and
|
|
74
|
+
`.input-button-text`); then `.input-copy__description` and a visually hidden `role="status"` span.
|
|
75
75
|
- Container: the root is `container: input-copy / inline-size`. Below `48rem` of its own width the
|
|
76
|
-
button's `.button-text` is hidden and only the icon shows (the button keeps its aria-label).
|
|
76
|
+
button's `.input-button-text` is hidden and only the icon shows (the button keeps its aria-label).
|
|
77
77
|
|
|
78
78
|
## Global theming
|
|
79
79
|
|
|
@@ -199,14 +199,14 @@ onUnmounted(() => {
|
|
|
199
199
|
flex-shrink: 0;
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
.button-text {
|
|
202
|
+
& .input-button-text {
|
|
203
203
|
font-size: inherit;
|
|
204
204
|
text-transform: none;
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
@container input-copy (width < 48rem) {
|
|
209
|
-
.input-copy__button.input-button .button-text {
|
|
209
|
+
.input-copy__button.input-button .input-button-text {
|
|
210
210
|
display: none;
|
|
211
211
|
}
|
|
212
212
|
}
|
|
@@ -56,7 +56,7 @@ describe("InputCopy", () => {
|
|
|
56
56
|
const w = await mountCopy();
|
|
57
57
|
const button = w.find("button");
|
|
58
58
|
expect(button.classes()).toContain("input-button");
|
|
59
|
-
expect(button.
|
|
59
|
+
expect(button.attributes("data-variant")).toBe("inline");
|
|
60
60
|
expect(button.classes()).toContain("input-copy__button");
|
|
61
61
|
expect(button.attributes("type")).toBe("button");
|
|
62
62
|
});
|
|
@@ -182,7 +182,7 @@ describe("InputCopy", () => {
|
|
|
182
182
|
await nextTick();
|
|
183
183
|
expect(w.classes()).not.toContain("input-copy-copied");
|
|
184
184
|
expect(w.find("button").text()).toContain("Copy");
|
|
185
|
-
expect(w.find("button").attributes("aria-disabled")).
|
|
185
|
+
expect(w.find("button").attributes("aria-disabled")).toBeUndefined();
|
|
186
186
|
});
|
|
187
187
|
|
|
188
188
|
it("logs and stays in the resting state when the clipboard write fails", async () => {
|
|
@@ -163,11 +163,11 @@ describe("GoogleReviews", () => {
|
|
|
163
163
|
setScrollMetrics(list.element, { scrollWidth: 1000, clientWidth: 400, scrollLeft: 0 });
|
|
164
164
|
await list.trigger("scroll");
|
|
165
165
|
expect(wrapper.find(".google-reviews-prev").attributes("aria-disabled")).toBe("true");
|
|
166
|
-
expect(wrapper.find(".google-reviews-next").attributes("aria-disabled")).
|
|
166
|
+
expect(wrapper.find(".google-reviews-next").attributes("aria-disabled")).toBeUndefined();
|
|
167
167
|
|
|
168
168
|
setScrollMetrics(list.element, { scrollWidth: 1000, clientWidth: 400, scrollLeft: 600 });
|
|
169
169
|
await list.trigger("scroll");
|
|
170
|
-
expect(wrapper.find(".google-reviews-prev").attributes("aria-disabled")).
|
|
170
|
+
expect(wrapper.find(".google-reviews-prev").attributes("aria-disabled")).toBeUndefined();
|
|
171
171
|
expect(wrapper.find(".google-reviews-next").attributes("aria-disabled")).toBe("true");
|
|
172
172
|
});
|
|
173
173
|
|
|
@@ -192,8 +192,8 @@ describe("GoogleReviews", () => {
|
|
|
192
192
|
const list = wrapper.find(".google-reviews-list");
|
|
193
193
|
setScrollMetrics(list.element, { scrollWidth: 1000, clientWidth: 400, scrollLeft: 0 });
|
|
194
194
|
await list.trigger("scroll");
|
|
195
|
-
expect(wrapper.find(".google-reviews-prev .button-text").text()).toBe("Previous reviews");
|
|
196
|
-
expect(wrapper.find(".google-reviews-next .button-text").text()).toBe("Next reviews");
|
|
195
|
+
expect(wrapper.find(".google-reviews-prev .input-button-text").text()).toBe("Previous reviews");
|
|
196
|
+
expect(wrapper.find(".google-reviews-next .input-button-text").text()).toBe("Next reviews");
|
|
197
197
|
});
|
|
198
198
|
|
|
199
199
|
// ─── Copy and slots ────────────────────────────────────────────────────
|
|
@@ -155,8 +155,8 @@ cards regardless of each card's description length, without needing CSS subgrid.
|
|
|
155
155
|
|
|
156
156
|
`.services-card`, `.services-card-image-wrapper`, `.services-card-details`, and `.services-card-footer` all set
|
|
157
157
|
`min-inline-size: 0`. Without it, an unbreakable child — most commonly a long `actions` slot
|
|
158
|
-
button label
|
|
159
|
-
|
|
158
|
+
button label kept on one line with `--input-button-text-white-space: nowrap` (the default was
|
|
159
|
+
`nowrap` until 2026-10-17) — forces its content's min-content width up through the flex/grid chain and widens
|
|
160
160
|
that one card's grid column in `ServicesCardGrid` wider than its siblings. If a card/column
|
|
161
161
|
ever looks wider than the rest with content cut off at the edge, check for a long unbreakable
|
|
162
162
|
string in a slot before assuming it's an image sizing issue — the image is just riding along
|
|
@@ -201,7 +201,7 @@ describe("ServicesCardGrid button icon", () => {
|
|
|
201
201
|
const wrapper = await mountSuspended(ServicesCardGrid, {
|
|
202
202
|
props: { servicesData: [makeService("untitled", "")] },
|
|
203
203
|
});
|
|
204
|
-
expect(wrapper.find(".button-text").text()).toBe("Enquire about");
|
|
205
|
-
expect(wrapper.find(".button-text").element.textContent).toBe("Enquire about");
|
|
204
|
+
expect(wrapper.find(".input-button-text").text()).toBe("Enquire about");
|
|
205
|
+
expect(wrapper.find(".input-button-text").element.textContent).toBe("Enquire about");
|
|
206
206
|
});
|
|
207
207
|
});
|
|
@@ -17,9 +17,9 @@ exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
|
|
|
17
17
|
<div class="services-card-meta">
|
|
18
18
|
<div class="services-card-meta-duration">60 mins</div>
|
|
19
19
|
<div class="services-card-meta-price">£50</div>
|
|
20
|
-
</div><a href="/services/locs-installation" data-testid="input-button" data-theme="default" class="input-button
|
|
20
|
+
</div><a href="/services/locs-installation" data-testid="input-button" data-theme="default" data-variant="secondary" data-link="" class="input-button mbs-24">
|
|
21
21
|
<!--v-if-->
|
|
22
|
-
<!--v-if--><span class="button-text">Enquire about Locs Installation</span><span class="
|
|
22
|
+
<!--v-if--><span class="input-button-text">Enquire about Locs Installation</span><span class="input-button-icon" data-slot="right"><span class="iconify i-mdi:arrow-right icon" aria-hidden="true"></span></span>
|
|
23
23
|
<!--v-if-->
|
|
24
24
|
</a>
|
|
25
25
|
</div>
|
|
@@ -40,9 +40,9 @@ exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
|
|
|
40
40
|
<div class="services-card-meta">
|
|
41
41
|
<div class="services-card-meta-duration">60 mins</div>
|
|
42
42
|
<div class="services-card-meta-price">£50</div>
|
|
43
|
-
</div><a href="/services/locs-retwist" data-testid="input-button" data-theme="default" class="input-button
|
|
43
|
+
</div><a href="/services/locs-retwist" data-testid="input-button" data-theme="default" data-variant="secondary" data-link="" class="input-button mbs-24">
|
|
44
44
|
<!--v-if-->
|
|
45
|
-
<!--v-if--><span class="button-text">Enquire about Locs Retwist</span><span class="
|
|
45
|
+
<!--v-if--><span class="input-button-text">Enquire about Locs Retwist</span><span class="input-button-icon" data-slot="right"><span class="iconify i-mdi:arrow-right icon" aria-hidden="true"></span></span>
|
|
46
46
|
<!--v-if-->
|
|
47
47
|
</a>
|
|
48
48
|
</div>
|
|
@@ -63,9 +63,9 @@ exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
|
|
|
63
63
|
<div class="services-card-meta">
|
|
64
64
|
<div class="services-card-meta-duration">60 mins</div>
|
|
65
65
|
<div class="services-card-meta-price">£50</div>
|
|
66
|
-
</div><a href="/services/colour-treatment" data-testid="input-button" data-theme="default" class="input-button
|
|
66
|
+
</div><a href="/services/colour-treatment" data-testid="input-button" data-theme="default" data-variant="secondary" data-link="" class="input-button mbs-24">
|
|
67
67
|
<!--v-if-->
|
|
68
|
-
<!--v-if--><span class="button-text">Enquire about Colour Treatment</span><span class="
|
|
68
|
+
<!--v-if--><span class="input-button-text">Enquire about Colour Treatment</span><span class="input-button-icon" data-slot="right"><span class="iconify i-mdi:arrow-right icon" aria-hidden="true"></span></span>
|
|
69
69
|
<!--v-if-->
|
|
70
70
|
</a>
|
|
71
71
|
</div>
|
|
@@ -110,3 +110,11 @@ tokens instead of the `--theme-error-*` ones** above).
|
|
|
110
110
|
`.input-error-message` element. Field wrappers don't forward it, so in practice use the tokens
|
|
111
111
|
above.
|
|
112
112
|
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Notes
|
|
116
|
+
|
|
117
|
+
- Message text wraps anywhere, so a long URL or unbroken word stays inside the strip. There is no
|
|
118
|
+
line-clamp token: error text should stay readable in full.
|
|
119
|
+
- Blank entries in an array `error-message` are dropped rather than rendered as empty list items.
|
|
120
|
+
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
<Icon :name="icon" class="input-error-message-icon" aria-hidden="true" />
|
|
17
17
|
</div>
|
|
18
18
|
<div class="input-error-message-text">
|
|
19
|
-
<ul v-if="isArray" class="input-error-message-list">
|
|
20
|
-
<li v-for="(message, index) in
|
|
19
|
+
<ul v-if="isArray" class="input-error-message-list" role="list">
|
|
20
|
+
<li v-for="(message, index) in messages" :key="index" class="input-error-message-list-item">
|
|
21
21
|
{{ message }}
|
|
22
22
|
</li>
|
|
23
23
|
</ul>
|
|
@@ -52,6 +52,9 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
const isArray = computed(() => Array.isArray(props.errorMessage));
|
|
55
|
+
const messages = computed(() =>
|
|
56
|
+
Array.isArray(props.errorMessage) ? props.errorMessage.filter((message) => String(message).trim() !== "") : []
|
|
57
|
+
);
|
|
55
58
|
|
|
56
59
|
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
57
60
|
|
|
@@ -141,48 +144,51 @@ watch(
|
|
|
141
144
|
outline-color: transparent;
|
|
142
145
|
}
|
|
143
146
|
}
|
|
144
|
-
}
|
|
145
147
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
& .input-error-message-inner {
|
|
149
|
+
align-items: center;
|
|
150
|
+
overflow: hidden;
|
|
151
|
+
}
|
|
150
152
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
& .input-error-message-content {
|
|
154
|
+
display: flex;
|
|
155
|
+
align-items: center;
|
|
156
|
+
}
|
|
155
157
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
& .input-error-message-icon-wrapper {
|
|
159
|
+
display: inline-block;
|
|
160
|
+
flex-shrink: 0;
|
|
161
|
+
padding-inline-start: var(--_padding-inline);
|
|
162
|
+
}
|
|
160
163
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
& .input-error-message-icon {
|
|
165
|
+
color: var(--input-error-icon-color, var(--_text-color));
|
|
166
|
+
font-size: var(--input-error-icon-size, 1em);
|
|
167
|
+
transform: translateY(3px);
|
|
168
|
+
}
|
|
166
169
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
var(--input-error-
|
|
175
|
-
|
|
176
|
-
|
|
170
|
+
& .input-error-message-text {
|
|
171
|
+
display: inline-block;
|
|
172
|
+
flex-grow: 1;
|
|
173
|
+
min-inline-size: 0;
|
|
174
|
+
overflow-wrap: anywhere;
|
|
175
|
+
font-family: var(--input-error-font-family, var(--font-family));
|
|
176
|
+
font-size: var(--input-error-font-size, 1.6rem);
|
|
177
|
+
font-weight: var(--input-error-font-weight, 500);
|
|
178
|
+
padding-block: var(--input-error-padding-block-start, var(--_padding-block-start))
|
|
179
|
+
var(--input-error-padding-block-end, 1rem);
|
|
180
|
+
padding-inline: var(--_padding-inline);
|
|
181
|
+
}
|
|
177
182
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
+
& .input-error-message-list {
|
|
184
|
+
list-style-type: none;
|
|
185
|
+
padding-inline-start: 0;
|
|
186
|
+
margin-block: 0;
|
|
187
|
+
}
|
|
183
188
|
|
|
184
|
-
|
|
185
|
-
|
|
189
|
+
& .input-error-message-list-item + .input-error-message-list-item {
|
|
190
|
+
margin-block-start: var(--input-error-list-gap, 0.6rem);
|
|
191
|
+
}
|
|
186
192
|
}
|
|
187
193
|
}
|
|
188
194
|
</style>
|