srcdev-nuxt-components 9.1.14 → 9.1.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.
@@ -18,7 +18,8 @@
18
18
  "Bash(git show:*)",
19
19
  "Edit(/.claude/skills/components/**)",
20
20
  "Bash(npx nuxi:*)",
21
- "Bash(node -e \"const t = require\\('/Users/simoncornforth/websites/nuxt-components/node_modules/pinia-plugin-persistedstate'\\); console.log\\(Object.keys\\(t\\)\\)\")"
21
+ "Bash(node -e \"const t = require\\('/Users/simoncornforth/websites/nuxt-components/node_modules/pinia-plugin-persistedstate'\\); console.log\\(Object.keys\\(t\\)\\)\")",
22
+ "Bash(npx vue-tsc:*)"
22
23
  ],
23
24
  "additionalDirectories": [
24
25
  "/Users/simoncornforth/websites/nuxt-components/app/components/01.atoms/content-wrappers/content-width",
@@ -0,0 +1,111 @@
1
+ # useWhatsApp Composable
2
+
3
+ ## Overview
4
+
5
+ `useWhatsApp` opens a pre-filled WhatsApp conversation in a new tab via the `wa.me` deep-link API. It formats an array of labelled fields into a bold-label WhatsApp message and requires a phone number configured in runtime config.
6
+
7
+ Composable location: `app/composables/useWhatsApp.ts`
8
+
9
+ ## Prerequisites
10
+
11
+ - `NUXT_PUBLIC_WHATSAPP_NUMBER` env var set to the recipient number in international format, no `+` or spaces (e.g. `447700900000`).
12
+
13
+ ## Runtime Config
14
+
15
+ `whatsappNumber` must live in `runtimeConfig.public` — **not** the private root block — because `openWhatsApp` runs client-side and private keys are server-only.
16
+
17
+ ```ts
18
+ // nuxt.config.ts
19
+ runtimeConfig: {
20
+ // private server-only keys (Resend, etc.)
21
+ public: {
22
+ whatsappNumber: "", // NUXT_PUBLIC_WHATSAPP_NUMBER
23
+ },
24
+ },
25
+ ```
26
+
27
+ Env var name follows Nuxt convention: `NUXT_PUBLIC_` prefix + SCREAMING_SNAKE of the key path.
28
+
29
+ ## Composable
30
+
31
+ ```ts
32
+ // app/composables/useWhatsApp.ts
33
+ export const useWhatsApp = () => {
34
+ const config = useRuntimeConfig(); // must be inside the function, not at module scope
35
+
36
+ const openWhatsApp = (fields: { label: string; value: string }[]) => {
37
+ const number = config.public.whatsappNumber;
38
+
39
+ if (!number) {
40
+ console.warn("[useWhatsApp] whatsappNumber is not configured");
41
+ return;
42
+ }
43
+
44
+ const message = fields
45
+ .filter((f) => f.value?.trim())
46
+ .map((f) => `*${f.label}:* ${f.value}`)
47
+ .join("\n");
48
+
49
+ const url = `https://wa.me/${number}?text=${encodeURIComponent(message)}`;
50
+ window.open(url, "_blank", "noopener,noreferrer"); // noopener prevents reverse tabnapping
51
+ };
52
+
53
+ return { openWhatsApp };
54
+ };
55
+ ```
56
+
57
+ ### Key rules
58
+
59
+ - **`useRuntimeConfig()` inside the function body** — never at module scope. Nuxt composables require an active context; module-scope calls run at import time, outside any context, and return empty values.
60
+ - **`noopener,noreferrer`** on `window.open` — prevents the opened WhatsApp page accessing `window.opener` (reverse tabnapping).
61
+ - **Guard for missing number** — avoids a silent `https://wa.me/?text=...` 404.
62
+
63
+ ## Usage in a form
64
+
65
+ ```ts
66
+ // 1. Destructure the composable
67
+ const { openWhatsApp } = useWhatsApp();
68
+
69
+ // 2. Build the payload from your form state
70
+ const buildWhatsAppPayload = () => [
71
+ { label: "Name", value: state.fullName },
72
+ { label: "Phone", value: state.telNumber },
73
+ { label: "Email", value: state.emailAddress },
74
+ { label: "Services", value: state.services.join(", ") },
75
+ { label: "Comments", value: state.comments ?? "" },
76
+ ];
77
+
78
+ // 3. Call on successful form submission
79
+ const submitForm = async () => {
80
+ zodFormControl.submitAttempted = true;
81
+ if (!(await doZodValidate(state))) {
82
+ scrollToFirstError();
83
+ return;
84
+ }
85
+ zodFormControl.displayLoader = true;
86
+ try {
87
+ zodFormControl.submitSuccessful = true;
88
+ openWhatsApp(buildWhatsAppPayload());
89
+ } catch (error) {
90
+ console.warn("Contact form submission failed", error);
91
+ } finally {
92
+ zodFormControl.displayLoader = false;
93
+ }
94
+ };
95
+ ```
96
+
97
+ ## Message format
98
+
99
+ Fields with an empty/whitespace `value` are filtered out. Remaining fields render as:
100
+
101
+ ```
102
+ *Name:* Jane Smith
103
+ *Phone:* 07700 900000
104
+ *Services:* Cut, Colour
105
+ ```
106
+
107
+ ## Notes
108
+
109
+ - `wa.me` opens the WhatsApp desktop app if installed, otherwise falls back to web.whatsapp.com.
110
+ - The phone number in `public` config is visible in the client bundle — acceptable for a public-facing WhatsApp business number.
111
+ - This is a client-side-only operation; no server route or API key is needed.
@@ -36,6 +36,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
36
36
  ├── component-inline-action-button.md — InputButtonCore variant="inline" pattern for buttons embedded in custom input wrappers
