srcdev-nuxt-components 9.4.4 → 9.4.6
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/commands/migrate-component.md +106 -0
- package/.claude/component-ledger/artifact-url.txt +1 -0
- package/.claude/component-ledger/audit.json +1 -0
- package/.claude/component-ledger/build.mjs +111 -0
- package/.claude/component-ledger/output.html +559 -0
- package/.claude/component-ledger/template.html +559 -0
- package/.claude/hooks/refresh-component-ledger.sh +32 -0
- package/.claude/settings.json +10 -0
- package/.claude/skills/components/animated-svg-text.md +78 -0
- package/.claude/skills/components/breadcrumb.md +1 -0
- package/.claude/skills/components/canvas-switcher.md +61 -0
- package/.claude/skills/components/clip-element.md +67 -0
- package/.claude/skills/components/clipped-panel.md +64 -0
- package/.claude/skills/components/content-docs.md +2 -0
- package/.claude/skills/components/deep-expanding-menu-classic.md +94 -0
- package/.claude/skills/components/deep-expanding-menu.md +92 -0
- package/.claude/skills/components/display-banner.md +60 -0
- package/.claude/skills/components/display-tooltip-defined.md +139 -0
- package/.claude/skills/components/display-tooltip.md +85 -0
- package/.claude/skills/components/marquee-scroller.md +130 -0
- package/.claude/skills/components/navigation-items.md +1 -0
- package/.claude/skills/components/responsive-header.md +3 -0
- package/.claude/skills/components/site-header.md +4 -0
- package/.claude/skills/components/site-navigation.md +1 -0
- package/.claude/skills/components/tab-navigation.md +1 -0
- package/.claude/skills/composable-tooltips-guide.md +10 -0
- package/.claude/skills/index.md +8 -1
- package/.claude/skills/storybook-add-story.md +30 -0
- package/.vscode/srcdev-component-animated-svg-text.code-snippets +15 -0
- package/.vscode/srcdev-component-breadcrumb.code-snippets +13 -0
- package/.vscode/srcdev-component-canvas-switcher.code-snippets +9 -0
- package/.vscode/srcdev-component-clip-element.code-snippets +20 -0
- package/.vscode/srcdev-component-clipped-panel.code-snippets +23 -0
- package/.vscode/srcdev-component-content-docs.code-snippets +18 -0
- package/.vscode/srcdev-component-deep-expanding-menu-classic.code-snippets +9 -0
- package/.vscode/srcdev-component-deep-expanding-menu.code-snippets +9 -0
- package/.vscode/srcdev-component-display-banner.code-snippets +30 -0
- package/.vscode/srcdev-component-display-tooltip-defined.code-snippets +29 -0
- package/.vscode/srcdev-component-display-tooltip.code-snippets +38 -0
- package/.vscode/srcdev-component-marquee-scroller.code-snippets +93 -0
- package/.vscode/srcdev-component-responsive-header.code-snippets +12 -0
- package/.vscode/srcdev-component-site-header.code-snippets +17 -0
- package/.vscode/srcdev-component-site-navigation.code-snippets +30 -0
- package/.vscode/srcdev-component-slider-gallery.code-snippets +38 -0
- package/.vscode/srcdev-component-tab-navigation.code-snippets +30 -0
- package/app/components/01.atoms/animations/animated-svg-text/AnimatedSvgText.vue +58 -0
- package/app/components/01.atoms/animations/animated-svg-text/CONSUMER-STYLING.md +34 -0
- package/app/components/01.atoms/animations/animated-svg-text/stories/AnimatedSvgText.stories.ts +69 -0
- package/app/components/01.atoms/animations/animated-svg-text/tests/AnimatedSvgText.spec.ts +63 -0
- package/app/components/01.atoms/animations/animated-svg-text/tests/__snapshots__/AnimatedSvgText.spec.ts.snap +7 -0
- package/app/components/01.atoms/animations/clip-element/CONSUMER-STYLING.md +27 -0
- package/app/components/{clip-element → 01.atoms/animations/clip-element}/ClipElement.vue +16 -11
- package/app/components/01.atoms/animations/clip-element/stories/ClipElement.stories.ts +133 -0
- package/app/components/01.atoms/animations/clip-element/tests/ClipElement.spec.ts +70 -0
- package/app/components/01.atoms/animations/clip-element/tests/__snapshots__/ClipElement.spec.ts.snap +7 -0
- package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +94 -0
- package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +331 -0
- package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +151 -0
- package/app/components/01.atoms/animations/marquee-scroller/tests/MarqueeScroller.spec.ts +315 -0
- package/app/components/01.atoms/animations/marquee-scroller/tests/__snapshots__/MarqueeScroller.spec.ts.snap +28 -0
- package/app/components/01.atoms/canvas-switcher/CONSUMER-STYLING.md +27 -0
- package/app/components/01.atoms/canvas-switcher/CanvasSwitcher.vue +111 -0
- package/app/components/01.atoms/canvas-switcher/stories/CanvasSwitcher.stories.ts +75 -0
- package/app/components/01.atoms/canvas-switcher/tests/CanvasSwitcher.spec.ts +67 -0
- package/app/components/01.atoms/clipped-panel/CONSUMER-STYLING.md +39 -0
- package/app/components/01.atoms/clipped-panel/ClippedPanel.vue +68 -0
- package/app/components/01.atoms/clipped-panel/stories/ClippedPanel.stories.ts +105 -0
- package/app/components/01.atoms/clipped-panel/tests/ClippedPanel.spec.ts +67 -0
- package/app/components/01.atoms/clipped-panel/tests/__snapshots__/ClippedPanel.spec.ts.snap +3 -0
- package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +8 -2
- package/app/components/01.atoms/display-banner/CONSUMER-STYLING.md +35 -0
- package/app/components/{display-banner → 01.atoms/display-banner}/DisplayBanner.vue +13 -35
- package/app/components/01.atoms/display-banner/stories/DisplayBanner.stories.ts +100 -0
- package/app/components/01.atoms/display-banner/tests/DisplayBanner.spec.ts +78 -0
- package/app/components/01.atoms/display-banner/tests/__snapshots__/DisplayBanner.spec.ts.snap +8 -0
- package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +62 -0
- package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +188 -0
- package/app/components/01.atoms/display-tooltip/stories/DisplayTooltip.stories.ts +94 -0
- package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +75 -0
- package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +12 -0
- package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +4 -1
- package/app/components/02.molecules/display-tooltip-defined/CONSUMER-STYLING.md +42 -0
- package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +116 -0
- package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +126 -0
- package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +66 -0
- package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +17 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +44 -0
- package/app/components/{deep-expanding-menu → 02.molecules/navigation/deep-expanding-menu}/DeepExpandingMenu.vue +53 -64
- package/app/components/02.molecules/navigation/deep-expanding-menu/stories/DeepExpandingMenu.stories.ts +79 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +92 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +17 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/CONSUMER-STYLING.md +40 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/DeepExpandingMenuClassic.vue +200 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/stories/DeepExpandingMenuClassic.stories.ts +79 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/DeepExpandingMenuClassic.spec.ts +82 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/__snapshots__/DeepExpandingMenuClassic.spec.ts.snap +18 -0
- package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +4 -1
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +4 -1
- package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +16 -4
- package/app/components/03.organisms/responsive-header/NavigationItems.vue +4 -1
- package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +16 -3
- package/app/components/03.organisms/site-header/SiteHeader.vue +16 -1
- package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +1 -1
- package/app/types/components/display-tooltip-defined.d.ts +10 -0
- package/app/types/components/index.ts +2 -0
- package/app/types/components/marquee-scroller.d.ts +10 -0
- package/package.json +1 -1
- package/app/components/animated-svg-text/AnimatedSvgText.vue +0 -89
- package/app/components/canvas-switcher/CanvasSwitcher.vue +0 -104
- package/app/components/clipped-panels/ClippedPanel.vue +0 -87
- package/app/components/deep-expanding-menu/DeepExpandingMenuOld.vue +0 -218
- package/app/components/display-details/DisplayDetailsCore.vue +0 -122
- package/app/components/display-tooltip/DisplayTooltip.vue +0 -170
- package/app/components/display-tooltip/DisplayTooltipDefined.vue +0 -103
- package/app/components/magnetic-navigation/MagneticNavigation.vue +0 -162
- package/app/components/marquee-scroller/MarqueeScroller.vue +0 -289
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import { nextTick } from "vue";
|
|
4
|
+
import MarqueeScroller from "../MarqueeScroller.vue";
|
|
5
|
+
|
|
6
|
+
const stubMatchMedia = (prefersReducedMotion: boolean) => {
|
|
7
|
+
vi.stubGlobal(
|
|
8
|
+
"matchMedia",
|
|
9
|
+
vi.fn().mockReturnValue({
|
|
10
|
+
matches: prefersReducedMotion,
|
|
11
|
+
addEventListener: vi.fn(),
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// jsdom reports 0 for offsetWidth/scrollWidth — stub them per-element by class so
|
|
17
|
+
// updateRepeatCount() sees realistic container/content dimensions.
|
|
18
|
+
const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth");
|
|
19
|
+
const originalScrollWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollWidth");
|
|
20
|
+
|
|
21
|
+
const stubDimensions = ({ containerWidth, groupScrollWidth }: { containerWidth: number; groupScrollWidth: number }) => {
|
|
22
|
+
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
|
|
23
|
+
configurable: true,
|
|
24
|
+
get() {
|
|
25
|
+
return this.classList.contains("marquee-scroller") ? containerWidth : 0;
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
Object.defineProperty(HTMLElement.prototype, "scrollWidth", {
|
|
29
|
+
configurable: true,
|
|
30
|
+
get() {
|
|
31
|
+
return this.classList.contains("marquee-group") ? groupScrollWidth : 0;
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const restoreDimensions = () => {
|
|
37
|
+
if (originalOffsetWidth) Object.defineProperty(HTMLElement.prototype, "offsetWidth", originalOffsetWidth);
|
|
38
|
+
if (originalScrollWidth) Object.defineProperty(HTMLElement.prototype, "scrollWidth", originalScrollWidth);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const marqueeData = [
|
|
42
|
+
{ id: 1, content: "logo-a" },
|
|
43
|
+
{ id: 2, content: "logo-b" },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
describe("MarqueeScroller", () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
stubMatchMedia(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
restoreDimensions();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ─── Mount ───────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
it("mounts without error", async () => {
|
|
58
|
+
const wrapper = await mountSuspended(MarqueeScroller);
|
|
59
|
+
expect(wrapper.vm).toBeTruthy();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("renders after mount (displayComponent flips true in onMounted)", async () => {
|
|
63
|
+
const wrapper = await mountSuspended(MarqueeScroller);
|
|
64
|
+
expect(wrapper.find(".marquee-scroller").exists()).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ─── Snapshots ───────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
it("renders correct HTML structure (default props)", async () => {
|
|
70
|
+
const wrapper = await mountSuspended(MarqueeScroller);
|
|
71
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("renders correct HTML structure (all props set)", async () => {
|
|
75
|
+
const wrapper = await mountSuspended(MarqueeScroller, {
|
|
76
|
+
props: {
|
|
77
|
+
animationRuntime: "20s",
|
|
78
|
+
reverse: true,
|
|
79
|
+
marqueeData,
|
|
80
|
+
itemConfig: { width: "80px", height: "80px", gap: "24px" },
|
|
81
|
+
ariaLabel: "Client logos",
|
|
82
|
+
ariaDescription: "Custom instructions",
|
|
83
|
+
showControls: true,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ─── marqueeData / slots ─────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
it("renders each marqueeData item twice (main track + duplicate for looping)", async () => {
|
|
92
|
+
const wrapper = await mountSuspended(MarqueeScroller, {
|
|
93
|
+
props: { marqueeData },
|
|
94
|
+
slots: {
|
|
95
|
+
"1": "<span class='logo-a'>A</span>",
|
|
96
|
+
"2": "<span class='logo-b'>B</span>",
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
expect(wrapper.findAll(".logo-a")).toHaveLength(2);
|
|
100
|
+
expect(wrapper.findAll(".logo-b")).toHaveLength(2);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("renders no items when marqueeData is empty", async () => {
|
|
104
|
+
const wrapper = await mountSuspended(MarqueeScroller);
|
|
105
|
+
expect(wrapper.findAll(".item")).toHaveLength(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ─── reverse ─────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
it("does not apply the reverse class by default", async () => {
|
|
111
|
+
const wrapper = await mountSuspended(MarqueeScroller);
|
|
112
|
+
expect(wrapper.classes()).not.toContain("reverse");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("applies the reverse class when reverse is true", async () => {
|
|
116
|
+
const wrapper = await mountSuspended(MarqueeScroller, {
|
|
117
|
+
props: { reverse: true },
|
|
118
|
+
});
|
|
119
|
+
expect(wrapper.classes()).toContain("reverse");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ─── Accessibility attributes ────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
it("defaults aria-label to 'Scrolling content'", async () => {
|
|
125
|
+
const wrapper = await mountSuspended(MarqueeScroller);
|
|
126
|
+
expect(wrapper.attributes("aria-label")).toBe("Scrolling content");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("uses a custom ariaLabel when provided", async () => {
|
|
130
|
+
const wrapper = await mountSuspended(MarqueeScroller, {
|
|
131
|
+
props: { ariaLabel: "Client logos" },
|
|
132
|
+
});
|
|
133
|
+
expect(wrapper.attributes("aria-label")).toBe("Client logos");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("uses default screen-reader instructions when ariaDescription is not set", async () => {
|
|
137
|
+
const wrapper = await mountSuspended(MarqueeScroller);
|
|
138
|
+
expect(wrapper.find(".sr-only").text()).toContain("Use spacebar to pause or play");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("uses a custom ariaDescription when provided", async () => {
|
|
142
|
+
const wrapper = await mountSuspended(MarqueeScroller, {
|
|
143
|
+
props: { ariaDescription: "Custom instructions" },
|
|
144
|
+
});
|
|
145
|
+
expect(wrapper.find(".sr-only").text()).toBe("Custom instructions");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("sets aria-live to off while playing", async () => {
|
|
149
|
+
const wrapper = await mountSuspended(MarqueeScroller);
|
|
150
|
+
expect(wrapper.attributes("aria-live")).toBe("off");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ─── Controls ────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
it("does not render the control button by default", async () => {
|
|
156
|
+
const wrapper = await mountSuspended(MarqueeScroller);
|
|
157
|
+
expect(wrapper.find(".control-btn").exists()).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("renders the control button when showControls is true", async () => {
|
|
161
|
+
const wrapper = await mountSuspended(MarqueeScroller, {
|
|
162
|
+
props: { showControls: true },
|
|
163
|
+
});
|
|
164
|
+
expect(wrapper.find(".control-btn").exists()).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("toggles paused state and aria-live when the control button is clicked", async () => {
|
|
168
|
+
const wrapper = await mountSuspended(MarqueeScroller, {
|
|
169
|
+
props: { showControls: true },
|
|
170
|
+
});
|
|
171
|
+
await wrapper.find(".control-btn").trigger("click");
|
|
172
|
+
expect(wrapper.classes()).toContain("paused");
|
|
173
|
+
expect(wrapper.attributes("aria-live")).toBe("polite");
|
|
174
|
+
expect(wrapper.find(".control-btn").attributes("aria-label")).toBe("Play animation");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ─── Control button icon / label customisation ──────────────────────────
|
|
178
|
+
|
|
179
|
+
it("renders the default play icon while playing", async () => {
|
|
180
|
+
const wrapper = await mountSuspended(MarqueeScroller, {
|
|
181
|
+
props: { showControls: true },
|
|
182
|
+
});
|
|
183
|
+
expect(wrapper.find(".control-btn").html()).toContain("mdi:pause");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("renders the default play icon once paused", async () => {
|
|
187
|
+
const wrapper = await mountSuspended(MarqueeScroller, {
|
|
188
|
+
props: { showControls: true },
|
|
189
|
+
});
|
|
190
|
+
await wrapper.find(".control-btn").trigger("click");
|
|
191
|
+
expect(wrapper.find(".control-btn").html()).toContain("mdi:play");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("renders a custom playIcon/pauseIcon", async () => {
|
|
195
|
+
const wrapper = await mountSuspended(MarqueeScroller, {
|
|
196
|
+
props: { showControls: true, playIcon: "mdi:play-circle", pauseIcon: "mdi:pause-circle" },
|
|
197
|
+
});
|
|
198
|
+
expect(wrapper.find(".control-btn").html()).toContain("mdi:pause-circle");
|
|
199
|
+
await wrapper.find(".control-btn").trigger("click");
|
|
200
|
+
expect(wrapper.find(".control-btn").html()).toContain("mdi:play-circle");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("uses custom playLabel/pauseLabel for the control button's aria-label", async () => {
|
|
204
|
+
const wrapper = await mountSuspended(MarqueeScroller, {
|
|
205
|
+
props: { showControls: true, playLabel: "Reproduire", pauseLabel: "Suspendre" },
|
|
206
|
+
});
|
|
207
|
+
expect(wrapper.find(".control-btn").attributes("aria-label")).toBe("Suspendre");
|
|
208
|
+
await wrapper.find(".control-btn").trigger("click");
|
|
209
|
+
expect(wrapper.find(".control-btn").attributes("aria-label")).toBe("Reproduire");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("replaces the toggle icon via the toggle-icon slot", async () => {
|
|
213
|
+
const wrapper = await mountSuspended(MarqueeScroller, {
|
|
214
|
+
props: { showControls: true },
|
|
215
|
+
slots: { "toggle-icon": "<span class='custom-icon'>custom</span>" },
|
|
216
|
+
});
|
|
217
|
+
expect(wrapper.find(".custom-icon").exists()).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("passes isPaused to the toggle-icon slot", async () => {
|
|
221
|
+
const wrapper = await mountSuspended(MarqueeScroller, {
|
|
222
|
+
props: { showControls: true },
|
|
223
|
+
slots: { "toggle-icon": "<template #toggle-icon=\"{ isPaused }\"><span class='state'>{{ isPaused }}</span></template>" },
|
|
224
|
+
});
|
|
225
|
+
expect(wrapper.find(".state").text()).toBe("false");
|
|
226
|
+
await wrapper.find(".control-btn").trigger("click");
|
|
227
|
+
expect(wrapper.find(".state").text()).toBe("true");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ─── Keyboard interaction ────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
it("toggles pause on spacebar keydown", async () => {
|
|
233
|
+
const wrapper = await mountSuspended(MarqueeScroller);
|
|
234
|
+
await wrapper.trigger("keydown", { key: " " });
|
|
235
|
+
expect(wrapper.classes()).toContain("paused");
|
|
236
|
+
await wrapper.trigger("keydown", { key: " " });
|
|
237
|
+
expect(wrapper.classes()).not.toContain("paused");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("does not react to arrow keys (no manual-stepping behaviour is implemented)", async () => {
|
|
241
|
+
const wrapper = await mountSuspended(MarqueeScroller);
|
|
242
|
+
await wrapper.trigger("keydown", { key: "ArrowLeft" });
|
|
243
|
+
await wrapper.trigger("keydown", { key: "ArrowRight" });
|
|
244
|
+
expect(wrapper.classes()).not.toContain("paused");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ─── Focus / blur ────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
it("pauses on focus when respectReducedMotion is true (default)", async () => {
|
|
250
|
+
const wrapper = await mountSuspended(MarqueeScroller);
|
|
251
|
+
await wrapper.trigger("focus");
|
|
252
|
+
expect(wrapper.classes()).toContain("paused");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("resumes on blur when the user does not prefer reduced motion", async () => {
|
|
256
|
+
const wrapper = await mountSuspended(MarqueeScroller);
|
|
257
|
+
await wrapper.trigger("focus");
|
|
258
|
+
await wrapper.trigger("blur");
|
|
259
|
+
expect(wrapper.classes()).not.toContain("paused");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("does not pause on focus when respectReducedMotion is false", async () => {
|
|
263
|
+
const wrapper = await mountSuspended(MarqueeScroller, {
|
|
264
|
+
props: { respectReducedMotion: false },
|
|
265
|
+
});
|
|
266
|
+
await wrapper.trigger("focus");
|
|
267
|
+
expect(wrapper.classes()).not.toContain("paused");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ─── Reduced motion ──────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
it("auto-pauses and applies reduced-motion class when the user prefers reduced motion", async () => {
|
|
273
|
+
stubMatchMedia(true);
|
|
274
|
+
const wrapper = await mountSuspended(MarqueeScroller);
|
|
275
|
+
expect(wrapper.classes()).toContain("paused");
|
|
276
|
+
expect(wrapper.classes()).toContain("reduced-motion");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("does not check matchMedia when respectReducedMotion is false", async () => {
|
|
280
|
+
stubMatchMedia(false);
|
|
281
|
+
const matchMediaSpy = vi.fn(() => ({ matches: false, addEventListener: vi.fn() }));
|
|
282
|
+
vi.stubGlobal("matchMedia", matchMediaSpy);
|
|
283
|
+
await mountSuspended(MarqueeScroller, {
|
|
284
|
+
props: { respectReducedMotion: false },
|
|
285
|
+
});
|
|
286
|
+
expect(matchMediaSpy).not.toHaveBeenCalled();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ─── Repeating marqueeData to fill a wide container ─────────────────────
|
|
290
|
+
|
|
291
|
+
it("repeats marqueeData enough times so one group's width covers a wide container", async () => {
|
|
292
|
+
stubDimensions({ containerWidth: 500, groupScrollWidth: 100 });
|
|
293
|
+
const wrapper = await mountSuspended(MarqueeScroller, {
|
|
294
|
+
props: { marqueeData },
|
|
295
|
+
slots: { "1": "<span class='logo-a'></span>", "2": "<span class='logo-b'></span>" },
|
|
296
|
+
});
|
|
297
|
+
await nextTick();
|
|
298
|
+
await nextTick();
|
|
299
|
+
// needs ceil(500/100) = 5 copies of the 2-item data per group
|
|
300
|
+
expect(wrapper.findAll(".logo-a")).toHaveLength(10);
|
|
301
|
+
expect(wrapper.findAll(".logo-b")).toHaveLength(10);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("does not add extra copies when a single copy already covers the container", async () => {
|
|
305
|
+
stubDimensions({ containerWidth: 50, groupScrollWidth: 200 });
|
|
306
|
+
const wrapper = await mountSuspended(MarqueeScroller, {
|
|
307
|
+
props: { marqueeData },
|
|
308
|
+
slots: { "1": "<span class='logo-a'></span>", "2": "<span class='logo-b'></span>" },
|
|
309
|
+
});
|
|
310
|
+
await nextTick();
|
|
311
|
+
await nextTick();
|
|
312
|
+
expect(wrapper.findAll(".logo-a")).toHaveLength(2);
|
|
313
|
+
expect(wrapper.findAll(".logo-b")).toHaveLength(2);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`MarqueeScroller > renders correct HTML structure (all props set) 1`] = `
|
|
4
|
+
"<div class="marquee-scroller reverse" role="region" aria-label="Client logos" aria-live="off" tabindex="0">
|
|
5
|
+
<div class="sr-only">Custom instructions</div><button class="control-btn" aria-label="Pause animation" type="button"><span class="iconify i-mdi:pause" aria-hidden="true"></span></button>
|
|
6
|
+
<div class="marquee-track" aria-hidden="true">
|
|
7
|
+
<div class="marquee-group">
|
|
8
|
+
<div class="item"></div>
|
|
9
|
+
<div class="item"></div>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="marquee-group" aria-hidden="true">
|
|
12
|
+
<div class="item"></div>
|
|
13
|
+
<div class="item"></div>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
</div>"
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
exports[`MarqueeScroller > renders correct HTML structure (default props) 1`] = `
|
|
20
|
+
"<div class="marquee-scroller" role="region" aria-label="Scrolling content" aria-live="off" tabindex="0">
|
|
21
|
+
<div class="sr-only">Use spacebar to pause or play the animation.</div>
|
|
22
|
+
<!--v-if-->
|
|
23
|
+
<div class="marquee-track" aria-hidden="true">
|
|
24
|
+
<div class="marquee-group"></div>
|
|
25
|
+
<div class="marquee-group" aria-hidden="true"></div>
|
|
26
|
+
</div>
|
|
27
|
+
</div>"
|
|
28
|
+
`;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# CanvasSwitcher — Consumer Styling
|
|
2
|
+
|
|
3
|
+
CanvasSwitcher renders its buttons via `InputButtonCore` (`variant="tertiary"`, icon-only) — see
|
|
4
|
+
that component's own `CONSUMER-STYLING.md` for the button surface/hover/focus tokens it inherits.
|
|
5
|
+
CanvasSwitcher itself exposes the following public tokens:
|
|
6
|
+
|
|
7
|
+
| Token | Default | Applies to |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `--canvas-switcher-gap` | `2rem` | Gap between the switcher's own children (currently just the list) |
|
|
10
|
+
| `--canvas-switcher-item-gap` | `1rem` | Gap between each button in the list |
|
|
11
|
+
| `--canvas-switcher-icon-size` | `2.4rem` | Width/height of each icon |
|
|
12
|
+
| `--canvas-switcher-icon-colour` | `light-dark(var(--slate-10), var(--slate-02))` | Icon colour, resting state |
|
|
13
|
+
| `--canvas-switcher-icon-colour-current` | `light-dark(var(--green-10), var(--green-04))` | Icon colour when that button's canvas is selected (`aria-pressed="true"`) |
|
|
14
|
+
|
|
15
|
+
## Class passthrough
|
|
16
|
+
|
|
17
|
+
`styleClassPassthrough` (string or string[]) is applied to the root `.canvas-switcher` element via
|
|
18
|
+
`useStyleClassPassthrough()`.
|
|
19
|
+
|
|
20
|
+
## Canvas-size utility classes
|
|
21
|
+
|
|
22
|
+
`.mobileCanvas`, `.tabletCanvas`, `.laptopCanvas`, `.desktopCanvas`, `.fullWidthCanvas` are also
|
|
23
|
+
declared in this component's stylesheet (not scoped to `.canvas-switcher`) so a consumer can apply
|
|
24
|
+
the same `MediaCanvas` value returned by `v-model:canvas-name` directly as a class on a preview
|
|
25
|
+
wrapper to constrain its width — see `LayoutGridA`/`LayoutGridB` stories for the pattern. These are
|
|
26
|
+
plain global utility classes, not overridable via a CSS custom property; import `CanvasSwitcher`
|
|
27
|
+
(or otherwise ensure its styles are bundled) wherever you rely on them.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="canvas-switcher" :class="[elementClasses]">
|
|
3
|
+
<ul class="canvas-switcher-list">
|
|
4
|
+
<li v-for="option in canvasOptions" :key="option.value">
|
|
5
|
+
<InputButtonCore
|
|
6
|
+
type="button"
|
|
7
|
+
variant="tertiary"
|
|
8
|
+
class="canvas-switcher-button"
|
|
9
|
+
:button-text="option.label"
|
|
10
|
+
:aria-pressed="canvasName === option.value"
|
|
11
|
+
@click="updateCanvas(option.value)"
|
|
12
|
+
>
|
|
13
|
+
<template #iconOnly>
|
|
14
|
+
<Icon :name="option.icon" class="icon"></Icon>
|
|
15
|
+
</template>
|
|
16
|
+
</InputButtonCore>
|
|
17
|
+
</li>
|
|
18
|
+
</ul>
|
|
19
|
+
</div>
|
|
20
|
+
</template>
|
|
21
|
+
|
|
22
|
+
<script setup lang="ts">
|
|
23
|
+
import type { MediaCanvas } from "~/types/components";
|
|
24
|
+
|
|
25
|
+
interface CanvasOption {
|
|
26
|
+
value: MediaCanvas;
|
|
27
|
+
label: string;
|
|
28
|
+
icon: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
styleClassPassthrough?: string | string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
36
|
+
styleClassPassthrough: () => [],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const canvasName = defineModel<MediaCanvas>("canvasName");
|
|
40
|
+
|
|
41
|
+
const canvasOptions: CanvasOption[] = [
|
|
42
|
+
{ value: "mobileCanvas", label: "Mobile", icon: "ic:baseline-phone-iphone" },
|
|
43
|
+
{ value: "tabletCanvas", label: "Tablet", icon: "ic:baseline-tablet-mac" },
|
|
44
|
+
{ value: "laptopCanvas", label: "Laptop", icon: "ic:baseline-laptop-mac" },
|
|
45
|
+
{ value: "desktopCanvas", label: "Desktop", icon: "ic:outline-desktop-mac" },
|
|
46
|
+
{ value: "fullWidthCanvas", label: "Full width", icon: "pixelarticons:viewport-wide" },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const updateCanvas = (setCanvas: MediaCanvas) => {
|
|
50
|
+
canvasName.value = setCanvas;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
54
|
+
|
|
55
|
+
watch(
|
|
56
|
+
() => props.styleClassPassthrough,
|
|
57
|
+
() => {
|
|
58
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<style lang="css">
|
|
64
|
+
@layer components {
|
|
65
|
+
.canvas-switcher {
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: var(--canvas-switcher-gap, 2rem);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.canvas-switcher-list {
|
|
72
|
+
display: flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
gap: var(--canvas-switcher-item-gap, 1rem);
|
|
75
|
+
list-style-type: none;
|
|
76
|
+
margin: 0;
|
|
77
|
+
padding: 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.canvas-switcher-button.input-button-core {
|
|
81
|
+
color: var(--canvas-switcher-icon-colour, light-dark(var(--slate-10), var(--slate-02)));
|
|
82
|
+
|
|
83
|
+
&[aria-pressed="true"] {
|
|
84
|
+
color: var(--canvas-switcher-icon-colour-current, light-dark(var(--green-10), var(--green-04)));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.icon {
|
|
88
|
+
width: var(--canvas-switcher-icon-size, 2.4rem);
|
|
89
|
+
height: var(--canvas-switcher-icon-size, 2.4rem);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* Canvas-size utility classes — applied by a consumer to a preview wrapper to constrain
|
|
94
|
+
it to the width matching a MediaCanvas value. Not scoped to .canvas-switcher itself. */
|
|
95
|
+
.mobileCanvas {
|
|
96
|
+
max-width: 41.2rem;
|
|
97
|
+
}
|
|
98
|
+
.tabletCanvas {
|
|
99
|
+
max-width: 76.8rem;
|
|
100
|
+
}
|
|
101
|
+
.laptopCanvas {
|
|
102
|
+
max-width: 106.4rem;
|
|
103
|
+
}
|
|
104
|
+
.desktopCanvas {
|
|
105
|
+
max-width: 128rem;
|
|
106
|
+
}
|
|
107
|
+
.fullWidthCanvas {
|
|
108
|
+
max-width: unset;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
</style>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
|
+
import CanvasSwitcher from "../CanvasSwitcher.vue";
|
|
3
|
+
import type { MediaCanvas } from "~/types/components";
|
|
4
|
+
|
|
5
|
+
interface CanvasSwitcherStoryArgs {
|
|
6
|
+
canvasName: MediaCanvas | undefined;
|
|
7
|
+
styleClassPassthrough: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
title: "Atoms/CanvasSwitcher",
|
|
12
|
+
component: CanvasSwitcher,
|
|
13
|
+
argTypes: {
|
|
14
|
+
canvasName: {
|
|
15
|
+
control: { type: "select" },
|
|
16
|
+
options: ["mobileCanvas", "tabletCanvas", "laptopCanvas", "desktopCanvas", "fullWidthCanvas"],
|
|
17
|
+
description: "Bound via v-model:canvas-name — the currently selected MediaCanvas value",
|
|
18
|
+
table: { category: "Model" },
|
|
19
|
+
},
|
|
20
|
+
styleClassPassthrough: {
|
|
21
|
+
control: "object",
|
|
22
|
+
description: "Additional CSS classes applied to the root element",
|
|
23
|
+
table: { category: "Styling" },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
args: {
|
|
27
|
+
canvasName: "desktopCanvas",
|
|
28
|
+
styleClassPassthrough: [],
|
|
29
|
+
},
|
|
30
|
+
parameters: {
|
|
31
|
+
docs: {
|
|
32
|
+
description: {
|
|
33
|
+
component:
|
|
34
|
+
"An icon button group for switching between MediaCanvas preview widths (mobile/tablet/laptop/desktop/full width). Also declares the `.mobileCanvas`/`.tabletCanvas`/etc. utility classes a consumer can apply to a preview wrapper to constrain it to the matching width — see LayoutGridA/LayoutGridB stories.",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
} as Meta<typeof CanvasSwitcher>;
|
|
39
|
+
|
|
40
|
+
const Template: StoryFn<CanvasSwitcherStoryArgs> = (args) => ({
|
|
41
|
+
components: { CanvasSwitcher },
|
|
42
|
+
setup() {
|
|
43
|
+
const { canvasName, ...otherArgs } = args;
|
|
44
|
+
const selected = ref(canvasName);
|
|
45
|
+
return { selected, args: otherArgs };
|
|
46
|
+
},
|
|
47
|
+
template: `
|
|
48
|
+
<div>
|
|
49
|
+
<CanvasSwitcher v-model:canvas-name="selected" v-bind="args" />
|
|
50
|
+
<p style="margin-top: 1.6rem;">Selected: {{ selected }}</p>
|
|
51
|
+
</div>
|
|
52
|
+
`,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export const Default = Template.bind({});
|
|
56
|
+
Default.args = {};
|
|
57
|
+
|
|
58
|
+
export const PreviewWidthDemo = Template.bind({});
|
|
59
|
+
PreviewWidthDemo.args = {};
|
|
60
|
+
PreviewWidthDemo.render = (args) => ({
|
|
61
|
+
components: { CanvasSwitcher },
|
|
62
|
+
setup() {
|
|
63
|
+
const { canvasName } = args;
|
|
64
|
+
const selected = ref(canvasName);
|
|
65
|
+
return { selected };
|
|
66
|
+
},
|
|
67
|
+
template: `
|
|
68
|
+
<div>
|
|
69
|
+
<CanvasSwitcher v-model:canvas-name="selected" />
|
|
70
|
+
<div :class="selected" style="border: 1px dashed currentColor; margin-top: 1.6rem; padding: 1.6rem;">
|
|
71
|
+
This box is constrained by the selected canvas's utility class.
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
`,
|
|
75
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import CanvasSwitcher from "../CanvasSwitcher.vue";
|
|
4
|
+
|
|
5
|
+
describe("CanvasSwitcher", () => {
|
|
6
|
+
it("mounts without error", async () => {
|
|
7
|
+
const wrapper = await mountSuspended(CanvasSwitcher);
|
|
8
|
+
expect(wrapper.vm).toBeTruthy();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("renders one button per canvas option", async () => {
|
|
12
|
+
const wrapper = await mountSuspended(CanvasSwitcher);
|
|
13
|
+
expect(wrapper.findAll("button").length).toBe(5);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("gives each button an accessible name", async () => {
|
|
17
|
+
const wrapper = await mountSuspended(CanvasSwitcher);
|
|
18
|
+
const labels = wrapper.findAll("button").map((button) => button.text());
|
|
19
|
+
expect(labels).toEqual(["Mobile", "Tablet", "Laptop", "Desktop", "Full width"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("marks no button as pressed when canvasName is unset", async () => {
|
|
23
|
+
const wrapper = await mountSuspended(CanvasSwitcher);
|
|
24
|
+
const pressed = wrapper.findAll('button[aria-pressed="true"]');
|
|
25
|
+
expect(pressed.length).toBe(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("marks the matching button as pressed via the canvasName model", async () => {
|
|
29
|
+
const wrapper = await mountSuspended(CanvasSwitcher, {
|
|
30
|
+
props: { canvasName: "tabletCanvas" },
|
|
31
|
+
});
|
|
32
|
+
const buttons = wrapper.findAll("button");
|
|
33
|
+
expect(buttons[1]?.attributes("aria-pressed")).toBe("true");
|
|
34
|
+
expect(buttons[0]?.attributes("aria-pressed")).toBe("false");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("updates the canvasName model when a button is clicked", async () => {
|
|
38
|
+
const wrapper = await mountSuspended(CanvasSwitcher, {
|
|
39
|
+
props: { canvasName: "mobileCanvas" },
|
|
40
|
+
});
|
|
41
|
+
await wrapper.findAll("button")[3]?.trigger("click");
|
|
42
|
+
expect(wrapper.emitted("update:canvasName")?.[0]).toEqual(["desktopCanvas"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("applies a single styleClassPassthrough string", async () => {
|
|
46
|
+
const wrapper = await mountSuspended(CanvasSwitcher, { props: { styleClassPassthrough: "custom-class" } });
|
|
47
|
+
expect(wrapper.classes()).toContain("custom-class");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("applies multiple styleClassPassthrough classes from an array", async () => {
|
|
51
|
+
const wrapper = await mountSuspended(CanvasSwitcher, {
|
|
52
|
+
props: { styleClassPassthrough: ["class-a", "class-b"] },
|
|
53
|
+
});
|
|
54
|
+
expect(wrapper.classes()).toContain("class-a");
|
|
55
|
+
expect(wrapper.classes()).toContain("class-b");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("updates classes when styleClassPassthrough prop changes", async () => {
|
|
59
|
+
const wrapper = await mountSuspended(CanvasSwitcher, {
|
|
60
|
+
props: { styleClassPassthrough: ["original"] },
|
|
61
|
+
});
|
|
62
|
+
expect(wrapper.classes()).toContain("original");
|
|
63
|
+
await wrapper.setProps({ styleClassPassthrough: ["updated"] });
|
|
64
|
+
expect(wrapper.classes()).not.toContain("original");
|
|
65
|
+
expect(wrapper.classes()).toContain("updated");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# ClippedPanel — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## Public token API
|
|
4
|
+
|
|
5
|
+
| Token | Default | Controls |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `--clipped-panel-background-colour` | `light-dark(hsl(0, 0%, 96%), hsl(0, 0%, 12%))` | Panel background |
|
|
8
|
+
| `--clipped-panel-outline-colour` | `light-dark(hsl(0, 29%, 3%), hsl(0, 0%, 92%))` | Outline colour, and the panel's text/icon colour (`color`) |
|
|
9
|
+
| `--clipped-panel-outline-width` | `1px` | Outline width |
|
|
10
|
+
|
|
11
|
+
```css
|
|
12
|
+
.my-page {
|
|
13
|
+
--clipped-panel-background-colour: #fef3e7;
|
|
14
|
+
--clipped-panel-outline-colour: #b5651d;
|
|
15
|
+
--clipped-panel-outline-width: 2px;
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or scope to a single instance via `styleClassPassthrough`:
|
|
20
|
+
|
|
21
|
+
```vue
|
|
22
|
+
<ClippedPanel variant="square" style-class-passthrough="promo-panel">...</ClippedPanel>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```css
|
|
26
|
+
.promo-panel {
|
|
27
|
+
--clipped-panel-background-colour: #fef3e7;
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Shapes are fixed-size, not tokenised
|
|
32
|
+
|
|
33
|
+
Each `variant`'s notched/cutout shape is drawn with `clip-path: path("...")` using literal pixel
|
|
34
|
+
coordinates (`square`/`circle-cutout` at 200×200px, `rectangle` at 300×200px). CSS custom
|
|
35
|
+
properties cannot be interpolated into a `path()` string, so the shape geometry itself — corner
|
|
36
|
+
radii, notch size, overall dimensions — is **not** overridable via tokens or `width`/`height`
|
|
37
|
+
overrides. Changing `width` on the element stretches the box without moving the clip-path
|
|
38
|
+
coordinates, which will misalign the shape. If a different size or geometry is needed, that
|
|
39
|
+
requires a new variant with its own `path()` string, not a CSS override.
|