srcdev-nuxt-components 9.6.14 → 9.6.16

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 (26) hide show
  1. package/.claude/skills/components/responsive-header.md +2 -0
  2. package/.claude/skills/components/sliding-toggle.md +108 -0
  3. package/.claude/skills/components/tabbed-content.md +8 -3
  4. package/.claude/skills/composable-sliding-indicator.md +60 -0
  5. package/.claude/skills/index.md +2 -0
  6. package/.claude/skills/storybook-add-story.md +17 -0
  7. package/.claude/skills/testing-add-unit-test.md +25 -0
  8. package/.claude/skills/theming-component-token-pattern.md +18 -0
  9. package/.vscode/srcdev-component-sliding-toggle.code-snippets +37 -0
  10. package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -1
  11. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +15 -2
  12. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +4 -4
  13. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +4 -4
  14. package/app/components/02.molecules/sliding-toggle/CONSUMER-STYLING.md +109 -0
  15. package/app/components/02.molecules/sliding-toggle/SlidingToggle.vue +307 -0
  16. package/app/components/02.molecules/sliding-toggle/stories/SlidingToggle.stories.ts +357 -0
  17. package/app/components/02.molecules/sliding-toggle/tests/SlidingToggle.spec.ts +335 -0
  18. package/app/components/02.molecules/sliding-toggle/tests/__snapshots__/SlidingToggle.spec.ts.snap +3 -0
  19. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +2 -0
  20. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +7 -4
  21. package/app/composables/tests/useSlidingIndicator.spec.ts +143 -0
  22. package/app/composables/useSlidingIndicator.ts +56 -0
  23. package/app/composables/useTabs.ts +4 -31
  24. package/app/types/components/index.ts +1 -0
  25. package/app/types/components/sliding-toggle.d.ts +5 -0
  26. package/package.json +1 -1
