srcdev-nuxt-components 9.6.10 → 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.
Files changed (108) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +4 -2
  2. package/.claude/skills/component-compliance-checklist.md +9 -3
  3. package/.claude/skills/component-inline-action-button.md +5 -4
  4. package/.claude/skills/component-naming.md +2 -1
  5. package/.claude/skills/components/{alert-mask-core.md → alert-mask.md} +38 -23
  6. package/.claude/skills/components/alert-masked-content.md +3 -3
  7. package/.claude/skills/components/container-glow.md +1 -1
  8. package/.claude/skills/components/display-theme-switch.md +8 -8
  9. package/.claude/skills/components/input-button.md +26 -7
  10. package/.claude/skills/components/input-checkbox-radio.md +2 -0
  11. package/.claude/skills/components/input-description.md +5 -0
  12. package/.claude/skills/components/input-error.md +9 -2
  13. package/.claude/skills/components/input-label.md +8 -2
  14. package/.claude/skills/components/input-number.md +2 -0
  15. package/.claude/skills/components/input-otp.md +2 -0
  16. package/.claude/skills/components/input-range.md +2 -0
  17. package/.claude/skills/components/input-select.md +2 -0
  18. package/.claude/skills/components/input-text.md +2 -0
  19. package/.claude/skills/components/input-textarea.md +2 -0
  20. package/.claude/skills/components/pending-effect.md +5 -4
  21. package/.claude/skills/components/{triple-toggle-switch.md → segmented-control.md} +34 -19
  22. package/.claude/skills/components/services-card.md +1 -1
  23. package/.claude/skills/components/tabbed-content.md +1 -1
  24. package/.claude/skills/components/toggle-switch.md +2 -0
  25. package/.claude/skills/index.md +5 -5
  26. package/.claude/skills/storybook-add-story.md +3 -1
  27. package/.claude/skills/theming-component-token-pattern.md +24 -0
  28. package/.claude/skills/theming-form-geometry-tokens.md +1 -1
  29. package/.claude/skills/theming-partial-override.md +1 -1
  30. package/.vscode/srcdev-component-alert-mask.code-snippets +41 -0
  31. package/.vscode/{srcdev-component-triple-toggle-switch.code-snippets → srcdev-component-segmented-control.code-snippets} +6 -7
  32. package/app/assets/styles/setup/03.theming/_theme-slots.css +1 -1
  33. package/app/assets/styles/setup/04.elements/forms/03.generic-input-geometry.css +4 -0
  34. package/app/components/01.atoms/alert-mask/AlertMask.vue +194 -0
  35. package/app/components/01.atoms/alert-mask/CONSUMER-STYLING.md +57 -42
  36. package/app/components/01.atoms/alert-mask/stories/AlertMask.stories.ts +198 -0
  37. package/app/components/01.atoms/alert-mask/tests/AlertMask.spec.ts +233 -0
  38. package/app/components/02.molecules/alert-masked-content/AlertMaskedContent.vue +2 -2
  39. package/app/components/02.molecules/alert-masked-content/CONSUMER-STYLING.md +4 -4
  40. package/app/components/02.molecules/alert-masked-content/stories/AlertMaskedContent.stories.ts +60 -0
  41. package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +12 -12
  42. package/app/components/02.molecules/cookie-consent-banner/CONSUMER-STYLING.md +3 -3
  43. package/app/components/02.molecules/cookie-consent-banner/CookieConsentBanner.vue +1 -1
  44. package/app/components/02.molecules/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +1 -1
  45. package/app/components/02.molecules/display-theme-switch/CONSUMER-STYLING.md +9 -9
  46. package/app/components/02.molecules/display-theme-switch/DisplayThemeSwitch.vue +16 -16
  47. package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +53 -1
  48. package/app/components/02.molecules/display-theme-switch/tests/DisplayThemeSwitch.spec.ts +8 -8
  49. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +4 -4
  50. package/app/components/02.molecules/input-copy/InputCopy.vue +2 -2
  51. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +2 -2
  52. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +4 -4
  53. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +2 -2
  54. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +2 -2
  55. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +6 -6
  56. package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +8 -0
  57. package/app/components/05.forms/form-errors/InputError.vue +43 -37
  58. package/app/components/05.forms/form-errors/stories/InputError.stories.ts +79 -1
  59. package/app/components/05.forms/form-errors/tests/InputError.spec.ts +29 -1
  60. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +80 -47
  61. package/app/components/05.forms/input-button/InputButton.vue +52 -100
  62. package/app/components/05.forms/input-button/playwright/input-button.playwright.ts +1 -1
  63. package/app/components/05.forms/input-button/stories/InputButton.stories.ts +145 -8
  64. package/app/components/05.forms/input-button/tests/InputButton.spec.ts +76 -46
  65. package/app/components/05.forms/input-button/tests/__snapshots__/InputButton.spec.ts.snap +17 -17
  66. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +9 -1
  67. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +8 -0
  68. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +2 -2
  69. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +14 -2
  70. package/app/components/05.forms/input-description/InputDescription.vue +2 -1
  71. package/app/components/05.forms/input-description/stories/InputDescription.stories.ts +70 -4
  72. package/app/components/05.forms/input-description/tests/InputDescription.spec.ts +2 -1
  73. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +8 -0
  74. package/app/components/05.forms/input-label/InputLabel.vue +1 -0
  75. package/app/components/05.forms/input-label/stories/InputLabel.stories.ts +68 -2
  76. package/app/components/05.forms/input-label/tests/InputLabel.spec.ts +13 -3
  77. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +7 -1
  78. package/app/components/05.forms/input-number/InputNumber.vue +4 -5
  79. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +7 -1
  80. package/app/components/05.forms/input-otp/InputOtp.vue +2 -2
  81. package/app/components/05.forms/input-range/InputRange.vue +1 -1
  82. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +7 -1
  83. package/app/components/05.forms/input-select/InputSelect.vue +14 -5
  84. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +7 -1
  85. package/app/components/05.forms/input-text/InputText.vue +4 -4
  86. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +7 -1
  87. package/app/components/05.forms/input-textarea/InputTextarea.vue +2 -3
  88. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +4 -4
  89. package/app/components/05.forms/pending-effect/CONSUMER-STYLING.md +7 -3
  90. package/app/components/05.forms/pending-effect/PendingEffect.vue +3 -3
  91. package/app/components/05.forms/pending-effect/tests/PendingEffect.spec.ts +1 -1
  92. package/app/components/05.forms/segmented-control/CONSUMER-STYLING.md +125 -0
  93. package/app/components/05.forms/segmented-control/SegmentedControl.vue +231 -0
  94. package/app/components/05.forms/segmented-control/stories/SegmentedControl.stories.ts +215 -0
  95. package/app/components/05.forms/segmented-control/tests/SegmentedControl.spec.ts +202 -0
  96. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +7 -0
  97. package/app/components/05.forms/toggle-switch/ToggleSwitch.vue +2 -0
  98. package/app/types/components/index.ts +1 -1
  99. package/package.json +1 -1
  100. package/.vscode/srcdev-component-alert-mask-core.code-snippets +0 -31
  101. package/app/components/01.atoms/alert-mask/AlertMaskCore.vue +0 -166
  102. package/app/components/01.atoms/alert-mask/stories/AlertMaskCore.stories.ts +0 -105
  103. package/app/components/01.atoms/alert-mask/tests/AlertMaskCore.spec.ts +0 -342
  104. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +0 -99
  105. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +0 -251
  106. package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitch.stories.ts +0 -104
  107. package/app/components/05.forms/triple-toggle-switch/tests/TripleToggleSwitch.spec.ts +0 -138
  108. /package/app/types/components/{alert-mask-core.d.ts → alert-mask.d.ts} +0 -0
