srcdev-nuxt-components 9.6.13 → 9.6.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/consumer/story-register-components.md +1 -1
- package/.claude/skills/component-compliance-checklist.md +5 -0
- package/.claude/skills/components/display-qr-code.md +22 -4
- package/.claude/skills/components/eyebrow-text.md +9 -5
- package/.claude/skills/components/header-block.md +13 -5
- package/.claude/skills/components/hero-text.md +13 -9
- package/.claude/skills/components/link-text.md +8 -5
- package/.claude/skills/components/pop-over.md +17 -3
- package/.claude/skills/components/sliding-toggle.md +108 -0
- package/.claude/skills/components/tabbed-content.md +8 -3
- package/.claude/skills/composable-sliding-indicator.md +60 -0
- package/.claude/skills/index.md +2 -0
- package/.claude/skills/storybook-add-story.md +22 -2
- package/.claude/skills/testing-add-playwright.md +12 -7
- package/.claude/skills/testing-add-unit-test.md +25 -0
- package/.claude/skills/theming-component-token-pattern.md +18 -0
- package/.vscode/srcdev-component-display-qr-code.code-snippets +12 -1
- package/.vscode/srcdev-component-hero-text.code-snippets +21 -5
- package/.vscode/srcdev-component-link-text.code-snippets +3 -3
- package/.vscode/srcdev-component-pop-over.code-snippets +28 -0
- package/.vscode/srcdev-component-sliding-toggle.code-snippets +37 -0
- package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -1
- package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +42 -9
- package/app/components/01.atoms/pop-over/PopOver.vue +28 -12
- package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +114 -2
- package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +35 -0
- package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +5 -1
- package/app/components/01.atoms/qr-code/CONSUMER-STYLING.md +16 -3
- package/app/components/01.atoms/qr-code/DisplayQrCode.vue +31 -0
- package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +190 -157
- package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +58 -0
- package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +15 -2
- package/app/components/01.atoms/tabbed-content/TabbedContent.vue +4 -4
- package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +4 -4
- package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +9 -1
- package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +5 -4
- package/app/components/01.atoms/text-blocks/eyebrow-text/playwright/eyebrow-text.playwright.ts +1 -1
- package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +104 -17
- package/app/components/01.atoms/text-blocks/eyebrow-text/tests/EyebrowText.spec.ts +68 -118
- package/app/components/01.atoms/text-blocks/eyebrow-text/tests/__snapshots__/EyebrowText.spec.ts.snap +8 -8
- package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +49 -24
- package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +9 -1
- package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +96 -26
- package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +64 -48
- package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +2 -2
- package/app/components/01.atoms/text-blocks/hero-text/CONSUMER-STYLING.md +22 -6
- package/app/components/01.atoms/text-blocks/hero-text/HeroText.vue +32 -23
- package/app/components/01.atoms/text-blocks/hero-text/playwright/hero-text.playwright.ts +1 -1
- package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +176 -23
- package/app/components/01.atoms/text-blocks/hero-text/tests/HeroText.spec.ts +129 -123
- package/app/components/01.atoms/text-blocks/hero-text/tests/__snapshots__/HeroText.spec.ts.snap +2 -2
- package/app/components/01.atoms/text-blocks/link-text/CONSUMER-STYLING.md +17 -0
- package/app/components/01.atoms/text-blocks/link-text/LinkText.vue +9 -3
- package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +96 -99
- package/app/components/01.atoms/text-blocks/link-text/tests/LinkText.spec.ts +84 -93
- package/app/components/02.molecules/price-list/tests/__snapshots__/PriceList.spec.ts.snap +8 -8
- package/app/components/02.molecules/sliding-toggle/CONSUMER-STYLING.md +109 -0
- package/app/components/02.molecules/sliding-toggle/SlidingToggle.vue +307 -0
- package/app/components/02.molecules/sliding-toggle/stories/SlidingToggle.stories.ts +357 -0
- package/app/components/02.molecules/sliding-toggle/tests/SlidingToggle.spec.ts +335 -0
- package/app/components/02.molecules/sliding-toggle/tests/__snapshots__/SlidingToggle.spec.ts.snap +3 -0
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +15 -15
- package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +3 -3
- package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +3 -3
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +3 -3
- package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -1
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +9 -9
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +9 -9
- package/app/composables/tests/useSlidingIndicator.spec.ts +143 -0
- package/app/composables/useSlidingIndicator.ts +56 -0
- package/app/composables/useTabs.ts +4 -31
- package/app/types/components/index.ts +1 -0
- package/app/types/components/sliding-toggle.d.ts +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
2
|
+
import { ref } from "vue";
|
|
3
|
+
import useSlidingIndicator from "../useSlidingIndicator";
|
|
4
|
+
|
|
5
|
+
const box = (props: Partial<Record<"offsetLeft" | "offsetTop" | "offsetWidth" | "offsetHeight", number>>) => {
|
|
6
|
+
const element = document.createElement("div");
|
|
7
|
+
Object.entries(props).forEach(([key, value]) => Object.defineProperty(element, key, { value }));
|
|
8
|
+
return element;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const container = (width = 500, height = 300) => {
|
|
12
|
+
const element = document.createElement("div");
|
|
13
|
+
Object.defineProperty(element, "clientWidth", { value: width });
|
|
14
|
+
Object.defineProperty(element, "clientHeight", { value: height });
|
|
15
|
+
return element;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const vars = (element: HTMLElement, name: string) => ({
|
|
19
|
+
start: element.style.getPropertyValue(`--_${name}-start`),
|
|
20
|
+
end: element.style.getPropertyValue(`--_${name}-end`),
|
|
21
|
+
duration: element.style.getPropertyValue(`--_${name}-duration`),
|
|
22
|
+
startDelay: element.style.getPropertyValue(`--_${name}-start-delay`),
|
|
23
|
+
endDelay: element.style.getPropertyValue(`--_${name}-end-delay`),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const originalMatchMedia = window.matchMedia;
|
|
27
|
+
|
|
28
|
+
const stubReducedMotion = (matches: boolean) => {
|
|
29
|
+
window.matchMedia = vi.fn(() => ({ matches }) as MediaQueryList);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
window.matchMedia = originalMatchMedia;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("useSlidingIndicator", () => {
|
|
38
|
+
it("snaps the first placement and writes the edges as insets", () => {
|
|
39
|
+
const track = container();
|
|
40
|
+
const { place } = useSlidingIndicator(ref(track), { duration: 200 });
|
|
41
|
+
|
|
42
|
+
const ms = place("active", box({ offsetLeft: 100, offsetWidth: 50 }), true);
|
|
43
|
+
|
|
44
|
+
expect(ms).toBe(0);
|
|
45
|
+
expect(vars(track, "active")).toEqual({ start: "100px", end: "350px", duration: "0ms", startDelay: "0ms", endDelay: "0ms" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("moving forward sends the end edge first and delays the start edge", () => {
|
|
49
|
+
const track = container();
|
|
50
|
+
const { place } = useSlidingIndicator(ref(track), { duration: 200 });
|
|
51
|
+
|
|
52
|
+
place("active", box({ offsetLeft: 0, offsetWidth: 100 }), true);
|
|
53
|
+
const ms = place("active", box({ offsetLeft: 300, offsetWidth: 100 }), true);
|
|
54
|
+
|
|
55
|
+
expect(ms).toBe(200);
|
|
56
|
+
expect(vars(track, "active")).toEqual({ start: "300px", end: "100px", duration: "200ms", startDelay: "200ms", endDelay: "0ms" });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("moving backward sends the start edge first and delays the end edge", () => {
|
|
60
|
+
const track = container();
|
|
61
|
+
const { place } = useSlidingIndicator(ref(track), { duration: 200 });
|
|
62
|
+
|
|
63
|
+
place("active", box({ offsetLeft: 300, offsetWidth: 100 }), true);
|
|
64
|
+
place("active", box({ offsetLeft: 100, offsetWidth: 100 }), true);
|
|
65
|
+
|
|
66
|
+
expect(vars(track, "active")).toMatchObject({ startDelay: "0ms", endDelay: "200ms" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("snaps when animate is false", () => {
|
|
70
|
+
const track = container();
|
|
71
|
+
const { place } = useSlidingIndicator(ref(track), { duration: 200 });
|
|
72
|
+
|
|
73
|
+
place("active", box({ offsetLeft: 0, offsetWidth: 100 }), true);
|
|
74
|
+
expect(place("active", box({ offsetLeft: 300, offsetWidth: 100 }), false)).toBe(0);
|
|
75
|
+
expect(vars(track, "active")).toMatchObject({ duration: "0ms", startDelay: "0ms" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("snaps under prefers-reduced-motion", () => {
|
|
79
|
+
stubReducedMotion(true);
|
|
80
|
+
const track = container();
|
|
81
|
+
const { place } = useSlidingIndicator(ref(track), { duration: 200 });
|
|
82
|
+
|
|
83
|
+
place("active", box({ offsetLeft: 0, offsetWidth: 100 }), true);
|
|
84
|
+
expect(place("active", box({ offsetLeft: 300, offsetWidth: 100 }), true)).toBe(0);
|
|
85
|
+
expect(vars(track, "active").duration).toBe("0ms");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("animates when reduced motion is not requested", () => {
|
|
89
|
+
stubReducedMotion(false);
|
|
90
|
+
const track = container();
|
|
91
|
+
const { place } = useSlidingIndicator(ref(track), { duration: 150 });
|
|
92
|
+
|
|
93
|
+
place("active", box({ offsetLeft: 0, offsetWidth: 100 }), true);
|
|
94
|
+
expect(place("active", box({ offsetLeft: 300, offsetWidth: 100 }), true)).toBe(150);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("measures along the y axis", () => {
|
|
98
|
+
const track = container(500, 300);
|
|
99
|
+
const { place } = useSlidingIndicator(ref(track), { axis: "y", duration: 200 });
|
|
100
|
+
|
|
101
|
+
place("active", box({ offsetTop: 40, offsetHeight: 60 }), false);
|
|
102
|
+
|
|
103
|
+
expect(vars(track, "active")).toMatchObject({ start: "40px", end: "200px" });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("tracks each named indicator separately", () => {
|
|
107
|
+
const track = container();
|
|
108
|
+
const { place } = useSlidingIndicator(ref(track), { duration: 200 });
|
|
109
|
+
|
|
110
|
+
place("active", box({ offsetLeft: 0, offsetWidth: 100 }), true);
|
|
111
|
+
place("hover", box({ offsetLeft: 200, offsetWidth: 100 }), true);
|
|
112
|
+
|
|
113
|
+
expect(vars(track, "hover").duration).toBe("0ms");
|
|
114
|
+
expect(vars(track, "active").start).toBe("0px");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("snaps again after reset", () => {
|
|
118
|
+
const track = container();
|
|
119
|
+
const { place, reset } = useSlidingIndicator(ref(track), { duration: 200 });
|
|
120
|
+
|
|
121
|
+
place("hover", box({ offsetLeft: 0, offsetWidth: 100 }), true);
|
|
122
|
+
reset("hover");
|
|
123
|
+
|
|
124
|
+
expect(place("hover", box({ offsetLeft: 300, offsetWidth: 100 }), true)).toBe(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("treats a negative or invalid duration as 0", () => {
|
|
128
|
+
const track = container();
|
|
129
|
+
const { place } = useSlidingIndicator(ref(track), { duration: Number.NaN });
|
|
130
|
+
|
|
131
|
+
place("active", box({ offsetLeft: 0, offsetWidth: 100 }), true);
|
|
132
|
+
expect(place("active", box({ offsetLeft: 300, offsetWidth: 100 }), true)).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("returns null and writes nothing without a container or target", () => {
|
|
136
|
+
const track = container();
|
|
137
|
+
const { place } = useSlidingIndicator(ref(track), { duration: 200 });
|
|
138
|
+
|
|
139
|
+
expect(place("active", null, true)).toBeNull();
|
|
140
|
+
expect(useSlidingIndicator(ref(null), { duration: 200 }).place("active", box({}), true)).toBeNull();
|
|
141
|
+
expect(track.style.getPropertyValue("--_active-start")).toBe("");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { MaybeRefOrGetter } from "vue";
|
|
2
|
+
|
|
3
|
+
interface SlidingIndicatorOptions {
|
|
4
|
+
axis?: MaybeRefOrGetter<"x" | "y">;
|
|
5
|
+
duration: MaybeRefOrGetter<number>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Positions sliding indicators inside a container by writing `--_{name}-start`, `-end`, `-duration`,
|
|
10
|
+
* `-start-delay` and `-end-delay` on it: the edges as insets from the container's start and end along
|
|
11
|
+
* the axis. On a move, the edge in the direction of travel goes first and the trailing edge follows
|
|
12
|
+
* one duration later, so the indicator stretches across and then catches up. Under
|
|
13
|
+
* `prefers-reduced-motion`, or with `animate: false` (mount, resize), both edges snap.
|
|
14
|
+
*/
|
|
15
|
+
const useSlidingIndicator = (containerRef: Ref<HTMLElement | null>, options: SlidingIndicatorOptions) => {
|
|
16
|
+
// Leading edge each indicator was last sent to, to tell the direction of the next move.
|
|
17
|
+
const lastStart = new Map<string, number>();
|
|
18
|
+
|
|
19
|
+
const prefersReducedMotion = () =>
|
|
20
|
+
typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
|
|
21
|
+
|
|
22
|
+
/** Returns the transition length in ms, or `null` when there was nothing to place. */
|
|
23
|
+
const place = (name: string, target: HTMLElement | null | undefined, animate: boolean): number | null => {
|
|
24
|
+
const container = containerRef.value;
|
|
25
|
+
if (!container || !target) return null;
|
|
26
|
+
|
|
27
|
+
const vertical = toValue(options.axis) === "y";
|
|
28
|
+
const start = vertical ? target.offsetTop : target.offsetLeft;
|
|
29
|
+
const size = vertical ? target.offsetHeight : target.offsetWidth;
|
|
30
|
+
const end = (vertical ? container.clientHeight : container.clientWidth) - start - size;
|
|
31
|
+
|
|
32
|
+
const previous = lastStart.get(name);
|
|
33
|
+
const duration = Math.max(0, Number(toValue(options.duration)) || 0);
|
|
34
|
+
const ms = animate && previous !== undefined && !prefersReducedMotion() ? duration : 0;
|
|
35
|
+
const forward = previous !== undefined && start > previous;
|
|
36
|
+
const backward = previous !== undefined && start < previous;
|
|
37
|
+
lastStart.set(name, start);
|
|
38
|
+
|
|
39
|
+
container.style.setProperty(`--_${name}-duration`, ms + "ms");
|
|
40
|
+
container.style.setProperty(`--_${name}-start-delay`, forward ? ms + "ms" : "0ms");
|
|
41
|
+
container.style.setProperty(`--_${name}-end-delay`, backward ? ms + "ms" : "0ms");
|
|
42
|
+
container.style.setProperty(`--_${name}-start`, start + "px");
|
|
43
|
+
container.style.setProperty(`--_${name}-end`, end + "px");
|
|
44
|
+
|
|
45
|
+
return ms;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Forgets where an indicator was, so its next placement snaps instead of sliding in from there. */
|
|
49
|
+
const reset = (name: string) => {
|
|
50
|
+
lastStart.delete(name);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return { place, reset };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export default useSlidingIndicator;
|
|
@@ -16,12 +16,10 @@ const useTabs = (
|
|
|
16
16
|
const currentHoveredTab = ref<HTMLElement>();
|
|
17
17
|
|
|
18
18
|
type IndicatorKind = "active" | "hovered";
|
|
19
|
-
// Leading edge (start along the axis) each indicator was last sent to, to tell the direction of a move.
|
|
20
|
-
const lastStart: Record<IndicatorKind, number | null> = { active: null, hovered: null };
|
|
21
19
|
let coverageFrameId: number | null = null;
|
|
22
20
|
let coverageUntil = 0;
|
|
23
21
|
|
|
24
|
-
const
|
|
22
|
+
const { place: placeEdges } = useSlidingIndicator(tabsNavRef, { axis, duration });
|
|
25
23
|
|
|
26
24
|
const activeIndex = computed(() => {
|
|
27
25
|
const index = currentActiveTab.value?.dataset.tabIndex;
|
|
@@ -144,36 +142,11 @@ const useTabs = (
|
|
|
144
142
|
activateTab(targetTab);
|
|
145
143
|
};
|
|
146
144
|
|
|
147
|
-
|
|
148
|
-
* Sends an indicator's two edges to its target tab, as insets from the bar's start and end edges
|
|
149
|
-
* along the axis. On a move, the edge in the direction of travel goes first and the trailing edge
|
|
150
|
-
* follows one duration later, so the indicator stretches across and then catches up. CSS retargets
|
|
151
|
-
* each edge from wherever it is, so a quick second move never pulls an edge backwards.
|
|
152
|
-
* `animate: false` (mount, resize, tab-count change) snaps both edges.
|
|
153
|
-
*/
|
|
145
|
+
// Edge motion lives in useSlidingIndicator; the CSS reads --_active-* and --_hovered-* off the bar.
|
|
154
146
|
const placeIndicator = (kind: IndicatorKind, animate: boolean) => {
|
|
155
|
-
const nav = tabsNavRef.value;
|
|
156
147
|
const target = indicatorTarget(kind === "active" ? currentActiveTab.value : currentHoveredTab.value);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const vertical = toValue(axis) === "y";
|
|
160
|
-
const start = vertical ? target.offsetTop : target.offsetLeft;
|
|
161
|
-
const size = vertical ? target.offsetHeight : target.offsetWidth;
|
|
162
|
-
const end = (vertical ? nav.clientHeight : nav.clientWidth) - start - size;
|
|
163
|
-
|
|
164
|
-
const previous = lastStart[kind];
|
|
165
|
-
const ms = animate && previous !== null ? transitionMs() : 0;
|
|
166
|
-
const forward = previous !== null && start > previous;
|
|
167
|
-
const backward = previous !== null && start < previous;
|
|
168
|
-
lastStart[kind] = start;
|
|
169
|
-
|
|
170
|
-
nav.style.setProperty(`--_${kind}-duration`, ms + "ms");
|
|
171
|
-
nav.style.setProperty(`--_${kind}-start-delay`, forward ? ms + "ms" : "0ms");
|
|
172
|
-
nav.style.setProperty(`--_${kind}-end-delay`, backward ? ms + "ms" : "0ms");
|
|
173
|
-
nav.style.setProperty(`--_${kind}-start`, start + "px");
|
|
174
|
-
nav.style.setProperty(`--_${kind}-end`, end + "px");
|
|
175
|
-
|
|
176
|
-
if (kind === "active") trackCoverage(ms * 2);
|
|
148
|
+
const ms = placeEdges(kind, target, animate);
|
|
149
|
+
if (ms !== null && kind === "active") trackCoverage(ms * 2);
|
|
177
150
|
};
|
|
178
151
|
|
|
179
152
|
// Marks each visible tab (and the overflow trigger) that the active indicator covers by at least half,
|