@owlmeans/web-consent 0.1.18-rc.43 → 0.1.18-rc.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +225 -1
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/web-consent/SKILL.md +79 -21
  4. package/build/consent/bar.d.ts +22 -0
  5. package/build/consent/bar.d.ts.map +1 -0
  6. package/build/consent/bar.js +37 -0
  7. package/build/consent/bar.js.map +1 -0
  8. package/build/consent/component.d.ts +11 -0
  9. package/build/consent/component.d.ts.map +1 -1
  10. package/build/consent/component.js +25 -31
  11. package/build/consent/component.js.map +1 -1
  12. package/build/consent/consts.local.d.ts +14 -0
  13. package/build/consent/consts.local.d.ts.map +1 -1
  14. package/build/consent/consts.local.js +14 -0
  15. package/build/consent/consts.local.js.map +1 -1
  16. package/build/consent/focus.d.ts +13 -0
  17. package/build/consent/focus.d.ts.map +1 -0
  18. package/build/consent/focus.js +55 -0
  19. package/build/consent/focus.js.map +1 -0
  20. package/build/consent/links.d.ts +10 -0
  21. package/build/consent/links.d.ts.map +1 -0
  22. package/build/consent/links.js +13 -0
  23. package/build/consent/links.js.map +1 -0
  24. package/build/consent/locating.d.ts +10 -0
  25. package/build/consent/locating.d.ts.map +1 -0
  26. package/build/consent/locating.js +19 -0
  27. package/build/consent/locating.js.map +1 -0
  28. package/build/consent/types.local.d.ts +36 -0
  29. package/build/consent/types.local.d.ts.map +1 -0
  30. package/build/consent/types.local.js +2 -0
  31. package/build/consent/types.local.js.map +1 -0
  32. package/build/consent/window.d.ts +13 -0
  33. package/build/consent/window.d.ts.map +1 -0
  34. package/build/consent/window.js +34 -0
  35. package/build/consent/window.js.map +1 -0
  36. package/build/consts.d.ts +4 -0
  37. package/build/consts.d.ts.map +1 -0
  38. package/build/consts.js +3 -0
  39. package/build/consts.js.map +1 -0
  40. package/build/hooks.d.ts.map +1 -1
  41. package/build/hooks.js +6 -4
  42. package/build/hooks.js.map +1 -1
  43. package/build/index.d.ts +3 -2
  44. package/build/index.d.ts.map +1 -1
  45. package/build/index.js +2 -1
  46. package/build/index.js.map +1 -1
  47. package/build/policy/component.d.ts +3 -0
  48. package/build/policy/component.d.ts.map +1 -1
  49. package/build/policy/component.js +4 -1
  50. package/build/policy/component.js.map +1 -1
  51. package/build/types.d.ts +30 -3
  52. package/build/types.d.ts.map +1 -1
  53. package/package.json +3 -3
  54. package/src/consent/bar.tsx +94 -0
  55. package/src/consent/component.tsx +45 -111
  56. package/src/consent/consts.local.ts +21 -0
  57. package/src/consent/focus.ts +56 -0
  58. package/src/consent/links.tsx +22 -0
  59. package/src/consent/locating.tsx +30 -0
  60. package/src/consent/types.local.ts +38 -0
  61. package/src/consent/window.tsx +105 -0
  62. package/src/consts.ts +4 -0
  63. package/src/hooks.ts +6 -4
  64. package/src/index.ts +6 -0
  65. package/src/policy/component.tsx +7 -0
  66. package/src/types.ts +32 -3
  67. package/tests/a11y.spec.ts +22 -7
  68. package/tests/bar.spec.ts +225 -0
  69. package/tests/consent.spec.ts +31 -25
  70. package/tests/context.ts +50 -1
  71. package/tests/geo.spec.ts +265 -0
  72. package/tests/harness/mount.tsx +40 -3
  73. package/tests/ssr.spec.tsx +13 -0
