Added accessibility support
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user