Skip to content

Commit 80be348

Browse files
committed
Consent preferences button appears in a page when it is explicity defined.
1 parent 36ef2b7 commit 80be348

30 files changed

Lines changed: 195 additions & 76 deletions

File tree

‎frontend/purestack.studio/components/site/consent/consent.mdx‎

Lines changed: 82 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Consent
3-
description: "A consent banner and preference panel driven by the site’s categories, policy version and service configuration."
3+
description: "Let visitors choose which optional cookies and services your site may use, and change their preferences later."
44
template: doc
55
layout:
66
showNav: true
@@ -16,11 +16,66 @@ nav:
1616
<ComponentHeader
1717
name="Consent"
1818
kind="Privacy preferences"
19-
summary="A consent banner and preference panel driven by the site’s categories, policy version and service configuration."
19+
summary="Let visitors choose which optional cookies and services your site may use, and change their preferences later."
2020
source="https://github.com/PureStackStudio/PureStack/blob/main/packages/ts-components/src/standard/consent/consent.ts"
2121
section="Site navigation &amp; services"
2222
sectionHref="/components/site/"/>
2323

24+
Consent gives visitors control over optional cookies and services, such as
25+
analytics or marketing. Its banner offers **Accept all**, **Reject non-essential**,
26+
and **Manage preferences**. The preferences panel lets visitors choose individual
27+
categories while required categories stay enabled.
28+
29+
Consent remembers the saved choice and loads configured optional services only
30+
after permission is granted. Visitors can reopen preferences using either option
31+
above, change their choices, and save again.
32+
33+
## Open privacy preferences
34+
35+
With consent enabled in your site configuration, Consent is included
36+
automatically. Choose either of these two ways to let visitors reopen their
37+
preferences after making a choice.
38+
39+
### 1. Use your own button or link
40+
41+
Add `data-consent-action="open-panel"` to a `Btn` or `BtnLink` in your footer,
42+
navigation, or page content. Clicking it opens the privacy preferences panel.
43+
44+
For a button:
45+
46+
```html
47+
<Btn tone="neutral" variant="outline" data-consent-action="open-panel">
48+
Manage privacy settings
49+
</Btn>
50+
```
51+
52+
For a link:
53+
54+
```html
55+
<BtnLink
56+
href="/legal/privacy/"
57+
tone="neutral"
58+
variant="link"
59+
data-consent-action="open-panel"
60+
>
61+
Privacy settings
62+
</BtnLink>
63+
```
64+
65+
### 2. Use the default teleport area
66+
67+
Place this empty element where you want the default **Privacy settings** button
68+
to appear:
69+
70+
```html
71+
<span class="consent-settings-teleport-area"></span>
72+
```
73+
74+
Consent automatically places its settings button inside that element.
75+
SiteFooter already provides this area. If the area is absent, Consent does not
76+
add a settings button.
77+
78+
2479
## Make and revise a choice
2580

2681
This preview uses this site's real consent settings and shares its saved privacy choice. Open settings to review the configured categories.
@@ -124,47 +179,35 @@ Use the working example to open, save, close, and reopen this site's real privac
124179
</div>
125180
</ComponentExample>
126181

127-
## Configuration and service activation
182+
## Configuration
128183

129184
| Configuration | Visible role |
130185
| --- | --- |
131186
| enabled | Determines whether the component renders. |
132-
| storageKey, policyVersion | Keep the example separate and invalidate outdated choices. |
187+
| storageKey, policyVersion | Set where choices are saved and when visitors must make a fresh choice. |
133188
| categories | Give each category an ID, label, description, and required flag. |
134-
| services | Associate a service with its consent category; optional services wait for permission. The example uses no tracking services. |
189+
| services | Associate each optional service with the category that gives it permission to load. |
135190
| Banner, action, and settings labels | Customize the language of the banner, buttons, and preferences. |
136191
| privacyPolicyUrl, privacyPolicyLabel | Add the policy destination beside the banner copy. |
137-
| Footer settings target | The settings control teleports to .consent-settings-teleport-area. |
138-
139-
Appearance galleries show the actual banner markup with every treatment. They are static comparisons; the isolated example above runs the full consent flow.
140-
141-
## Feature map
142-
143-
| Feature | Where to explore it |
144-
| --- | --- |
145-
| Banner choices, required and optional categories, saved settings, and services | Context preview, feature comparisons, and original copyable source examples. |
146-
| Public appearance options | Complete galleries above. Only options supported by this component are shown. |
147-
| Native semantics and integration | Behavior notes and API contracts below explain the component’s roots, slots, and runtime dependencies. |
148-
149-
## Behavior and accessibility
150192

151-
Consent renders only when site.consent.enabled is true. The runtime opens the banner when no current saved choice exists, persists choices under storageKey and uses policyVersion to identify the policy. Necessary categories remain selected. Keep labels and descriptions specific to the services your site actually runs.
193+
Keep category labels and descriptions specific to the services your site uses.
152194

153195
## API reference
154196