@@ -0,0 +1,202 @@
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { defineComponent, ref } from "vue";
4
+ import SegmentedControl from "../SegmentedControl.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 mounted: { unmount: () => void }[] = [];
19
+
20
+ const mountSwitch = async (props: Record<string, unknown> = {}) => {
21
+ const wrapper = await mountSuspended(SegmentedControl, {
22
+ props: {
23
+ modelValue: "system",
24
+ fieldData: buildOptions(),
25
+ ...props,
26
+ },
27
+ });
28
+ mounted.push(wrapper);
29
+ return wrapper;
30
+ };
31
+
32
+ interface SegmentedControlInstance {
33
+ selectedOptionIndex: number;
34
+ markerIndex: number;
35
+ optionCount: number;
36
+ }
37
+
38
+ const inputs = (wrapper: Awaited<ReturnType<typeof mountSwitch>>) => wrapper.findAll("input.segmented-control-option-input");
39
+ const optionLabels = (wrapper: Awaited<ReturnType<typeof mountSwitch>>) => wrapper.findAll("label.segmented-control-option");
40
+
41
+ describe("SegmentedControl", () => {
42
+ afterEach(() => {
43
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
44
+ vi.restoreAllMocks();
45
+ });
46
+
47
+ it("mounts without error", async () => {
48
+ const wrapper = await mountSwitch();
49
+ expect(wrapper.vm).toBeTruthy();
50
+ expect(wrapper.find(".segmented-control").exists()).toBe(true);
51
+ });
52
+
53
+ it("renders one radio per option with its value and the shared name", async () => {
54
+ const wrapper = await mountSwitch({ name: "colour-scheme" });
55
+ expect(inputs(wrapper)).toHaveLength(3);
56
+ expect(inputs(wrapper).map((i) => (i.element as HTMLInputElement).value)).toEqual(["system", "light", "dark"]);
57
+ inputs(wrapper).forEach((i) => {
58
+ expect(i.attributes("type")).toBe("radio");
59
+ expect(i.attributes("name")).toBe("colour-scheme");
60
+ });
61
+ });
62
+
63
+ it("builds ids from useId, not from option data", async () => {
64
+ const wrapper = await mountSwitch();
65
+ const ids = inputs(wrapper).map((i) => i.attributes("id"));
66
+ expect(ids).not.toContain("system");
67
+ expect(new Set(ids).size).toBe(3);
68
+ expect(optionLabels(wrapper).map((l) => l.attributes("for"))).toEqual(ids);
69
+ });
70
+
71
+ it("gives two instances separate radio groups and ids", async () => {
72
+ const Host = defineComponent({
73
+ components: { SegmentedControl },
74
+ setup() {
75
+ return { first: ref("system"), second: ref("dark"), options: ref(buildOptions()) };
76
+ },
77
+ template: `
78
+ <div>
79
+ <SegmentedControl v-model="first" v-model:field-data="options" />
80
+ <SegmentedControl v-model="second" v-model:field-data="options" />
81
+ </div>
82
+ `,
83
+ });
84
+ const wrapper = await mountSuspended(Host);
85
+ mounted.push(wrapper);
86
+ const all = wrapper.findAll("input.segmented-control-option-input");
87
+ const names = new Set(all.map((i) => i.attributes("name")));
88
+ const ids = new Set(all.map((i) => i.attributes("id")));
89
+ expect(names.size).toBe(2);
90
+ expect(ids.size).toBe(6);
91
+ });
92
+
93
+ it("renders the label text for screen readers", async () => {
94
+ const wrapper = await mountSwitch();
95
+ expect(wrapper.findAll(".segmented-control-option .sr-only").map((s) => s.text())).toEqual([
96
+ "System",
97
+ "Light",
98
+ "Dark",
99
+ ]);
100
+ });
101
+
102
+ it("exposes the options as a radiogroup", async () => {
103
+ const wrapper = await mountSwitch();
104
+ const group = wrapper.find(".segmented-control-options");
105
+ expect(group.attributes("role")).toBe("radiogroup");
106
+ expect(group.attributes("aria-label")).toBeUndefined();
107
+ });
108
+
109
+ it("applies ariaLabel to the radiogroup", async () => {
110
+ const wrapper = await mountSwitch({ ariaLabel: "Colour scheme" });
111
+ expect(wrapper.find(".segmented-control-options").attributes("aria-label")).toBe("Colour scheme");
112
+ });
113
+
114
+ it("hides the decorative marker from assistive tech", async () => {
115
+ const wrapper = await mountSwitch();
116
+ expect(wrapper.find(".segmented-control-marker-layer").attributes("aria-hidden")).toBe("true");
117
+ });
118
+
119
+ it("checks the radio matching modelValue", async () => {
120
+ const wrapper = await mountSwitch({ modelValue: "light" });
121
+ const checked = inputs(wrapper).map((i) => (i.element as HTMLInputElement).checked);
122
+ expect(checked).toEqual([false, true, false]);
123
+ });
124
+
125
+ it("emits update:modelValue when another option is chosen", async () => {
126
+ const wrapper = await mountSwitch();
127
+ await inputs(wrapper)[2]!.setValue(true);
128
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual(["dark"]);
129
+ });
130
+
131
+ it("marks only the selected option with data-checked and exposes its value", async () => {
132
+ const wrapper = await mountSwitch({ modelValue: "dark" });
133
+ expect(optionLabels(wrapper).map((l) => l.attributes("data-checked"))).toEqual([undefined, undefined, ""]);
134
+ expect(optionLabels(wrapper).map((l) => l.attributes("data-value"))).toEqual(["system", "light", "dark"]);
135
+
136
+ await wrapper.setProps({ modelValue: "light" });
137
+ expect(optionLabels(wrapper).map((l) => l.attributes("data-checked"))).toEqual([undefined, "", undefined]);
138
+ });
139
+
140
+ it("does not use collision-prone bare class names", async () => {
141
+ const wrapper = await mountSwitch();
142
+ for (const name of [".option-group", ".option-icon", ".option-input", ".selected-option-marker", ".active", ".show"]) {
143
+ expect(wrapper.find(name).exists()).toBe(false);
144
+ }
145
+ });
146
+
147
+ it("omits the icon when an option has none", async () => {
148
+ const options = buildOptions();
149
+ delete options.data[1]!.icon;
150
+ const wrapper = await mountSwitch({ fieldData: options });
151
+ expect(wrapper.findAll(".segmented-control-option-icon")).toHaveLength(2);
152
+ expect(inputs(wrapper)).toHaveLength(3);
153
+ });
154
+
155
+ it("marks icons as decorative", async () => {
156
+ const wrapper = await mountSwitch();
157
+ wrapper.findAll(".segmented-control-option-icon").forEach((icon) => {
158
+ expect(icon.attributes("aria-hidden")).toBe("true");
159
+ });
160
+ });
161
+
162
+ it("tracks the selected index and hides the marker for an unknown value", async () => {
163
+ const wrapper = await mountSwitch({ modelValue: "dark" });
164
+ const vm = wrapper.vm as unknown as SegmentedControlInstance;
165
+ const root = () => wrapper.find(".segmented-control");
166
+ expect(vm.selectedOptionIndex).toBe(2);
167
+ expect(vm.markerIndex).toBe(2);
168
+ expect(root().attributes("data-has-selection")).toBe("");
169
+
170
+ await wrapper.setProps({ modelValue: "unknown" });
171
+ expect(vm.selectedOptionIndex).toBe(-1);
172
+ expect(vm.markerIndex).toBe(0);
173
+ expect(root().attributes("data-has-selection")).toBeUndefined();
174
+ });
175
+
176
+ it("sizes the grid from the option count, never below one column", async () => {
177
+ const four = buildOptions();
178
+ four.data.push({ id: "auto", name: "scheme", value: "auto", label: "Auto", icon: "radix-icons:gear" });
179
+ const wrapper = await mountSwitch({ fieldData: four });
180
+ expect((wrapper.vm as unknown as SegmentedControlInstance).optionCount).toBe(4);
181
+
182
+ const empty = await mountSwitch({ fieldData: { data: [], total: 0, skip: 0, limit: 0 } });
183
+ expect((empty.vm as unknown as SegmentedControlInstance).optionCount).toBe(1);
184
+ expect(empty.findAll("input")).toHaveLength(0);
185
+ });
186
+
187
+ it("applies the theme as data-theme", async () => {
188
+ const wrapper = await mountSwitch({ theme: "success" });
189
+ expect(wrapper.find(".segmented-control").attributes("data-theme")).toBe("success");
190
+ });
191
+
192
+ it("applies styleClassPassthrough classes to the root and reacts to changes", async () => {
193
+ const wrapper = await mountSwitch({ styleClassPassthrough: ["small", "extra"] });
194
+ const root = () => wrapper.find(".segmented-control");
195
+ expect(root().classes()).toContain("small");
196
+ expect(root().classes()).toContain("extra");
197
+
198
+ await wrapper.setProps({ styleClassPassthrough: ["other"] });
199
+ expect(root().classes()).toContain("other");
200
+ expect(root().classes()).not.toContain("small");
201
+ });
202
+ });
@@ -18,6 +18,8 @@ this two-tier shape exists.
18
18
  | `--toggle-switch-surface` | `var(--theme-checkbox-symbol-surface)` | Track background (resting) |
