Added accessibility support

This commit is contained in:
2026-09-26 22:29:53 +02:00
parent fa2b607979
commit 827cd9bcbc
29 changed files with 1021 additions and 3 deletions
+7 -1
View File
@@ -1,6 +1,13 @@
'use client'
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'
export { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js'
export {
AccessibilityProvider,
AccessibilityWidget,
useAccessibility,
} from '../modules/accessibility/client.js'
export type { A11yClassNames, A11yState, A11yTexts } from '../modules/accessibility/client.js'
export { Analytics } from '../modules/analytics/client.js'
/**
* Entry point: ipal-kit/client
@@ -16,7 +23,6 @@ export {
useConsent,
useConsentContext,
} from '../modules/consent/client.js'
export type { CookieBannerClassNames } from '../modules/consent/client.js'
// Pure i18n path helpers — no server/RSC deps, safe to import in client
// components (e.g. a LanguageSwitcher that computes locale URLs on the client).
+8
View File
@@ -13,6 +13,14 @@ export {
requireRoleField,
ROLE_HIERARCHY,
} from './modules/access/index.js'
export {
A11Y_COOKIE,
A11Y_DEFAULT,
a11yAttributes,
parseA11y,
serializeA11y,
} from './modules/accessibility/index.js'
export type { A11yState } from './modules/accessibility/index.js'
export type { AnalyticsConfig } from './modules/analytics/index.js'
export { getAnalyticsConfig } from './modules/analytics/index.js'
export {
@@ -0,0 +1,94 @@
'use client'
import { createContext, use, useCallback, useEffect, useState } from 'react'
import {
A11Y_COOKIE,
A11Y_COOKIE_MAX_AGE,
A11Y_DEFAULT,
a11yAttributes,
type A11yState,
parseA11y,
serializeA11y,
} from './state.js'
type A11yContextValue = {
reset: () => void
set: <K extends keyof A11yState>(key: K, value: A11yState[K]) => void
state: A11yState
}
const A11yContext = createContext<A11yContextValue | null>(null)
function readCookie(name: string): string | undefined {
if (typeof document === 'undefined') {return undefined}
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
return match ? decodeURIComponent(match[1]) : undefined
}
/**
* Provides accessibility preferences, persists them in a cookie, and applies them
* as data-attributes on <html> so the project's CSS can react. Like
* ConsentProvider for cookies — wrap the app once; the widget/button consume it.
*
* The plugin ships NO styles: it only sets attributes (data-a11y-*). The project
* writes CSS for those it supports (see docs/accessibility.md). This keeps the
* design in the project's hands.
*
* // layout.tsx
* import { AccessibilityProvider } from '@intecion/ipal-kit/client'
* <AccessibilityProvider>{children}</AccessibilityProvider>
*
* To avoid a flash, the project can read the a11y-prefs cookie server-side and
* set the attributes on <html> during SSR (see docs). This provider re-applies
* on the client and keeps them in sync.
*/
export function AccessibilityProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<A11yState>(A11Y_DEFAULT)
// Hydrate from cookie on mount.
useEffect(() => {
setState(parseA11y(readCookie(A11Y_COOKIE)))
}, [])
// Apply attributes to <html> whenever state changes.
useEffect(() => {
const el = document.documentElement
const attrs = a11yAttributes(state)
for (const [attr, value] of Object.entries(attrs)) {
if (value === null) {el.removeAttribute(attr)}
else {el.setAttribute(attr, value)}
}
}, [state])
const persist = useCallback((next: A11yState) => {
document.cookie = `${A11Y_COOKIE}=${encodeURIComponent(
serializeA11y(next),
)}; path=/; max-age=${A11Y_COOKIE_MAX_AGE}; samesite=lax`
}, [])
const set = useCallback(
<K extends keyof A11yState>(key: K, value: A11yState[K]) => {
setState((prev) => {
const next = { ...prev, [key]: value }
persist(next)
return next
})
},
[persist],
)
const reset = useCallback(() => {
setState(A11Y_DEFAULT)
persist(A11Y_DEFAULT)
}, [persist])
return <A11yContext value={{ reset, set, state }}>{children}</A11yContext>
}
/** Access accessibility preferences + setters. Use inside AccessibilityProvider. */
export function useAccessibility(): A11yContextValue {
const ctx = use(A11yContext)
if (!ctx) {throw new Error('useAccessibility must be used within <AccessibilityProvider>')}
return ctx
}
@@ -0,0 +1,182 @@
'use client'
import { useState } from 'react'
import type { A11yState } from './state.js'
import { useAccessibility } from './AccessibilityProvider.js'
export type A11yTexts = {
bigCursor?: string
close?: string
contrast?: string
contrastHigh?: string
contrastInverted?: string
grayscale?: string
lineHeight?: string
open?: string
readableFont?: string
reduceMotion?: string
reset?: string
textSize?: string
title?: string
underlineLinks?: string
}
const DEFAULT_TEXTS: Required<A11yTexts> = {
bigCursor: 'Duży kursor',
close: 'Zamknij',
contrast: 'Kontrast',
contrastHigh: 'Wysoki',
contrastInverted: 'Odwrócony',
grayscale: 'Skala szarości',
lineHeight: 'Odstęp między liniami',
open: 'Otwórz panel dostępności',
readableFont: 'Czytelna czcionka',
reduceMotion: 'Wyłącz animacje',
reset: 'Resetuj',
textSize: 'Rozmiar tekstu',
title: 'Dostępność',
underlineLinks: 'Podkreśl linki',
}
export type A11yClassNames = {
active?: string
button?: string
closeButton?: string
control?: string
label?: string
panel?: string
resetButton?: string
row?: string
}
/**
* Accessibility toolbar: a floating button that opens a panel of options (text
* size, line height, contrast, grayscale, underline links, readable font, reduce
* motion, big cursor). Choices persist in a cookie and apply as data-attributes
* on <html> (the project's CSS styles them).
*
* Unstyled by default — pass classNames to match the project's design (like
* CookieBanner). Wrap the app in <AccessibilityProvider> first.
*
* <AccessibilityWidget classNames={{ button: 'a11y-btn', panel: 'a11y-panel' }} />
*/
export function AccessibilityWidget({
classNames,
texts,
}: {
classNames?: A11yClassNames
texts?: A11yTexts
}) {
const { reset, set, state } = useAccessibility()
const [open, setOpen] = useState(false)
const t = { ...DEFAULT_TEXTS, ...texts }
const cn = classNames ?? {}
const toggle = <K extends keyof A11yState>(key: K) => set(key, !state[key] as A11yState[K])
const isActive = (on: boolean) => (on ? (cn.active ?? '') : '')
return (
<>
<button
aria-expanded={open}
aria-label={t.open}
className={cn.button}
onClick={() => setOpen((o) => !o)}
type="button"
>
{/* Project can style/replace via CSS; simple glyph fallback. */}
<span aria-hidden="true">♿</span>
</button>
{open && (
<div aria-label={t.title} className={cn.panel} role="dialog">
<div className={cn.row}>
<span className={cn.label}>{t.textSize}</span>
<div className={cn.control}>
{[0, 1, 2, 3].map((n) => (
<button
className={isActive(state.textSize === n)}
key={n}
onClick={() => set('textSize', n as A11yState['textSize'])}
type="button"
>
A{n > 0 ? '+'.repeat(n) : ''}
</button>
))}
</div>
</div>
<div className={cn.row}>
<span className={cn.label}>{t.lineHeight}</span>
<div className={cn.control}>
{[0, 1, 2].map((n) => (
<button
className={isActive(state.lineHeight === n)}
key={n}
onClick={() => set('lineHeight', n as A11yState['lineHeight'])}
type="button"
>
{n === 0 ? '—' : '≡'.repeat(n)}
</button>
))}
</div>
</div>
<div className={cn.row}>
<span className={cn.label}>{t.contrast}</span>
<div className={cn.control}>
<button
className={isActive(state.contrast === 'high')}
onClick={() => set('contrast', state.contrast === 'high' ? 'default' : 'high')}
type="button"
>
{t.contrastHigh}
</button>
<button
className={isActive(state.contrast === 'inverted')}
onClick={() =>
set('contrast', state.contrast === 'inverted' ? 'default' : 'inverted')
}
type="button"
>
{t.contrastInverted}
</button>
</div>
</div>
{(
[
['grayscale', t.grayscale],
['underlineLinks', t.underlineLinks],
['readableFont', t.readableFont],
['reduceMotion', t.reduceMotion],
['bigCursor', t.bigCursor],
] as Array<[keyof A11yState, string]>
).map(([key, label]) => (
<div className={cn.row} key={key}>
<span className={cn.label}>{label}</span>
<button
aria-pressed={Boolean(state[key])}
className={`${cn.control ?? ''} ${isActive(Boolean(state[key]))}`}
onClick={() => toggle(key)}
type="button"
>
{state[key] ? 'ON' : 'OFF'}
</button>
</div>
))}
<button className={cn.resetButton} onClick={reset} type="button">
{t.reset}
</button>
<button className={cn.closeButton} onClick={() => setOpen(false)} type="button">
{t.close}
</button>
</div>
)}
</>
)
}
+6
View File
@@ -0,0 +1,6 @@
'use client'
export { AccessibilityProvider, useAccessibility } from './AccessibilityProvider.js'
export { AccessibilityWidget } from './AccessibilityWidget.js'
export type { A11yClassNames, A11yTexts } from './AccessibilityWidget.js'
export type { A11yState } from './state.js'
export { A11Y_COOKIE, a11yAttributes, parseA11y } from './state.js'
+12
View File
@@ -0,0 +1,12 @@
export { AccessibilityProvider, useAccessibility } from './AccessibilityProvider.js'
export { AccessibilityWidget } from './AccessibilityWidget.js'
export type { A11yClassNames, A11yTexts } from './AccessibilityWidget.js'
export {
A11Y_COOKIE,
A11Y_COOKIE_MAX_AGE,
A11Y_DEFAULT,
a11yAttributes,
parseA11y,
serializeA11y,
} from './state.js'
export type { A11yState } from './state.js'
+72
View File
@@ -0,0 +1,72 @@
/**
* Accessibility preferences state. Each option maps to a data-attribute on
* <html> (e.g. data-a11y-contrast="high"); the PROJECT's CSS reacts to those
* attributes. The plugin sets the attributes and persists the choice — it does
* NOT ship styles, so it never fights the project's design.
*/
export type A11yState = {
/** Larger cursor. */
bigCursor: boolean
/** 'default' | 'high' (high contrast) | 'inverted' (dark-on-light flip). */
contrast: 'default' | 'high' | 'inverted'
/** Grayscale filter on the whole page. */
grayscale: boolean
/** Line spacing: 0 = default, 1..2 = looser. */
lineHeight: 0 | 1 | 2
/** Readable font (project maps this to a dyslexia-friendly / simple font). */
readableFont: boolean
/** Stop animations / transitions (prefers-reduced-motion equivalent). */
reduceMotion: boolean
/** Text size step: 0 = default, 1..3 = larger. */
textSize: 0 | 1 | 2 | 3
/** Underline all links (WCAG: don't rely on color alone). */
underlineLinks: boolean
}
export const A11Y_DEFAULT: A11yState = {
bigCursor: false,
contrast: 'default',
grayscale: false,
lineHeight: 0,
readableFont: false,
reduceMotion: false,
textSize: 0,
underlineLinks: false,
}
export const A11Y_COOKIE = 'a11y-prefs'
export const A11Y_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year
/** Serialize for the cookie (compact). */
export function serializeA11y(state: A11yState): string {
return JSON.stringify(state)
}
/** Parse from the cookie; falls back to defaults on any bad value. */
export function parseA11y(raw: null | string | undefined): A11yState {
if (!raw) {return A11Y_DEFAULT}
try {
const parsed = JSON.parse(raw) as Partial<A11yState>
return { ...A11Y_DEFAULT, ...parsed }
} catch {
return A11Y_DEFAULT
}
}
/**
* Maps state → data-attributes to set on <html>. Returns { attr: value|null };
* null means remove the attribute (option is at default). The project's CSS
* targets these, e.g. `[data-a11y-contrast="high"] { … }`.
*/
export function a11yAttributes(state: A11yState): Record<string, null | string> {
return {
'data-a11y-contrast': state.contrast !== 'default' ? state.contrast : null,
'data-a11y-cursor': state.bigCursor ? 'big' : null,
'data-a11y-font': state.readableFont ? 'readable' : null,
'data-a11y-grayscale': state.grayscale ? 'on' : null,
'data-a11y-line': state.lineHeight > 0 ? String(state.lineHeight) : null,
'data-a11y-motion': state.reduceMotion ? 'reduce' : null,
'data-a11y-text': state.textSize > 0 ? String(state.textSize) : null,
'data-a11y-underline': state.underlineLinks ? 'on' : null,
}
}