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.
Files changed (74) hide show
  1. package/.claude/rules/consumer/story-register-components.md +1 -1
  2. package/.claude/skills/component-compliance-checklist.md +5 -0
  3. package/.claude/skills/components/display-qr-code.md +22 -4
  4. package/.claude/skills/components/eyebrow-text.md +9 -5
  5. package/.claude/skills/components/header-block.md +13 -5
  6. package/.claude/skills/components/hero-text.md +13 -9
  7. package/.claude/skills/components/link-text.md +8 -5
  8. package/.claude/skills/components/pop-over.md +17 -3
  9. package/.claude/skills/components/sliding-toggle.md +108 -0
  10. package/.claude/skills/components/tabbed-content.md +8 -3
  11. package/.claude/skills/composable-sliding-indicator.md +60 -0
  12. package/.claude/skills/index.md +2 -0
  13. package/.claude/skills/storybook-add-story.md +22 -2
  14. package/.claude/skills/testing-add-playwright.md +12 -7
  15. package/.claude/skills/testing-add-unit-test.md +25 -0
  16. package/.claude/skills/theming-component-token-pattern.md +18 -0
  17. package/.vscode/srcdev-component-display-qr-code.code-snippets +12 -1
  18. package/.vscode/srcdev-component-hero-text.code-snippets +21 -5
  19. package/.vscode/srcdev-component-link-text.code-snippets +3 -3
  20. package/.vscode/srcdev-component-pop-over.code-snippets +28 -0
  21. package/.vscode/srcdev-component-sliding-toggle.code-snippets +37 -0
  22. package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -1
  23. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +42 -9
  24. package/app/components/01.atoms/pop-over/PopOver.vue +28 -12
  25. package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +114 -2
  26. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +35 -0
  27. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +5 -1
  28. package/app/components/01.atoms/qr-code/CONSUMER-STYLING.md +16 -3
  29. package/app/components/01.atoms/qr-code/DisplayQrCode.vue +31 -0
  30. package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +190 -157
  31. package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +58 -0
  32. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +15 -2
  33. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +4 -4
  34. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +4 -4
  35. package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +9 -1
  36. package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +5 -4
  37. package/app/components/01.atoms/text-blocks/eyebrow-text/playwright/eyebrow-text.playwright.ts +1 -1
  38. package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +104 -17
  39. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/EyebrowText.spec.ts +68 -118
  40. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/__snapshots__/EyebrowText.spec.ts.snap +8 -8
  41. package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +49 -24
  42. package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +9 -1
  43. package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +96 -26
  44. package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +64 -48
  45. package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +2 -2
  46. package/app/components/01.atoms/text-blocks/hero-text/CONSUMER-STYLING.md +22 -6
  47. package/app/components/01.atoms/text-blocks/hero-text/HeroText.vue +32 -23
  48. package/app/components/01.atoms/text-blocks/hero-text/playwright/hero-text.playwright.ts +1 -1
  49. package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +176 -23
  50. package/app/components/01.atoms/text-blocks/hero-text/tests/HeroText.spec.ts +129 -123
  51. package/app/components/01.atoms/text-blocks/hero-text/tests/__snapshots__/HeroText.spec.ts.snap +2 -2
  52. package/app/components/01.atoms/text-blocks/link-text/CONSUMER-STYLING.md +17 -0
  53. package/app/components/01.atoms/text-blocks/link-text/LinkText.vue +9 -3
  54. package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +96 -99
  55. package/app/components/01.atoms/text-blocks/link-text/tests/LinkText.spec.ts +84 -93
  56. package/app/components/02.molecules/price-list/tests/__snapshots__/PriceList.spec.ts.snap +8 -8
  57. package/app/components/02.molecules/sliding-toggle/CONSUMER-STYLING.md +109 -0
  58. package/app/components/02.molecules/sliding-toggle/SlidingToggle.vue +307 -0
  59. package/app/components/02.molecules/sliding-toggle/stories/SlidingToggle.stories.ts +357 -0
  60. package/app/components/02.molecules/sliding-toggle/tests/SlidingToggle.spec.ts +335 -0
  61. package/app/components/02.molecules/sliding-toggle/tests/__snapshots__/SlidingToggle.spec.ts.snap +3 -0
  62. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +15 -15
  63. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +3 -3
  64. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +3 -3
  65. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +3 -3
  66. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -1
  67. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +9 -9
  68. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +9 -9
  69. package/app/composables/tests/useSlidingIndicator.spec.ts +143 -0
  70. package/app/composables/useSlidingIndicator.ts +56 -0
  71. package/app/composables/useTabs.ts +4 -31
  72. package/app/types/components/index.ts +1 -0
  73. package/app/types/components/sliding-toggle.d.ts +5 -0
  74. 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 transitionMs = () => Math.max(0, Number(toValue(duration)) || 0);
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
- if (!nav || !target) return;
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,
@@ -27,3 +27,4 @@ export * from "./profile-section.d"
27
27
  export * from "./services-card.d"
28
28
  export * from "./google-reviews.d"
29
29
  export * from "./progress-tracker.d"
30
+ export * from "./sliding-toggle.d"
@@ -0,0 +1,5 @@
1
+ export interface SlidingToggleOption<T extends string | number = string> {
2
+ value: T;
3
+ label: string;
4
+ disabled?: boolean;
5
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.6.13",
4
+ "version": "9.6.15",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",