19
19
  | `--toggle-switch-surface-hover` | `var(--theme-surface-subtle)` | Track background on hover |
20
20
  | `--toggle-switch-border` | `var(--theme-border)` | Track border colour |
21
+ | `--toggle-switch-border-hover` | the resolved border | 1px track outline colour on hover |
22
+ | `--toggle-switch-shadow` | `var(--form-element-shadow-colour, var(--colour-theme-2))` | Resting shadow ring outside the border (on the track). Width is the global `--form-element-shadow-width` (`0.2rem`; `0` removes it) |
21
23
  | `--toggle-switch-border-focus` | `var(--theme-border-focus)` | Track outline colour on `:focus-visible` |
22
24
  | `--toggle-switch-symbol-border` | `var(--theme-text)` | Thumb border colour |
23
25
  | `--toggle-switch-symbol-surface-off` | `var(--theme-on-surface)` | Thumb background, off |
@@ -54,6 +56,11 @@ Only set `--toggle-switch-track-width` for a width the formula doesn't produce.
54
56
 
55
57
  ---
56
58
 
59
+ > **Changed 2026-10-17**: bordered controls carry a light shadow ring (`box-shadow`, theme step 2,
60
+ > so red when invalid), and hover draws a 1px outline in the border colour with no offset, over the
61
+ > ring's inner edge. Hover used to draw a 2px outline at a 2px offset in the focus colour. Focus is
62
+ > unchanged and sits just outside the ring.
63
+
57
64
  ## State hooks