@@ -0,0 +1,335 @@
1
+ import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { nextTick } from "vue";
4
+ import SlidingToggle from "../SlidingToggle.vue";
5
+ import type { SlidingToggleOption } from "~/types/components";
6
+
7
+ const billing: SlidingToggleOption[] = [
8
+ { value: "monthly", label: "Monthly" },
9
+ { value: "yearly", label: "Yearly" },
10
+ { value: "lifetime", label: "Lifetime" },
11
+ ];
12
+
13
+ const observed: Element[] = [];
14
+
15
+ class MockResizeObserver {
16
+ observe = vi.fn((element: Element) => observed.push(element));
17
+ unobserve = vi.fn();
18
+ disconnect = vi.fn(() => observed.splice(0));
19
+ }
20
+
21
+ const mounted: Awaited<ReturnType<typeof mountSuspended>>[] = [];
22
+
23
+ const mountToggle = async (props: Record<string, unknown> = {}, attrs: Record<string, unknown> = {}) => {
24
+ const holder: { current?: Awaited<ReturnType<typeof mountSuspended>> } = {};
25
+ const onUpdate = (value: string | number) => {
26
+ void holder.current?.setProps({ modelValue: value });
27
+ };
28
+ const wrapper = await mountSuspended(SlidingToggle, {
29
+ props: { options: billing, modelValue: "monthly", ariaLabel: "Billing period", "onUpdate:modelValue": onUpdate, ...props },
30
+ attrs,
31
+ attachTo: document.body,
32
+ });
33
+ holder.current = wrapper;
34
+ mounted.push(wrapper);
35
+ return wrapper;
36
+ };
37
+
38
+ const radios = (wrapper: Awaited<ReturnType<typeof mountToggle>>) => wrapper.findAll('[role="radio"]');
39
+
40
+ const mockRow = () => {
41
+ vi.spyOn(HTMLElement.prototype, "offsetLeft", "get").mockImplementation(function (this: HTMLElement) {
42
+ return this.dataset.index === undefined ? 0 : Number(this.dataset.index) * 100;
43
+ });
44
+ vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (this: HTMLElement) {
45
+ return this.dataset.index === undefined ? 0 : 100;
46
+ });
47
+ vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(function (this: HTMLElement) {
48
+ return this.classList.contains("sliding-toggle") ? 300 : 0;
49
+ });
50
+ };
51
+
52
+ const edge = (wrapper: Awaited<ReturnType<typeof mountToggle>>, name: string) =>
53
+ (wrapper.element as HTMLElement).style.getPropertyValue(`--_sliding-toggle-${name}`);
54
+
55
+ beforeEach(() => {
56
+ vi.stubGlobal("ResizeObserver", MockResizeObserver);
57
+ });
58
+
59
+ afterEach(() => {
60
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
61
+ vi.restoreAllMocks();
62
+ observed.splice(0);
63
+ });
64
+
65
+ describe("SlidingToggle", () => {
66
+ it("mounts without error", async () => {
67
+ const wrapper = await mountToggle();
68
+ expect(wrapper.vm).toBeTruthy();
69
+ });
70
+
71
+ it("renders correct HTML structure", async () => {
72
+ const wrapper = await mountToggle({ styleClassPassthrough: "custom-class" });
73
+ expect(wrapper.html()).toMatchSnapshot();
74
+ });
75
+
76
+ // ─── Semantics ───────────────────────────────────────────────────────────
77
+
78
+ it("renders a named radiogroup", async () => {
79
+ const wrapper = await mountToggle();
80
+ expect(wrapper.attributes("role")).toBe("radiogroup");
81
+ expect(wrapper.attributes("aria-label")).toBe("Billing period");
82
+ });
83
+
84
+ it("can be named by aria-labelledby instead", async () => {
85
+ const wrapper = await mountToggle({ ariaLabel: "", ariaLabelledby: "billing-heading" });
86
+ expect(wrapper.attributes("aria-label")).toBeUndefined();
87
+ expect(wrapper.attributes("aria-labelledby")).toBe("billing-heading");
88
+ });
89
+
90
+ it("renders one type=button radio per option, labelled by its text", async () => {
91
+ const wrapper = await mountToggle();
92
+ const options = radios(wrapper);
93
+ expect(options).toHaveLength(3);
94
+ options.forEach((option, index) => {
95
+ expect(option.element.tagName).toBe("BUTTON");
96
+ expect(option.attributes("type")).toBe("button");
97
+ expect(option.text()).toBe(billing[index]!.label);
98
+ });
99
+ });
100
+
101
+ it("marks the selected option aria-checked", async () => {
102
+ const wrapper = await mountToggle({ modelValue: "yearly" });
103
+ expect(radios(wrapper).map((option) => option.attributes("aria-checked"))).toEqual(["false", "true", "false"]);
104
+ });
105
+
106
+ it("hides both indicators from assistive tech", async () => {
107
+ const wrapper = await mountToggle();
108
+ wrapper.findAll(".sliding-toggle-indicator").forEach((indicator) => {
109
+ expect(indicator.attributes("aria-hidden")).toBe("true");
110
+ });
111
+ });
112
+
113
+ // ─── Selection ───────────────────────────────────────────────────────────
114
+
115
+ it("selects on click", async () => {
116
+ const wrapper = await mountToggle();
117
+ await radios(wrapper)[2]!.trigger("click");
118
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["lifetime"]);
119
+ expect(radios(wrapper)[2]!.attributes("aria-checked")).toBe("true");
120
+ });
121
+
122
+ it("follows an external v-model change", async () => {
123
+ const wrapper = await mountToggle();
124
+ await wrapper.setProps({ modelValue: "lifetime" });
125
+ expect(radios(wrapper)[2]!.attributes("aria-checked")).toBe("true");
126
+ expect(radios(wrapper)[0]!.attributes("aria-checked")).toBe("false");
127
+ });
128
+
129
+ it("supports numeric values", async () => {
130
+ const wrapper = await mountToggle({
131
+ options: [
132
+ { value: 1, label: "One" },
133
+ { value: 2, label: "Two" },
134
+ ],
135
+ modelValue: 1,
136
+ });
137
+ await radios(wrapper)[1]!.trigger("click");
138
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([2]);
139
+ });
140
+
141
+ // ─── Roving tabindex ─────────────────────────────────────────────────────
142
+
143
+ it("gives only the checked option the tab stop", async () => {
144
+ const wrapper = await mountToggle({ modelValue: "yearly" });
145
+ expect(radios(wrapper).map((option) => option.attributes("tabindex"))).toEqual(["-1", "0", "-1"]);
146
+ });
147
+
148
+ it("gives the first enabled option the tab stop when the value matches no option", async () => {
149
+ const wrapper = await mountToggle({
150
+ modelValue: "weekly",
151
+ options: [{ value: "a", label: "A", disabled: true }, ...billing],
152
+ });
153
+ expect(radios(wrapper).map((option) => option.attributes("tabindex"))).toEqual(["-1", "0", "-1", "-1"]);
154
+ expect(wrapper.attributes("data-has-selection")).toBeUndefined();
155
+ });
156
+
157
+ // ─── Keyboard ────────────────────────────────────────────────────────────
158
+
159
+ it("ArrowRight selects and focuses the next option", async () => {
160
+ const wrapper = await mountToggle();
161
+ await radios(wrapper)[0]!.trigger("keydown", { key: "ArrowRight" });
162
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["yearly"]);
163
+ expect(document.activeElement).toBe(radios(wrapper)[1]!.element);
164
+ });
165
+
166
+ it("ArrowDown also moves to the next option, and ArrowLeft/ArrowUp to the previous", async () => {
167
+ const wrapper = await mountToggle({ modelValue: "yearly" });
168
+ await radios(wrapper)[1]!.trigger("keydown", { key: "ArrowDown" });
169
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["lifetime"]);
170
+ await radios(wrapper)[2]!.trigger("keydown", { key: "ArrowUp" });
171
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["yearly"]);
172
+ await radios(wrapper)[1]!.trigger("keydown", { key: "ArrowLeft" });
173
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["monthly"]);
174
+ });
175
+
176
+ it("wraps from the last option to the first and back", async () => {
177
+ const wrapper = await mountToggle({ modelValue: "lifetime" });
178
+ await radios(wrapper)[2]!.trigger("keydown", { key: "ArrowRight" });
179
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["monthly"]);
180
+ await radios(wrapper)[0]!.trigger("keydown", { key: "ArrowLeft" });
181
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["lifetime"]);
182
+ });
183
+
184
+ it("Home and End select the first and last options", async () => {
185
+ const wrapper = await mountToggle({ modelValue: "yearly" });
186
+ await radios(wrapper)[1]!.trigger("keydown", { key: "End" });
187
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["lifetime"]);
188
+ await radios(wrapper)[2]!.trigger("keydown", { key: "Home" });
189
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["monthly"]);
190
+ });
191
+
192
+ it("skips disabled options", async () => {
193
+ const wrapper = await mountToggle({
194
+ options: [billing[0]!, { ...billing[1]!, disabled: true }, billing[2]!],
195
+ });
196
+ await radios(wrapper)[0]!.trigger("keydown", { key: "ArrowRight" });
197
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["lifetime"]);
198
+ });
199
+
200
+ it("RTL swaps the arrow directions", async () => {
201
+ const wrapper = await mountToggle({}, { dir: "rtl" });
202
+ await radios(wrapper)[0]!.trigger("keydown", { key: "ArrowLeft" });
203
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["yearly"]);
204
+ });
205
+
206
+ it("ignores other keys", async () => {
207
+ const wrapper = await mountToggle();
208
+ await radios(wrapper)[0]!.trigger("keydown", { key: "a" });
209
+ expect(wrapper.emitted("update:modelValue")).toBeUndefined();
210
+ });
211
+
212
+ // ─── Disabled ────────────────────────────────────────────────────────────
213
+
214
+ it("disables every option and blocks selection when the group is disabled", async () => {
215
+ const wrapper = await mountToggle({ disabled: true });
216
+ expect(wrapper.attributes("aria-disabled")).toBe("true");
217
+ radios(wrapper).forEach((option) => expect(option.attributes("disabled")).toBeDefined());
218
+ await radios(wrapper)[1]!.trigger("keydown", { key: "ArrowRight" });
219
+ expect(wrapper.emitted("update:modelValue")).toBeUndefined();
220
+ });
221
+
222
+ it("does not select a disabled option on click", async () => {
223
+ const wrapper = await mountToggle({ options: [billing[0]!, { ...billing[1]!, disabled: true }, billing[2]!] });
224
+ const vm = wrapper.vm as unknown as { select: (index: number) => void };
225
+ vm.select(1);
226
+ await nextTick();
227
+ expect(wrapper.emitted("update:modelValue")).toBeUndefined();
228
+ });
229
+
230
+ // ─── Indicators ──────────────────────────────────────────────────────────
231
+
232
+ it("sets data-ready once mounted and measured", async () => {
233
+ const wrapper = await mountToggle();
234
+ expect(wrapper.attributes("data-ready")).toBeDefined();
235
+ expect(wrapper.attributes("data-has-selection")).toBeDefined();
236
+ });
237
+
238
+ it("snaps the active indicator onto the checked option on mount", async () => {
239
+ mockRow();
240
+ const wrapper = await mountToggle({ modelValue: "yearly" });
241
+ expect(edge(wrapper, "active-start")).toBe("100px");
242
+ expect(edge(wrapper, "active-end")).toBe("100px");
243
+ expect(edge(wrapper, "active-duration")).toBe("0ms");
244
+ });
245
+
246
+ it("slides the active indicator with the leading edge first", async () => {
247
+ mockRow();
248
+ const wrapper = await mountToggle();
249
+ await radios(wrapper)[2]!.trigger("click");
250
+ await nextTick();
251
+ expect(edge(wrapper, "active-start")).toBe("200px");
252
+ expect(edge(wrapper, "active-start-delay")).toBe("200ms");
253
+ expect(edge(wrapper, "active-end-delay")).toBe("0ms");
254
+ });
255
+
256
+ it("moves the hover indicator with the pointer and back to the checked option on leave", async () => {
257
+ mockRow();
258
+ const wrapper = await mountToggle();
259
+ await radios(wrapper)[2]!.trigger("mouseenter");
260
+ expect(edge(wrapper, "hover-start")).toBe("200px");
261
+ expect(wrapper.attributes("data-hovering")).toBeDefined();
262
+ await wrapper.trigger("mouseleave");
263
+ expect(edge(wrapper, "hover-start")).toBe("0px");
264
+ expect(wrapper.attributes("data-hovering")).toBeUndefined();
265
+ });
266
+
267
+ it("does not track hover when trackHover is false", async () => {
268
+ mockRow();
269
+ const wrapper = await mountToggle({ trackHover: false });
270
+ await radios(wrapper)[2]!.trigger("mouseenter");
271
+ expect(edge(wrapper, "hover-start")).toBe("0px");
272
+ expect(wrapper.attributes("data-hovering")).toBeUndefined();
273
+ });
274
+
275
+ it("uses transitionDuration for the slide", async () => {
276
+ mockRow();
277
+ const wrapper = await mountToggle({ transitionDuration: 350 });
278
+ await radios(wrapper)[1]!.trigger("click");
279
+ await nextTick();
280
+ expect(edge(wrapper, "active-duration")).toBe("350ms");
281
+ });
282
+
283
+ // ─── Re-measuring ────────────────────────────────────────────────────────
284
+
285
+ it("observes the track and each option for size changes", async () => {
286
+ const wrapper = await mountToggle();
287
+ expect(observed).toContain(wrapper.element);
288
+ radios(wrapper).forEach((option) => expect(observed).toContain(option.element));
289
+ });
290
+
291
+ it("re-places the indicators when the options change", async () => {
292
+ mockRow();
293
+ const wrapper = await mountToggle({ modelValue: "lifetime" });
294
+ await wrapper.setProps({ options: [billing[2]!, billing[0]!] });
295
+ await nextTick();
296
+ await nextTick();
297
+ expect(edge(wrapper, "active-start")).toBe("0px");
298
+ });
299
+
300
+ // ─── Worst-case data ─────────────────────────────────────────────────────
301
+
302
+ it("renders an empty group without errors", async () => {
303
+ const wrapper = await mountToggle({ options: [] });
304
+ expect(radios(wrapper)).toHaveLength(0);
305
+ expect(wrapper.attributes("data-has-selection")).toBeUndefined();
306
+ });
307
+
308
+ it("renders duplicate labels as separate radios", async () => {
309
+ const wrapper = await mountToggle({
310
+ options: [
311
+ { value: "a", label: "Same" },
312
+ { value: "b", label: "Same" },
313
+ ],
314
+ modelValue: "b",
315
+ });
316
+ expect(radios(wrapper)).toHaveLength(2);
317
+ expect(radios(wrapper)[1]!.attributes("aria-checked")).toBe("true");
318
+ });
319
+
320
+ it("renders HTML-like labels as text", async () => {
321
+ const wrapper = await mountToggle({ options: [{ value: "x", label: "<b>not bold</b>" }], modelValue: "x" });
322
+ expect(wrapper.find("b").exists()).toBe(false);
323
+ expect(radios(wrapper)[0]!.text()).toBe("<b>not bold</b>");
324
+ });
325
+
326
+ // ─── styleClassPassthrough ───────────────────────────────────────────────
327
+
328
+ it("updates classes when styleClassPassthrough changes after mount", async () => {
329
+ const wrapper = await mountToggle({ styleClassPassthrough: ["original"] });
330
+ expect(wrapper.classes()).toContain("sliding-toggle");
331
+ await wrapper.setProps({ styleClassPassthrough: ["updated"] });
332
+ expect(wrapper.classes()).not.toContain("original");
333
+ expect(wrapper.classes()).toContain("updated");
334
+ });
335
+ });
@@ -0,0 +1,3 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`SlidingToggle > renders correct HTML structure 1`] = `"<div class="sliding-toggle custom-class" role="radiogroup" aria-label="Billing period" data-has-selection="" style="--_sliding-toggle-active-duration: 0ms; --_sliding-toggle-active-start-delay: 0ms; --_sliding-toggle-active-end-delay: 0ms; --_sliding-toggle-active-start: 0px; --_sliding-toggle-active-end: 0px; --_sliding-toggle-hover-duration: 0ms; --_sliding-toggle-hover-start-delay: 0ms; --_sliding-toggle-hover-end-delay: 0ms; --_sliding-toggle-hover-start: 0px; --_sliding-toggle-hover-end: 0px;" data-ready=""><span class="sliding-toggle-indicator sliding-toggle-indicator-hover" aria-hidden="true"></span><span class="sliding-toggle-indicator sliding-toggle-indicator-active" aria-hidden="true"></span><button type="button" role="radio" class="sliding-toggle-option" data-index="0" aria-checked="true" tabindex="0">Monthly</button><button type="button" role="radio" class="sliding-toggle-option" data-index="1" aria-checked="false" tabindex="-1">Yearly</button><button type="button" role="radio" class="sliding-toggle-option" data-index="2" aria-checked="false" tabindex="-1">Lifetime</button></div>"`;
@@ -10,6 +10,8 @@ are documented alongside it since that's where they're consumed.
10
10
  | Token | Default | Controls |