37
37
  ├── icon-sets.md — icon set packages required by layer components, FOUC prevention, component→package map
38
38
  ├── robots-env-aware.md — @nuxtjs/robots: allow crawling on prod domain only, block on preview/staging via env var
39
+ ├── composable-whatsapp.md — useWhatsApp: open pre-filled wa.me link from form payload; runtime config, security, usage
39
40
  └── components/
40
41
  ├── accordian-core.md — AccordianCore indexed dynamic slots (accordian-{n}-summary/icon/content), exclusive-open grouping
41
42
  ├── eyebrow-text.md — EyebrowText props, usage patterns, styling
@@ -5,7 +5,7 @@
5
5
  :class="[
6
6
  elementClasses,
7
7
  `tab-navigation--${navAlign}`,
8
- { 'is-collapsed': isCollapsed, 'is-loaded': isLoaded, 'menu-open': isMenuOpen },
8
+ { 'is-collapsed': isCollapsed, 'is-loaded': isLoaded, 'menu-open': isMenuOpen, 'is-animated': isAnimated },
9
9
  ]"
10
10
  aria-label="Site navigation"
11
11
  >
@@ -106,6 +106,28 @@ const props = withDefaults(defineProps<Props>(), {
106
106
  const { navRef, navListRef, isCollapsed, isLoaded, isMenuOpen, isActiveItem, toggleMenu, closeMenu } =
107
107
  useNavCollapse("tab-nav-loaded");
108
108
 
109
+ // ─── Animation gate — disables indicator transitions during route changes ────
110
+ // Starts true: CSS anchor positioning resolves before first paint so there is
111
+ // no previous position to animate from on initial render.
112
+ // Uses flush:"pre" so isAnimated = false lands in the same DOM update as the
113
+ // is-active class moving — the browser never sees the anchor shift with
114
+ // transitions active.
115
+ const isAnimated = ref(true);
116
+ const route = useRoute();
117
+
118
+ watch(
119
+ () => route.path,
120
+ () => {
121
+ isAnimated.value = false;
122
+ requestAnimationFrame(() => {
123
+ requestAnimationFrame(() => {
124
+ isAnimated.value = true;
125
+ });
126
+ });
127
+ },
128
+ { flush: "pre" }
129
+ );
130
+
109
131
  const hoveredItemHref = ref<string | null>(null);
110
132
  const showCollapsed = computed(() => isCollapsed.value && isLoaded.value);
111
133
 
@@ -426,13 +448,16 @@ watch(
426
448
  bottom: 0;
427
449
  opacity: 0;
428
450
  pointer-events: none;
451
+ background: var(--tab-nav-decorator-hovered-bg, transparent);
452
+ border-radius: 4px;
453
+ z-index: 1;
454
+ }
455
+
456
+ .tab-navigation.is-animated .tab-nav-list .nav__hovered {
429
457
  transition:
430
458
  left 200ms ease,
431
459
  right 200ms ease,
432
460
  opacity 150ms ease;
433
- background: var(--tab-nav-decorator-hovered-bg, transparent);
434
- border-radius: 4px;
435
- z-index: 1;
436
461
  }
437
462
 
438
463
  .tab-navigation .tab-nav-list:has(.is-hovered) .nav__hovered {
@@ -449,11 +474,14 @@ watch(
449
474
  bottom: 0;
450
475
  height: 2px;
451
476
  pointer-events: none;
477
+ background-color: var(--tab-nav-decorator-indicator-color, var(--slate-01, currentColor));
478
+ z-index: 3;
479
+ }
480
+
481
+ .tab-navigation.is-animated .tab-nav-list .nav__active-indicator {
452
482
  transition:
453
483
  left 200ms ease,
454
484
  right 200ms ease;
455
- background-color: var(--tab-nav-decorator-indicator-color, var(--slate-01, currentColor));
456
- z-index: 3;
457
485
  }
458
486
  /* } */
459
487
  }
@@ -1,7 +1,7 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
3
  exports[`TabNavigation > renders correct HTML structure 1`] = `
4
- "<nav class="tab-navigation tab-navigation--left is-loaded" aria-label="Site navigation">
4
+ "<nav class="tab-navigation tab-navigation--left is-loaded is-animated" aria-label="Site navigation">
5
5
  <ul class="tab-nav-list">
6
6
  <li data-href="/" class="is-active"><a href="/" class="tab-nav-link" data-nav-item="">
7
7
  <!--v-if--> Home
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import useApiRequest from "../useApiRequest";
3
+
4
+ class NetworkError extends Error {
5
+ override name = "NetworkError";
6
+ }
7
+
8
+ class ValidationError extends Error {
9
+ override name = "ValidationError";
10
+ }
11
+
12
+ describe("useApiRequest", () => {
13
+ // ─── Success ──────────────────────────────────────────────────────────────
14
+
15
+ describe("on success", () => {
16
+ it("returns [undefined, data]", async () => {
17
+ const [error, data] = await useApiRequest(Promise.resolve("ok"));
18
+ expect(error).toBeUndefined();
19
+ expect(data).toBe("ok");
20
+ });
21
+
22
+ it("passes through any resolved value type", async () => {
23
+ const payload = { id: 1, name: "test" };
24
+ const [, data] = await useApiRequest(Promise.resolve(payload));
25
+ expect(data).toEqual(payload);
26
+ });
27
+ });
28
+
29
+ // ─── Error — no errorsToCatch ─────────────────────────────────────────────
30
+
31
+ describe("on rejection with no errorsToCatch", () => {
32
+ it("returns [error] for any thrown error", async () => {
33
+ const err = new Error("boom");
34
+ const result = await useApiRequest(Promise.reject(err));
35
+ expect(result).toEqual([err]);
36
+ });
37
+
38
+ it("returns [error] for unknown error types", async () => {
39
+ const err = new NetworkError("network fail");
40
+ const result = await useApiRequest(Promise.reject(err));
41
+ expect(result).toEqual([err]);
42
+ });
43
+ });
44
+
45
+ // ─── Error — matching errorsToCatch ───────────────────────────────────────
46
+
47
+ describe("on rejection with matching errorsToCatch", () => {
48
+ it("returns [error] when the error matches the caught class", async () => {
49
+ const err = new NetworkError("network fail");
50
+ const result = await useApiRequest(Promise.reject(err), [NetworkError]);
51
+ expect(result).toEqual([err]);
52
+ });
53
+
54
+ it("returns [error] when the error matches one of multiple caught classes", async () => {
55
+ const err = new ValidationError("invalid");
56
+ const result = await useApiRequest(Promise.reject(err), [NetworkError, ValidationError]);
57
+ expect(result).toEqual([err]);
58
+ });
59
+ });
60
+
61
+ // ─── Error — non-matching errorsToCatch ───────────────────────────────────
62
+
63
+ describe("on rejection with non-matching errorsToCatch", () => {
64
+ it("re-throws when the error does not match any caught class", async () => {
65
+ const err = new NetworkError("network fail");
66
+ await expect(useApiRequest(Promise.reject(err), [ValidationError])).rejects.toThrow(err);
67
+ });
68
+
69
+ it("re-throws a base Error when only a subclass is caught", async () => {
70
+ const err = new Error("plain error");
71
+ await expect(useApiRequest(Promise.reject(err), [NetworkError])).rejects.toThrow(err);
72
+ });
73
+ });
74
+ });
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { ref } from "vue";
3
+ import type { Slots } from "vue";
4
+ import { mockNuxtImport } from "@nuxt/test-utils/runtime";
5
+ import { useAriaDescribedById } from "../useAriaDescribedById";
6
+
7
+ let idCounter = 0;
8
+ const { useIdMock } = vi.hoisted(() => ({
9
+ useIdMock: vi.fn(() => `test-id-${++idCounter}`),
10
+ }));
11
+ mockNuxtImport("useId", () => useIdMock);
12
+
13
+ const noSlots: Slots = {};
14
+ const withSlot = (name: string): Slots => ({ [name]: () => [] });
15
+
16
+ describe("useAriaDescribedById", () => {
17
+ // ─── ID structure ─────────────────────────────────────────────────────────
18
+
19
+ describe("id structure", () => {
20
+ it("id is prefixed with the name", () => {
21
+ const { id } = useAriaDescribedById("email", ref(false), noSlots);
22
+ expect(id).toMatch(/^email-/);
23
+ });
24
+
25
+ it("errorId is suffixed with -error-message", () => {
26
+ const { id, errorId } = useAriaDescribedById("email", ref(false), noSlots);
27
+ expect(errorId).toBe(`${id}-error-message`);
28
+ });
29
+
30
+ it("descriptionId is suffixed with -description", () => {
31
+ const { id, descriptionId } = useAriaDescribedById("email", ref(false), noSlots);
32
+ expect(descriptionId).toBe(`${id}-description`);
33
+ });
34
+ });
35
+
36
+ // ─── ariaDescribedby — no slots, no error ─────────────────────────────────
37
+
38
+ describe("ariaDescribedby", () => {
39
+ it("is null when no slots and no error", () => {
40
+ const { ariaDescribedby } = useAriaDescribedById("email", ref(false), noSlots);
41
+ expect(ariaDescribedby.value).toBeNull();
42
+ });
43
+
44
+ it("includes descriptionId when descriptionText slot is present", () => {
45
+ const { descriptionId, ariaDescribedby } = useAriaDescribedById(
46
+ "email",
47
+ ref(false),
48
+ withSlot("descriptionText")
49
+ );
50
+ expect(ariaDescribedby.value).toContain(descriptionId);
51
+ });
52
+
53
+ it("includes descriptionId when descriptionHtml slot is present", () => {
54
+ const { descriptionId, ariaDescribedby } = useAriaDescribedById(
55
+ "email",
56
+ ref(false),
57
+ withSlot("descriptionHtml")
58
+ );
59
+ expect(ariaDescribedby.value).toContain(descriptionId);
60
+ });
61
+
62
+ it("includes descriptionId when description slot is present", () => {
63
+ const { descriptionId, ariaDescribedby } = useAriaDescribedById(
64
+ "email",
65
+ ref(false),
66
+ withSlot("description")
67
+ );
68
+ expect(ariaDescribedby.value).toContain(descriptionId);
69
+ });
70
+
71
+ it("includes errorId when fieldHasError is true", () => {
72
+ const { errorId, ariaDescribedby } = useAriaDescribedById("email", ref(true), noSlots);
73
+ expect(ariaDescribedby.value).toContain(errorId);
74
+ });
75
+
76
+ it("does not include errorId when fieldHasError is false (value is null)", () => {
77
+ const { ariaDescribedby } = useAriaDescribedById("email", ref(false), noSlots);
78
+ expect(ariaDescribedby.value).toBeNull();
79
+ });
80
+
81
+ it("does not include errorId when slot is present but fieldHasError is false", () => {
82
+ const { errorId, ariaDescribedby } = useAriaDescribedById(
83
+ "email",
84
+ ref(false),
85
+ withSlot("descriptionText")
86
+ );
87
+ expect(ariaDescribedby.value).not.toContain(errorId);
88
+ });
89
+
90
+ it("includes both descriptionId and errorId when slot present and error active", () => {
91
+ const fieldHasError = ref(true);
92
+ const { descriptionId, errorId, ariaDescribedby } = useAriaDescribedById(
93
+ "email",
94
+ fieldHasError,
95
+ withSlot("descriptionText")
96
+ );
97
+ expect(ariaDescribedby.value).toContain(descriptionId);
98
+ expect(ariaDescribedby.value).toContain(errorId);
99
+ });
100
+
101
+ it("descriptionId appears before errorId in the combined string", () => {
102
+ const fieldHasError = ref(true);
103
+ const { descriptionId, errorId, ariaDescribedby } = useAriaDescribedById(
104
+ "email",
105
+ fieldHasError,
106
+ withSlot("descriptionText")
107
+ );
108
+ const value = ariaDescribedby.value!;
109
+ expect(value.indexOf(descriptionId)).toBeLessThan(value.indexOf(errorId));
110
+ });
111
+ });
112
+
113
+ // ─── Reactivity ───────────────────────────────────────────────────────────
114
+
115
+ describe("reactivity", () => {
116
+ it("ariaDescribedby updates when fieldHasError changes to true", async () => {
117
+ const fieldHasError = ref(false);
118
+ const { errorId, ariaDescribedby } = useAriaDescribedById("email", fieldHasError, noSlots);
119
+ expect(ariaDescribedby.value).toBeNull();
120
+ fieldHasError.value = true;
121
+ await nextTick();
122
+ expect(ariaDescribedby.value).toContain(errorId);
123
+ });
124
+
125
+ it("ariaDescribedby updates when fieldHasError changes back to false", async () => {
126
+ const fieldHasError = ref(true);
127
+ const { ariaDescribedby } = useAriaDescribedById("email", fieldHasError, noSlots);
128
+ expect(ariaDescribedby.value).toBeTruthy();
129
+ fieldHasError.value = false;
130
+ await nextTick();
131
+ expect(ariaDescribedby.value).toBeNull();
132
+ });
133
+ });
134
+ });
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { ref } from "vue";
3
+ import { mockNuxtImport } from "@nuxt/test-utils/runtime";
4
+ import { useAriaLabelledById } from "../useAriaLabelledById";
5
+
6
+ let idCounter = 0;
7
+ const { useIdMock } = vi.hoisted(() => ({
8
+ useIdMock: vi.fn(() => `test-id-${++idCounter}`),
9
+ }));
10
+ mockNuxtImport("useId", () => useIdMock);
11
+
12
+ describe("useAriaLabelledById", () => {
13
+ // ─── headingId ────────────────────────────────────────────────────────────
14
+
15
+ describe("headingId", () => {
16
+ it("returns a non-empty string", () => {
17
+ const { headingId } = useAriaLabelledById("div");
18
+ expect(headingId).toBeTruthy();
19
+ expect(typeof headingId).toBe("string");
20
+ });
21
+
22
+ it("is stable — same value returned by ariaLabelledby when applicable", () => {
23
+ const { headingId, ariaLabelledby } = useAriaLabelledById("section");
24
+ expect(ariaLabelledby.value).toBe(headingId);
25
+ });
26
+ });
27
+
28
+ // ─── Labelled tags ────────────────────────────────────────────────────────
29
+
30
+ describe("labelled tags", () => {
31
+ it.each(["section", "main", "article", "aside"])(
32
+ '"%s" returns ariaLabelledby = headingId',
33
+ (tag) => {
34
+ const { headingId, ariaLabelledby } = useAriaLabelledById(tag);
35
+ expect(ariaLabelledby.value).toBe(headingId);
36
+ }
37
+ );
38
+ });
39
+
40
+ // ─── Non-labelled tags ────────────────────────────────────────────────────
41
+
42
+ describe("non-labelled tags", () => {
43
+ it.each(["div", "span", "h1", "p", "ul", "nav"])(
44
+ '"%s" returns ariaLabelledby = undefined',
45
+ (tag) => {
46
+ const { ariaLabelledby } = useAriaLabelledById(tag);
47
+ expect(ariaLabelledby.value).toBeUndefined();
48
+ }
49
+ );
50
+ });
51
+
52
+ // ─── Reactivity ───────────────────────────────────────────────────────────
53
+
54
+ describe("reactivity", () => {
55
+ it("updates ariaLabelledby when a ref tag changes to a labelled tag", async () => {
56
+ const tag = ref("div");
57
+ const { headingId, ariaLabelledby } = useAriaLabelledById(tag);
58
+ expect(ariaLabelledby.value).toBeUndefined();
59
+ tag.value = "section";
60
+ await nextTick();
61
+ expect(ariaLabelledby.value).toBe(headingId);
62
+ });
63
+
64
+ it("updates ariaLabelledby when a ref tag changes away from a labelled tag", async () => {
65
+ const tag = ref("section");
66
+ const { ariaLabelledby } = useAriaLabelledById(tag);
67
+ expect(ariaLabelledby.value).toBeTruthy();
68
+ tag.value = "div";
69
+ await nextTick();
70
+ expect(ariaLabelledby.value).toBeUndefined();
71
+ });
72
+ });
73
+ });
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { useDialogControls } from "../useDialogControls";
3
+
4
+ describe("useDialogControls", () => {
5
+ // ─── initialiseDialogs ────────────────────────────────────────────────────
6
+
7
+ describe("initialiseDialogs", () => {
8
+ it("registers each dialog id as false", () => {
9
+ const { dialogsConfig, initialiseDialogs } = useDialogControls();
10
+ initialiseDialogs(["confirm", "delete"]);
11
+ expect(dialogsConfig.confirm).toBe(false);
12
+ expect(dialogsConfig.delete).toBe(false);
13
+ });
14
+
15
+ it("does not affect pre-existing dialog state", () => {
16
+ const { dialogsConfig, initialiseDialogs, controlDialogs } = useDialogControls();
17
+ initialiseDialogs(["a"]);
18
+ controlDialogs("a", true);
19
+ initialiseDialogs(["b"]);
20
+ expect(dialogsConfig.a).toBe(true); // unchanged
21
+ expect(dialogsConfig.b).toBe(false);
22
+ });
23
+ });
24
+
25
+ // ─── controlDialogs ───────────────────────────────────────────────────────
26
+
27
+ describe("controlDialogs", () => {
28
+ it("sets dialog state to true (open)", () => {
29
+ const { dialogsConfig, initialiseDialogs, controlDialogs } = useDialogControls();
30
+ initialiseDialogs(["modal"]);
31
+ controlDialogs("modal", true);
32
+ expect(dialogsConfig.modal).toBe(true);
33
+ });
34
+
35
+ it("sets dialog state to false (close)", () => {
36
+ const { dialogsConfig, initialiseDialogs, controlDialogs } = useDialogControls();
37
+ initialiseDialogs(["modal"]);
38
+ controlDialogs("modal", true);
39
+ controlDialogs("modal", false);
40
+ expect(dialogsConfig.modal).toBe(false);
41
+ });
42
+
43
+ it("multiple dialogs are independent", () => {
44
+ const { dialogsConfig, initialiseDialogs, controlDialogs } = useDialogControls();
45
+ initialiseDialogs(["a", "b"]);
46
+ controlDialogs("a", true);
47
+ expect(dialogsConfig.a).toBe(true);
48
+ expect(dialogsConfig.b).toBe(false);
49
+ });
50
+ });
51
+
52
+ // ─── registerDialogCallbacks + controlDialogs actions ────────────────────
53
+
54
+ describe("callbacks", () => {
55
+ it("fires onConfirm when closing with action='confirm'", () => {
56
+ const { initialiseDialogs, controlDialogs, registerDialogCallbacks } = useDialogControls();
57
+ initialiseDialogs(["modal"]);
58
+ const onConfirm = vi.fn();
59
+ registerDialogCallbacks("modal", { onConfirm });
60
+ controlDialogs("modal", false, "confirm");
61
+ expect(onConfirm).toHaveBeenCalledOnce();
62
+ });
63
+
64
+ it("fires onCancel when closing with action='cancel'", () => {
65
+ const { initialiseDialogs, controlDialogs, registerDialogCallbacks } = useDialogControls();
66
+ initialiseDialogs(["modal"]);
67
+ const onCancel = vi.fn();
68
+ registerDialogCallbacks("modal", { onCancel });
69
+ controlDialogs("modal", false, "cancel");
70
+ expect(onCancel).toHaveBeenCalledOnce();
71
+ });
72
+
73
+ it("does not fire onConfirm when opening (state=true)", () => {
74
+ const { initialiseDialogs, controlDialogs, registerDialogCallbacks } = useDialogControls();
75
+ initialiseDialogs(["modal"]);
76
+ const onConfirm = vi.fn();
77
+ registerDialogCallbacks("modal", { onConfirm });
78
+ controlDialogs("modal", true, "confirm");
79
+ expect(onConfirm).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it("does not fire onCancel when opening (state=true)", () => {
83
+ const { initialiseDialogs, controlDialogs, registerDialogCallbacks } = useDialogControls();
84
+ initialiseDialogs(["modal"]);
85
+ const onCancel = vi.fn();
86
+ registerDialogCallbacks("modal", { onCancel });
87
+ controlDialogs("modal", true, "cancel");
88
+ expect(onCancel).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it("does not fire onConfirm when closing without an action", () => {
92
+ const { initialiseDialogs, controlDialogs, registerDialogCallbacks } = useDialogControls();
93
+ initialiseDialogs(["modal"]);
94
+ const onConfirm = vi.fn();
95
+ registerDialogCallbacks("modal", { onConfirm });
96
+ controlDialogs("modal", false);
97
+ expect(onConfirm).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it("does not fire confirm callback for a different dialog", () => {
101
+ const { initialiseDialogs, controlDialogs, registerDialogCallbacks } = useDialogControls();
102
+ initialiseDialogs(["a", "b"]);
103
+ const onConfirm = vi.fn();
104
+ registerDialogCallbacks("a", { onConfirm });
105
+ controlDialogs("b", false, "confirm");
106
+ expect(onConfirm).not.toHaveBeenCalled();
107
+ });
108
+ });
109
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import useSleep from "../useSleep";
3
+
4
+ describe("useSleep", () => {
5
+ it("returns a Promise", () => {
6
+ const result = useSleep(100);
7
+ expect(result).toBeInstanceOf(Promise);
8
+ });
9
+
10
+ it("resolves with true after the given duration", async () => {
11
+ const promise = useSleep(500);
12
+ await vi.advanceTimersByTimeAsync(500);
13
+ await expect(promise).resolves.toBe(true);
14
+ });
15
+
16
+ it("does not resolve before the duration elapses", async () => {
17
+ let resolved = false;
18
+ useSleep(1000).then(() => {
19
+ resolved = true;
20
+ });
21
+ await vi.advanceTimersByTimeAsync(999);
22
+ expect(resolved).toBe(false);
23
+ });
24
+
25
+ it("resolves immediately after the exact duration", async () => {
26
+ let resolved = false;
27
+ useSleep(1000).then(() => {
28
+ resolved = true;
29
+ });
30
+ await vi.advanceTimersByTimeAsync(1000);
31
+ expect(resolved).toBe(true);
32
+ });
33
+ });
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { nextTick } from "vue";
3
+ import { useStyleClassPassthrough } from "../useStyleClassPassthrough";
4
+
5
+ describe("useStyleClassPassthrough", () => {
6
+ // ─── Initialisation ───────────────────────────────────────────────────────
7
+
8
+ describe("initialisation", () => {
9
+ it("accepts a string and normalizes to an array", () => {
10
+ const { elementClasses } = useStyleClassPassthrough("foo");
11
+ expect(elementClasses.value).toBe("foo");
12
+ });
13
+
14
+ it("splits a multi-word string on whitespace", () => {
15
+ const { elementClasses } = useStyleClassPassthrough("foo bar baz");
16
+ expect(elementClasses.value).toBe("foo bar baz");
17
+ });
18
+
19
+ it("accepts an array directly", () => {
20
+ const { elementClasses } = useStyleClassPassthrough(["foo", "bar"]);
21
+ expect(elementClasses.value).toBe("foo bar");
22
+ });
23
+
24
+ it("returns empty string for empty string input", () => {
25
+ const { elementClasses } = useStyleClassPassthrough("");
26
+ expect(elementClasses.value).toBe("");
27
+ });
28
+
29
+ it("returns empty string for empty array input", () => {
30
+ const { elementClasses } = useStyleClassPassthrough([]);
31
+ expect(elementClasses.value).toBe("");
32
+ });
33
+
34
+ it("strips leading and trailing whitespace from string input", () => {
35
+ const { elementClasses } = useStyleClassPassthrough(" foo bar ");
36
+ expect(elementClasses.value).toBe("foo bar");
37
+ });
38
+ });
39
+
40
+ // ─── elementClasses ───────────────────────────────────────────────────────
41
+
42
+ describe("elementClasses", () => {
43
+ it("reflects the current class list joined by spaces", () => {
44
+ const { elementClasses } = useStyleClassPassthrough(["a", "b", "c"]);
45
+ expect(elementClasses.value).toBe("a b c");
46
+ });
47
+
48
+ it("is reactive — updates when styleClassPassthroughRef changes", async () => {
49
+ const { elementClasses, styleClassPassthroughRef } = useStyleClassPassthrough(["a"]);
50
+ styleClassPassthroughRef.value = ["a", "b"];
51
+ await nextTick();
52
+ expect(elementClasses.value).toBe("a b");
53
+ });
54
+ });
55
+
56
+ // ─── updateElementClasses ─────────────────────────────────────────────────
57
+
58
+ describe("updateElementClasses", () => {
59
+ it("adds a class that is not present (string)", () => {
60
+ const { elementClasses, updateElementClasses } = useStyleClassPassthrough("foo");
61
+ updateElementClasses("bar");
62
+ expect(elementClasses.value).toContain("bar");
63
+ });
64
+
65
+ it("removes a class that is already present (toggle off)", () => {
66
+ const { elementClasses, updateElementClasses } = useStyleClassPassthrough("foo bar");
67
+ updateElementClasses("foo");
68
+ expect(elementClasses.value).not.toContain("foo");
69
+ expect(elementClasses.value).toContain("bar");
70
+ });
71
+
72
+ it("adds multiple classes from an array", () => {
73
+ const { elementClasses, updateElementClasses } = useStyleClassPassthrough([]);
74
+ updateElementClasses(["a", "b", "c"]);
75
+ expect(elementClasses.value).toBe("a b c");
76
+ });
77
+
78
+ it("toggles each class in an array independently", () => {
79
+ const { elementClasses, updateElementClasses } = useStyleClassPassthrough(["a", "b"]);
80
+ // "a" is present (will be removed), "c" is absent (will be added)
81
+ updateElementClasses(["a", "c"]);
82
+ expect(elementClasses.value).not.toContain("a");
83
+ expect(elementClasses.value).toContain("b");
84
+ expect(elementClasses.value).toContain("c");
85
+ });
86
+
87
+ it("calling twice with the same class returns to original state", () => {
88
+ const { elementClasses, updateElementClasses } = useStyleClassPassthrough("foo");
89
+ updateElementClasses("bar");
90
+ updateElementClasses("bar");
91
+ expect(elementClasses.value).toBe("foo");
92
+ });
93
+ });
94
+
95
+ // ─── resetElementClasses ──────────────────────────────────────────────────
96
+
97
+ describe("resetElementClasses", () => {
98
+ it("resets to a new string value", () => {
99
+ const { elementClasses, updateElementClasses, resetElementClasses } =
100
+ useStyleClassPassthrough("foo");
101
+ updateElementClasses("bar");
102
+ resetElementClasses("baz");
103
+ expect(elementClasses.value).toBe("baz");
104
+ });
105
+
106
+ it("resets to a new array value", () => {
107
+ const { elementClasses, updateElementClasses, resetElementClasses } =
108
+ useStyleClassPassthrough("foo");
109
+ updateElementClasses("extra");
110
+ resetElementClasses(["x", "y"]);
111
+ expect(elementClasses.value).toBe("x y");
112
+ });
113
+
114
+ it("clears all classes when reset with empty string", () => {
115
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough("foo bar");
116
+ resetElementClasses("");
117
+ expect(elementClasses.value).toBe("");
118
+ });
119
+
120
+ it("discards any classes added via updateElementClasses", () => {
121
+ const { elementClasses, updateElementClasses, resetElementClasses } =
122
+ useStyleClassPassthrough("original");
123
+ updateElementClasses("added");
124
+ resetElementClasses("original");
125
+ expect(elementClasses.value).toBe("original");
126
+ expect(elementClasses.value).not.toContain("added");
127
+ });
128
+ });
129
+ });
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi, type MockInstance } from "vitest";
2
+ import { mockNuxtImport } from "@nuxt/test-utils/runtime";
3
+ import { useWhatsApp } from "../useWhatsApp";
4
+
5
+ const PHONE_NUMBER = "447700900000";
6
+
7
+ // mockNuxtImport intercepts Nuxt's auto-import resolution — vi.stubGlobal cannot.
8
+ // Use vi.hoisted so the mock function is available before module evaluation.
9
+ const { useRuntimeConfigMock } = vi.hoisted(() => ({
10
+ useRuntimeConfigMock: vi.fn(() => ({ public: { whatsappNumber: PHONE_NUMBER } })),
11
+ }));
12
+
13
+ mockNuxtImport("useRuntimeConfig", () => useRuntimeConfigMock);
14
+
15
+ describe("useWhatsApp", () => {
16
+ let windowOpenSpy: MockInstance<typeof window.open>;
17
+
18
+ beforeEach(() => {
19
+ windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
20
+ useRuntimeConfigMock.mockReturnValue({ public: { whatsappNumber: PHONE_NUMBER } });
21
+ });
22
+
23
+ afterEach(() => {
24
+ vi.restoreAllMocks();
25
+ });
26
+
27
+ // ─── openWhatsApp ─────────────────────────────────────────────────────────
28
+
29
+ describe("openWhatsApp", () => {
30
+ it("opens a wa.me URL with the configured number", () => {
31
+ const { openWhatsApp } = useWhatsApp();
32
+ openWhatsApp([{ label: "Name", value: "Jane" }]);
33
+ expect(windowOpenSpy).toHaveBeenCalledOnce();
34
+ const url = windowOpenSpy.mock.calls[0]![0] as string;
35
+ expect(url).toContain(`https://wa.me/${PHONE_NUMBER}`);
36
+ });
37
+
38
+ it("opens with _blank target and noopener,noreferrer", () => {
39
+ const { openWhatsApp } = useWhatsApp();
40
+ openWhatsApp([{ label: "Name", value: "Jane" }]);
41
+ expect(windowOpenSpy).toHaveBeenCalledWith(expect.any(String), "_blank", "noopener,noreferrer");
42
+ });
43
+
44
+ it("formats fields as bold labels in the message", () => {
45
+ const { openWhatsApp } = useWhatsApp();
46
+ openWhatsApp([
47
+ { label: "Name", value: "Jane Smith" },
48
+ { label: "Phone", value: "07700900000" },
49
+ ]);
50
+ const url = windowOpenSpy.mock.calls[0]![0] as string;
51
+ const message = decodeURIComponent(url.split("?text=")[1]!);
52
+ expect(message).toBe("*Name:* Jane Smith\n*Phone:* 07700900000");
53
+ });
54
+
55
+ it("URL-encodes the message", () => {
56
+ const { openWhatsApp } = useWhatsApp();
57
+ openWhatsApp([{ label: "Name", value: "Jane Smith" }]);
58
+ const url = windowOpenSpy.mock.calls[0]![0] as string;
59
+ expect(url).toContain("?text=");
60
+ expect(url).not.toContain(" "); // spaces must be encoded
61
+ });
62
+
63
+ it("filters out fields with empty values", () => {
64
+ const { openWhatsApp } = useWhatsApp();
65
+ openWhatsApp([
66
+ { label: "Name", value: "Jane" },
67
+ { label: "Comments", value: "" },
68
+ { label: "Phone", value: "07700900000" },
69
+ ]);
70
+ const url = windowOpenSpy.mock.calls[0]![0] as string;
71
+ const message = decodeURIComponent(url.split("?text=")[1]!);
72
+ expect(message).not.toContain("Comments");
73
+ expect(message).toBe("*Name:* Jane\n*Phone:* 07700900000");
74
+ });
75
+
76
+ it("filters out fields with whitespace-only values", () => {
77
+ const { openWhatsApp } = useWhatsApp();
78
+ openWhatsApp([
79
+ { label: "Name", value: "Jane" },
80
+ { label: "Comments", value: " " },
81
+ ]);
82
+ const url = windowOpenSpy.mock.calls[0]![0] as string;
83
+ const message = decodeURIComponent(url.split("?text=")[1]!);
84
+ expect(message).toBe("*Name:* Jane");
85
+ });
86
+
87
+ it("does not call window.open when number is not configured", () => {
88
+ useRuntimeConfigMock.mockReturnValue({ public: { whatsappNumber: "" } });
89
+ const { openWhatsApp } = useWhatsApp();
90
+ openWhatsApp([{ label: "Name", value: "Jane" }]);
91
+ expect(windowOpenSpy).not.toHaveBeenCalled();
92
+ });
93
+
94
+ it("logs a warning when number is not configured", () => {
95
+ useRuntimeConfigMock.mockReturnValue({ public: { whatsappNumber: "" } });
96
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
97
+ const { openWhatsApp } = useWhatsApp();
98
+ openWhatsApp([{ label: "Name", value: "Jane" }]);
99
+ expect(warnSpy).toHaveBeenCalledWith("[useWhatsApp] whatsappNumber is not configured");
100
+ });
101
+ });
102
+ });
@@ -0,0 +1,24 @@
1
+ // composables/useWhatsApp.ts
2
+
3
+ export const useWhatsApp = () => {
4
+ const config = useRuntimeConfig();
5
+
6
+ const openWhatsApp = (fields: { label: string; value: string }[]) => {
7
+ const number = config.public.whatsappNumber;
8
+
9
+ if (!number) {
10
+ console.warn("[useWhatsApp] whatsappNumber is not configured");
11
+ return;
12
+ }
13
+
14
+ const message = fields
15
+ .filter((f) => f.value?.trim())
16
+ .map((f) => `*${f.label}:* ${f.value}`)
17
+ .join("\n");
18
+
19
+ const url = `https://wa.me/${number}?text=${encodeURIComponent(message)}`;
20
+ window.open(url, "_blank", "noopener,noreferrer");
21
+ };
22
+
23
+ return { openWhatsApp };
24
+ };
package/nuxt.config.ts CHANGED
@@ -17,6 +17,7 @@ export default defineNuxtConfig({
17
17
  contactEmailTo: "", // NUXT_CONTACT_EMAIL_TO — inbox that receives enquiries
18
18
  contactEmailFrom: "", // NUXT_CONTACT_EMAIL_FROM — must be a verified Resend domain
19
19
  public: {
20
+ whatsappNumber: "", // NUXT_PUBLIC_WHATSAPP_NUMBER — in international format, no + or spaces, e.g. 447700900000
20
21
  // Consumer apps that don't support dark/light mode can opt out entirely:
21
22
  // set NUXT_PUBLIC_COLOUR_SCHEME_ENABLED=false (env var) or override in their nuxt.config.ts
22
23
  colourScheme: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.1.14",
4
+ "version": "9.1.16",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",