58
65
 
59
66
  | Attribute | On | When |
@@ -133,12 +133,14 @@ watch(
133
133
  background-color: var(--toggle-switch-surface, var(--theme-checkbox-symbol-surface));
134
134
  border: var(--form-element-border-width) solid var(--toggle-switch-border, var(--theme-border));
135
135
  outline: var(--form-element-outline-width) solid transparent;
136
+ box-shadow: 0 0 0 var(--form-element-shadow-width) var(--toggle-switch-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
136
137
  border-radius: var(--_border-radius);
137
138
  width: var(--_track-width);
138
139
  padding: calc(var(--input-toggle-wrapper-padding) + var(--input-toggle-symbol-outline-width));
139
140
 
140
141
  &:hover {
141
142
  background-color: var(--toggle-switch-surface-hover, var(--theme-surface-subtle));
143
+ outline-color: var(--toggle-switch-border-hover, var(--toggle-switch-border, var(--theme-border)));
142
144
  cursor: pointer;
143
145
  }
144
146
 
@@ -8,7 +8,7 @@ export * from "./responsive-header.d"
8
8
  export * from "./display-toast.d"
9
9
  export * from "./display-prompt.d"
10
10
  export * from "./qr-code.d"
11
- export * from "./alert-mask-core.d"
11
+ export * from "./alert-mask.d"
12
12
  export * from "./container-glow.d"
13
13
  export * from "./hero-text"
14
14
  export * from "./social-icons-list.d"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.6.10",
4
+ "version": "9.6.11",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",
@@ -1,31 +0,0 @@
1
- {
2
- "SRCDEV AlertMaskCore Basic": {
3
- "description": "AlertMaskCore — SVG border/background mask sized to slotted content",
4
- "scope": "vue,html",
5
- "body": [
6
- "<AlertMaskCore>",
7
- " $0",
8
- "</AlertMaskCore>"
9
- ]
10
- },
11
- "SRCDEV AlertMaskCore Custom Config": {
12
- "description": "AlertMaskCore with custom colour/geometry config",
13
- "scope": "vue,html",
14
- "body": [
15
- "<AlertMaskCore",
16
- " :config=\"{",
17
- " borderColour: '$1var(--theme-accent)',",
18
- " backgroundColour: '$2rgba(0, 0, 0, 0.3)',",
19
- " radiusLeft: $38,",
20
- " radiusRight: $44,",
21
- " borderLeft: $56,",
22
- " borderTop: $61,",
23
- " borderRight: $71,",
24
- " borderBottom: $81,",
25
- " }\"",
26
- ">",
27
- " $0",
28
- "</AlertMaskCore>"
29
- ]
30
- }
31
- }
@@ -1,166 +0,0 @@
1
- <template>
2
- <div class="alert-mask-core" :class="[elementClasses]">
3
- <svg
4
- class="alert-mask-decorator"
5
- :style="{ '--_height': svgHeight + 'px' }"
6
- xmlns="http://www.w3.org/2000/svg"
7
- aria-hidden="true"
8
- focusable="false"
9
- >
10
- <defs>
11
- <mask :id="maskId" maskUnits="userSpaceOnUse">
12
- <path :d="outerPath" fill="white" />
13
- <path :d="innerPath" fill="black" />
14
- </mask>
15
- </defs>
16
-
17
- <path
18
- :d="outerPath"
19
- :style="{ fill: cfg.borderColour }"
20
- :mask="`url(#${maskId})`"
21
- vector-effect="non-scaling-stroke"
22
- />
23
- <path :d="innerPath" :style="{ fill: cfg.backgroundColour }" />
24
- </svg>
25
-
26
- <div
27
- class="alert-mask-content"
28
- :style="{
29
- '--_inset-inline-start': (props.config?.borderLeft ?? 0) + 'px',
30
- '--_inset-inline-end': (props.config?.borderRight ?? 0) + 'px',
31
- '--_inset-block-start': (props.config?.borderTop ?? 0) + 'px',
32
- '--_inset-block-end': (props.config?.borderBottom ?? 0) + 'px',
33
- }"
34
- >
35
- <div ref="alertContentRef" class="alert-mask-content-slot">
36
- <slot name="default"></slot>
37
- </div>
38
- </div>
39
- </div>
40
- </template>
41
-
42
- <script setup lang="ts">
43
- import type { AlertMaskConfig } from "~/types/components";
44
-
45
- interface Props {
46
- config?: AlertMaskConfig;
47
- styleClassPassthrough?: string | string[];
48
- }
49
-
50
- const props = withDefaults(defineProps<Props>(), {
51
- config: () => ({}),
52
- styleClassPassthrough: () => [],
53
- });
54
-
55
- const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
56
-
57
- const maskId = `alert-mask-${useId()}`;
58
- const alertContentRef = useTemplateRef<HTMLElement | null>("alertContentRef");
59
- const svgWidth = ref(0);
60
- const svgHeight = ref(0);
61
- let resizeObserver: ResizeObserver | null = null;
62
-
63
- // Update dimensions based on content
64
- onMounted(() => {
65
- const updateDimensions = () => {
66
- const contentEl = alertContentRef.value;
67
- if (!contentEl) return;
68
-
69
- const rect = contentEl.getBoundingClientRect();
70
- const { borderLeft, borderTop, borderRight, borderBottom } = cfg.value;
71
-
72
- // Calculate total dimensions including borders
73
- svgWidth.value = rect.width + borderLeft + borderRight;
74
- svgHeight.value = rect.height + borderTop + borderBottom;
75
- };
76
-
77
- // Initial measurement
78
- nextTick(updateDimensions);
79
-
80
- // Observe content changes
81
- const contentEl = alertContentRef.value;
82
- if (contentEl) {
83
- resizeObserver = new ResizeObserver(updateDimensions);
84
- resizeObserver.observe(contentEl);
85
- }
86
- });
87
-
88
- onBeforeUnmount(() => {
89
- resizeObserver?.disconnect();
90
- resizeObserver = null;
91
- });
92
-
93
- const cfg = computed(() => ({
94
- backgroundColour: props.config?.backgroundColour ?? "rgba(0,0,0,0.25)",
95
- borderColour: props.config?.borderColour ?? "var(--orange-08)",
96
- radiusLeft: props.config?.radiusLeft ?? 12,
97
- radiusRight: props.config?.radiusRight ?? 12,
98
- borderLeft: props.config?.borderLeft ?? 8,
99
- borderTop: props.config?.borderTop ?? 8,
100
- borderRight: props.config?.borderRight ?? 8,
101
- borderBottom: props.config?.borderBottom ?? 8,
102
- }));
103
-
104
- // Outer border path (fixed radii)
105
- const outerPath = computed(() => {
106
- const width = svgWidth.value;
107
- const height = svgHeight.value;
108
- const { radiusLeft, radiusRight } = cfg.value;
109
-
110
- if (!width || !height) return "";
111
-
112
- return `
113
- M ${radiusLeft} 0
114
- L ${width - radiusRight} 0
115
- Q ${width} 0 ${width} ${radiusRight}
116
- L ${width} ${height - radiusRight}
117
- Q ${width} ${height} ${width - radiusRight} ${height}
118
- L ${radiusLeft} ${height}
119
- Q 0 ${height} 0 ${height - radiusLeft}
120
- L 0 ${radiusLeft}
121
- Q 0 0 ${radiusLeft} 0 Z
122
- `;
123
- });
124
-
125
- // Inner cutout (based on fixed pixel border thickness)
126
- const innerPath = computed(() => {
127
- const width = svgWidth.value;
128
- const height = svgHeight.value;
129
- const { radiusLeft, radiusRight, borderLeft, borderTop, borderRight, borderBottom: borderBottom } = cfg.value;
130
-
131
- if (!width || !height) return "";
132
-
133
- return `
134
- M ${radiusLeft + borderLeft} ${borderTop}
135
- L ${width - radiusRight - borderRight} ${borderTop}
136
- Q ${width - borderRight} ${borderTop} ${width - borderRight} ${radiusRight + borderTop}
137
- L ${width - borderRight} ${height - radiusRight - borderBottom}
138
- Q ${width - borderRight} ${height - borderBottom} ${width - radiusRight - borderRight} ${height - borderBottom}
139
- L ${radiusLeft + borderLeft} ${height - borderBottom}
140
- Q ${borderLeft} ${height - borderBottom} ${borderLeft} ${height - radiusLeft - borderBottom}
141
- L ${borderLeft} ${radiusLeft + borderTop}
142
- Q ${borderLeft} ${borderTop} ${radiusLeft + borderLeft} ${borderTop} Z
143
- `;
144
- });
145
- </script>
146
-
147
- <style lang="css">
148
- @layer components {
149
- .alert-mask-core {
150
- display: grid;
151
- grid-template-areas: "mask";
152
-
153
- .alert-mask-decorator {
154
- grid-area: mask;
155
- width: 100%;
156
- height: var(--_height);
157
- }
158
-
159
- .alert-mask-content {
160
- grid-area: mask;
161
- margin-block: var(--_inset-block-start) var(--_inset-block-end);
162
- margin-inline: var(--_inset-inline-start) var(--_inset-inline-end);
163
- }
164
- }
165
- }
166
- </style>
@@ -1,105 +0,0 @@
1
- import AlertMaskCore from "../AlertMaskCore.vue";
2
- import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
-
4
- const meta: Meta<typeof AlertMaskCore> = {
5
- title: "Atoms/AlertMaskCore",
6
- component: AlertMaskCore,
7
- argTypes: {
8
- config: {
9
- control: "object",
10
- description:
11
- "Border/background colour and geometry: backgroundColour, borderColour, radiusLeft, radiusRight, borderLeft, borderTop, borderRight, borderBottom",
12
- table: { category: "Basic" },
13
- },
14
- styleClassPassthrough: {
15
- table: { disable: true },
16
- },
17
- },
18
- parameters: {
19
- docs: {
20
- description: {
21
- component:
22
- "Draws a border/background mask around slotted content via an SVG path, sized to the measured content. Colour and geometry are set via the `config` prop, not CSS tokens — see CONSUMER-STYLING.md.",
23
- },
24
- },
25
- },
26
- };
27
-
28
- export default meta;
29
- type Story = StoryObj<typeof AlertMaskCore>;
30
-
31
- // The mask is a translucent fill plus a cut-out border, so it needs something busy behind it to read at all.
32
- const backdrop =
33
- "background: url('/images/banners/video/lake-banner.jpg') center / cover; padding: 4rem 2.4rem; border-radius: 1.2rem; max-width: 60rem;";
34
- const gradientBackdrop =
35
- "background: repeating-linear-gradient(45deg, oklch(100% 0 0 / 0.18) 0 1.2rem, transparent 1.2rem 2.4rem), linear-gradient(120deg, oklch(70% 0.2 30), oklch(65% 0.2 200) 60%, oklch(55% 0.22 300)); padding: 4rem 2.4rem; border-radius: 1.2rem; max-width: 60rem;";
36
-
37
- export const CustomConfig: Story = {
38
- args: {
39
- config: {
40
- backgroundColour: "rgba(0, 0, 0, 0.3)",
41
- borderColour: "var(--theme-accent)",
42
- radiusLeft: 8,
43
- radiusRight: 4,
44
- borderLeft: 6,
45
- borderTop: 1,
46
- borderRight: 1,
47
- borderBottom: 1,
48
- },
49
- },
50
- render: (args) => ({
51
- components: { AlertMaskCore },
52
- setup() {
53
- return { args, backdrop };
54
- },
55
- template: `
56
- <div data-theme="info" :style="backdrop">
57
- <AlertMaskCore v-bind="args">
58
- <p style="margin: 0; padding: 1.6rem; color: white;">Custom accent-bar config, matching AlertMaskedContent's default shape</p>
59
- </AlertMaskCore>
60
- </div>
61
- `,
62
- }),
63
- parameters: {
64
- docs: {
65
- description: {
66
- story: "The accent-bar shape used by AlertMaskedContent: thick left radius/border, thin elsewhere.",
67
- },
68
- },
69
- },
70
- };
71
-
72
- export const OverGradient: Story = {
73
- args: {
74
- config: {
75
- backgroundColour: "rgba(0, 0, 0, 0.3)",
76
- borderColour: "white",
77
- radiusLeft: 16,
78
- radiusRight: 16,
79
- borderLeft: 4,
80
- borderTop: 4,
81
- borderRight: 4,
82
- borderBottom: 4,
83
- },
84
- },
85
- render: (args) => ({
86
- components: { AlertMaskCore },
87
- setup() {
88
- return { args, gradientBackdrop };
89
- },
90
- template: `
91
- <div :style="gradientBackdrop">
92
- <AlertMaskCore v-bind="args">
93
- <p style="margin: 0; padding: 1.6rem; color: white;">Even border all round: the stripes show through the fill and stop at the border's inner edge.</p>
94
- </AlertMaskCore>
95
- </div>
96
- `,
97
- }),
98
- parameters: {
99
- docs: {
100
- description: {
101
- story: "A striped gradient behind an even 4px white border, so the translucent fill and the border's cut-out are both visible.",
102
- },
103
- },
104
- },
105
- };