11
11
  |---|---|---|
12
12
  | `--responsive-header-link-font-size` | `inherit` | Nav-link font-size. **Always set this to a fixed value.** Leaving it `inherit` means a fluid ancestor font-size (`clamp()`/`vw`) can drift nav-item text width without ever re-triggering the overflow-collapse measurement — see the component skill doc's "measurement pipeline" section for the full mechanism. |
13
+ | `--responsive-header-link-padding-block` / `--responsive-header-link-padding-inline` | `0.8rem` / `0.2rem` | Padding around each top-bar link and summary. The active underline and hover highlight span this box, so larger inline padding makes them reach past the label. Set fixed values, as with the font size: the overflow-collapse measurement only re-runs when the header resizes. |
14
+ | `--responsive-header-nav-item-gap` | `30px` | Gap between items within each top-bar list. Reduce it when you raise the inline padding to keep the label-to-label spacing. |
13
15
  | `--responsive-header-link-color` | `inherit` | Link/summary text colour. |
14
16
  | `--responsive-header-main-nav-justify-content` | `space-between` | How the main nav's groups are spread along the bar. Use `flex-start`, `safe center` or `safe end`; plain `center`/`end` let items that don't fit spill off the start edge, where they're clipped. (Safari 16 ignores `safe` values and falls back to start alignment.) |
