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
+2
View File
@@ -1,5 +1,7 @@
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'; export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js';
export { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.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'; export { Analytics } from '../modules/analytics/client.js';
/** /**
* Entry point: ipal-kit/client * Entry point: ipal-kit/client
+1
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'; export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js';
export { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js'; export { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js';
export { AccessibilityProvider, AccessibilityWidget, useAccessibility } from '../modules/accessibility/client.js';
export { Analytics } from '../modules/analytics/client.js'; export { Analytics } from '../modules/analytics/client.js';
/** /**
* Entry point: ipal-kit/client * Entry point: ipal-kit/client
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'\nexport { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\n\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\n// Pure i18n path helpers — no server/RSC deps, safe to import in client\n// components (e.g. a LanguageSwitcher that computes locale URLs on the client).\nexport {\n buildLocalizedPath,\n getLocaleCodes,\n getLocalizedSlugs,\n switchLocalePath,\n} from '../modules/i18n/index.js'\nexport type { I18nConfig, LocalizedSlugs } from '../modules/i18n/index.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport { TurnstileProvider, useTurnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n"],"names":["MaskedField","TestEmailButton","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","buildLocalizedPath","getLocaleCodes","getLocalizedSlugs","switchLocalePath","Turnstile","TurnstileProvider","useTurnstile"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,SAASC,eAAe,QAAQ,4DAA2D;AAC3F,SAASC,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAGrC,wEAAwE;AACxE,gFAAgF;AAChF,SACEC,kBAAkB,EAClBC,cAAc,EACdC,iBAAiB,EACjBC,gBAAgB,QACX,2BAA0B;AAEjC,SAASC,SAAS,QAAQ,iCAAgC;AAC1D,SAASC,iBAAiB,EAAEC,YAAY,QAAQ,iCAAgC"} {"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'\nexport { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js'\nexport {\n AccessibilityProvider,\n AccessibilityWidget,\n useAccessibility,\n} from '../modules/accessibility/client.js'\nexport type { A11yClassNames, A11yState, A11yTexts } from '../modules/accessibility/client.js'\n\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\n// Pure i18n path helpers — no server/RSC deps, safe to import in client\n// components (e.g. a LanguageSwitcher that computes locale URLs on the client).\nexport {\n buildLocalizedPath,\n getLocaleCodes,\n getLocalizedSlugs,\n switchLocalePath,\n} from '../modules/i18n/index.js'\nexport type { I18nConfig, LocalizedSlugs } from '../modules/i18n/index.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport { TurnstileProvider, useTurnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n"],"names":["MaskedField","TestEmailButton","AccessibilityProvider","AccessibilityWidget","useAccessibility","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","buildLocalizedPath","getLocaleCodes","getLocalizedSlugs","switchLocalePath","Turnstile","TurnstileProvider","useTurnstile"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,SAASC,eAAe,QAAQ,4DAA2D;AAC3F,SACEC,qBAAqB,EACrBC,mBAAmB,EACnBC,gBAAgB,QACX,qCAAoC;AAG3C,SAASC,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,wEAAwE;AACxE,gFAAgF;AAChF,SACEC,kBAAkB,EAClBC,cAAc,EACdC,iBAAiB,EACjBC,gBAAgB,QACX,2BAA0B;AAEjC,SAASC,SAAS,QAAQ,iCAAgC;AAC1D,SAASC,iBAAiB,EAAEC,YAAY,QAAQ,iCAAgC"}
+2
View File
@@ -1,5 +1,7 @@
export type { AccessOption, Role } from './modules/access/index.js'; export type { AccessOption, Role } from './modules/access/index.js';
export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSelf, authenticated, hasMinimumRole, isAdmin, isEditor, requireRole, requireRoleField, ROLE_HIERARCHY, } from './modules/access/index.js'; export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSelf, authenticated, hasMinimumRole, isAdmin, isEditor, requireRole, 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 type { AnalyticsConfig } from './modules/analytics/index.js';
export { getAnalyticsConfig } from './modules/analytics/index.js'; export { getAnalyticsConfig } from './modules/analytics/index.js';
export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, DEFAULT_CONSENT, getConsentTexts, parseConsent, REJECT_ALL_CONSENT, serializeConsent, setDefaultConsent, updateConsent, } from './modules/consent/index.js'; export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, DEFAULT_CONSENT, getConsentTexts, parseConsent, REJECT_ALL_CONSENT, serializeConsent, setDefaultConsent, updateConsent, } from './modules/consent/index.js';
+1
View File
@@ -1,4 +1,5 @@
export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSelf, authenticated, hasMinimumRole, isAdmin, isEditor, requireRole, requireRoleField, ROLE_HIERARCHY } from './modules/access/index.js'; export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSelf, authenticated, hasMinimumRole, isAdmin, isEditor, requireRole, requireRoleField, ROLE_HIERARCHY } from './modules/access/index.js';
export { A11Y_COOKIE, A11Y_DEFAULT, a11yAttributes, parseA11y, serializeA11y } from './modules/accessibility/index.js';
export { getAnalyticsConfig } from './modules/analytics/index.js'; export { getAnalyticsConfig } from './modules/analytics/index.js';
export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, DEFAULT_CONSENT, getConsentTexts, parseConsent, REJECT_ALL_CONSENT, serializeConsent, setDefaultConsent, updateConsent } from './modules/consent/index.js'; export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, DEFAULT_CONSENT, getConsentTexts, parseConsent, REJECT_ALL_CONSENT, serializeConsent, setDefaultConsent, updateConsent } from './modules/consent/index.js';
export { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, resolveRoute } from './modules/content/index.js'; export { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, resolveRoute } from './modules/content/index.js';
+1 -1
View File
File diff suppressed because one or more lines are too long
+29
View File
@@ -0,0 +1,29 @@
import { type A11yState } from './state.js';
type A11yContextValue = {
reset: () => void;
set: <K extends keyof A11yState>(key: K, value: A11yState[K]) => void;
state: A11yState;
};
/**
* 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 declare function AccessibilityProvider({ children }: {
children: React.ReactNode;
}): import("react/jsx-runtime").JSX.Element;
/** Access accessibility preferences + setters. Use inside AccessibilityProvider. */
export declare function useAccessibility(): A11yContextValue;
export {};
+87
View File
@@ -0,0 +1,87 @@
'use client';
import { jsx as _jsx } from "react/jsx-runtime";
import { createContext, use, useCallback, useEffect, useState } from 'react';
import { A11Y_COOKIE, A11Y_COOKIE_MAX_AGE, A11Y_DEFAULT, a11yAttributes, parseA11y, serializeA11y } from './state.js';
const A11yContext = /*#__PURE__*/ createContext(null);
function readCookie(name) {
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 }) {
const [state, setState] = useState(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)=>{
document.cookie = `${A11Y_COOKIE}=${encodeURIComponent(serializeA11y(next))}; path=/; max-age=${A11Y_COOKIE_MAX_AGE}; samesite=lax`;
}, []);
const set = useCallback((key, value)=>{
setState((prev)=>{
const next = {
...prev,
[key]: value
};
persist(next);
return next;
});
}, [
persist
]);
const reset = useCallback(()=>{
setState(A11Y_DEFAULT);
persist(A11Y_DEFAULT);
}, [
persist
]);
return /*#__PURE__*/ _jsx(A11yContext, {
value: {
reset,
set,
state
},
children: children
});
}
/** Access accessibility preferences + setters. Use inside AccessibilityProvider. */ export function useAccessibility() {
const ctx = use(A11yContext);
if (!ctx) {
throw new Error('useAccessibility must be used within <AccessibilityProvider>');
}
return ctx;
}
//# sourceMappingURL=AccessibilityProvider.js.map
File diff suppressed because one or more lines are too long
+41
View File
@@ -0,0 +1,41 @@
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;
};
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 declare function AccessibilityWidget({ classNames, texts, }: {
classNames?: A11yClassNames;
texts?: A11yTexts;
}): import("react/jsx-runtime").JSX.Element;
+188
View File
@@ -0,0 +1,188 @@
'use client';
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useState } from 'react';
import { useAccessibility } from './AccessibilityProvider.js';
const DEFAULT_TEXTS = {
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'
};
/**
* 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 }) {
const { reset, set, state } = useAccessibility();
const [open, setOpen] = useState(false);
const t = {
...DEFAULT_TEXTS,
...texts
};
const cn = classNames ?? {};
const toggle = (key)=>set(key, !state[key]);
const isActive = (on)=>on ? cn.active ?? '' : '';
return /*#__PURE__*/ _jsxs(_Fragment, {
children: [
/*#__PURE__*/ _jsx("button", {
"aria-expanded": open,
"aria-label": t.open,
className: cn.button,
onClick: ()=>setOpen((o)=>!o),
type: "button",
children: /*#__PURE__*/ _jsx("span", {
"aria-hidden": "true",
children: "♿"
})
}),
open && /*#__PURE__*/ _jsxs("div", {
"aria-label": t.title,
className: cn.panel,
role: "dialog",
children: [
/*#__PURE__*/ _jsxs("div", {
className: cn.row,
children: [
/*#__PURE__*/ _jsx("span", {
className: cn.label,
children: t.textSize
}),
/*#__PURE__*/ _jsx("div", {
className: cn.control,
children: [
0,
1,
2,
3
].map((n)=>/*#__PURE__*/ _jsxs("button", {
className: isActive(state.textSize === n),
onClick: ()=>set('textSize', n),
type: "button",
children: [
"A",
n > 0 ? '+'.repeat(n) : ''
]
}, n))
})
]
}),
/*#__PURE__*/ _jsxs("div", {
className: cn.row,
children: [
/*#__PURE__*/ _jsx("span", {
className: cn.label,
children: t.lineHeight
}),
/*#__PURE__*/ _jsx("div", {
className: cn.control,
children: [
0,
1,
2
].map((n)=>/*#__PURE__*/ _jsx("button", {
className: isActive(state.lineHeight === n),
onClick: ()=>set('lineHeight', n),
type: "button",
children: n === 0 ? '—' : '≡'.repeat(n)
}, n))
})
]
}),
/*#__PURE__*/ _jsxs("div", {
className: cn.row,
children: [
/*#__PURE__*/ _jsx("span", {
className: cn.label,
children: t.contrast
}),
/*#__PURE__*/ _jsxs("div", {
className: cn.control,
children: [
/*#__PURE__*/ _jsx("button", {
className: isActive(state.contrast === 'high'),
onClick: ()=>set('contrast', state.contrast === 'high' ? 'default' : 'high'),
type: "button",
children: t.contrastHigh
}),
/*#__PURE__*/ _jsx("button", {
className: isActive(state.contrast === 'inverted'),
onClick: ()=>set('contrast', state.contrast === 'inverted' ? 'default' : 'inverted'),
type: "button",
children: t.contrastInverted
})
]
})
]
}),
[
[
'grayscale',
t.grayscale
],
[
'underlineLinks',
t.underlineLinks
],
[
'readableFont',
t.readableFont
],
[
'reduceMotion',
t.reduceMotion
],
[
'bigCursor',
t.bigCursor
]
].map(([key, label])=>/*#__PURE__*/ _jsxs("div", {
className: cn.row,
children: [
/*#__PURE__*/ _jsx("span", {
className: cn.label,
children: label
}),
/*#__PURE__*/ _jsx("button", {
"aria-pressed": Boolean(state[key]),
className: `${cn.control ?? ''} ${isActive(Boolean(state[key]))}`,
onClick: ()=>toggle(key),
type: "button",
children: state[key] ? 'ON' : 'OFF'
})
]
}, key)),
/*#__PURE__*/ _jsx("button", {
className: cn.resetButton,
onClick: reset,
type: "button",
children: t.reset
}),
/*#__PURE__*/ _jsx("button", {
className: cn.closeButton,
onClick: ()=>setOpen(false),
type: "button",
children: t.close
})
]
})
]
});
}
//# sourceMappingURL=AccessibilityWidget.js.map
File diff suppressed because one or more lines are too long
+5
View File
@@ -0,0 +1,5 @@
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';
+6
View File
@@ -0,0 +1,6 @@
'use client';
export { AccessibilityProvider, useAccessibility } from './AccessibilityProvider.js';
export { AccessibilityWidget } from './AccessibilityWidget.js';
export { A11Y_COOKIE, a11yAttributes, parseA11y } from './state.js';
//# sourceMappingURL=client.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/accessibility/client.ts"],"sourcesContent":["'use client'\nexport { AccessibilityProvider, useAccessibility } from './AccessibilityProvider.js'\nexport { AccessibilityWidget } from './AccessibilityWidget.js'\nexport type { A11yClassNames, A11yTexts } from './AccessibilityWidget.js'\nexport type { A11yState } from './state.js'\nexport { A11Y_COOKIE, a11yAttributes, parseA11y } from './state.js'\n"],"names":["AccessibilityProvider","useAccessibility","AccessibilityWidget","A11Y_COOKIE","a11yAttributes","parseA11y"],"mappings":"AAAA;AACA,SAASA,qBAAqB,EAAEC,gBAAgB,QAAQ,6BAA4B;AACpF,SAASC,mBAAmB,QAAQ,2BAA0B;AAG9D,SAASC,WAAW,EAAEC,cAAc,EAAEC,SAAS,QAAQ,aAAY"}
+5
View File
@@ -0,0 +1,5 @@
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';
+5
View File
@@ -0,0 +1,5 @@
export { AccessibilityProvider, useAccessibility } from './AccessibilityProvider.js';
export { AccessibilityWidget } from './AccessibilityWidget.js';
export { A11Y_COOKIE, A11Y_COOKIE_MAX_AGE, A11Y_DEFAULT, a11yAttributes, parseA11y, serializeA11y } from './state.js';
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/accessibility/index.ts"],"sourcesContent":["export { AccessibilityProvider, useAccessibility } from './AccessibilityProvider.js'\nexport { AccessibilityWidget } from './AccessibilityWidget.js'\nexport type { A11yClassNames, A11yTexts } from './AccessibilityWidget.js'\nexport {\n A11Y_COOKIE,\n A11Y_COOKIE_MAX_AGE,\n A11Y_DEFAULT,\n a11yAttributes,\n parseA11y,\n serializeA11y,\n} from './state.js'\nexport type { A11yState } from './state.js'\n"],"names":["AccessibilityProvider","useAccessibility","AccessibilityWidget","A11Y_COOKIE","A11Y_COOKIE_MAX_AGE","A11Y_DEFAULT","a11yAttributes","parseA11y","serializeA11y"],"mappings":"AAAA,SAASA,qBAAqB,EAAEC,gBAAgB,QAAQ,6BAA4B;AACpF,SAASC,mBAAmB,QAAQ,2BAA0B;AAE9D,SACEC,WAAW,EACXC,mBAAmB,EACnBC,YAAY,EACZC,cAAc,EACdC,SAAS,EACTC,aAAa,QACR,aAAY"}
+37
View File
@@ -0,0 +1,37 @@
/**
* 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 declare const A11Y_DEFAULT: A11yState;
export declare const A11Y_COOKIE = "a11y-prefs";
export declare const A11Y_COOKIE_MAX_AGE: number;
/** Serialize for the cookie (compact). */
export declare function serializeA11y(state: A11yState): string;
/** Parse from the cookie; falls back to defaults on any bad value. */
export declare function parseA11y(raw: null | string | undefined): A11yState;
/**
* 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 declare function a11yAttributes(state: A11yState): Record<string, null | string>;
+53
View File
@@ -0,0 +1,53 @@
/**
* 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 const A11Y_DEFAULT = {
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) {
return JSON.stringify(state);
}
/** Parse from the cookie; falls back to defaults on any bad value. */ export function parseA11y(raw) {
if (!raw) {
return A11Y_DEFAULT;
}
try {
const parsed = JSON.parse(raw);
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) {
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
};
}
//# sourceMappingURL=state.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/accessibility/state.ts"],"sourcesContent":["/**\n * Accessibility preferences state. Each option maps to a data-attribute on\n * <html> (e.g. data-a11y-contrast=\"high\"); the PROJECT's CSS reacts to those\n * attributes. The plugin sets the attributes and persists the choice — it does\n * NOT ship styles, so it never fights the project's design.\n */\nexport type A11yState = {\n /** Larger cursor. */\n bigCursor: boolean\n /** 'default' | 'high' (high contrast) | 'inverted' (dark-on-light flip). */\n contrast: 'default' | 'high' | 'inverted'\n /** Grayscale filter on the whole page. */\n grayscale: boolean\n /** Line spacing: 0 = default, 1..2 = looser. */\n lineHeight: 0 | 1 | 2\n /** Readable font (project maps this to a dyslexia-friendly / simple font). */\n readableFont: boolean\n /** Stop animations / transitions (prefers-reduced-motion equivalent). */\n reduceMotion: boolean\n /** Text size step: 0 = default, 1..3 = larger. */\n textSize: 0 | 1 | 2 | 3\n /** Underline all links (WCAG: don't rely on color alone). */\n underlineLinks: boolean\n}\n\nexport const A11Y_DEFAULT: A11yState = {\n bigCursor: false,\n contrast: 'default',\n grayscale: false,\n lineHeight: 0,\n readableFont: false,\n reduceMotion: false,\n textSize: 0,\n underlineLinks: false,\n}\n\nexport const A11Y_COOKIE = 'a11y-prefs'\nexport const A11Y_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year\n\n/** Serialize for the cookie (compact). */\nexport function serializeA11y(state: A11yState): string {\n return JSON.stringify(state)\n}\n\n/** Parse from the cookie; falls back to defaults on any bad value. */\nexport function parseA11y(raw: null | string | undefined): A11yState {\n if (!raw) {return A11Y_DEFAULT}\n try {\n const parsed = JSON.parse(raw) as Partial<A11yState>\n return { ...A11Y_DEFAULT, ...parsed }\n } catch {\n return A11Y_DEFAULT\n }\n}\n\n/**\n * Maps state → data-attributes to set on <html>. Returns { attr: value|null };\n * null means remove the attribute (option is at default). The project's CSS\n * targets these, e.g. `[data-a11y-contrast=\"high\"] { … }`.\n */\nexport function a11yAttributes(state: A11yState): Record<string, null | string> {\n return {\n 'data-a11y-contrast': state.contrast !== 'default' ? state.contrast : null,\n 'data-a11y-cursor': state.bigCursor ? 'big' : null,\n 'data-a11y-font': state.readableFont ? 'readable' : null,\n 'data-a11y-grayscale': state.grayscale ? 'on' : null,\n 'data-a11y-line': state.lineHeight > 0 ? String(state.lineHeight) : null,\n 'data-a11y-motion': state.reduceMotion ? 'reduce' : null,\n 'data-a11y-text': state.textSize > 0 ? String(state.textSize) : null,\n 'data-a11y-underline': state.underlineLinks ? 'on' : null,\n }\n}\n"],"names":["A11Y_DEFAULT","bigCursor","contrast","grayscale","lineHeight","readableFont","reduceMotion","textSize","underlineLinks","A11Y_COOKIE","A11Y_COOKIE_MAX_AGE","serializeA11y","state","JSON","stringify","parseA11y","raw","parsed","parse","a11yAttributes","String"],"mappings":"AAAA;;;;;CAKC,GAoBD,OAAO,MAAMA,eAA0B;IACrCC,WAAW;IACXC,UAAU;IACVC,WAAW;IACXC,YAAY;IACZC,cAAc;IACdC,cAAc;IACdC,UAAU;IACVC,gBAAgB;AAClB,EAAC;AAED,OAAO,MAAMC,cAAc,aAAY;AACvC,OAAO,MAAMC,sBAAsB,KAAK,KAAK,KAAK,IAAI,SAAS;CAAV;AAErD,wCAAwC,GACxC,OAAO,SAASC,cAAcC,KAAgB;IAC5C,OAAOC,KAAKC,SAAS,CAACF;AACxB;AAEA,oEAAoE,GACpE,OAAO,SAASG,UAAUC,GAA8B;IACtD,IAAI,CAACA,KAAK;QAAC,OAAOhB;IAAY;IAC9B,IAAI;QACF,MAAMiB,SAASJ,KAAKK,KAAK,CAACF;QAC1B,OAAO;YAAE,GAAGhB,YAAY;YAAE,GAAGiB,MAAM;QAAC;IACtC,EAAE,OAAM;QACN,OAAOjB;IACT;AACF;AAEA;;;;CAIC,GACD,OAAO,SAASmB,eAAeP,KAAgB;IAC7C,OAAO;QACL,sBAAsBA,MAAMV,QAAQ,KAAK,YAAYU,MAAMV,QAAQ,GAAG;QACtE,oBAAoBU,MAAMX,SAAS,GAAG,QAAQ;QAC9C,kBAAkBW,MAAMP,YAAY,GAAG,aAAa;QACpD,uBAAuBO,MAAMT,SAAS,GAAG,OAAO;QAChD,kBAAkBS,MAAMR,UAAU,GAAG,IAAIgB,OAAOR,MAAMR,UAAU,IAAI;QACpE,oBAAoBQ,MAAMN,YAAY,GAAG,WAAW;QACpD,kBAAkBM,MAAML,QAAQ,GAAG,IAAIa,OAAOR,MAAML,QAAQ,IAAI;QAChE,uBAAuBK,MAAMJ,cAAc,GAAG,OAAO;IACvD;AACF"}
+171
View File
@@ -0,0 +1,171 @@
# Dostępność — widget a11y (WCAG)
Widget dostępności: pływający przycisk otwierający panel z opcjami dla osób z
niepełnosprawnościami (rozmiar tekstu, kontrast, skala szarości, podkreślone
linki, czytelna czcionka, wyłączenie animacji, duży kursor).
> **OPCJONALNY — nie dodawaj domyślnie.** Widget a11y jest wymagany prawnie
> TYLKO dla niektórych stron (podmioty publiczne, część e-commerce/usług objętych
> European Accessibility Act). Dla większości stron komercyjnych to OPCJA, nie
> obowiązek. Dodawaj GDY klient/projekt tego wymaga — nie na każdej stronie z
> automatu. W razie wątpliwości: zapytaj, czy strona podlega wymogom dostępności.
Wzorzec jak CookieBanner: provider + widget, wpinasz raz. Preferencje w cookie
(bez flash), stosowane jako atrybuty `data-a11y-*` na `<html>`.
> **Granica plugin/projekt:** plugin dostarcza MECHANIZM (widget, stan, cookie,
> atrybuty na html). Projekt dostarcza CSS reagujący na atrybuty — bo style
> zależą od designu projektu (kolory, czcionki, Tailwind). Plugin NIE narzuca
> stylów, żeby nie kolidować. Gotowy CSS do skopiowania niżej.
---
## 1. Wpięcie — Provider + Widget
```tsx
// app/(frontend)/[locale]/layout.tsx
import { AccessibilityProvider, AccessibilityWidget } from '@intecion/ipal-kit/client'
<AccessibilityProvider>
<body>
{children}
<AccessibilityWidget
classNames={{
button: 'a11y-button',
panel: 'a11y-panel',
row: 'a11y-row',
label: 'a11y-label',
control: 'a11y-control',
active: 'a11y-active',
resetButton: 'a11y-reset',
closeButton: 'a11y-close',
}}
texts={{ title: 'Dostępność', reset: 'Resetuj' }} // opcjonalne, PL domyślnie
/>
</body>
</AccessibilityProvider>
```
Widget jest bez stylów (jak CookieBanner) — classNames dopasowujesz do designu.
---
## 2. CSS reagujący na atrybuty (OBOWIĄZKOWE — projekt)
Widget ustawia atrybuty na `<html>`. Bez tego CSS nic się nie dzieje. Skopiuj do
globalnego CSS projektu (dostosuj do designu):
```css
/* Rozmiar tekstu */
html[data-a11y-text="1"] { font-size: 112.5%; }
html[data-a11y-text="2"] { font-size: 125%; }
html[data-a11y-text="3"] { font-size: 150%; }
/* Odstęp między liniami */
html[data-a11y-line="1"] * { line-height: 1.8 !important; }
html[data-a11y-line="2"] * { line-height: 2.2 !important; }
/* Kontrast wysoki */
html[data-a11y-contrast="high"] {
filter: contrast(1.4);
}
/* Kontrast odwrócony */
html[data-a11y-contrast="inverted"] {
filter: invert(1) hue-rotate(180deg);
}
html[data-a11y-contrast="inverted"] img,
html[data-a11y-contrast="inverted"] video {
filter: invert(1) hue-rotate(180deg); /* przywróć media */
}
/* Skala szarości */
html[data-a11y-grayscale="on"] { filter: grayscale(1); }
/* Uwaga: filter na html nie kumuluje się — jeśli łączysz kontrast+szarość,
zastosuj na body albo połącz w jednej regule. */
/* Podkreślone linki */
html[data-a11y-underline="on"] a { text-decoration: underline !important; }
/* Czytelna czcionka (podmień na swoją dyslexia-friendly / prostą) */
html[data-a11y-font="readable"] * {
font-family: Verdana, Tahoma, sans-serif !important;
letter-spacing: 0.02em;
}
/* Wyłączenie animacji */
html[data-a11y-motion="reduce"] *,
html[data-a11y-motion="reduce"] *::before,
html[data-a11y-motion="reduce"] *::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
/* Duży kursor */
html[data-a11y-cursor="big"] * {
cursor: url('/cursors/big.svg') 4 4, auto !important;
}
```
Dostosuj wartości do projektu (kolory kontrastu, czcionka, kursor). To Twój CSS —
plugin tylko ustawia atrybuty.
> **Kontrast + filter:** wiele opcji używa `filter` na `<html>`. CSS `filter` na
> jednym elemencie NIE kumuluje wielu wartości z różnych reguł — ostatnia wygrywa.
> Jeśli chcesz łączyć (np. szarość + kontrast), zastosuj filtry na `body` z
> pełną wartością, albo zbuduj reguły kombinowane. Dla pojedynczych opcji działa
> bez problemu.
---
## 3. Bez flash (SSR) — opcjonalne
Domyślnie widget stosuje atrybuty po hydratacji (krótki flash przy ładowaniu,
jeśli użytkownik miał ustawienia). Żeby tego uniknąć, odczytaj cookie server-side
i ustaw atrybuty na `<html>` w SSR:
```tsx
// layout.tsx (server) — odczytaj cookie i ustaw atrybuty od razu
import { cookies } from 'next/headers'
import { A11Y_COOKIE, parseA11y, a11yAttributes } from '@intecion/ipal-kit'
const raw = (await cookies()).get(A11Y_COOKIE)?.value
const attrs = a11yAttributes(parseA11y(raw))
const htmlAttrs = Object.fromEntries(
Object.entries(attrs).filter(([, v]) => v !== null),
)
return <html lang={locale} {...htmlAttrs}>...</html>
```
Provider i tak re-aplikuje na kliencie i synchronizuje. To tylko eliminuje flash.
---
## 4. Osobny przycisk otwierający (opcjonalnie)
Widget ma wbudowany pływający przycisk. Jeśli chcesz otwierać panel z innego
miejsca (np. stopka „Dostępność"), użyj hooka:
```tsx
'use client'
import { useAccessibility } from '@intecion/ipal-kit/client'
// stan otwarcia trzymaj sam, albo rozbuduj widget — hook daje state/set/reset
```
---
## 5. Compliance — kiedy dostępność jest wymagana
Dostępność (WCAG) jest wymagana prawnie TYLKO dla części stron:
- **Podmioty publiczne** (urzędy, szkoły, instytucje) — ustawa o dostępności cyfrowej
- **Duże e-commerce / usługi** objęte European Accessibility Act (2019/882, od 2025)
- Strony, gdzie klient sam tego wymaga (polityka firmy, przetarg)
Dla **większości stron komercyjnych** (wizytówka, mała firma, katalog) widget a11y
to **opcja, nie obowiązek** — dodawaj gdy klient wymaga, nie z automatu.
Gdy dodajesz: widget sam w sobie NIE czyni strony w pełni dostępną — to pomoc dla
użytkownika. Pełna dostępność to też semantyczny HTML, alt teksty, nawigacja
klawiaturą, kontrast bazowy. Widget uzupełnia, nie zastępuje. Nie sprzedawaj
klientowi „mamy widget = jesteśmy zgodni z WCAG". Patrz wymagania-prawne.md.
+7 -1
View File
@@ -1,6 +1,13 @@
'use client' 'use client'
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js' export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'
export { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.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' export { Analytics } from '../modules/analytics/client.js'
/** /**
* Entry point: ipal-kit/client * Entry point: ipal-kit/client
@@ -16,7 +23,6 @@ export {
useConsent, useConsent,
useConsentContext, useConsentContext,
} from '../modules/consent/client.js' } from '../modules/consent/client.js'
export type { CookieBannerClassNames } 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 // 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). // components (e.g. a LanguageSwitcher that computes locale URLs on the client).
+8
View File
@@ -13,6 +13,14 @@ export {
requireRoleField, requireRoleField,
ROLE_HIERARCHY, ROLE_HIERARCHY,
} from './modules/access/index.js' } 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 type { AnalyticsConfig } from './modules/analytics/index.js'
export { getAnalyticsConfig } from './modules/analytics/index.js' export { getAnalyticsConfig } from './modules/analytics/index.js'
export { 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,
}
}