package/README.md CHANGED
@@ -1,3 +1,227 @@
1
+ # @owlmeans/web-consent
2
+
3
+ The React components of OwlMeans cookie consent: the preferences dialog with its persistent re-open
4
+ button, a menu row for hosts that reopen it from their own navigation, the generated cookie-policy
5
+ page, and the `useConsent` hooks. It re-exports a named selection of `@owlmeans/consent` (the store,
6
+ storage, Consent Mode and linker helpers, constants and types), so a web app usually needs one
7
+ import. Any React surface uses it — a panel app, a generated app, an Astro island — because it
8
+ depends on no shadcn primitive, no `@` alias and no OwlMeans context. An app built on
9
+ `@owlmeans/web-panel` mounts `PanelCookieConsent` / `PanelCookiePolicy` from
10
+ `@owlmeans/web-panel/consent` instead, which binds these components to OwlMeans i18n; code that only
11
+ needs the model (no React) imports `@owlmeans/consent`; the Google tag snippet is `@owlmeans/web-gtm`.
12
+ The `web-consent` skill covers the components, the `consent` skill the model behind them.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ bun add @owlmeans/web-consent@^0.1.18-rc.44
18
+ ```
19
+
20
+ Peer dependencies the app provides: `react`, `tailwindcss`, `tailwind-merge`, `clsx`, `lucide-react`.
21
+
22
+ The components emit Tailwind classes that appear nowhere in the app's own sources, so the app's
23
+ Tailwind entry must scan the package's `src`:
24
+
25
+ ```css
26
+ @import "tailwindcss";
27
+
28
+ @source "<relative path to node_modules>/@owlmeans/web-consent/src";
29
+ ```
30
+
31
+ ## Concepts
32
+
33
+ - **Category** — a `ConsentCategory` (`essential` required, `analytics`, `marketing` by default). A
34
+ required category is disclosure, not a question: it renders locked and labelled *Required*.
35
+ - **Record** — the visitor's decision, `ConsentRecord`, stored under `site_cookie_consent` in both
36
+ localStorage and a cookie.
37
+ - **Store** — `consentStore`, a module singleton: consent belongs to the document, not to a component
38
+ tree. Both hooks subscribe to it through `useSyncExternalStore`.
39
+ - **Reason** — why the dialog is open: `'initial'` (no record), `'reopen'`, or `'login'` (raised by
40
+ the sign-in precondition, which relabels the primary action *Accept & continue*).
41
+ - **Parity** — the category set, `storageKey`, `cookieDays` and `cookieDomain` given to the dialog
42
+ must match what stamps the head snippet and what renders the policy page.
43
+ - **Linker** — optional cross-domain consent (`linker: { domains }`): a decision made on one listed
44
+ first-party domain is carried to another by a decorated link.
45
+
46
+ ## Usage
47
+
48
+ ### 1. Mount it once
49
+
50
+ At the application root or layout. It opens itself when no decision is stored and renders only the
51
+ re-open button once one is. A first-time visitor is asked with a tall bar across the bottom of a
52
+ transparent overlay — "Cookie preferences", "Accept only mandatory", "Accept all" — and every later
53
+ opening is the preferences window; `mode="window"` asks with the window straight away.
54
+
55
+ ```tsx
56
+ import { CookieConsent } from '@owlmeans/web-consent'
57
+
58
+ export const Layout = ({ children, locale }: { children: ReactNode, locale: string }) => <>
59
+ {children}
60
+ <CookieConsent
61
+ locale={locale}
62
+ policyHref="/legal/cookies"
63
+ links={[{ href: '/legal/privacy', labelKey: 'privacy', defaultLabel: 'Privacy Policy' }]}
64
+ />
65
+ </>
66
+ ```
67
+
68
+ Pass `geo={{ cloudflare: true }}` on a Cloudflare-proxied host to ask only visitors located in a
69
+ consent country (a spinner shows while they are located; anyone else is decided for automatically —
70
+ see `@owlmeans/consent`). Pass `linker={{ domains: ['owlmeans.com', 'owlmeans.pl'] }}` to share the
71
+ decision across domains; the surface then discloses the domain list. `noReopenButton` hides the floating button for an app
72
+ that offers a footer link calling `consentStore.open('reopen')`.
73
+
74
+ ### 2. Read the decision
75
+
76
+ ```tsx
77
+ import { consentStore, useConsentCategory, CONSENT_ANALYTICS } from '@owlmeans/web-consent'
78
+
79
+ export const AnalyticsChart = () => {
80
+ const allowed = useConsentCategory(CONSENT_ANALYTICS) // subscribes, does not init
81
+
82
+ return allowed ? <Chart /> : <p>Enable analytics cookies to see this chart.</p>
83
+ }
84
+
85
+ // outside React — a click handler, a service
86
+ if (consentStore.granted(CONSENT_ANALYTICS)) {
87
+ startTracking()
88
+ }
89
+ ```
90
+
91
+ `useConsent(opts?)` returns the whole model (`record`, `open`, `reason`, `granted`, `save`,
92
+ `acceptAll`, `openDialog`, `close`) but also runs `consentStore.init(opts)` on mount — use it only
93
+ where the dialog's own options are in hand.
94
+
95
+ ### 3. Reopen from a host menu
96
+
97
+ ```tsx
98
+ import { ConsentMenuWidget } from '@owlmeans/web-consent'
99
+
100
+ <nav aria-label="Settings">
101
+ <ConsentMenuWidget locale={locale} className="px-3 py-2" />
102
+ </nav>
103
+ ```
104
+
105
+ The row calls `consentStore.open('reopen')` (or your `onSelect`). It does not hide the floating
106
+ button by itself; pass `noReopenButton` to `CookieConsent` when the menu fully replaces it.
107
+
108
+ ### 4. Render the cookie policy
109
+
110
+ ```tsx
111
+ import { CookiePolicy } from '@owlmeans/web-consent'
112
+ import { googleTagHelper } from '@owlmeans/web-gtm'
113
+
114
+ <CookiePolicy
115
+ locale={locale}
116
+ operator="Example Sp. z o.o."
117
+ privacyHref="/legal/privacy"
118
+ termsHref="/legal/terms"
119
+ services={googleTag !== '' ? googleTagHelper.googleTagServices(googleTag) : undefined}
120
+ />
121
+ ```
122
+
123
+ The page lists the categories in force, the services each one gates (unmatched services go to a
124
+ trailing *Other services* item), the storage key, the retention and, with `linker`, the domains. It
125
+ ends with a *Manage preferences* button that reopens the dialog.
126
+
127
+ ### 5. Build your own dialog body
128
+
129
+ `ConsentToggle` is one accessible category row; its wording is the caller's, already resolved.
130
+
131
+ ```tsx
132
+ import { ConsentToggle } from '@owlmeans/web-consent'
133
+
134
+ <ConsentToggle
135
+ id="cc-analytics" label="Analytics" description="Helps us understand usage."
136
+ checked={draft.analytics === true} requiredLabel="Required"
137
+ onChange={value => setDraft({ ...draft, analytics: value })}
138
+ />
139
+ ```
140
+
141
+ ## API
142
+
143
+ ### Components and hooks
144
+
145
+ | Symbol | Kind | Purpose |
146
+ |---|---|---|
147
+ | `CookieConsent` | component (`CookieConsentProps`) | The consent UI — the bar, the preferences window, the locating spinner — and the floating re-open button (`[data-consent-reopen]`) |
148
+ | `CONSENT_DEFAULT_MODE`, `ConsentDisplayMode` | const, type | `'bar'`; `'bar' \| 'window'` |
149
+ | `ConsentMenuWidget` | component (`ConsentMenuWidgetProps`) | One menu row reopening the dialog; never initialises the store |
150
+ | `CookiePolicy` | component (`CookiePolicyProps`) | The generated cookie-policy page |
151
+ | `ConsentToggle` | component (`ConsentToggleProps`) | One category row with a switch; locked when `required` |
152
+ | `useConsent(opts?)` | hook → `UseConsentModel` | Full consent state and actions; **initialises the store on mount** |
153
+ | `useConsentCategory(key)` | hook → `boolean` | Whether one category is granted (false while no record exists); no init |
154
+
155
+ ### Props
156
+
157
+ | Type | Fields |
158
+ |---|---|
159
+ | `CookieConsentProps` | `locale?`, `categories?`, `translate?`, `mode?`, `geo?`, `policyHref?`, `links?: ConsentLink[]`, `storageKey?`, `cookieDays?`, `cookieDomain?`, `silent?`, `noReopenButton?`, `className?` (the window's card), `barClassName?`, `linker?` |
160
+ | `CookiePolicyProps` | `locale?`, `translate?`, `categories?`, `operator?`, `privacyHref?`, `termsHref?`, `storageKey?`, `cookieDays?`, `services?: ConsentService[]`, `className?`, `linker?`, `geo?` (adds the regional rule) |
161
+ | `ConsentMenuWidgetProps` | `locale?`, `translate?`, `label?`, `className?`, `onSelect?` |
162
+ | `ConsentToggleProps` | `id`, `label`, `description`, `checked`, `required?`, `requiredLabel`, `onChange(value)` |
163
+ | `ConsentLink` | `{ href, labelKey, defaultLabel }` |
164
+ | `UseConsentModel` | `{ record, open, reason, locating, granted(key), save(record), acceptAll(), openDialog(reason?), close() }` |
165
+
166
+ `translate` is `(key, defaultValue) => string`; without it the packaged bundle for `locale` is used
167
+ (`CONSENT_LOCALES`: en, pl, ru, be, uk, es, de, fr; anything else falls back to English).
168
+
169
+ ### Re-exported from `@owlmeans/consent`
170
+
171
+ | Group | Symbols |
172
+ |---|---|
173
+ | Store | `consentStore` |
174
+ | Helpers | `consentStorageHelper`, `consentModeHelper`, `consentI18nHelper`, `consentPluginHelper`, `consentLinkHelper`, `consentGeoHelper` |
175
+ | Geo gate | `CONSENT_REQUIRED_COUNTRIES`, `CONSENT_COUNTRIES_GDPR`, `CONSENT_COUNTRIES_ALIGNED`, `CONSENT_COUNTRIES_OPT_IN`, `CONSENT_GEO_UNKNOWN`, `CONSENT_TRACE_PATH`, `CONSENT_GEO_TIMEOUT`, `CONSENT_AUTO_MAX_AGE`, `CONSENT_IDLE_STATE`, `CONSENT_STATE_ATTRIBUTE` and the types `ConsentGeoOptions`, `ConsentGeoPlugin`, `ConsentGeoLocation`, `ConsentCloudflareOptions`, `ConsentLocating`, `ConsentGeoHelper`, `ConsentGeoVerdict`, `ConsentAutoState` |
176
+ | Defaults | `DEFAULT_CONSENT_CATEGORIES`, `DEFAULT_CONSENT_MESSAGES` |
177
+ | Constants | `CONSENT_KEY`, `CONSENT_COOKIE_DAYS`, `CONSENT_SCHEMA_VERSION`, `CONSENT_LOCALES`, `CONSENT_ESSENTIAL`, `CONSENT_ANALYTICS`, `CONSENT_MARKETING`, `CONSENT_LANGUAGE_KEY`, `CONSENT_EVENT`, `CONSENT_LINK_PARAM`, `CONSENT_LINK_MAX_AGE`, `CONSENT_LINK_SKEW` |
178
+ | Deprecated wrappers | `openConsent`, `isConsented`, `readConsent`, `writeConsent`, `clearConsent`, `consentBootstrapScript`, `defaultConsentTranslate`, `decorateConsentUrl`, `encodeConsentLink`, `consentLinkerScript` — use the helper members instead |
179
+ | Types | `ConsentCategory`, `ConsentOptions`, `ConsentReason`, `ConsentRecord`, `ConsentService`, `ConsentSignal`, `ConsentState`, `ConsentStore`, `ConsentLocale`, `ConsentLinkerOptions`, `ConsentLinkerLanguage`, `ConsentPlugin`, `ConsentLinkPayload`, and the five helper interfaces |
180
+
181
+ `makeConsentStore`, `CONSENT_SETUP_FLAG`, `CONSENT_SIGNAL_DEFAULTS` and `ConsentListener` are not
182
+ re-exported; import them from `@owlmeans/consent`.
183
+
184
+ ## Test and styling hooks
185
+
186
+ | Selector | Element |
187
+ |---|---|
188
+ | `[data-consent-dialog]` | whichever surface asks — the bar or the window (`data-consent-mode="bar\|window"`) |
189
+ | `[data-consent-bar]`, `[data-consent-overlay]` | the bar, and the transparent overlay it sits on |
190
+ | `[data-consent-preferences]`, `[data-consent-mandatory]`, `[data-consent-accept-all]` | the bar's three answers |
191
+ | `[data-consent-save]`, `[data-consent-accept-all]` | the window's two actions |
192
+ | `[data-consent-locating="first"]`, `[data-consent-spinner]` | the locating overlay and its spinner |
193
+ | `[data-consent-links]`, `[data-consent-auto]` | the legal links row; the "applied automatically" line |
194
+ | `html[data-consent]` | the phase: `locating`, `open`, `decided`, `idle` |
195
+ | `[data-consent-reopen]` | the floating re-open button |
196
+ | `[data-consent-reason]`, `[data-consent-domains]` | login-reason line, disclosed domains |
197
+ | `[data-consent-menu-widget]` | the menu row |
198
+ | `[data-cookie-policy]`, `[data-cookie-policy-category="<key>"]`, `[data-cookie-policy-services]`, `[data-cookie-policy-service]`, `[data-cookie-policy-other]`, `[data-cookie-policy-domains]`, `[data-cookie-policy-regional]`, `[data-cookie-policy-manage]` | the policy page |
199
+
200
+ ## Common pitfalls
201
+
202
+ - Pointing `@source` at `build` instead of `src`: under a linked workspace it scans zero files and
203
+ the dialog renders half-styled with no error. Verify with `grep -c 'max-w-lg' dist/assets/*.css`.
204
+ - A bare `useConsent()` in a component that only reads opens the dialog for a first-time visitor and
205
+ may fix the Consent Mode defaults from the default categories. Read with `useConsentCategory` or
206
+ `consentStore.granted`.
207
+ - `useConsent` reads `opts` only on its mounting pass; changing them later has no effect.
208
+ - Passing `translate` disables the packaged bundle entirely — a resolver must fall back to
209
+ `consentI18nHelper.defaultConsentTranslate(locale)` or every unmapped key renders in English.
210
+ - Different `categories`, `storageKey`, `cookieDays` or `cookieDomain` between the dialog, the head
211
+ snippet and the policy page make them disagree about what was asked.
212
+ - Do not add decorative classes (gradients, shadows, blur) to the dialog; the package tests reject
213
+ them, and the look comes from the host's theme tokens.
214
+ - `silent` suppresses runtime `dataLayer` and global writes only; the stamped bootstrap snippet still
215
+ pushes Consent Mode commands.
216
+
217
+ ## Related packages
218
+
219
+ - [`@owlmeans/consent`](../consent) — the model: categories, storage, store, Consent Mode, linker
220
+ - [`@owlmeans/web-gtm`](../web-gtm) — the Google tag head snippet and `googleTagServices` for the policy page
221
+ - [`@owlmeans/web-panel`](../web-panel) — `./consent` subpath binding these components to OwlMeans i18n
222
+ - [`@owlmeans/web-log`](../web-log) — consent-gated analytics plugins for `@owlmeans/log`
223
+ - [`@owlmeans/astro`](../astro) — stamping the consent scripts from a static site's layout
224
+
1
225
  <!-- owlmeans:agent-guidance:start -->
2
226
  ## Agent guidance
3
227
 
@@ -6,7 +230,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
6
230
  your project's skill store (`.agents/skills/`):
7
231
 
8
232
  ```sh
9
- npx @owlmeans/agent-skills@^0.1.18-rc.51
233
+ npx @owlmeans/agent-skills@^0.1.18-rc.52
10
234
  ```
11
235
 
12
236
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/web-consent",
4
- "version": "0.1.18-rc.43",
5
- "generatedAt": "2026-10-06T11:45:57.669Z",
4
+ "version": "0.1.18-rc.44",
5
+ "generatedAt": "2026-10-08T21:27:54.304Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: web-consent
3
- description: How to use @owlmeans/web-consent — the React cookie-consent dialog, its re-open button, the generated cookie-policy page and the useConsent hooks, plus the Tailwind @source line every consumer must add. Auto-invoked when mounting a consent dialog, rendering a cookie policy, gating a feature on a consent category, or importing CookieConsent, CookiePolicy or useConsent.
3
+ description: How to use @owlmeans/web-consent — the React cookie-consent UI (the bottom bar by default, the preferences window, the locating spinner), its re-open button, the geo gate's props, the generated cookie-policy page and the useConsent hooks, plus the Tailwind @source line every consumer must add. Auto-invoked when mounting a consent bar or dialog, rendering a cookie policy, gating a feature on a consent category, or importing CookieConsent, CookiePolicy or useConsent.
4
4
  user-invocable: false
5
5
  ---
6
6
  <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/web-consent
9
9
 
10
10
  **Layer:** Web (React)
11
- **Install:** `"@owlmeans/web-consent": "^0.1.18-rc.43"` in `dependencies`
11
+ **Install:** `"@owlmeans/web-consent": "^0.1.18-rc.44"` in `dependencies`
12
12
 
13
13
  The browser components of the consent set. The model — categories, storage, migration, the store,
14
14
  Consent Mode signalling — is `@owlmeans/consent`, and this package re-exports a **named selection**
@@ -34,20 +34,34 @@ components — see below.
34
34
 
35
35
  | Export | Description |
36
36
  |--------|-------------|
37
- | `CookieConsent` | The preferences dialog **and** the persistent re-open button |
37
+ | `CookieConsent` | The consent UI — the bar, the preferences window, the locating spinner — **and** the persistent re-open button |
38
+ | `ConsentDisplayMode` / `CONSENT_DEFAULT_MODE` | `'bar'` (the default) or `'window'` — how a first-time visitor is asked |
38
39
  | `ConsentMenuWidget` | A plain row a HOST'S OWN menu renders to reopen the dialog, in place of (or beside) `CookieConsent`'s own floating button — see below |
39
40
  | `CookiePolicy` | The cookie-policy page, generated from the configuration in force |
40
41
  | `ConsentToggle` | One category row — locked and labelled when the category is required |
41
42
  | `useConsent(opts?)` | This document's consent state and the actions over it (`UseConsentModel`) — **it also initialises the store on mount**, see below |
42
43
  | `useConsentCategory(key)` | Whether one category is granted, for a component gating a single thing |
43
44
  | `CookieConsentProps` / `CookiePolicyProps` / `ConsentLink` | The component props |
44
- | Re-exports from `@owlmeans/consent` | The complete list: `consentStore`; `consentStorageHelper` (`readConsent`, `writeConsent`, `clearConsent`, `migrateConsent`); `consentModeHelper` (`applyConsent`, `pushConsentDefaults`, `consentBootstrapScript`, `consentDefaults`, `consentUpdate`, `gtagConsent`, `trackingGranted`, `consentGateScript`); `consentI18nHelper` (`defaultConsentTranslate`, `interpolate`, `normalizeLocale`); `DEFAULT_CONSENT_CATEGORIES`, `DEFAULT_CONSENT_MESSAGES`, `CONSENT_KEY`, `CONSENT_COOKIE_DAYS`, `CONSENT_SCHEMA_VERSION`, `CONSENT_LOCALES`, `CONSENT_ESSENTIAL` / `CONSENT_ANALYTICS` / `CONSENT_MARKETING`, the plugin seam (`consentPluginHelper`: `registerConsentPlugin`, `consentPlugins`, `decorateConsentUrl`, `consentDomains`, `adoptConsent`, `startConsentPlugins`, `adoptConsentLanguage`), the linker (`consentLinkHelper`: `consentLinker`, `encodeConsentLink`, `decodeConsentLink`, `stripConsentLinkParam`, `consentLinkerScript`, `writeConsentLanguage`; `CONSENT_LANGUAGE_KEY`, `CONSENT_EVENT`, `CONSENT_LINK_PARAM`, `CONSENT_LINK_MAX_AGE`, `CONSENT_LINK_SKEW`), and the types `ConsentCategory`, `ConsentOptions`, `ConsentReason`, `ConsentRecord`, `ConsentService`, `ConsentSignal`, `ConsentState`, `ConsentStore`, `ConsentLocale`, `ConsentLinkerOptions`, `ConsentLinkerLanguage`, `ConsentPlugin`, `ConsentLinkPayload` and the five helper interfaces |
45
+ | Re-exports from `@owlmeans/consent` | The complete list: `consentStore`; `consentStorageHelper` (`readConsent`, `writeConsent`, `clearConsent`, `migrateConsent`); `consentModeHelper` (`applyConsent`, `pushConsentDefaults`, `consentBootstrapScript`, `consentDefaults`, `consentUpdate`, `gtagConsent`, `trackingGranted`, `consentGateScript`); `consentI18nHelper` (`defaultConsentTranslate`, `interpolate`, `normalizeLocale`); `DEFAULT_CONSENT_CATEGORIES`, `DEFAULT_CONSENT_MESSAGES`, `CONSENT_KEY`, `CONSENT_COOKIE_DAYS`, `CONSENT_SCHEMA_VERSION`, `CONSENT_LOCALES`, `CONSENT_ESSENTIAL` / `CONSENT_ANALYTICS` / `CONSENT_MARKETING`, the plugin seam (`consentPluginHelper`: `registerConsentPlugin`, `unregisterConsentPlugin`, `consentPlugins`, `decorateConsentUrl`, `consentDomains`, `adoptConsent`, `startConsentPlugins`, `adoptConsentLanguage`, `locateConsent`), the geo gate (`consentGeoHelper`; `CONSENT_REQUIRED_COUNTRIES`, `CONSENT_COUNTRIES_GDPR` / `_ALIGNED` / `_OPT_IN`, `CONSENT_GEO_UNKNOWN`, `CONSENT_TRACE_PATH`, `CONSENT_GEO_TIMEOUT`, `CONSENT_AUTO_MAX_AGE`, `CONSENT_IDLE_STATE`, `CONSENT_STATE_ATTRIBUTE`; the types `ConsentGeoOptions`, `ConsentGeoPlugin`, `ConsentGeoLocation`, `ConsentCloudflareOptions`, `ConsentLocating`, `ConsentGeoHelper`, `ConsentGeoVerdict`, `ConsentAutoState`), the linker (`consentLinkHelper`: `consentLinker`, `encodeConsentLink`, `decodeConsentLink`, `stripConsentLinkParam`, `consentLinkerScript`, `writeConsentLanguage`; `CONSENT_LANGUAGE_KEY`, `CONSENT_EVENT`, `CONSENT_LINK_PARAM`, `CONSENT_LINK_MAX_AGE`, `CONSENT_LINK_SKEW`), and the types `ConsentCategory`, `ConsentOptions`, `ConsentReason`, `ConsentRecord`, `ConsentService`, `ConsentSignal`, `ConsentState`, `ConsentStore`, `ConsentLocale`, `ConsentLinkerOptions`, `ConsentLinkerLanguage`, `ConsentPlugin`, `ConsentLinkPayload` and the five helper interfaces |
45
46
 
46
47
  ## Mounting the dialog
47
48
 
48
49
  `CookieConsent` is mounted **once**, at the application root or in the layout. It opens itself when
49
50
  no decision is stored, renders nothing but the re-open button once one is, and needs no state from
50
- the caller:
51
+ the caller. One surface at a time:
52
+
53
+ - **while a first-time visitor is located** (`geo`): a transparent overlay that blocks the page,
54
+ and a spinner (`[data-consent-locating="first"]`, `[data-consent-spinner]` after
55
+ `CONSENT_LOCATING_DELAY`, 300 ms) — never the bar;
56
+ - **the first ask** (`reason: 'initial'`) in `bar` mode, the default: the bar;
57
+ - **every other opening** — the bar's own "Cookie preferences" (`'preferences'`), a footer link or
58
+ the corner button (`'reopen'`), the sign-in gate (`'login'`) — and the first ask in `window`
59
+ mode: the preferences window.
60
+
61
+ `[data-consent-dialog]` (with `role="dialog"`, `aria-modal`) and `[data-consent-accept-all]` sit on
62
+ whichever surface is up, so a test or a host keys on them without knowing the mode;
63
+ `data-consent-mode="bar|window"` and `[data-consent-bar]` tell them apart. "Cookie preferences"
64
+ REPLACES the bar with the window — exactly one of each exists at a time.
51
65
 
52
66
  ```tsx
53
67
  import { CookieConsent } from '@owlmeans/web-consent'
@@ -68,6 +82,11 @@ import { CookieConsent } from '@owlmeans/web-consent'
68
82
  work — the component must not know how its host does routing.
69
83
  - `noReopenButton` hides the floating button for an app that offers a footer link instead; that link
70
84
  calls `consentStore.open('reopen')`.
85
+ - **`mode`** (`'bar'` | `'window'`, default `CONSENT_DEFAULT_MODE`) picks the first ask's surface.
86
+ - **`geo`** (`ConsentGeoOptions`, plain data — an Astro island can pass it) turns on the geo gate:
87
+ `geo={{ cloudflare: true }}` on a Cloudflare-proxied host. It is passed through to
88
+ `consentStore.init` like `linker`; the `consent` skill's "Where consent is asked" is the model. A
89
+ locator plugin is code and is registered, not passed. Pass the same `geo` to `CookiePolicy`.
71
90
  - **The re-open button is a bare icon in the very corner (`bottom-1 left-1`), not a card.** No
72
91
  filled background, no border, no shadow, no hover-scale — `bg-transparent`, dimmed
73
92
  (`opacity-70`) at rest and picked out on hover/focus — because it sits on every page of a site
@@ -87,15 +106,45 @@ import { CookieConsent } from '@owlmeans/web-consent'
87
106
  analytics and marketing toggles only; the language is strictly necessary storage that no toggle
88
107
  governs (the `consent` skill). The built-in bundle (8 languages) describes it in the required row.
89
108
 
109
+ ## The bar
110
+
111
+ A tall bar across the bottom of a **transparent** overlay (`[data-consent-overlay]`,
112
+ `fixed inset-0 bg-transparent`): the page stays in sight and scrolls, but nothing on it can be
113
+ pressed until the visitor answers. The bar (`fixed inset-x-0 bottom-0 max-h-[85vh] border-t
114
+ bg-background`) holds, left to right on a wide screen and stacked on a phone:
115
+
116
+ - the cookie icon and `barTitle`;
117
+ - `barDescription` — what the essential cookies do, the optional categories in force as a list
118
+ after a colon (`{{categories}}`, so no language has to decline a label), that they stay off until
119
+ allowed, and that the choice can be changed or withdrawn at any time; `barDescriptionEssential`
120
+ when nothing is optional;
121
+ - the cross-domain line (`[data-consent-domains]`) when `linker` names more than this host;
122
+ - the links row (`[data-consent-links]`: `policyHref` then `links`, 44px targets, new tab);
123
+ - three answers: `[data-consent-preferences]` "Cookie preferences" (outlined), then
124
+ `[data-consent-mandatory]` "Accept only mandatory" and `[data-consent-accept-all]` "Accept All" —
125
+ the two answers carry the SAME accent class: refusing is exactly as prominent as accepting, and
126
+ one click away. With no optional category there is nothing to refuse, so "mandatory" is absent.
127
+
128
+ `barClassName` styles the bar; `className` still styles the window's card.
129
+
130
+ **Focus.** On open, focus moves to the surface itself (`tabIndex={-1}`) — never to an answer, which
131
+ would be a nudge; Tab and Shift+Tab cycle inside it; on close focus returns where it was. Nothing
132
+ pulls focus back on `focusin` (a script focusing a field is not a keyboard user escaping). The
133
+ spinner overlay takes no focus.
134
+
90
135
  ## The look — flat, from the host's tokens
91
136
 
92
- The dialog is the first thing every new visitor of a generated app sees, so it follows the flat
93
- rule those apps are held to: **no gradient, shadow, glow, glass or `backdrop-filter`** anywhere in
94
- it. Everything is a theme token, so light and dark follow the host:
137
+ The bar and the window are the first thing every new visitor of a generated app sees, so they
138
+ follow the flat rule those apps are held to: **no gradient, shadow, glow, glass or
139
+ `backdrop-filter`** anywhere. Everything is a theme token, so light and dark follow the host:
95
140
 
96
141
  | Part | Classes |
97
142
  |---|---|
98
- | Overlay | `bg-black/70`, flat — no blur |
143
+ | Bar overlay, locating overlay | `bg-transparent` — the page stays in sight |
144
+ | Bar | `border-t border-border bg-background text-foreground`, no shadow |
145
+ | Bar answers | "Accept only mandatory" and "Accept All" both the accent pill; "Cookie preferences" outlined |
146
+ | Spinner | `text-muted-foreground motion-safe:animate-spin` |
147
+ | Window overlay | `bg-black/70`, flat — no blur |
99
148
  | Card | `rounded-3xl border border-border bg-background text-foreground`, no shadow or ring |
100
149
  | Icon | `rounded-full bg-muted text-primary` |
101
150
  | Category row | `rounded-2xl border border-border`; *Required* is plain uppercase muted text, never a badge |
@@ -115,19 +164,24 @@ carries `aria-labelledby="<id>-label"` (the category label) and `aria-describedb
115
164
  `<id>-required` and `<id>-desc`. Drop those and axe reports three unnamed checkboxes (`label`,
116
165
  critical). A required category stays `disabled` and keeps its name.
117
166
 
118
- `tests/a11y.spec.ts` runs axe-core (a dev dependency) against the open dialog in both schemes, the
119
- re-open button, and the policy page with services, and fails on any serious or critical
120
- violation. It loads the harness with `?styled=1` (plus `&theme=dark`), which compiles the package
167
+ `tests/a11y.spec.ts` runs axe-core (a dev dependency) against the open bar and the open window in
168
+ both schemes, the locating overlay, the re-open button, and the policy page with services and the
169
+ regional rule, and fails on any serious or critical violation. It loads the harness with `?styled=1` (plus `&theme=dark`), which compiles the package
121
170
  sources with a generated app's neutral tokens — contrast cannot be judged on the unstyled harness
122
- the other specs need.
171
+ the other specs need. The harness also takes `?mode=bar|window`, `?categories=essential`,
172
+ `?geo=<CC>|fail|html|hang` (its server answers `/cdn-cgi/trace` from the page's own query, so every
173
+ case is still chosen by URL; `&geoDelay=<ms>`, `&geoTimeout=<ms>`), `?geoPlugin=<CC>|fail` and
174
+ `?gpc=1` — `tests/bar.spec.ts` and `tests/geo.spec.ts` drive them.
123
175
  - `silent` skips every `dataLayer` and global write. It is for tests and for an app that runs no
124
176
  tags at all.
125
- - The draft is **re-seeded from storage every time the dialog opens**, not from the last render — a
126
- visitor reopening preferences must see the answer they gave.
177
+ - The window's switches are **seeded from storage every time it opens** — it is mounted only while
178
+ open — never from the last render: a visitor reopening preferences must see the answer they gave.
179
+ An automatic decision past its age seeds nothing.
127
180
 
128
- **When the dialog was raised by something waiting on it** — `reason === 'login'`, which the sign-in
181
+ **When the window was raised by something waiting on it** — `reason === 'login'`, which the sign-in
129
182
  precondition in `@owlmeans/client-iam` raises — it says so and relabels the primary action *Accept
130
- & continue*. That is what makes the interruption legible instead of looking like the page asking
183
+ & continue*. The sign-in gate always gets the window, never the bar. An automatic decision is said
184
+ to be one when the window opens over it (`[data-consent-auto]`, `autoReason`). That is what makes the interruption legible instead of looking like the page asking
131
185
  twice. Word that path as an acknowledgement, never as "you must consent to essential cookies": a
132
186
  required category is disclosure, not a question.
133
187
 
@@ -162,14 +216,15 @@ if (consentStore.granted('analytics')) { /* outside React — a click handler, a
162
216
  Both hooks subscribe through `useSyncExternalStore` over the module-singleton store, because consent
163
217
  is a property of the DOCUMENT rather than of a component tree: the dialog, the re-open button, the
164
218
  policy page and whatever an app gates on it all read one value, and local copies would disagree the
165
- moment one of them saved. During server rendering the snapshot is "no record, closed", so the dialog
166
- never flashes into static HTML before hydration corrects it.
219
+ moment one of them saved. During server rendering the snapshot is `CONSENT_IDLE_STATE` — one frozen
220
+ object, "no record, closed, not locating" — so no surface flashes into static HTML before hydration
221
+ corrects it (`tests/ssr.spec.tsx`). `UseConsentModel.locating` exposes the lookup.
167
222
 
168
223
  **`useConsent` is not a pure reader — it runs `consentStore.init(opts)` on mount.** Two consequences
169
224
  a component that only wanted to read has to plan for:
170
225
 
171
- - **It opens the dialog.** `init` reads storage, and with no stored record it publishes
172
- `open: true, reason: 'initial'`. So a `useConsent()` in a card that merely wanted `granted('analytics')`
226
+ - **It opens the consent UI.** `init` reads storage, and with no stored record it publishes
227
+ `open: true, reason: 'initial'` — or, with `geo`, starts locating. So a `useConsent()` in a card that merely wanted `granted('analytics')`
173
228
  gates the page for a first-time visitor. Read a single category with `useConsentCategory(key)` —
174
229
  that hook subscribes and does **not** init — or `consentStore.granted(key)` outside React.
175
230
  - **The first `useConsent` to mount fixes the Consent Mode defaults.** `init` calls
@@ -203,6 +258,9 @@ Everything OwlMeans cannot assert on the operator's behalf — who the controlle
203
258
  basis, how to exercise rights — is deferred to those two links. It also renders a *Manage
204
259
  preferences* control, so the policy page is a way back into the decision.
205
260
 
261
+ **`geo`**, the same object the dialog was given, adds the regional rule
262
+ (`[data-cookie-policy-regional]`, `policyRegional`) after the category list.
263
+
206
264
  **`services`** (`ConsentService[]`, see the `consent` skill) discloses WHO receives data: each
207
265
  service is listed inside the item of the category whose `key` it names — name, provider, purpose,
208
266
  cookie names in `<code>`, and a link to the provider's privacy policy (new tab). A service whose
@@ -0,0 +1,22 @@
1
+ import { type FC } from 'react';
2
+ import type { ConsentBarProps } from './types.local.js';
3
+ /**
4
+ * The first question a visitor is asked, as a tall bar across the bottom of the page.
5
+ *
6
+ * It sits on a TRANSPARENT overlay: the page stays in sight and scrolls, but nothing on it can be
7
+ * pressed until the visitor answers. The text says what the essential cookies do, names the
8
+ * optional categories in force (as a list after a colon, so no language has to decline a label),
9
+ * that they stay off until allowed, and that the choice can be changed or withdrawn — then the
10
+ * policy and the host's other links.
11
+ *
12
+ * Three answers: "Cookie preferences" replaces the bar with the preferences window, "Accept only
13
+ * mandatory" refuses every optional category, "Accept all" grants them. The two answers carry the
14
+ * same accent — refusing is exactly as prominent as accepting, and exactly one click away. With no
15
+ * optional category at all there is nothing to refuse, so "Accept only mandatory" is not offered.
16
+ *
17
+ * `[data-consent-dialog]` and `[data-consent-accept-all]` are here as on the window — one surface is
18
+ * open at a time, so a test or a host keys on them without knowing the mode; `[data-consent-bar]`
19
+ * tells the two apart.
20
+ */
21
+ export declare const ConsentBar: FC<ConsentBarProps>;
22
+ //# sourceMappingURL=bar.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bar.d.ts","sourceRoot":"","sources":["../../src/consent/bar.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,EAAE,EAAE,MAAM,OAAO,CAAA;AAOvC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAEvD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,UAAU,EAAE,EAAE,CAAC,eAAe,CAkE1C,CAAA"}
@@ -0,0 +1,37 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef } from 'react';
3
+ import { Cookie } from 'lucide-react';
4
+ import { consentI18nHelper } from '@owlmeans/consent';
5
+ import { webConsentUtils } from '../lib/utils.js';
6
+ import { ACCENT, BAR_PILL, OUTLINED } from './consts.local.js';
7
+ import { useConsentFocus } from './focus.js';
8
+ import { ConsentLinks } from './links.js';
9
+ /**
10
+ * The first question a visitor is asked, as a tall bar across the bottom of the page.
11
+ *
12
+ * It sits on a TRANSPARENT overlay: the page stays in sight and scrolls, but nothing on it can be
13
+ * pressed until the visitor answers. The text says what the essential cookies do, names the
14
+ * optional categories in force (as a list after a colon, so no language has to decline a label),
15
+ * that they stay off until allowed, and that the choice can be changed or withdrawn — then the
16
+ * policy and the host's other links.
17
+ *
18
+ * Three answers: "Cookie preferences" replaces the bar with the preferences window, "Accept only
19
+ * mandatory" refuses every optional category, "Accept all" grants them. The two answers carry the
20
+ * same accent — refusing is exactly as prominent as accepting, and exactly one click away. With no
21
+ * optional category at all there is nothing to refuse, so "Accept only mandatory" is not offered.
22
+ *
23
+ * `[data-consent-dialog]` and `[data-consent-accept-all]` are here as on the window — one surface is
24
+ * open at a time, so a test or a host keys on them without knowing the mode; `[data-consent-bar]`
25
+ * tells the two apart.
26
+ */
27
+ export const ConsentBar = props => {
28
+ const { t, categories, domains } = props;
29
+ const surface = useRef(null);
30
+ useConsentFocus(surface, true);
31
+ const optional = categories.filter(category => category.required !== true);
32
+ const description = optional.length > 0
33
+ ? consentI18nHelper.interpolate(t('barDescription', 'We use essential cookies to make this site work — for signing in, security and remembering your choices. With your consent we would also use these optional cookies: {{categories}}. They stay off until you allow them, and you can change or withdraw your choice at any time in the cookie preferences.'), { categories: optional.map(category => t(category.labelKey, category.key)).join(', ') })
34
+ : t('barDescriptionEssential', 'This site uses only essential cookies — for signing in, security and remembering your choices. They need no consent and cannot be switched off.');
35
+ return _jsx("div", { className: "fixed inset-0 z-[999998] bg-transparent", "data-consent-overlay": true, children: _jsx("div", { ref: surface, tabIndex: -1, className: webConsentUtils.cn('fixed inset-x-0 bottom-0 z-[999999] max-h-[85vh] overflow-y-auto border-t border-border bg-background text-foreground outline-none', props.barClassName), role: "dialog", "aria-modal": "true", "aria-labelledby": "cc-bar-title", "aria-describedby": domains.length > 1 ? 'cc-bar-desc cc-bar-domains' : 'cc-bar-desc', "data-consent-dialog": true, "data-consent-bar": true, "data-consent-mode": "bar", children: _jsxs("div", { className: "mx-auto flex max-w-6xl flex-col gap-6 px-4 py-6 sm:px-8 sm:py-8 lg:flex-row lg:items-end lg:gap-10", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "mb-3 flex items-center gap-3", children: [_jsx("div", { className: "flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-muted text-primary", children: _jsx(Cookie, { className: "h-5 w-5", "aria-hidden": "true" }) }), _jsx("h2", { id: "cc-bar-title", className: "text-balance text-lg font-extrabold tracking-tight text-foreground sm:text-xl", children: t('barTitle', 'Cookies on this site') })] }), _jsx("p", { id: "cc-bar-desc", className: "text-pretty text-sm leading-relaxed text-muted-foreground", children: description }), domains.length > 1 && _jsx("p", { id: "cc-bar-domains", "data-consent-domains": true, className: "mt-2 text-pretty text-xs text-muted-foreground", children: consentI18nHelper.interpolate(t('domains', 'This choice applies to {{domains}}.'), { domains: domains.join(', ') }) }), _jsx(ConsentLinks, { t: t, policyHref: props.policyHref, links: props.links, className: "mt-1" })] }), _jsxs("div", { className: "flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end lg:w-auto lg:max-w-xl lg:flex-shrink-0", children: [_jsx("button", { type: "button", onClick: props.onPreferences, "data-consent-preferences": true, className: webConsentUtils.cn(BAR_PILL, OUTLINED), children: t('openPreferences', 'Cookie preferences') }), optional.length > 0 && _jsx("button", { type: "button", onClick: props.onMandatory, "data-consent-mandatory": true, className: webConsentUtils.cn(BAR_PILL, ACCENT), children: t('acceptMandatory', 'Accept only mandatory') }), _jsx("button", { type: "button", onClick: props.onAcceptAll, "data-consent-accept-all": true, className: webConsentUtils.cn(BAR_PILL, ACCENT), children: t('acceptAll', 'Accept All') })] })] }) }) });
36
+ };
37
+ //# sourceMappingURL=bar.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bar.js","sourceRoot":"","sources":["../../src/consent/bar.tsx"],"names":[],"mappings":"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"}
@@ -1,4 +1,15 @@
1
1
  import { type FC } from 'react';