15
17
  | `--responsive-header-color` | `inherit` | Root element text colour. |
@@ -779,6 +779,9 @@ watch(
779
779
  --responsive-nav-decorator-hovered-bg (default: color-mix(in oklch, var(--theme-text) 8%, transparent))
780
780
 
781
781
  --responsive-header-link-font-size (default: inherit)
782
+ --responsive-header-link-padding-block (default: 0.8rem)
783
+ --responsive-header-link-padding-inline (default: 0.2rem)
784
+ --responsive-header-nav-item-gap (default: 30px)
782
785
  --responsive-header-main-nav-justify-content (default: space-between)
783
786
  ──────────────────────────────────────────────────────────────────────── */
784
787
 
@@ -810,8 +813,8 @@ watch(
810
813
 
811
814
  .main-navigation {
812
815
  /* Set up some global vars */
813
- --_link-padding-block: 0.8rem;
814
- --_link-padding-inline: 0.2rem;
816
+ --_link-padding-block: var(--responsive-header-link-padding-block, 0.8rem);
817
+ --_link-padding-inline: var(--responsive-header-link-padding-inline, 0.2rem);
815
818
  --_link-margin-block: 0.1rem;
816
819
  --_link-margin-inline: 0.1rem;
817
820
  --_link-focus-visible-outline-width: 0.2rem;
@@ -835,11 +838,11 @@ watch(
835
838
  flex-wrap: nowrap;
836
839
 
837
840
  &:nth-of-type(1) {
838
- gap: 30px;
841
+ gap: var(--responsive-header-nav-item-gap, 30px);
839
842
  }
840
843
 
841
844
  &:nth-of-type(2) {
842
- gap: 30px;
845
+ gap: var(--responsive-header-nav-item-gap, 30px);
843
846
  }
844
847
 
845
848
  .main-navigation-item {
@@ -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.14",
4
+ "version": "9.6.16",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",