155197
<div class="api-overview">
156198
<p class="text-eyebrow">Consent contract</p>
157199
<p>
158-
The three public props style the component. Banner copy, categories,
159-
storage, policy version and services come from site.consent, not markup
160-
props.
200+
Appearance props style the component; settingsTeleport places its settings
201+
button. Banner copy, categories, storage, policy version and services come
202+
from site.consent, not markup props.
161203
</p>
162204
</div>
163205

164206
<nav class="api-index" aria-label="Consent properties">
165207
<a href="#consent-prop-tone">tone</a>
166208
<a href="#consent-prop-variant">variant</a>
167209
<a href="#consent-prop-variantMode">variantMode</a>
210+
<a href="#consent-prop-settingsTeleport">settingsTeleport</a>
168211
</nav>
169212

170213
<ApiProperty
@@ -204,28 +247,30 @@ Consent renders only when site.consent.enabled is true. The runtime opens the ba
204247
</p>
205248
</ApiProperty>
206249

250+
<ApiProperty
251+
anchor="consent-prop-settingsTeleport"
252+
name="settingsTeleport"
253+
valueType="string"
254+
defaultValue=".consent-settings-teleport-area"
255+
>
256+
<p>
257+
CSS selector for the built-in settings button's destination. Provide the
258+
target before Consent renders. An empty selector or a missing target omits
259+
the button; the banner and preference panel remain available.
260+
</p>
261+
</ApiProperty>
262+
207263
### Composition
208264

209265
<div class="api-contract">
210266
<p>
211-
No public slots. The settings button teleports to
212-
.consent-settings-teleport-area, provided by SiteFooter or by your own
213-
template. Register defineConsentComponents with Panel, Flex, Btn, BtnLink
214-
and FormCheck. Include the standard consent runtime with the same resolved
215-
configuration.
267+
Consent is included automatically when enabled. Use a Btn or BtnLink with
268+
data-consent-action="open-panel" to open preferences, or provide
269+
.consent-settings-teleport-area for the default settings button. No public
270+
slots.
216271
</p>
217272
</div>
218273

219-
### Configuration responsibilities
220-
221-
- `enabled` controls whether the component exists.
222-
- `storageKey` isolates saved choices; `policyVersion` identifies the current policy.
223-
- `categories` define IDs, visible labels, descriptions and required state.
224-
- `services` describe the services controlled by those categories.
225-
- Banner, button and policy-link copy belong in the consent configuration.
226-
227-
The preview source shows the complete scoped override used by the working example.
228-
229274
## Related components
230275

231276
- [SiteFooter](/components/site/site-footer/)

‎frontend/purestack.studio/components/site/consent/preview.mdx‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,17 @@ title: consent preview
88
title="Privacy settings example"
99
titleTag="h2"
1010
subtitle="Make a choice, then reopen settings to change it."/>
11-
<div class="consent-settings-teleport-area"></div>
12-
<Consent/>
13-
<Btn variant="outline" class="mt-3" data-consent-open
14-
>Manage privacy settings</Btn>
11+
<Consent settingsTeleport=""/>
12+
<Flex wrap="true" class="mt-3">
13+
<Btn tone="neutral" variant="outline" data-consent-action="open-panel">
14+
Manage privacy settings
15+
</Btn>
16+
<BtnLink
17+
href="/legal/privacy/"
18+
tone="neutral"
19+
variant="link"
20+
data-consent-action="open-panel"
21+
>
22+
Privacy settings
23+
</BtnLink>
24+
</Flex>

‎frontend/purestack.studio/components/site/page-toc/_playground.mdx‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,3 @@
33
class="d-block min-w-0"
44
data-toc-playground
55
src="./app.ts"/>
6-
<noscript
7-
>Enable JavaScript to try client refresh. The rest of this guide works without
8-
it.</noscript>
9-
<span class="consent-settings-teleport-area hidden"></span>

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"private": true,
33
"name": "purestack-repo",
4-
"version": "1.1.27",
4+
"version": "1.1.28",
55
"packageManager": "yarn@4.18.1",
66
"type": "module",
77
"sideEffects": false,

‎packages/purestack/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "purestack",
3-
"version": "1.1.27",
3+
"version": "1.1.28",
44
"packageManager": "yarn@4.9.2",
55
"module": "./dist/purestack.mjs",
66
"types": "./dist/purestack.d.mts",

‎packages/purestack/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
export const version: string = '1.1.27'
1+
export const version: string = '1.1.28'
22

33
export * from '@purestack/ts-ssg'

‎packages/ts-common/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@purestack/ts-common",
3-
"version": "1.1.27",
3+
"version": "1.1.28",
44
"packageManager": "yarn@4.9.2",
55
"module": "./dist/ts-common.mjs",
66
"types": "./dist/ts-common.d.mts",

‎packages/ts-components/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@purestack/ts-components",
3-
"version": "1.1.27",
3+
"version": "1.1.28",
44
"packageManager": "yarn@4.9.2",
55
"module": "./dist/ts-components.mjs",
66
"types": "./dist/ts-components.d.mts",