2
2
  import type { CookieConsentProps } from '../types.js';
3
+ /**
4
+ * The cookie-consent UI of a document: one of three surfaces at a time, and the corner button.
5
+ *
6
+ * - While a first-time visitor is being located (`geo`), a transparent overlay with a spinner.
7
+ * - The first ask (`reason: 'initial'`) in `bar` mode — the default — the bar.
8
+ * - Every other opening, and the first ask in `window` mode, the preferences window.
9
+ *
10
+ * Mounted once, at the application root, outside the router: a surface mounted in a route is torn
11
+ * down by the first navigation. The bar and the window share one save path, so whichever answers,
12
+ * the record carries every required category on and exactly the optional ones chosen.
13
+ */
3
14
  export declare const CookieConsent: FC<CookieConsentProps>;
4
15
  //# sourceMappingURL=component.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"component.d.ts","sourceRoot":"","sources":["../../src/consent/component.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAoC,KAAK,EAAE,EAAE,MAAM,OAAO,CAAA;AAMjE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAerD,eAAO,MAAM,aAAa,EAAE,EAAE,CAAC,kBAAkB,CA0IhD,CAAA"}
1
+ {"version":3,"file":"component.d.ts","sourceRoot":"","sources":["../../src/consent/component.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,EAAE,EAAE,MAAM,OAAO,CAAA;AAM5C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAMrD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa,EAAE,EAAE,CAAC,kBAAkB,CAsEhD,CAAA"}
@@ -1,41 +1,40 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useState } from 'react';
2
+ import { useCallback } from 'react';
3
3
  import { Cookie } from 'lucide-react';
