srcdev-nuxt-components 9.4.0 → 9.4.1
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/skills/components/cookie-consent-banner.md +9 -9
- package/.claude/skills/composable-cookie-consent.md +4 -4
- package/app/components/01.atoms/cookie-consent-banner/CookieConsentBanner.vue +24 -24
- package/app/components/01.atoms/cookie-consent-banner/stories/CookieConsentBanner.stories.ts +4 -4
- package/app/components/01.atoms/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +3 -3
- package/app/composables/useCookieConsent.ts +1 -1
- package/package.json +1 -1
|
@@ -68,21 +68,21 @@ useGoogleAnalytics();
|
|
|
68
68
|
|
|
69
69
|
## CSS / styling
|
|
70
70
|
|
|
71
|
-
Public tokens (all on `.
|
|
71
|
+
Public tokens (all on `.privacy-notice-banner`, `var(--privacy-notice-banner-*, fallback)` pattern):
|
|
72
72
|
|
|
73
73
|
| Token | Default |
|
|
74
74
|
|---|---|
|
|
75
|
-
| `--
|
|
76
|
-
| `--
|
|
77
|
-
| `--
|
|
78
|
-
| `--
|
|
79
|
-
| `--
|
|
80
|
-
| `--
|
|
81
|
-
| `--
|
|
75
|
+
| `--privacy-notice-banner-z-index` | `999999` |
|
|
76
|
+
| `--privacy-notice-banner-gutter` | `1.6rem` |
|
|
77
|
+
| `--privacy-notice-banner-max-width` | `64rem` |
|
|
78
|
+
| `--privacy-notice-banner-border-radius` | `0.8rem` |
|
|
79
|
+
| `--privacy-notice-banner-border` | `0.1rem solid light-dark(var(--slate-10), var(--slate-02))` |
|
|
80
|
+
| `--privacy-notice-banner-background` | `light-dark(var(--slate-00), var(--slate-10))` |
|
|
81
|
+
| `--privacy-notice-banner-transition-duration` | `200ms` |
|
|
82
82
|
|
|
83
83
|
## Notes
|
|
84
84
|
|
|
85
|
-
- **Teleported to `<body>`** — like `DisplayToastProvider`, query it in tests via `document.querySelector(".
|
|
85
|
+
- **Teleported to `<body>`** — like `DisplayToastProvider`, query it in tests via `document.querySelector(".privacy-notice-banner")`, not `wrapper.find(...)`.
|
|
86
86
|
- **No focus trap / backdrop** — this is a dismiss-by-decision banner, not a modal. It collapses via a `grid-template-rows` transition (same mechanic as `DisplayPrompt`) once `status` leaves `"unset"`, rather than unmounting.
|
|
87
87
|
- **Only ever one instance** — `useCookieConsent()`'s underlying state is a module-scope singleton, so mounting the banner twice in one app just duplicates the UI, it doesn't create separate consent state.
|
|
88
88
|
- To let a visitor change their mind later (e.g. from a cookie-policy page), call `useCookieConsent().rejectAll()` or clear the `cookie-consent` cookie — the banner reappears since `status` returns to `"unset"` only once the cookie is gone; `rejectAll()` itself sets it to `"denied"`, which keeps the banner hidden but stops GA. Expose a dedicated "reset my choice" affordance if you want the banner itself to resurface.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
`useCookieConsent` tracks the visitor's cookie-consent decision (`unset` / `granted` / `denied`), persists it in a `
|
|
5
|
+
`useCookieConsent` tracks the visitor's cookie-consent decision (`unset` / `granted` / `denied`), persists it in a `privacy-notice-consent` cookie, and wraps `@nuxt/scripts`' `useScriptTriggerConsent()` gate so any consent-dependent script (Google Analytics via [[composable-google-analytics]], or anything else added later) can be wired to it.
|
|
6
6
|
|
|
7
7
|
**This composable ships inside the `srcdev-nuxt-components` layer** (`app/composables/useCookieConsent.ts`). Consuming apps get it via Nuxt's layer auto-import — **do not create a local copy** in the consuming app.
|
|
8
8
|
|
|
@@ -28,7 +28,7 @@ Source lives at `app/composables/useCookieConsent.ts` in the layer. Shown here f
|
|
|
28
28
|
const consentTrigger = useScriptTriggerConsent(); // module-scope singleton
|
|
29
29
|
|
|
30
30
|
export function useCookieConsent() {
|
|
31
|
-
const stored = useCookie<"granted" | "denied" | null>("
|
|
31
|
+
const stored = useCookie<"granted" | "denied" | null>("privacy-notice-consent", {
|
|
32
32
|
maxAge: 60 * 60 * 24 * 365,
|
|
33
33
|
sameSite: "lax",
|
|
34
34
|
default: () => null,
|
|
@@ -70,9 +70,9 @@ if (status.value === "unset") {
|
|
|
70
70
|
}
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
To let a user change their mind later (e.g. a "cookie preferences" link in the footer or a cookie-policy page), call `rejectAll()` or clear the `
|
|
73
|
+
To let a user change their mind later (e.g. a "cookie preferences" link in the footer or a cookie-policy page), call `rejectAll()` or clear the `privacy-notice-consent` cookie to bring the banner back.
|
|
74
74
|
|
|
75
75
|
## Notes
|
|
76
76
|
|
|
77
|
-
- Cookie name `
|
|
77
|
+
- Cookie name `privacy-notice-consent` is fixed by the layer, not configurable per app.
|
|
78
78
|
- This composable only tracks the yes/no decision — it does not itself load any script. See [[composable-google-analytics]] for the GA4 integration that consumes `trigger`.
|
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<Teleport to="body">
|
|
3
3
|
<div
|
|
4
|
-
class="
|
|
4
|
+
class="privacy-notice-banner"
|
|
5
5
|
:class="[{ closed: status !== 'unset' }, elementClasses]"
|
|
6
6
|
:data-theme="resolved.theme"
|
|
7
|
-
data-test-id="
|
|
7
|
+
data-test-id="privacy-notice-banner"
|
|
8
8
|
>
|
|
9
|
-
<div class="
|
|
10
|
-
<div class="
|
|
9
|
+
<div class="privacy-notice-banner-inner" role="region" :aria-label="ariaLabel">
|
|
10
|
+
<div class="privacy-notice-banner-message">
|
|
11
11
|
<slot name="message">This site uses cookies to understand how it's used. You can accept or reject them.</slot>
|
|
12
12
|
</div>
|
|
13
|
-
<div class="
|
|
13
|
+
<div class="privacy-notice-banner-actions">
|
|
14
14
|
<button
|
|
15
15
|
type="button"
|
|
16
|
-
class="
|
|
17
|
-
data-test-id="
|
|
16
|
+
class="privacy-notice-banner-reject"
|
|
17
|
+
data-test-id="privacy-notice-banner-reject"
|
|
18
18
|
@click="rejectAll()"
|
|
19
19
|
>
|
|
20
20
|
<slot name="rejectLabel">Reject</slot>
|
|
21
21
|
</button>
|
|
22
22
|
<button
|
|
23
23
|
type="button"
|
|
24
|
-
class="
|
|
25
|
-
data-test-id="
|
|
24
|
+
class="privacy-notice-banner-accept"
|
|
25
|
+
data-test-id="privacy-notice-banner-accept"
|
|
26
26
|
@click="acceptAll()"
|
|
27
27
|
>
|
|
28
28
|
<slot name="acceptLabel">Accept</slot>
|
|
@@ -57,17 +57,17 @@ const ariaLabel = "Cookie consent";
|
|
|
57
57
|
|
|
58
58
|
<style lang="css">
|
|
59
59
|
@layer components {
|
|
60
|
-
.
|
|
60
|
+
.privacy-notice-banner {
|
|
61
61
|
/* Matches DisplayToastProvider/DisplayDialog's z-index convention so
|
|
62
62
|
this clears ordinary page chrome (and a consumer's sticky header) but
|
|
63
63
|
still sits below an active modal dialog if one somehow overlaps. */
|
|
64
|
-
--_z-index: var(--
|
|
65
|
-
--_gutter: var(--
|
|
66
|
-
--_max-width: var(--
|
|
67
|
-
--_border-radius: var(--
|
|
68
|
-
--_border: var(--
|
|
69
|
-
--_background: var(--
|
|
70
|
-
--_transition-duration: var(--
|
|
64
|
+
--_z-index: var(--privacy-notice-banner-z-index, 999999);
|
|
65
|
+
--_gutter: var(--privacy-notice-banner-gutter, 1.6rem);
|
|
66
|
+
--_max-width: var(--privacy-notice-banner-max-width, 64rem);
|
|
67
|
+
--_border-radius: var(--privacy-notice-banner-border-radius, 0.8rem);
|
|
68
|
+
--_border: var(--privacy-notice-banner-border, 0.1rem solid light-dark(var(--slate-10), var(--slate-02)));
|
|
69
|
+
--_background: var(--privacy-notice-banner-background, light-dark(var(--slate-00), var(--slate-10)));
|
|
70
|
+
--_transition-duration: var(--privacy-notice-banner-transition-duration, 200ms);
|
|
71
71
|
|
|
72
72
|
position: fixed;
|
|
73
73
|
inset-inline: var(--_gutter);
|
|
@@ -87,7 +87,7 @@ const ariaLabel = "Cookie consent";
|
|
|
87
87
|
pointer-events: none;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
.
|
|
90
|
+
.privacy-notice-banner-inner {
|
|
91
91
|
overflow: hidden;
|
|
92
92
|
display: flex;
|
|
93
93
|
flex-wrap: wrap;
|
|
@@ -103,18 +103,18 @@ const ariaLabel = "Cookie consent";
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
.
|
|
106
|
+
.privacy-notice-banner-message {
|
|
107
107
|
flex: 1 1 24rem;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
.
|
|
110
|
+
.privacy-notice-banner-actions {
|
|
111
111
|
display: flex;
|
|
112
112
|
gap: 0.8rem;
|
|
113
113
|
margin-inline-start: auto;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
.
|
|
117
|
-
.
|
|
116
|
+
.privacy-notice-banner-reject,
|
|
117
|
+
.privacy-notice-banner-accept {
|
|
118
118
|
padding: 0.8rem 1.6rem;
|
|
119
119
|
border-radius: 0.4rem;
|
|
120
120
|
border: 0.1rem solid transparent;
|
|
@@ -124,7 +124,7 @@ const ariaLabel = "Cookie consent";
|
|
|
124
124
|
background-color var(--_transition-duration);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
.
|
|
127
|
+
.privacy-notice-banner-reject {
|
|
128
128
|
background-color: transparent;
|
|
129
129
|
border: 0.1rem solid light-dark(var(--slate-08), var(--slate-04));
|
|
130
130
|
|
|
@@ -134,7 +134,7 @@ const ariaLabel = "Cookie consent";
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
.
|
|
137
|
+
.privacy-notice-banner-accept {
|
|
138
138
|
background-color: var(--theme-accent);
|
|
139
139
|
color: light-dark(var(--slate-00), var(--slate-12));
|
|
140
140
|
|
package/app/components/01.atoms/cookie-consent-banner/stories/CookieConsentBanner.stories.ts
CHANGED
|
@@ -29,18 +29,18 @@ export default {
|
|
|
29
29
|
// reloads. Each story below has "Show banner again" (resets state
|
|
30
30
|
// in-place) and "Delete cookie" (clears document.cookie directly, for
|
|
31
31
|
// verifying the cookie itself is actually gone, e.g. in devtools).
|
|
32
|
-
component: "Reads/writes a real '
|
|
32
|
+
component: "Reads/writes a real 'privacy-notice-consent' cookie via useCookieConsent(). Use the story's reset controls to bring the banner back.",
|
|
33
33
|
},
|
|
34
34
|
},
|
|
35
35
|
},
|
|
36
36
|
} as Meta<typeof StorybookComponent>;
|
|
37
37
|
|
|
38
|
-
// Resets the same "
|
|
38
|
+
// Resets the same "privacy-notice-consent" cookie useCookieConsent() reads, so the
|
|
39
39
|
// banner's status goes back to "unset" and reappears without a page reload
|
|
40
40
|
// (Nuxt dedupes useCookie() by key, so this shares the ref CookieConsentBanner
|
|
41
41
|
// itself reads).
|
|
42
42
|
function useResetConsent() {
|
|
43
|
-
const consentCookie = useCookie<"granted" | "denied" | null>("
|
|
43
|
+
const consentCookie = useCookie<"granted" | "denied" | null>("privacy-notice-consent");
|
|
44
44
|
const showAgain = () => {
|
|
45
45
|
consentCookie.value = null;
|
|
46
46
|
};
|
|
@@ -48,7 +48,7 @@ function useResetConsent() {
|
|
|
48
48
|
// rather than just the in-memory ref, so devtools/Application tab reflects
|
|
49
49
|
// it too, not only the story's live re-render.
|
|
50
50
|
const deleteCookie = () => {
|
|
51
|
-
document.cookie = "
|
|
51
|
+
document.cookie = "privacy-notice-consent=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
|
|
52
52
|
consentCookie.value = null;
|
|
53
53
|
};
|
|
54
54
|
return { showAgain, deleteCookie };
|
|
@@ -23,15 +23,15 @@ mockNuxtImport("useCookieConsent", () => () => ({
|
|
|
23
23
|
}));
|
|
24
24
|
|
|
25
25
|
function banner() {
|
|
26
|
-
return document.querySelector(".
|
|
26
|
+
return document.querySelector(".privacy-notice-banner");
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function acceptButton() {
|
|
30
|
-
return document.querySelector("[data-test-id='
|
|
30
|
+
return document.querySelector("[data-test-id='privacy-notice-banner-accept']") as HTMLElement;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
function rejectButton() {
|
|
34
|
-
return document.querySelector("[data-test-id='
|
|
34
|
+
return document.querySelector("[data-test-id='privacy-notice-banner-reject']") as HTMLElement;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
describe("CookieConsentBanner", () => {
|
|
@@ -18,7 +18,7 @@ export function useCookieConsent() {
|
|
|
18
18
|
// Persists the decision across visits. Read fresh on every call so this
|
|
19
19
|
// stays request-safe during SSR (Nuxt dedupes useCookie() by key within a
|
|
20
20
|
// request, so this is cheap to call repeatedly).
|
|
21
|
-
const stored = useCookie<"granted" | "denied" | null>("
|
|
21
|
+
const stored = useCookie<"granted" | "denied" | null>("privacy-notice-consent", {
|
|
22
22
|
maxAge: 60 * 60 * 24 * 365,
|
|
23
23
|
sameSite: "lax",
|
|
24
24
|
default: () => null,
|