‎packages/ts-components/src/standard/consent/consent.test.ts‎

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ensureDomGlobals } from '@purestack/ts-minidom'
22
import { renderApp } from '@purestack/ts-render'
3-
import { describe, expect, it } from 'vitest'
3+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
44
import { createTestContext } from '../../test/testContext'
55
import { defineButtonComponents } from '../btn/btn'
66
import { defineFlexComponents } from '../flex/flex'
@@ -9,8 +9,13 @@ import { definePanelComponents } from '../panel/panel'
99
import { defineConsentComponents } from './consent'
1010

1111
describe('Consent component rendering', () => {
12+
let cleanup: () => void
13+
beforeEach(() => {
14+
cleanup = ensureDomGlobals()
15+
})
16+
afterEach(() => cleanup())
17+
1218
it('renders consent shell and categories when enabled', async () => {
13-
const cleanup = ensureDomGlobals()
1419
const components = {
1520
...defineButtonComponents(),
1621
...defineFlexComponents(),
@@ -39,14 +44,49 @@ describe('Consent component rendering', () => {
3944
},
4045
}),
4146
})
42-
cleanup()
4347

4448
expect(html).toContain('data-consent-root')
4549
expect(html).toContain('data-consent-banner')
4650
expect(html).toContain('data-consent-panel')
47-
expect(html).toContain('data-consent-settings')
51+
expect(html).not.toContain('data-consent-settings')
4852
expect(html).toContain('data-consent-category-id="necessary"')
4953
expect(html).toContain('data-consent-category-id="analytics"')
5054
expect(html).toContain('Reject non-essential')
5155
})
56+
57+
it.each([
58+
['<Consent />', '.consent-settings-teleport-area', true],
59+
['<Consent settingsTeleport="#privacy" />', '#privacy', true],
60+
['<Consent settingsTeleport="#missing" />', '#privacy', false],
61+
[
62+
'<Consent settingsTeleport="" />',
63+
'.consent-settings-teleport-area',
64+
false,
65+
],
66+
])(
67+
'renders settings only at an available target: %s',
68+
async (markup, target, expected) => {
69+
const html = await renderApp(
70+
`<div ${target.startsWith('#') ? `id="${target.slice(1)}"` : `class="${target.slice(1)}"`}></div>${markup}`,
71+
{
72+
components: {
73+
...defineButtonComponents(),
74+
...defineFlexComponents(),
75+
...defineFormComponents(),
76+
...definePanelComponents(),
77+
...defineConsentComponents(),
78+
},
79+
context: createTestContext({ site: { consent: { enabled: true } } }),
80+
onRendered(document) {
81+
const button = document.querySelector('[data-consent-settings]')
82+
expect(!!button).toBe(expected)
83+
if (expected) {
84+
expect(button?.parentElement).toBe(document.querySelector(target))
85+
}
86+
},
87+
},
88+
)
89+
expect(html).toContain('data-consent-panel')
90+
},
91+
)
5292
})

‎packages/ts-components/src/standard/consent/consent.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface ConsentCategoryState extends ConsentCategory {
1616
}
1717

1818
export interface Consent extends Partial<ConsentConfig> {
19+
/** Settings button target selector. Empty or unmatched selectors omit the button. */
1920
settingsTeleport?: string
2021
categories: ConsentCategoryState[]
2122
tone?: RefOrValue<SemanticTone>
@@ -130,6 +131,7 @@ const consentTemplate = html`<section class="consent" data-consent-root r-if="en
130131
</section>
131132
132133
<Btn
134+
r-if="settingsTeleport && hasSettingsTarget()"
133135
tone="ghost"
134136
size="sm"
135137
data-consent-settings
@@ -143,16 +145,18 @@ const consentTemplate = html`<section class="consent" data-consent-root r-if="en
143145

144146
function defineConsentComponent() {
145147
return defineComponent<Consent>(consentTemplate, {
146-
props: ['tone', 'variant', 'variantMode'],
148+
props: ['tone', 'variant', 'variantMode', 'settingsTeleport'],
147149
context: (head) => resolveConsent(resolvePageContext(head), head.props),
148150
})
149151
}
150152

151153
function resolveConsent(
152154
context: PageContext,
153155
props: Partial<Consent>,
154-
): Consent {
156+
): Consent & { hasSettingsTarget: () => boolean } {
155157
const consent = context.site.consent
158+
const settingsTeleport =
159+
props.settingsTeleport ?? '.consent-settings-teleport-area'
156160
const categories = (consent?.categories ?? []).map((category) => ({
157161
id: category.id,
158162
label: category.label,
@@ -165,7 +169,8 @@ function resolveConsent(
165169
tone: props.tone,
166170
variant: props.variant,
167171
variantMode: props.variantMode,
168-
settingsTeleport: '.consent-settings-teleport-area',
172+
settingsTeleport,
173+
hasSettingsTarget: () => !!document.querySelector(settingsTeleport),
169174
categories,
170175
}
171176
}

0 commit comments

Comments
 (0)