Compound Components
The toolkit's lowest-level UI primitives. The state machine ships with the library; the layout is yours.
Overview
Compound components are namespace objects (Consent, DSR, Breach, etc.) whose properties are small, focused sub-components that share state through React context. You assemble them like building blocks rather than configuring a single monolithic component via props.
They sit between the two ends of the toolkit's API spectrum:
- Presets — drop in a single component, get a working flow.
- Compound components — keep the toolkit's state machine, control every DOM node.
- Headless hooks — pure state, no markup at all.
Use a compound component when you want the toolkit to own the consent / DSR / DPIA lifecycle (persistence, validation, "Accept All" semantics, NDPA invariants) but you want to write the layout yourself — different copy, different ordering, different wrapping elements, your own design system.
Quickstart — the Consent compound
The Consent compound is the most complete of the bunch. It exposes a Provider, individual buttons (AcceptButton, RejectButton, SaveButton), an OptionList with per-option checkboxes, and the pre-assembled Banner, Settings, and Storage primitives.
import { Consent } from '@tantainnovative/ndpr-toolkit/consent';
import { localStorageAdapter } from '@tantainnovative/ndpr-toolkit/adapters';
import type { ConsentOption, ConsentSettings } from '@tantainnovative/ndpr-toolkit/consent';
const options: ConsentOption[] = [
{
id: 'essential',
label: 'Essential',
description: 'Required for the site to work. Cannot be disabled.',
required: true,
purpose: 'Site operation',
},
{
id: 'analytics',
label: 'Analytics',
description: 'Helps us understand how visitors use the site.',
required: false,
purpose: 'Usage analytics',
},
];
export function MyConsentBanner() {
return (
<Consent.Provider
options={options}
adapter={localStorageAdapter<ConsentSettings>('ndpr_consent')}
version="1.0"
>
<aside role="dialog" aria-labelledby="consent-title" className="my-banner">
<h2 id="consent-title">We value your privacy</h2>
<p>You can change your choices at any time.</p>
<Consent.OptionList />
<div className="my-banner__actions">
<Consent.AcceptButton>Accept all</Consent.AcceptButton>
<Consent.RejectButton>Reject all</Consent.RejectButton>
<Consent.SaveButton>Save preferences</Consent.SaveButton>
</div>
</aside>
</Consent.Provider>
);
}The Provider calls useConsent under the hood, wires the adapter, and publishes the consent state to every descendant. The sub-components below it render the parts you ask for — no more, no less.
The Consent sub-components
Consent.Provider
Establishes the consent context. Required ancestor for every other piece.
interface ConsentProviderProps {
options: ConsentOption[];
adapter?: StorageAdapter<ConsentSettings>;
version?: string;
onChange?: (settings: ConsentSettings) => void;
children: React.ReactNode;
}Each ConsentOption has id, label, description, required, and purpose. The purpose field is required by NDPA Section 25(2) — consent must be specific to each purpose.
Consent.OptionList
Renders an accessible checkbox per option, with labels and descriptions. Honours the required flag (those checkboxes render disabled and pre-checked).
<Consent.OptionList
classNames={{
root: 'space-y-3',
optionItem: 'flex items-start gap-3',
optionCheckbox: 'mt-1',
optionLabel: 'font-medium',
optionDescription: 'text-sm text-muted-foreground',
}}
/>Consent.AcceptButton, Consent.RejectButton, Consent.SaveButton
Each renders a single <button> wired to acceptAll, rejectAll, or updateConsent respectively. They all accept children (label override), className, and unstyled. SaveButton additionally accepts a consents map if you want to drive its payload from your own checkbox state.
Consent.Banner, Consent.Settings, Consent.Storage
These are re-exports of the higher-level ConsentBanner, ConsentManager, and ConsentStoragecomponents — useful when you want most of the toolkit's default UI but still wrap it in a Consent.Providerto share state with custom children. They don't consume the compound context; they manage their own state from the options you hand them.
Custom sub-components with useConsentCompound
To write your own buttons or read-only views, call useConsentCompound inside any descendant of Consent.Provider:
import { useConsentCompound } from '@tantainnovative/ndpr-toolkit/consent';
function AnalyticsToggle() {
const { hasConsent, updateConsent } = useConsentCompound();
const enabled = hasConsent('analytics');
return (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={enabled}
onChange={e => updateConsent({ analytics: e.target.checked })}
/>
Enable analytics
</label>
);
}The hook returns the full ConsentContextValue: options, settings, hasConsent, updateConsent, acceptAll, rejectAll, resetConsent, shouldShowBanner, isValid, validationErrors, and isLoading.
Calling it outside a Consent.Provider throws — keep custom components inside the same subtree.
Compound namespaces for the other modules
The other modules ship a Provider plus a slot for each existing high-level component. They are thinner than the Consent compound — useful when you want a Provider/context wrapper for state sharing and a stable naming convention, but not a per-button breakdown.
| Namespace | Import | Sub-components |
|---|---|---|
| Consent | /consent | Provider, OptionList, AcceptButton, RejectButton, SaveButton, Banner, Settings, Storage |
| DSR | /dsr | Provider, Form, Dashboard, Tracker |
| DPIA | /dpia | Provider, Questionnaire, Report, StepIndicator |
| Breach | /breach | Provider, ReportForm, RiskAssessment, NotificationManager, ReportGenerator |
| Policy | /policy | Provider, Generator, Preview, Exporter |
| LawfulBasis | /lawful-basis | Provider, Tracker |
| CrossBorder | /cross-border | Provider, Manager |
| ROPA | /ropa | Provider, Manager |
Only the Consent compound exposes a granular button/option breakdown today. For the other modules, use the compound when you want a Provider with state context, and reach for headless hooks (e.g. useDSR, useBreach) when you need to swap the whole UI.
DSR example
import { DSR } from '@tantainnovative/ndpr-toolkit/dsr';
import type { RequestType } from '@tantainnovative/ndpr-toolkit/dsr';
const requestTypes: RequestType[] = [
{
id: 'access',
name: 'Access my data',
description: 'Request a copy of your personal data.',
ndpaSection: 'Section 34(1)(a)–(b)',
estimatedCompletionTime: 30,
requiresAdditionalInfo: false,
},
];
export function DSRPage() {
return (
<DSR.Provider
requestTypes={requestTypes}
onSubmit={(req) => console.log('submitted', req.id)}
>
<DSR.Form />
<DSR.Dashboard />
</DSR.Provider>
);
}Breach example
import { Breach } from '@tantainnovative/ndpr-toolkit/breach';
import type { BreachCategory } from '@tantainnovative/ndpr-toolkit/breach';
const categories: BreachCategory[] = [
{ id: 'unauthorized_access', name: 'Unauthorized access', description: '…', defaultSeverity: 'high' },
];
export function BreachResponse() {
return (
<Breach.Provider categories={categories}>
<Breach.ReportForm />
<Breach.RiskAssessment />
<Breach.NotificationManager />
<Breach.ReportGenerator />
</Breach.Provider>
);
}Unstyled mode
Every Consent sub-component (OptionList, AcceptButton, RejectButton, SaveButton) accepts an unstyledprop that strips the toolkit's default BEM classes. Pair it with className or classNames to plug in your own design system.
<Consent.Provider options={options}>
<Consent.OptionList unstyled classNames={{ root: 'space-y-2' }} />
<Consent.AcceptButton unstyled className="btn-primary">Accept all</Consent.AcceptButton>
<Consent.RejectButton unstyled className="btn-ghost">Reject all</Consent.RejectButton>
</Consent.Provider>