4
- import { DEFAULT_CONSENT_CATEGORIES, consentI18nHelper, consentStorageHelper } from '@owlmeans/consent';
4
+ import { DEFAULT_CONSENT_CATEGORIES, consentI18nHelper } from '@owlmeans/consent';
5
5
  import { webConsentUtils } from '../lib/utils.js';
6
6
  import { useConsent } from '../hooks.js';
7
- import { ConsentToggle } from './toggle.js';
7
+ import { CONSENT_DEFAULT_MODE } from '../consts.js';
8
+ import { ConsentBar } from './bar.js';
9
+ import { ConsentLocatingOverlay } from './locating.js';
10
+ import { ConsentWindow } from './window.js';
8
11
  import { FOCUS } from './consts.local.js';
9
- /** A pill button: 44px tall at minimum, full width on a phone, sharing the row above that. */
10
- const PILL = webConsentUtils.cn('inline-flex min-h-11 flex-1 items-center justify-center rounded-full px-6 py-2.5 text-[15px] transition-colors motion-safe:active:scale-[0.98]', FOCUS);
11
- /** A text link: muted, underlined at rest (colour is never the only signal), 44px tall to tap. */
12
- const LINK = webConsentUtils.cn('inline-flex min-h-11 items-center font-semibold underline decoration-1 underline-offset-4 transition-colors hover:text-foreground hover:decoration-2', FOCUS);
12
+ /**
13
+ * The cookie-consent UI of a document: one of three surfaces at a time, and the corner button.
14
+ *
15
+ * - While a first-time visitor is being located (`geo`), a transparent overlay with a spinner.
16
+ * - The first ask (`reason: 'initial'`) in `bar` mode — the default — the bar.
17
+ * - Every other opening, and the first ask in `window` mode, the preferences window.
18
+ *
19
+ * Mounted once, at the application root, outside the router: a surface mounted in a route is torn
20
+ * down by the first navigation. The bar and the window share one save path, so whichever answers,
21
+ * the record carries every required category on and exactly the optional ones chosen.
22
+ */
13
23
  export const CookieConsent = props => {
14
24
  const categories = props.categories ?? DEFAULT_CONSENT_CATEGORIES;
15
25
  const t = props.translate ?? consentI18nHelper.defaultConsentTranslate(props.locale);
16
- const consentOpts = {
26
+ const mode = props.mode ?? CONSENT_DEFAULT_MODE;
27
+ const consent = useConsent({
17
28
  categories,
18
29
  ...(props.storageKey != null ? { storageKey: props.storageKey } : {}),
19
30
  ...(props.cookieDays != null ? { cookieDays: props.cookieDays } : {}),
20
31
  ...(props.cookieDomain != null ? { cookieDomain: props.cookieDomain } : {}),
21
32
  ...(props.silent != null ? { silent: props.silent } : {}),
22
33
  ...(props.linker != null ? { linker: props.linker } : {}),
23
- };
24
- const consent = useConsent(consentOpts);
34
+ ...(props.geo != null ? { geo: props.geo } : {}),
35
+ });
25
36
  const domains = webConsentUtils.disclosedDomains(props.linker);
26
37
  const optional = categories.filter(category => category.required !== true);
27
- const [draft, setDraft] = useState({});
28
- // Re-seed the draft whenever the dialog opens, from what is actually stored: a visitor who opens
29
- // preferences a second time must see the answer they gave, not the one the last render held.
30
- useEffect(() => {
31
- if (!consent.open) {
32
- return;
33
- }
34
- const stored = consent.record ?? consentStorageHelper.readConsent({
35
- ...(props.storageKey != null ? { storageKey: props.storageKey } : {}),
36
- });
37
- setDraft(Object.fromEntries(optional.map(category => [category.key, stored?.[category.key] === true])));
38
- }, [consent.open, consent.record]);
39
38
  const persist = useCallback((values) => {
40
39
  const record = Object.fromEntries([
41
40
  ...categories.filter(c => c.required === true).map(c => [c.key, true]),
@@ -43,14 +42,9 @@ export const CookieConsent = props => {
43
42
  ]);
44
43
  consent.save(record);
45
44
  }, [categories, consent]);
46
- const onSave = useCallback(() => persist(draft), [draft, persist]);
47
- const onAcceptAll = useCallback(() => persist(Object.fromEntries(optional.map(c => [c.key, true]))), [optional, persist]);
48
- // The dialog was raised by something that needs an answer before it can continue — signing in,
49
- // today. Saying so, and relabelling the primary action, is what makes the interruption make
50
- // sense rather than look like the page asking twice.
51
- const gated = consent.reason === 'login';
52
- return _jsxs(_Fragment, { children: [consent.open && _jsx("div", { className: "fixed inset-0 z-[999998] flex items-center justify-center overflow-y-auto bg-black/70 px-4 py-6", "aria-modal": "true", role: "dialog", "aria-labelledby": "cc-title", "aria-describedby": domains.length > 1 ? 'cc-desc cc-domains' : 'cc-desc', "data-consent-dialog": true, children: _jsxs("div", { className: webConsentUtils.cn('relative z-[999999] w-full max-w-lg rounded-3xl border border-border bg-background p-6 text-foreground sm:p-8', props.className), children: [_jsxs("div", { className: "mb-4 flex items-center gap-3", children: [_jsx("div", { className: "flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-muted text-primary", children: _jsx(Cookie, { className: "h-5 w-5", "aria-hidden": "true" }) }), _jsx("h2", { id: "cc-title", className: "text-balance text-xl font-extrabold tracking-tight text-foreground sm:text-2xl", children: t('title', 'Cookie Preferences') })] }), _jsx("p", { id: "cc-desc", className: "mb-6 text-pretty text-sm leading-relaxed text-muted-foreground", children: t('description', 'We use cookies to enhance your browsing experience, serve personalized ads or content, and analyze our traffic.') }), domains.length > 1 && _jsx("p", { id: "cc-domains", "data-consent-domains": true, className: "mb-6 -mt-3 text-pretty text-xs text-muted-foreground", children: consentI18nHelper.interpolate(t('domains', 'This choice applies to {{domains}}.'), { domains: domains.join(', ') }) }), _jsx("div", { className: "space-y-3", children: categories.map(category => _jsx(ConsentToggle, { id: `cc-${category.key}`, label: t(category.labelKey, category.key), description: t(category.descriptionKey, ''), checked: draft[category.key] === true, ...(category.required === true ? { required: true } : {}), requiredLabel: t('required', 'Required'), onChange: value => setDraft(current => ({ ...current, [category.key]: value })) }, category.key)) }), gated && _jsx("p", { role: "status", "data-consent-reason": true, className: "mt-4 text-sm font-semibold text-foreground", children: t('loginReason', 'Signing in stores a session cookie. Accept essential cookies to continue.') }), _jsxs("div", { className: "mt-8 flex flex-col gap-3 sm:flex-row sm:gap-4", children: [_jsx("button", { type: "button", onClick: onSave, "data-consent-save": true, className: webConsentUtils.cn(PILL, 'border-[1.5px] border-foreground bg-transparent font-semibold text-foreground hover:bg-muted'), children: t('savePreferences', 'Save Preferences') }), _jsx("button", { type: "button", onClick: onAcceptAll, "data-consent-accept-all": true, className: webConsentUtils.cn(PILL, 'bg-primary font-bold text-primary-foreground hover:bg-primary/90'), children: gated
53
- ? t('acceptAndContinue', 'Accept & continue')
54
- : t('acceptAll', 'Accept All') })] }), (props.policyHref != null || props.links != null) && _jsxs("div", { className: "mt-3 flex flex-wrap justify-center gap-x-4 text-[13px] text-muted-foreground", children: [props.policyHref != null && _jsx("a", { href: props.policyHref, target: "_blank", rel: "noopener noreferrer", className: LINK, children: t('policyLink', 'Cookie Policy') }), props.links?.map(link => _jsx("a", { href: link.href, target: "_blank", rel: "noopener noreferrer", className: LINK, children: t(link.labelKey, link.defaultLabel) }, link.href))] })] }) }), props.noReopenButton !== true && consent.record != null && !consent.open && _jsx("button", { type: "button", onClick: () => consent.openDialog('reopen'), "aria-label": t('openPreferences', 'Cookie preferences'), "data-consent-reopen": true, className: webConsentUtils.cn('fixed bottom-1 left-1 z-[999997] inline-flex h-11 w-11 items-center justify-center rounded border-0 bg-transparent p-0 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 hover:text-primary focus-visible:opacity-100', FOCUS), children: _jsx(Cookie, { className: "h-5 w-5", "aria-hidden": "true" }) })] });
45
+ const surface = consent.open
46
+ ? mode === 'bar' && consent.reason === 'initial' ? 'bar' : 'window'
47
+ : consent.locating === 'first' ? 'locating' : null;
48
+ return _jsxs(_Fragment, { children: [surface === 'locating' && _jsx(ConsentLocatingOverlay, { t: t }), surface === 'bar' && _jsx(ConsentBar, { t: t, categories: categories, domains: domains, policyHref: props.policyHref, links: props.links, barClassName: props.barClassName, onPreferences: () => consent.openDialog('preferences'), onMandatory: () => persist({}), onAcceptAll: () => persist(Object.fromEntries(optional.map(c => [c.key, true]))) }), surface === 'window' && _jsx(ConsentWindow, { t: t, categories: categories, domains: domains, record: consent.record, ...(props.storageKey != null ? { storageKey: props.storageKey } : {}), gated: consent.reason === 'login', policyHref: props.policyHref, links: props.links, className: props.className, onSave: persist }, consent.reason ?? 'window'), props.noReopenButton !== true && consent.record != null && !consent.open && consent.locating == null && _jsx("button", { type: "button", onClick: () => consent.openDialog('reopen'), "aria-label": t('openPreferences', 'Cookie preferences'), "data-consent-reopen": true, className: webConsentUtils.cn('fixed bottom-1 left-1 z-[999997] inline-flex h-11 w-11 items-center justify-center rounded border-0 bg-transparent p-0 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 hover:text-primary focus-visible:opacity-100', FOCUS), children: _jsx(Cookie, { className: "h-5 w-5", "aria-hidden": "true" }) })] });
55
49
  };
56
50
  //# sourceMappingURL=component.js.map