srcdev-nuxt-components 9.1.15 → 9.1.17
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/settings.json +3 -1
- package/.claude/skills/composable-colour-scheme.md +66 -0
- package/.claude/skills/composable-dialog-controls.md +78 -0
- package/.claude/skills/composable-tooltips-guide.md +97 -0
- package/.claude/skills/composable-whatsapp.md +117 -0
- package/.claude/skills/composable-zod-validation.md +154 -0
- package/.claude/skills/index.md +5 -0
- package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +1 -1
- package/app/composables/tests/useApiRequest.spec.ts +74 -0
- package/app/composables/tests/useAriaDescribedById.spec.ts +134 -0
- package/app/composables/tests/useAriaLabelledById.spec.ts +73 -0
- package/app/composables/tests/useDialogControls.spec.ts +109 -0
- package/app/composables/tests/useSleep.spec.ts +33 -0
- package/app/composables/tests/useStyleClassPassthrough.spec.ts +129 -0
- package/app/composables/tests/useWhatsApp.spec.ts +102 -0
- package/app/composables/useWhatsApp.ts +24 -0
- package/nuxt.config.ts +1 -0
- package/package.json +1 -1
package/.claude/settings.json
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
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:*)",
|
|
23
|
+
"Bash(git -C /Users/simoncornforth/websites/nuxt-components log --oneline -5)"
|
|
22
24
|
],
|
|
23
25
|
"additionalDirectories": [
|
|
24
26
|
"/Users/simoncornforth/websites/nuxt-components/app/components/01.atoms/content-wrappers/content-width",
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# useColourScheme Composable
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`useColourScheme` provides reactive dark/light/auto mode switching, persisted in `localStorage` and applied via a CSS class on `<html>`. It reads an `enabled` flag from runtime config so consuming apps can disable the feature entirely.
|
|
6
|
+
|
|
7
|
+
**This composable ships inside the `srcdev-nuxt-components` layer** (`app/composables/useColourScheme.ts`). It is auto-imported via the Nuxt layer — **do not create a local copy** in the consuming app.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- `runtimeConfig.public.colourScheme.enabled` set in the consuming app's `nuxt.config.ts` (see below)
|
|
12
|
+
- If disabled, the `colour-scheme-disable.md` skill covers the one-line CSS override needed to lock the theme
|
|
13
|
+
|
|
14
|
+
## Runtime config
|
|
15
|
+
|
|
16
|
+
The composable reads `config.public.colourScheme.enabled` at runtime. Add this to the consuming app's `nuxt.config.ts`:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// nuxt.config.ts
|
|
20
|
+
runtimeConfig: {
|
|
21
|
+
public: {
|
|
22
|
+
colourScheme: {
|
|
23
|
+
enabled: true, // set false to disable switching (see colour-scheme-disable.md)
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
When `enabled` is `false`, `useColourScheme()` returns `{ currentColourScheme }` but the watcher and `localStorage` logic are skipped — `currentColourScheme` stays at `"auto"` and the composable is inert.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
const { currentColourScheme } = useColourScheme()
|
|
35
|
+
// currentColourScheme is a Ref<"light" | "dark" | "auto">
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
To let the user toggle the scheme, bind `currentColourScheme` to a control:
|
|
39
|
+
|
|
40
|
+
```vue
|
|
41
|
+
<select v-model="currentColourScheme">
|
|
42
|
+
<option value="auto">Auto</option>
|
|
43
|
+
<option value="light">Light</option>
|
|
44
|
+
<option value="dark">Dark</option>
|
|
45
|
+
</select>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Setting `currentColourScheme.value` triggers the watcher, which writes to `localStorage` and calls `applyColourScheme()` to update the `<html>` class immediately.
|
|
49
|
+
|
|
50
|
+
## How scheme application works
|
|
51
|
+
|
|
52
|
+
The layer ships a head script (`utils/colour-scheme-init.ts`) that runs before paint to read `localStorage` and apply the correct class to `<html>`, preventing flash of wrong theme. `useColourScheme` then syncs its reactive state to match on `onMounted`.
|
|
53
|
+
|
|
54
|
+
The three valid values are:
|
|
55
|
+
|
|
56
|
+
| Value | Behaviour |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `"auto"` | Follows `prefers-color-scheme` media query |
|
|
59
|
+
| `"light"` | Forces light theme regardless of OS setting |
|
|
60
|
+
| `"dark"` | Forces dark theme regardless of OS setting |
|
|
61
|
+
|
|
62
|
+
## Notes
|
|
63
|
+
|
|
64
|
+
- `onMounted` is used to read `localStorage` — `currentColourScheme` is always `"auto"` during SSR and hydrates client-side. Do not read it server-side.
|
|
65
|
+
- To disable scheme switching completely in a consuming app, set `enabled: false` in runtime config **and** follow `colour-scheme-disable.md` to lock the CSS to a single theme.
|
|
66
|
+
- The composable name exported from the layer is `useColourScheme` (named export).
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# useDialogControls Composable
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`useDialogControls` manages open/closed state for one or more named dialogs (modals, confirmation panels, etc.) with optional confirm/cancel callbacks. It is the standard pattern for dialog orchestration in consuming apps.
|
|
6
|
+
|
|
7
|
+
**This composable ships inside the `srcdev-nuxt-components` layer** (`app/composables/useDialogControls.ts`). It is auto-imported via the Nuxt layer — **do not create a local copy** in the consuming app.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
### 1. Initialise named dialogs
|
|
12
|
+
|
|
13
|
+
Call `initialiseDialogs` with an array of string IDs — one per dialog you need to control:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
const { dialogsConfig, controlDialogs, initialiseDialogs, registerDialogCallbacks } = useDialogControls()
|
|
17
|
+
|
|
18
|
+
initialiseDialogs(["confirmDelete", "editProfile"])
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Each ID gets a reactive boolean in `dialogsConfig` (initially `false` = closed).
|
|
22
|
+
|
|
23
|
+
### 2. Bind to a dialog component
|
|
24
|
+
|
|
25
|
+
Use `dialogsConfig[id]` as the `v-model` or `:open` prop on your dialog:
|
|
26
|
+
|
|
27
|
+
```vue
|
|
28
|
+
<ExpandingPanel v-model="dialogsConfig.confirmDelete">
|
|
29
|
+
<template #summary>Confirm delete</template>
|
|
30
|
+
<template #content>
|
|
31
|
+
<p>Are you sure?</p>
|
|
32
|
+
<button @click="controlDialogs('confirmDelete', false, 'confirm')">Yes, delete</button>
|
|
33
|
+
<button @click="controlDialogs('confirmDelete', false, 'cancel')">Cancel</button>
|
|
34
|
+
</template>
|
|
35
|
+
</ExpandingPanel>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### 3. Open and close dialogs
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// Open
|
|
42
|
+
controlDialogs("confirmDelete", true)
|
|
43
|
+
|
|
44
|
+
// Close without action
|
|
45
|
+
controlDialogs("confirmDelete", false)
|
|
46
|
+
|
|
47
|
+
// Close and fire a callback
|
|
48
|
+
controlDialogs("confirmDelete", false, "confirm") // fires onConfirm if registered
|
|
49
|
+
controlDialogs("confirmDelete", false, "cancel") // fires onCancel if registered
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 4. Register callbacks (optional)
|
|
53
|
+
|
|
54
|
+
Register confirm/cancel callbacks before the dialog is opened:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
registerDialogCallbacks("confirmDelete", {
|
|
58
|
+
onConfirm: () => deleteItem(),
|
|
59
|
+
onCancel: () => console.log("Cancelled"),
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Callbacks fire when `controlDialogs` is called with a matching action string.
|
|
64
|
+
|
|
65
|
+
## API reference
|
|
66
|
+
|
|
67
|
+
| Return value | Description |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `dialogsConfig` | Reactive object — `{ [id]: boolean }`. Bind to dialog `v-model` or `:open`. |
|
|
70
|
+
| `initialiseDialogs(ids)` | Seeds `dialogsConfig` with `false` for each ID. Call once in `<script setup>`. |
|
|
71
|
+
| `controlDialogs(name, state, action?)` | Set `dialogsConfig[name]` to `state`. If `state` is `false` and `action` is provided, fires the registered callback before closing. |
|
|
72
|
+
| `registerDialogCallbacks(id, { onConfirm?, onCancel? })` | Register lifecycle callbacks for a dialog ID. |
|
|
73
|
+
|
|
74
|
+
## Notes
|
|
75
|
+
|
|
76
|
+
- `useDialogControls` is scoped to the component instance — each component that calls it gets its own `dialogsConfig`. It is not a global store; do not expect state to persist across components.
|
|
77
|
+
- For a single dialog, `initialiseDialogs(["myDialog"])` is still required — omitting it means `dialogsConfig.myDialog` is `undefined` and reactivity won't work.
|
|
78
|
+
- `controlDialogs` checks for the callback before setting state, so the callback always runs before the dialog closes.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# useTooltipsGuide Composable
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`useTooltipsGuide` runs a sequential popover guide — it finds all `[popover]` elements inside a container, shows them one by one, and waits for the user to dismiss each before advancing. Useful for onboarding flows and feature introductions.
|
|
6
|
+
|
|
7
|
+
**This composable ships inside the `srcdev-nuxt-components` layer** (`app/composables/useTooltips.ts`, exported as `useTooltipsGuide`). It is auto-imported via the Nuxt layer — **do not create a local copy** in the consuming app.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- Uses the native HTML Popover API — supported in all modern browsers (Chrome 114+, Firefox 125+, Safari 17+)
|
|
12
|
+
- Popovers must have `id` attributes and corresponding trigger buttons with `popovertarget` and `popovertargetaction="toggle"` attributes
|
|
13
|
+
- Close buttons inside each popover must have `popovertargetaction="hide"`
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
### 1. Mark up the popovers
|
|
18
|
+
|
|
19
|
+
Each step in the guide is a native `[popover]` element. Include a trigger button (used internally to open the popover respecting anchor positioning) and a close button:
|
|
20
|
+
|
|
21
|
+
```vue
|
|
22
|
+
<div ref="guideContainerRef">
|
|
23
|
+
<button popovertarget="step-1" popovertargetaction="toggle" style="display:none">Open step 1</button>
|
|
24
|
+
<div id="step-1" popover>
|
|
25
|
+
<p>Welcome! This is step one.</p>
|
|
26
|
+
<button popovertarget="step-1" popovertargetaction="hide">Got it</button>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<button popovertarget="step-2" popovertargetaction="toggle" style="display:none">Open step 2</button>
|
|
30
|
+
<div id="step-2" popover>
|
|
31
|
+
<p>This is step two.</p>
|
|
32
|
+
<button popovertarget="step-2" popovertargetaction="hide">Got it</button>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 2. Initialise the composable
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
const guideContainerRef = ref<HTMLElement | null>(null)
|
|
41
|
+
|
|
42
|
+
const {
|
|
43
|
+
isGuideRunning,
|
|
44
|
+
currentTooltipIndex,
|
|
45
|
+
startGuide,
|
|
46
|
+
restartGuide,
|
|
47
|
+
stopGuide,
|
|
48
|
+
hasPopovers,
|
|
49
|
+
totalPopovers,
|
|
50
|
+
} = useTooltipsGuide(guideContainerRef, {
|
|
51
|
+
autoStart: true, // default: true — starts after startDelay on mount
|
|
52
|
+
startDelay: 2000, // default: 2000ms — delay before auto-start
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Pass `guideContainerRef` to the container element via `ref="guideContainerRef"`.
|
|
57
|
+
|
|
58
|
+
### 3. Manual controls (optional)
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// Restart the guide from the beginning
|
|
62
|
+
await restartGuide()
|
|
63
|
+
|
|
64
|
+
// Stop mid-guide
|
|
65
|
+
stopGuide()
|
|
66
|
+
|
|
67
|
+
// Start manually (when autoStart: false)
|
|
68
|
+
await startGuide()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## How it works
|
|
72
|
+
|
|
73
|
+
1. On `onMounted`, waits `startDelay` ms, then calls `initializePopovers()` to collect all `[popover]` elements inside the container.
|
|
74
|
+
2. If `autoStart` is `true`, calls `startGuide()` which iterates the popovers in DOM order.
|
|
75
|
+
3. For each popover: finds the `[popovertarget][popovertargetaction="toggle"]` trigger button and clicks it to open the popover, then waits for the `[popovertargetaction="hide"]` button to be clicked before advancing.
|
|
76
|
+
4. After the last popover is dismissed, `autoRunGuide` is set to `false` and `isGuideRunning` becomes `false`.
|
|
77
|
+
|
|
78
|
+
## API reference
|
|
79
|
+
|
|
80
|
+
| Return value | Type | Description |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `isGuideRunning` | `Readonly<Ref<boolean>>` | `true` while the guide is active |
|
|
83
|
+
| `currentTooltipIndex` | `Readonly<Ref<number>>` | Zero-based index of the currently shown popover |
|
|
84
|
+
| `autoRunGuide` | `Readonly<Ref<boolean>>` | Whether auto-start is still enabled |
|
|
85
|
+
| `hasPopovers` | `ComputedRef<boolean>` | `true` if any `[popover]` elements were found |
|
|
86
|
+
| `totalPopovers` | `ComputedRef<number>` | Count of `[popover]` elements found |
|
|
87
|
+
| `startGuide()` | `async () => void` | Start from the first popover; no-op if already running |
|
|
88
|
+
| `restartGuide()` | `async () => void` | Close any open popovers and restart from the beginning |
|
|
89
|
+
| `stopGuide()` | `() => void` | Close any open popovers and stop the guide |
|
|
90
|
+
| `initializePopovers()` | `() => void` | Re-scan the container for `[popover]` elements; call if popovers are added dynamically |
|
|
91
|
+
|
|
92
|
+
## Notes
|
|
93
|
+
|
|
94
|
+
- If no trigger button is found for a popover, `togglePopover(true)` is called directly as a fallback — anchor positioning may not apply in this case.
|
|
95
|
+
- `restartGuide` is a no-op if the guide is already running (`isGuideRunning` is `true`).
|
|
96
|
+
- Popovers are collected in DOM order — control guide sequence by ordering elements in the markup.
|
|
97
|
+
- `startDelay` uses `useSleep` (also from the layer) — the delay runs on `onMounted` so it is always client-side only.
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
**This composable ships inside the `srcdev-nuxt-components` layer** (`app/composables/useWhatsApp.ts`). Consuming apps get it via Nuxt's layer auto-import — **do not create a local copy** in the consuming app.
|
|
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
|
+
## Setup in the consuming app
|
|
14
|
+
|
|
15
|
+
### 1. Runtime config
|
|
16
|
+
|
|
17
|
+
Add `whatsappNumber` to `runtimeConfig.public` in the consuming app's `nuxt.config.ts`. It must be in `public` — **not** the private root block — because `openWhatsApp` runs client-side and private keys are server-only.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// nuxt.config.ts
|
|
21
|
+
runtimeConfig: {
|
|
22
|
+
public: {
|
|
23
|
+
whatsappNumber: "", // NUXT_PUBLIC_WHATSAPP_NUMBER
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Env var name follows Nuxt convention: `NUXT_PUBLIC_` prefix + SCREAMING_SNAKE of the key path. Set `NUXT_PUBLIC_WHATSAPP_NUMBER` in `.env` locally and in your hosting provider's environment variables (e.g. Vercel) for production.
|
|
29
|
+
|
|
30
|
+
### 2. No import needed
|
|
31
|
+
|
|
32
|
+
`useWhatsApp` is auto-imported by Nuxt from the layer. Use it directly in `<script setup>` or any composable without an explicit import.
|
|
33
|
+
|
|
34
|
+
## Composable reference
|
|
35
|
+
|
|
36
|
+
Source lives at `app/composables/useWhatsApp.ts` in the layer. Shown here for reference only — do not recreate it in the consuming app.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
export const useWhatsApp = () => {
|
|
40
|
+
const config = useRuntimeConfig(); // must be inside the function, not at module scope
|
|
41
|
+
|
|
42
|
+
const openWhatsApp = (fields: { label: string; value: string }[]) => {
|
|
43
|
+
const number = config.public.whatsappNumber;
|
|
44
|
+
|
|
45
|
+
if (!number) {
|
|
46
|
+
console.warn("[useWhatsApp] whatsappNumber is not configured");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const message = fields
|
|
51
|
+
.filter((f) => f.value?.trim())
|
|
52
|
+
.map((f) => `*${f.label}:* ${f.value}`)
|
|
53
|
+
.join("\n");
|
|
54
|
+
|
|
55
|
+
const url = `https://wa.me/${number}?text=${encodeURIComponent(message)}`;
|
|
56
|
+
window.open(url, "_blank", "noopener,noreferrer"); // noopener prevents reverse tabnapping
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return { openWhatsApp };
|
|
60
|
+
};
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Key rules
|
|
64
|
+
|
|
65
|
+
- **`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.
|
|
66
|
+
- **`noopener,noreferrer`** on `window.open` — prevents the opened WhatsApp page accessing `window.opener` (reverse tabnapping).
|
|
67
|
+
- **Guard for missing number** — avoids a silent `https://wa.me/?text=...` 404.
|
|
68
|
+
|
|
69
|
+
## Usage in a form
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// 1. Destructure the composable
|
|
73
|
+
const { openWhatsApp } = useWhatsApp();
|
|
74
|
+
|
|
75
|
+
// 2. Build the payload from your form state
|
|
76
|
+
const buildWhatsAppPayload = () => [
|
|
77
|
+
{ label: "Name", value: state.fullName },
|
|
78
|
+
{ label: "Phone", value: state.telNumber },
|
|
79
|
+
{ label: "Email", value: state.emailAddress },
|
|
80
|
+
{ label: "Services", value: state.services.join(", ") },
|
|
81
|
+
{ label: "Comments", value: state.comments ?? "" },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// 3. Call on successful form submission
|
|
85
|
+
const submitForm = async () => {
|
|
86
|
+
zodFormControl.submitAttempted = true;
|
|
87
|
+
if (!(await doZodValidate(state))) {
|
|
88
|
+
scrollToFirstError();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
zodFormControl.displayLoader = true;
|
|
92
|
+
try {
|
|
93
|
+
zodFormControl.submitSuccessful = true;
|
|
94
|
+
openWhatsApp(buildWhatsAppPayload());
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.warn("Contact form submission failed", error);
|
|
97
|
+
} finally {
|
|
98
|
+
zodFormControl.displayLoader = false;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Message format
|
|
104
|
+
|
|
105
|
+
Fields with an empty/whitespace `value` are filtered out. Remaining fields render as:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
*Name:* Jane Smith
|
|
109
|
+
*Phone:* 07700 900000
|
|
110
|
+
*Services:* Cut, Colour
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Notes
|
|
114
|
+
|
|
115
|
+
- `wa.me` opens the WhatsApp desktop app if installed, otherwise falls back to web.whatsapp.com.
|
|
116
|
+
- The phone number in `public` config is visible in the client bundle — acceptable for a public-facing WhatsApp business number.
|
|
117
|
+
- This is a client-side-only operation; no server route or API key is needed.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# useZodValidation Composable
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`useZodValidation` wires a Zod schema to a form ref, providing reactive validation state, error formatting, field-level error messages, and scroll-to-error behaviour. It is the standard form validation composable in this layer — use it in any form in consuming apps.
|
|
6
|
+
|
|
7
|
+
**This composable ships inside the `srcdev-nuxt-components` layer** (`app/composables/useZodValidation.ts`). It is auto-imported via the Nuxt layer — **do not create a local copy** in the consuming app.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- `zod` installed in the consuming app (`npm i zod`)
|
|
12
|
+
- A `<form>` element accessible via a template ref
|
|
13
|
+
|
|
14
|
+
## Setup in the consuming app
|
|
15
|
+
|
|
16
|
+
### 1. Define a Zod schema
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { z } from "zod"
|
|
20
|
+
|
|
21
|
+
const formSchema = z.object({
|
|
22
|
+
fullName: z
|
|
23
|
+
.string({ error: (i) => (i.input === undefined ? "Full name is required" : "Full name must be a string") })
|
|
24
|
+
.trim()
|
|
25
|
+
.min(2, "Name is too short")
|
|
26
|
+
.max(80, "Name is too long"),
|
|
27
|
+
emailAddress: z
|
|
28
|
+
.string({ error: (i) => (i.input === undefined ? "Email is required" : "Email must be a string") })
|
|
29
|
+
.email({ error: "Invalid email address" }),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
type FormSchema = z.infer<typeof formSchema>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### 2. Create form state and a form ref
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const formRef = ref<HTMLFormElement | null>(null)
|
|
39
|
+
|
|
40
|
+
const state = reactive({
|
|
41
|
+
fullName: "",
|
|
42
|
+
emailAddress: "",
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 3. Initialise the composable
|
|
47
|
+
|
|
48
|
+
`useZodValidation` must be typed with the schema type to get typed error objects:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
type ReturnTypeUseZodValidation = ReturnType<typeof useZodValidation>
|
|
52
|
+
|
|
53
|
+
const { initZodForm, zodFormControl, zodErrorObj, doZodValidate, fieldMaxLength, scrollToFirstError } =
|
|
54
|
+
useZodValidation<typeof formSchema>(formSchema, formRef) as ReturnTypeUseZodValidation
|
|
55
|
+
|
|
56
|
+
initZodForm()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 4. Derive typed form errors
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const formErrors = computed<z.ZodFormattedError<FormSchema> | null>(() => zodErrorObj.value)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 5. Bind to the template
|
|
66
|
+
|
|
67
|
+
Pass `formRef` to the `<form>` and bind `formErrors` to each field's `error-message` and `field-has-error` props:
|
|
68
|
+
|
|
69
|
+
```vue
|
|
70
|
+
<form ref="formRef" @submit.stop.prevent="submitForm()">
|
|
71
|
+
<InputTextWithLabel
|
|
72
|
+
id="fullName"
|
|
73
|
+
v-model="state.fullName"
|
|
74
|
+
name="fullName"
|
|
75
|
+
label="Full name"
|
|
76
|
+
:error-message="formErrors?.fullName?._errors[0] ?? ''"
|
|
77
|
+
:field-has-error="Boolean(zodFormControl.submitAttempted && formErrors?.fullName)"
|
|
78
|
+
:required="true"
|
|
79
|
+
/>
|
|
80
|
+
<InputButtonCore
|
|
81
|
+
type="submit"
|
|
82
|
+
:is-pending="zodFormControl.displayLoader"
|
|
83
|
+
:readonly="zodFormControl.submitDisabled"
|
|
84
|
+
button-text="Submit"
|
|
85
|
+
@click.stop.prevent="submitForm()"
|
|
86
|
+
/>
|
|
87
|
+
</form>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 6. Submit handler
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const submitForm = async () => {
|
|
94
|
+
zodFormControl.submitAttempted = true
|
|
95
|
+
if (!(await doZodValidate(state))) {
|
|
96
|
+
scrollToFirstError()
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
zodFormControl.displayLoader = true
|
|
100
|
+
try {
|
|
101
|
+
// await $fetch("/api/contact", { method: "POST", body: state })
|
|
102
|
+
zodFormControl.submitSuccessful = true
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.warn("Form submission failed", error)
|
|
105
|
+
} finally {
|
|
106
|
+
zodFormControl.displayLoader = false
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### 7. Live validation on state change (optional)
|
|
112
|
+
|
|
113
|
+
Re-run validation on every state change so errors clear as the user types:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
watch(
|
|
117
|
+
() => state,
|
|
118
|
+
() => { doZodValidate(state) },
|
|
119
|
+
{ deep: true }
|
|
120
|
+
)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## API reference
|
|
124
|
+
|
|
125
|
+
### `useZodValidation<T>(formSchema, formRef)`
|
|
126
|
+
|
|
127
|
+
| Return value | Type | Description |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| `initZodForm()` | `() => void` | Initialises previous-value tracking — call once after setup |
|
|
130
|
+
| `zodFormControl` | `reactive` | Mutable form state (see below) |
|
|
131
|
+
| `zodErrorObj` | `Ref<ZodFormattedError \| null>` | Raw formatted error object from Zod |
|
|
132
|
+
| `doZodValidate(state)` | `async (state) => boolean` | Runs `safeParse` and updates `zodErrorObj`; returns `true` if valid |
|
|
133
|
+
| `pushCustomErrors(apiErrorResponse, state)` | `async` | Merges server-side API errors into the Zod error object |
|
|
134
|
+
| `fieldMaxLength(name)` | `(name: string) => undefined` | Currently returns `undefined` (Zod v4 internal API change — do not rely on this) |
|
|
135
|
+
| `scrollToFirstError()` | `async () => void` | Scrolls to the first `[aria-invalid=true]` element in the form |
|
|
136
|
+
| `scrollToFormHead()` | `() => void` | Scrolls to the top of the form element |
|
|
137
|
+
|
|
138
|
+
### `zodFormControl` properties
|
|
139
|
+
|
|
140
|
+
| Property | Type | Description |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `submitAttempted` | `boolean` | Set to `true` when submit is first clicked — gates error display |
|
|
143
|
+
| `displayLoader` | `boolean` | Drive `:is-pending` on the submit button |
|
|
144
|
+
| `submitDisabled` | `boolean` | Drive `:readonly` on the submit button |
|
|
145
|
+
| `submitSuccessful` | `boolean` | Set to `true` after a successful submission |
|
|
146
|
+
| `formIsValid` | `boolean` | `true` when `zodErrorObj` is null |
|
|
147
|
+
| `errorCount` | `number` | Number of invalid fields |
|
|
148
|
+
|
|
149
|
+
## Notes
|
|
150
|
+
|
|
151
|
+
- `useZodValidation` is exported as a default export from the layer, not a named export — the auto-import handles this transparently.
|
|
152
|
+
- The `as ReturnTypeUseZodValidation` cast is required because TypeScript cannot infer the generic return type through the layer auto-import.
|
|
153
|
+
- `fieldMaxLength` is currently a no-op (returns `undefined`) due to a Zod v4 internal API change — omit it from templates or pass `undefined` to `:maxlength`.
|
|
154
|
+
- For server-side validation errors (e.g. from a Resend or API call), use `pushCustomErrors` with the API error response shape `{ data: { errors: Record<string, string | string[]> } }`.
|
package/.claude/skills/index.md
CHANGED
|
@@ -36,6 +36,11 @@ 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
|
|
40
|
+
├── composable-zod-validation.md — useZodValidation: schema-driven form validation, error binding, submit flow, API error push
|
|
41
|
+
├── composable-colour-scheme.md — useColourScheme: reactive light/dark/auto switching, localStorage persistence, runtime config
|
|
42
|
+
├── composable-dialog-controls.md — useDialogControls: named dialog open/close state with confirm/cancel callbacks
|
|
43
|
+
├── composable-tooltips-guide.md — useTooltipsGuide: sequential popover guide with auto-start, dismiss-to-advance, manual controls
|
|
39
44
|
└── components/
|
|
40
45
|
├── accordian-core.md — AccordianCore indexed dynamic slots (accordian-{n}-summary/icon/content), exclusive-open grouping
|
|
41
46
|
├── eyebrow-text.md — EyebrowText props, usage patterns, styling
|
|
@@ -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: {
|