srcdev-nuxt-components 9.4.4 → 9.4.5
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 +68 -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/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/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 +136 -0
- package/.claude/skills/components/display-tooltip.md +84 -0
- package/.claude/skills/composable-tooltips-guide.md +10 -0
- package/.claude/skills/index.md +7 -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-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-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 +14 -0
- package/.vscode/srcdev-component-display-tooltip.code-snippets +27 -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/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/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 +185 -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/02.molecules/display-tooltip-defined/CONSUMER-STYLING.md +42 -0
- package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +103 -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/types/components/display-tooltip-defined.d.ts +10 -0
- package/app/types/components/index.ts +1 -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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import ClipElement from "../ClipElement.vue";
|
|
2
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof ClipElement> = {
|
|
5
|
+
title: "Atoms/Effects/ClipElement",
|
|
6
|
+
component: ClipElement,
|
|
7
|
+
argTypes: {
|
|
8
|
+
maxClip: {
|
|
9
|
+
control: { type: "number", min: 0, step: 10 },
|
|
10
|
+
description: "Scroll distance (px) over which the element clips in as it enters the viewport, and clips further out as it scrolls above it.",
|
|
11
|
+
table: { category: "Behaviour" },
|
|
12
|
+
},
|
|
13
|
+
styleClassPassthrough: {
|
|
14
|
+
table: { disable: true },
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
parameters: {
|
|
18
|
+
docs: {
|
|
19
|
+
description: {
|
|
20
|
+
component:
|
|
21
|
+
"Clips its slot content in from the top edge as it scrolls into the viewport, using a scroll listener to drive a `clip-path: inset(...)` value. For a CSS-only, listener-free parallax reveal, prefer ScrollRevealFrame/ScrollRevealImage instead — reach for ClipElement only when you specifically need this scroll-position-driven clip behaviour.",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default meta;
|
|
28
|
+
type Story = StoryObj<typeof ClipElement>;
|
|
29
|
+
|
|
30
|
+
// A fixed marker at the top of the viewport, since that's exactly where the
|
|
31
|
+
// clip finishes resolving (top === 0) — makes the effect's endpoint visible
|
|
32
|
+
// instead of something you have to guess at while scrolling.
|
|
33
|
+
const topMarker = `
|
|
34
|
+
<div style="position: fixed; top: 0; left: 0; right: 0; height: 2px; background: #ff5c5c; z-index: 10;"></div>
|
|
35
|
+
<div style="position: fixed; top: 6px; left: 12px; font-size: 1.2rem; color: #ff5c5c; z-index: 10;">clip fully resolves at this line ↑</div>
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
// Horizontal rules baked into the image itself so the wipe boundary is
|
|
39
|
+
// visible against the photo, not just a subtle crop of pixels.
|
|
40
|
+
const rulerOverlayStyle = `
|
|
41
|
+
background-image: repeating-linear-gradient(
|
|
42
|
+
to bottom,
|
|
43
|
+
transparent,
|
|
44
|
+
transparent 49px,
|
|
45
|
+
rgba(255, 92, 92, 0.6) 49px,
|
|
46
|
+
rgba(255, 92, 92, 0.6) 50px
|
|
47
|
+
);
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
const scrollWrapper = (maxClip: number, inner: string) => `
|
|
51
|
+
${topMarker}
|
|
52
|
+
<div style="padding-block: 30vh 6px; max-width: 860px; margin-inline: auto;">
|
|
53
|
+
<p style="text-align: center; font-size: 1.4rem; opacity: 0.6; margin-block-end: 1rem;">
|
|
54
|
+
Scroll slowly — the image clips in over the ${maxClip}px just before it reaches the red line.
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
${inner}
|
|
58
|
+
<div style="height: 100vh;"></div>
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
export const Default: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
maxClip: 100,
|
|
64
|
+
},
|
|
65
|
+
render: (args) => ({
|
|
66
|
+
components: { ClipElement },
|
|
67
|
+
setup() {
|
|
68
|
+
return { args };
|
|
69
|
+
},
|
|
70
|
+
template: scrollWrapper(
|
|
71
|
+
args.maxClip ?? 100,
|
|
72
|
+
`
|
|
73
|
+
<div style="max-width: 860px; margin-inline: auto; position: relative;">
|
|
74
|
+
<ClipElement v-bind="args">
|
|
75
|
+
<div style="position: relative;">
|
|
76
|
+
<img
|
|
77
|
+
src="/images/page/hero/hero-blonde.jpg"
|
|
78
|
+
alt="Blonde hair portrait"
|
|
79
|
+
style="display: block; width: 100%;"
|
|
80
|
+
/>
|
|
81
|
+
<div style="position: absolute; inset: 0; ${rulerOverlayStyle}"></div>
|
|
82
|
+
</div>
|
|
83
|
+
</ClipElement>
|
|
84
|
+
</div>
|
|
85
|
+
`
|
|
86
|
+
),
|
|
87
|
+
}),
|
|
88
|
+
parameters: {
|
|
89
|
+
docs: {
|
|
90
|
+
description: {
|
|
91
|
+
story:
|
|
92
|
+
"Default props — the ruled overlay makes the clip boundary visible as it wipes in over the last 100px before the image reaches the top of the viewport (marked by the red line).",
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const LargeClipDistance: Story = {
|
|
99
|
+
args: {
|
|
100
|
+
maxClip: 500,
|
|
101
|
+
},
|
|
102
|
+
render: (args) => ({
|
|
103
|
+
components: { ClipElement },
|
|
104
|
+
setup() {
|
|
105
|
+
return { args };
|
|
106
|
+
},
|
|
107
|
+
template: scrollWrapper(
|
|
108
|
+
args.maxClip ?? 500,
|
|
109
|
+
`
|
|
110
|
+
<div style="max-width: 860px; margin-inline: auto; position: relative;">
|
|
111
|
+
<ClipElement v-bind="args">
|
|
112
|
+
<div style="position: relative;">
|
|
113
|
+
<img
|
|
114
|
+
src="/images/page/hero/hero-blonde.jpg"
|
|
115
|
+
alt="Blonde hair portrait"
|
|
116
|
+
style="display: block; width: 100%;"
|
|
117
|
+
/>
|
|
118
|
+
<div style="position: absolute; inset: 0; ${rulerOverlayStyle}"></div>
|
|
119
|
+
</div>
|
|
120
|
+
</ClipElement>
|
|
121
|
+
</div>
|
|
122
|
+
`
|
|
123
|
+
),
|
|
124
|
+
}),
|
|
125
|
+
parameters: {
|
|
126
|
+
docs: {
|
|
127
|
+
description: {
|
|
128
|
+
story:
|
|
129
|
+
"A much larger maxClip (500px) stretches the wipe over a longer, easier-to-watch scroll distance — useful for previewing the effect without scrolling pixel-by-pixel.",
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import ClipElement from "../ClipElement.vue";
|
|
4
|
+
|
|
5
|
+
function setTopAndScroll(wrapper: Awaited<ReturnType<typeof mountSuspended>>, top: number) {
|
|
6
|
+
const clipElement = wrapper.find(".clipped-element").element as HTMLElement;
|
|
7
|
+
clipElement.getBoundingClientRect = () => ({ top } as DOMRect);
|
|
8
|
+
window.dispatchEvent(new Event("scroll"));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("ClipElement", () => {
|
|
12
|
+
it("mounts without error", async () => {
|
|
13
|
+
const wrapper = await mountSuspended(ClipElement);
|
|
14
|
+
expect(wrapper.vm).toBeTruthy();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("renders correct HTML structure (default props)", async () => {
|
|
18
|
+
const wrapper = await mountSuspended(ClipElement);
|
|
19
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("renders the wrapper and clipped-element structure", async () => {
|
|
23
|
+
const wrapper = await mountSuspended(ClipElement);
|
|
24
|
+
expect(wrapper.find(".clip-element-wrapper").exists()).toBe(true);
|
|
25
|
+
expect(wrapper.find(".clipped-element").exists()).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("renders slot content inside .clipped-element", async () => {
|
|
29
|
+
const wrapper = await mountSuspended(ClipElement, {
|
|
30
|
+
slots: { default: "<p class='slot-child'>Hello</p>" },
|
|
31
|
+
});
|
|
32
|
+
const content = wrapper.find(".clipped-element");
|
|
33
|
+
expect(content.find(".slot-child").exists()).toBe(true);
|
|
34
|
+
expect(content.find(".slot-child").text()).toBe("Hello");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("applies styleClassPassthrough classes to the wrapper", async () => {
|
|
38
|
+
const wrapper = await mountSuspended(ClipElement, {
|
|
39
|
+
props: { styleClassPassthrough: ["custom-class"] },
|
|
40
|
+
});
|
|
41
|
+
expect(wrapper.find(".clip-element-wrapper").classes()).toContain("custom-class");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("clips fully (0px inset) once the element has scrolled past maxClip", async () => {
|
|
45
|
+
const wrapper = await mountSuspended(ClipElement, { props: { maxClip: 100 } });
|
|
46
|
+
setTopAndScroll(wrapper, 150);
|
|
47
|
+
await wrapper.vm.$nextTick();
|
|
48
|
+
|
|
49
|
+
const clipElement = wrapper.find(".clipped-element").element as HTMLElement;
|
|
50
|
+
expect(clipElement.style.getPropertyValue("--_clip-path")).toBe("inset(0px 0 0 0)");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("increases the clip offset as the element approaches the top of the viewport", async () => {
|
|
54
|
+
const wrapper = await mountSuspended(ClipElement, { props: { maxClip: 100 } });
|
|
55
|
+
setTopAndScroll(wrapper, 40);
|
|
56
|
+
await wrapper.vm.$nextTick();
|
|
57
|
+
|
|
58
|
+
const clipElement = wrapper.find(".clipped-element").element as HTMLElement;
|
|
59
|
+
expect(clipElement.style.getPropertyValue("--_clip-path")).toBe("inset(60px 0 0 0)");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("keeps clipping past 0px once the element has scrolled above the viewport", async () => {
|
|
63
|
+
const wrapper = await mountSuspended(ClipElement, { props: { maxClip: 100 } });
|
|
64
|
+
setTopAndScroll(wrapper, -20);
|
|
65
|
+
await wrapper.vm.$nextTick();
|
|
66
|
+
|
|
67
|
+
const clipElement = wrapper.find(".clipped-element").element as HTMLElement;
|
|
68
|
+
expect(clipElement.style.getPropertyValue("--_clip-path")).toBe("inset(120px 0 0 0)");
|
|
69
|
+
});
|
|
70
|
+
});
|
package/app/components/01.atoms/animations/clip-element/tests/__snapshots__/ClipElement.spec.ts.snap
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`ClipElement > renders correct HTML structure (default props) 1`] = `
|
|
4
|
+
"<div class="clip-element-wrapper">
|
|
5
|
+
<div class="clipped-element" style="--_clip-path: inset(0px 0 0 0);"></div>
|
|
6
|
+
</div>"
|
|
7
|
+
`;
|
|
@@ -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.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<component :is="tag" class="clipped-panel" :class="[variant, elementClasses]">
|
|
3
|
+
<slot name="default"></slot>
|
|
4
|
+
</component>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup lang="ts">
|
|
8
|
+
interface Props {
|
|
9
|
+
tag?: "div" | "p" | "span" | "section" | "article" | "aside" | "header" | "footer" | "main" | "nav" | "ul" | "ol";
|
|
10
|
+
variant?: "circle-cutout" | "rectangle" | "square";
|
|
11
|
+
styleClassPassthrough?: string | string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
15
|
+
tag: "div",
|
|
16
|
+
variant: "square",
|
|
17
|
+
styleClassPassthrough: () => [],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
21
|
+
|
|
22
|
+
watch(
|
|
23
|
+
() => props.styleClassPassthrough,
|
|
24
|
+
() => {
|
|
25
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<style lang="css">
|
|
31
|
+
@layer components {
|
|
32
|
+
.clipped-panel {
|
|
33
|
+
--_outline-colour: var(--clipped-panel-outline-colour, light-dark(hsl(0, 29%, 3%), hsl(0, 0%, 92%)));
|
|
34
|
+
|
|
35
|
+
background-color: var(--clipped-panel-background-colour, light-dark(hsl(0, 0%, 96%), hsl(0, 0%, 12%)));
|
|
36
|
+
color: var(--_outline-colour);
|
|
37
|
+
outline: var(--clipped-panel-outline-width, 1px) solid var(--_outline-colour);
|
|
38
|
+
|
|
39
|
+
aspect-ratio: 1;
|
|
40
|
+
|
|
41
|
+
/*
|
|
42
|
+
* clip-path: path(...) coordinates are fixed to each variant's exact
|
|
43
|
+
* pixel width below — CSS custom properties cannot be interpolated into
|
|
44
|
+
* a path() string, so these shapes cannot be resized via tokens. See
|
|
45
|
+
* CONSUMER-STYLING.md.
|
|
46
|
+
*/
|
|
47
|
+
&.rectangle {
|
|
48
|
+
clip-path: path(
|
|
49
|
+
"M 10, 50 L 140, 50 A 10, 10, 0, 0, 0 150, 40 L 150, 10 A 10, 10, 0, 0, 1 160, 0 L 290, 0 A 10, 10, 0, 0, 1 300, 10 L 300, 190 A 10, 10, 0, 0, 1 290, 200 L 10, 200 A 10, 10, 0, 0, 1 0, 190 L 0, 60 A 10, 10, 0, 0, 1 10, 50 Z"
|
|
50
|
+
);
|
|
51
|
+
width: 300px;
|
|
52
|
+
}
|
|
53
|
+
&.square {
|
|
54
|
+
clip-path: path(
|
|
55
|
+
"M 10, 50 L 90, 50 A 10, 10, 0, 0, 0 100, 40 L 100, 10 A 10, 10, 0, 0, 1 110, 0 L 190, 0 A 10, 10, 0, 0, 1 200, 10 L 200, 190 A 10, 10, 0, 0, 1 190, 200 L 10, 200 A 10, 10, 0, 0, 1 0, 190 L 0, 60 A 10, 10, 0, 0, 1 10, 50 Z"
|
|
56
|
+
);
|
|
57
|
+
width: 200px;
|
|
58
|
+
}
|
|
59
|
+
&.circle-cutout {
|
|
60
|
+
/* 200x200 rounded square (10px corners) with a 50px-radius circular notch bitten out of the top-right corner. */
|
|
61
|
+
clip-path: path(
|
|
62
|
+
"M 10, 0 L 150, 0 A 50, 50, 0, 0, 0 200, 50 L 200, 190 A 10, 10, 0, 0, 1 190, 200 L 10, 200 A 10, 10, 0, 0, 1 0, 190 L 0, 10 A 10, 10, 0, 0, 1 10, 0 Z"
|
|
63
|
+
);
|
|
64
|
+
width: 200px;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
</style>
|