diff --git a/CHANGELOG.md b/CHANGELOG.md index ef68b0e..4de0044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2.4.0 — 2026-10-23 + +- Implement reflow-safe stepwise text scaling (`1.0`, `1.125`, `1.25`, `1.5`, `2.0`) via scoped CSS properties without CSS transforms (closes #41). +- Introduce `TextScaleController` component dedicated to WCAG SC 1.4.4 / 1.4.10 reflow-safe text enlargement. +- Enhance `FontSizeController` with `stepScale` and custom `steps` props while maintaining backward compatibility with continuous 10% steps. +- Export `TEXT_SCALE_STEPS`, `TextScaleStep`, `TEXT_SCALE_PROTECTED_SELECTOR`, and `isProtectedFromTextScale`. +- Protect form controls (`input`, `textarea`, `select`, `button`), labels, code blocks (`pre`, `code`, `kbd`, `samp`), vector icons (`svg`, `[data-icon]`), and widget internals from destructive font-size and line-height mutations. +- Measure all baselines before writing inline styles, preventing nested compounding across deep DOM trees. +- Wire `fontScale` and `textScale` into `featureSchema` builtins and `effects.ts`, providing full runtime support for accessibility profiles (e.g. Cognitive and Low Vision). +- Add unit test coverage in `tests/textScaling.test.tsx` and browser integration test in `tests/browser/widget.spec.ts`. + ## 2.3.2 — 2026-10-23 - Harden GitHub Actions CI workflow (`ci.yml`) with job timeouts and comprehensive artifact capture for test results and Playwright HTML reports on failure (closes #40). diff --git a/README.md b/README.md index ab3257b..d51a376 100644 --- a/README.md +++ b/README.md @@ -88,23 +88,24 @@ export function App() { Every controller is opt-in. Add only the controls that make sense for your site, and choose their order in JSX. -| Controller | Effect | -| ------------------------- | --------------------------------------------------------------------------------- | -| `FontSizeController` | Scales eligible HTML text from 50% to 200%. | -| `LineHeightController` | Scales computed line height from 50% to 200%. | -| `LetterSpacingController` | Applies 0–10px letter spacing. | -| `ContrastController` | Turns contrast off, reduces it, increases it, or selects a named palette. | -| `SaturationController` | Reduces or increases saturation. | -| `InvertColorsController` | Inverts eligible page content. | -| `GrayscaleController` | Removes color while preserving the selected saturation value for later. | -| `LinkHighlightController` | Highlights links or selects a link palette. | -| `TextAlignController` | Restores the host alignment or forces left, center, right, or justified text. | -| `HideImagesController` | Hides images and CSS background images while retaining layout. | -| `HideVideosController` | Hides video elements and recognized YouTube/Vimeo embeds visually. | -| `BigCursorController` | Applies a larger high-contrast cursor where supported. | -| `ReadingMaskController` | Dims the page around a transparent reading window. | -| `ReadingGuideController` | Follows pointer/focus with a reading guide line. | -| `ResetController` | Resets display features and custom registered features without moving the widget. | +| Controller | Effect | +| ------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `TextScaleController` | Stepwise reflow-safe text enlargement (1.0x, 1.125x, 1.25x, 1.5x, 2.0x) protecting forms, code, and icons. | +| `FontSizeController` | Scales eligible HTML text from 50% to 200%; supports continuous 10% steps or `stepScale` mode. | +| `LineHeightController` | Scales computed line height from 50% to 200%. | +| `LetterSpacingController` | Applies 0–10px letter spacing. | +| `ContrastController` | Turns contrast off, reduces it, increases it, or selects a named palette. | +| `SaturationController` | Reduces or increases saturation. | +| `InvertColorsController` | Inverts eligible page content. | +| `GrayscaleController` | Removes color while preserving the selected saturation value for later. | +| `LinkHighlightController` | Highlights links or selects a link palette. | +| `TextAlignController` | Restores the host alignment or forces left, center, right, or justified text. | +| `HideImagesController` | Hides images and CSS background images while retaining layout. | +| `HideVideosController` | Hides video elements and recognized YouTube/Vimeo embeds visually. | +| `BigCursorController` | Applies a larger high-contrast cursor where supported. | +| `ReadingMaskController` | Dims the page around a transparent reading window. | +| `ReadingGuideController` | Follows pointer/focus with a reading guide line. | +| `ResetController` | Resets display features and custom registered features without moving the widget. | Effects compose where appropriate. For example, contrast, saturation, invert, and grayscale are applied in a deterministic order. Defaults leave host styles diff --git a/docs/API.md b/docs/API.md index 93d7c18..7d37509 100644 --- a/docs/API.md +++ b/docs/API.md @@ -4,18 +4,19 @@ All exports are named from `accessibility-plugin-react`. JavaScript consumers re ## Components -| Component | Props | -| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| AccessibilityPlugin | `children: ReactNode` required; `position?: 'left' \| 'right' \| 'top' \| 'bottom'` default right; `persistenceKey?: string` default `react-accessibility-plugin:state`; `persist?: boolean` default true; `storage?: 'local' \| 'session' \| 'none'` default local; `locale?: string`; `statementUrl?: string`; `largeControls?: boolean` default false | -| AccessibilityButton | `draggable?: boolean` default true (pointer/Alt+arrow movement); `icon?: ReactNode` default inline accessibility SVG; `title?: string` accessible name, default Accessibility; `description?: string` supplementary accessible description; `className?: string`; `style?: CSSProperties`; `largeControls?: boolean` | -| AccessibilityList | `footer?: ReactNode` extra fixed actions; `children: ReactNode` required; `title?: string` visible heading and region name, default Accessibility settings; `description?: string` default Adjust your reading experience; `closeLabel?: string` default Close; `statementUrl?: string`; `largeControls?: boolean`; `locale?: string`; `className?: string`; `style?: CSSProperties` | -| FeatureCard | `title: ReactNode` required; `children: ReactNode` required; `featureKey?: string`; `icon?: ReactNode`; `description?: ReactNode`; `statusBadge?: string`; `status?: 'idle' \| 'active' \| 'error' \| 'unavailable' \| 'permission-required'`; `errorMessage?: string`; `unavailableMessage?: string`; `permissionMessage?: string`; `onRequestPermission?: () => void`; `largeControls?: boolean` | -| ToggleControl | `label: ReactNode` required; `checked: boolean` required; `onChange: (checked: boolean) => void` required; `disabled?: boolean`; `unavailable?: boolean`; `unavailableReason?: string`; `largeControls?: boolean` | -| ChoiceControl | `label: string` required; `value: T` required; `options?: ChoiceOption[]`; `groups?: ChoiceGroup[]`; `onChange: (value: T) => void` required; `disabled?: boolean`; `largeControls?: boolean` | -| StepperControl | `label: string` required; `value: number` required; `min: number`; `max: number`; `step: number`; `unit?: string` default '%'; `showSlider?: boolean`; `formatValue?: (val: number) => string`; `onChange: (value: number) => void` required; `disabled?: boolean`; `largeControls?: boolean` | -| ProfilePreviewCard | `profile: ProfileDefinition` required; `isActive: boolean` required; `onApply: (profile: ProfileDefinition) => void` required; `onRevert: () => void` required; `largeControls?: boolean` | -| SearchCount / EmptyState / ErrorState | Standalone accessible status and announcement primitives for search counts, zero-results fallbacks, and error recovery banners. | -| All fifteen controllers listed in FEATURES.md | No props. Pick/omit in JSX. For custom labels or behavior, compose a control with useAccessibility or the modular control primitives. | +| Component | Props | +| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| AccessibilityPlugin | `children: ReactNode` required; `position?: 'left' \| 'right' \| 'top' \| 'bottom'` default right; `persistenceKey?: string` default `react-accessibility-plugin:state`; `persist?: boolean` default true; `storage?: 'local' \| 'session' \| 'none'` default local; `locale?: string`; `statementUrl?: string`; `largeControls?: boolean` default false | +| AccessibilityButton | `draggable?: boolean` default true (pointer/Alt+arrow movement); `icon?: ReactNode` default inline accessibility SVG; `title?: string` accessible name, default Accessibility; `description?: string` supplementary accessible description; `className?: string`; `style?: CSSProperties`; `largeControls?: boolean` | +| AccessibilityList | `footer?: ReactNode` extra fixed actions; `children: ReactNode` required; `title?: string` visible heading and region name, default Accessibility settings; `description?: string` default Adjust your reading experience; `closeLabel?: string` default Close; `statementUrl?: string`; `largeControls?: boolean`; `locale?: string`; `className?: string`; `style?: CSSProperties` | +| FeatureCard | `title: ReactNode` required; `children: ReactNode` required; `featureKey?: string`; `icon?: ReactNode`; `description?: ReactNode`; `statusBadge?: string`; `status?: 'idle' \| 'active' \| 'error' \| 'unavailable' \| 'permission-required'`; `errorMessage?: string`; `unavailableMessage?: string`; `permissionMessage?: string`; `onRequestPermission?: () => void`; `largeControls?: boolean` | +| ToggleControl | `label: ReactNode` required; `checked: boolean` required; `onChange: (checked: boolean) => void` required; `disabled?: boolean`; `unavailable?: boolean`; `unavailableReason?: string`; `largeControls?: boolean` | +| ChoiceControl | `label: string` required; `value: T` required; `options?: ChoiceOption[]`; `groups?: ChoiceGroup[]`; `onChange: (value: T) => void` required; `disabled?: boolean`; `largeControls?: boolean` | +| StepperControl | `label: string` required; `value: number` required; `min: number`; `max: number`; `step: number`; `unit?: string` default '%'; `showSlider?: boolean`; `formatValue?: (val: number) => string`; `onChange: (value: number) => void` required; `disabled?: boolean`; `largeControls?: boolean` | +| ProfilePreviewCard | `profile: ProfileDefinition` required; `isActive: boolean` required; `onApply: (profile: ProfileDefinition) => void` required; `onRevert: () => void` required; `largeControls?: boolean` | +| TextScaleController | `steps?: readonly number[]` default `[1.0, 1.125, 1.25, 1.5, 2.0]`; `label?: string` default `Bigger Text`. Stepped text scaling without transforms. | +| FontSizeController | Optional `stepScale?: boolean` (snaps to WCAG 1x–2x reflow steps); `steps?: readonly number[]` (custom steps); or omitted for continuous 10% steps. Protects forms, code, and icons. | +| All other controllers in FEATURES.md | Pick/omit in JSX. For custom labels or behavior, compose a control with useAccessibility or the modular control primitives. | Use one button/list pair per provider and one provider per document. UI IDs use React `useId`; do not target the old fixed `arap-panel` ID. Plugin children can include ordinary app content, but mounting the widget as a sibling makes intent clearer. Persistence configuration is fixed at mount; use `` when switching storage identity. @@ -43,9 +44,11 @@ Definitions/defaults are immutable for the provider lifetime; the first register `FeatureRegistration` is `FeatureOptions` plus `defaultValue: unknown`. Use the generic hook instead of manually casting callbacks. Legacy `registerReset(key, fn)` / `unregisterReset(key)` remain available; callbacks run before global state is cleared. They are not the primary effect lifecycle API. Callback owners must unregister their callback on teardown. -Exported types: `AccessibilityPluginProps`, `AccessibilityButtonProps`, `AccessibilityListProps`, `AccessibilityState`, `AccessibilityContextValue`, `PluginPosition`, `WidgetPlacement`, `FeatureOptions`, `FeatureRegistration`, `ToggleControlProps`, `ChoiceControlProps`, `ChoiceOption`, `ChoiceGroup`, `StepperControlProps`, `FeatureCardProps`, `FeatureStatus`, `SearchCountProps`, `EmptyStateProps`, `ErrorStateProps`, `ProfilePreviewCardProps`, `AccessibilityPreferences`, `ContrastMode`, `CursorSizePreference`, `FontFamilyPreference`, `ProfileDefinition`, `ProfilePresetId`, `ReaderMode`, `SaturationPreference`, `StorageMode`, `TextAlignPreference`, `LocaleStrings`. +Exported types: `AccessibilityPluginProps`, `AccessibilityButtonProps`, `AccessibilityListProps`, `AccessibilityState`, `AccessibilityContextValue`, `PluginPosition`, `WidgetPlacement`, `FeatureOptions`, `FeatureRegistration`, `ToggleControlProps`, `ChoiceControlProps`, `ChoiceOption`, `ChoiceGroup`, `StepperControlProps`, `FeatureCardProps`, `FeatureStatus`, `SearchCountProps`, `EmptyStateProps`, `ErrorStateProps`, `ProfilePreviewCardProps`, `FontSizeControllerProps`, `TextScaleControllerProps`, `TextScaleStep`, `AccessibilityPreferences`, `ContrastMode`, `CursorSizePreference`, `FontFamilyPreference`, `ProfileDefinition`, `ProfilePresetId`, `ReaderMode`, `SaturationPreference`, `StorageMode`, `TextAlignPreference`, `LocaleStrings`. -Exported control components: `ToggleControl`, `ChoiceControl`, `StepperControl`, `FeatureCard`, `SearchCount`, `EmptyState`, `ErrorState`, `ProfilePreviewCard`. +Exported control components: `ToggleControl`, `ChoiceControl`, `StepperControl`, `FeatureCard`, `SearchCount`, `EmptyState`, `ErrorState`, `ProfilePreviewCard`, `FontSizeController`, `TextScaleController`. + +Exported typography and reflow utilities: `TEXT_SCALE_STEPS`, `TEXT_SCALE_PROTECTED_SELECTOR`, `isProtectedFromTextScale`. Exported preference and profile utilities: `DEFAULT_PREFERENCES`, `PROFILE_DEFINITIONS`, `normalizePreferences`, `normalizePreferenceValue`, `resolveEffectivePreferences`, `preferencesReducer`, `createInitialPreferencesState`. diff --git a/docs/BUILD_AND_RELEASE_PLAN.md b/docs/BUILD_AND_RELEASE_PLAN.md index 95daa66..5d14219 100644 --- a/docs/BUILD_AND_RELEASE_PLAN.md +++ b/docs/BUILD_AND_RELEASE_PLAN.md @@ -82,7 +82,7 @@ completed. Closing a child issue does not replace the milestone exit review. ### M2 — Stable visual and typographic features -- [ ] [A11Y-201 — Bigger text and reflow-safe scaling #41](https://github.com/prabothcharith/accessibility-react/issues/41) — Add stepwise 1x–2x text scaling through reflow-safe scoped styles without nested compounding, clipping or changes to protected controls. +- [x] [A11Y-201 — Bigger text and reflow-safe scaling #41](https://github.com/prabothcharith/accessibility-react/issues/41) — Add stepwise 1x–2x text scaling through reflow-safe scoped styles without nested compounding, clipping or changes to protected controls. - [ ] [A11Y-202 — Letter, word, paragraph spacing and line height #42](https://github.com/prabothcharith/accessibility-react/issues/42) — Add independent letter, word and paragraph spacing plus line-height options with script-aware limits and restoration tests. - [ ] [A11Y-203 — Text alignment, legible fonts and language fallback #43](https://github.com/prabothcharith/accessibility-react/issues/43) — Add scoped text alignment and legally distributable readable font choices with Sinhala/Tamil fallbacks while protecting icons, code and symbols. - [ ] [A11Y-204 — Contrast modes and native forced-color protection #44](https://github.com/prabothcharith/accessibility-react/issues/44) — Add targeted original/light/dark/high/invert contrast modes that preserve host content and respect forced colors and other native settings. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 10886a6..75e50d2 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -4,7 +4,8 @@ All features are opt-in via controllers, use native buttons, support Enter/Space | Controller / key | Default; states and bounds | Host behavior / edge cases | | --------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| FontSizeController / fontSize | 100; 50–200%, step 10 | Scales each computed HTML element font size and line height together. Stops at bounds; not a substitute for browser zoom. | +| TextScaleController / fontScale | 1.0; 1.0, 1.125, 1.25, 1.5, 2.0 (stepwise) | Reflow-safe stepwise text enlargement without CSS transforms or compounding. Protects forms, labels, code, icons, widget, and exempt content. | +| FontSizeController / fontSize | 100; 50–200%, step 10 or stepwise | Scales computed font size and line height together. Supports optional `stepScale` / `steps` props; protects forms, labels, code blocks, icons, and widget UI. | | LineHeightController / lineHeight | 100; 50–200%, step 10 | Scales existing computed line height, multiplied by active font scale. Normal line height uses 1.2× font approximation. | | LetterSpacingController / letterSpacing | 0; 0–10px, step 1 | Nonzero value sets spacing on HTML descendants. Zero restores host. | | ContrastController / contrast | 0; 0/1/2/3, reduced, yellow-on-black, white-on-black, blue-on-yellow | Off, 1.25×, 1.5×, 2×; reduced=0.5×. Named palettes set foreground/background instead of contrast filter. Palette doesn't guarantee contrast of all rendered content. | diff --git a/package-lock.json b/package-lock.json index 721f658..f388004 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "accessibility-plugin-react", - "version": "2.3.2", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "accessibility-plugin-react", - "version": "2.3.2", + "version": "2.4.0", "license": "ISC", "devDependencies": { "@axe-core/playwright": "^4.13.0", diff --git a/package.json b/package.json index a88928b..71f380c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "accessibility-plugin-react", - "version": "2.3.2", + "version": "2.4.0", "description": "A composable React display-preference widget with a clean light design, persistent settings and movable accessible controls.", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/src/core/effects.ts b/src/core/effects.ts index be87f0b..66286d1 100644 --- a/src/core/effects.ts +++ b/src/core/effects.ts @@ -30,6 +30,64 @@ export function composeFilter(values: Record): string { .join(" "); } +export const TEXT_SCALE_PROTECTED_SELECTOR = [ + "input", + "textarea", + "select", + "select *", + "option", + "optgroup", + "button", + "button *", + "datalist", + "output", + "progress", + "meter", + "label", + "label *", + "legend", + "legend *", + "pre", + "pre *", + "code", + "kbd", + "samp", + "var", + "svg", + "svg *", + "[data-icon]", + "[data-icon] *", + 'i[class*="icon"]', + 'span[class*="icon"]', + 'i[class*="fa-"]', + 'span[class*="fa-"]', + 'i[class*="material-symbols"]', + 'span[class*="material-symbols"]', + 'i[class*="lucide"]', + 'span[class*="lucide"]', + "[data-arap-ignore]", + "[data-arap-ignore] *", + "[data-arap-exempt]", + "[data-arap-exempt] *", +].join(", "); + +export function isProtectedFromTextScale(element: HTMLElement): boolean { + try { + if (element.matches(EXCLUDED)) return true; + if (element.matches(TEXT_SCALE_PROTECTED_SELECTOR)) return true; + if ( + element.closest( + "pre, button, label, legend, select, [data-arap-ignore], [data-arap-exempt], [data-arap-ui]", + ) + ) { + return true; + } + } catch { + return false; + } + return false; +} + function applyPage(values: Record): () => void { const undo: (() => void)[] = []; function set(element: HTMLElement, property: string, value: string) { @@ -56,21 +114,34 @@ function applyPage(values: Record): () => void { allElements.length > MAX_HOST_ELEMENTS ? allElements.slice(0, MAX_HOST_ELEMENTS) : allElements; - const font = Number(values.fontSize ?? 100) / 100; + const fontScaleValue = + typeof values.fontScale === "number" && values.fontScale !== 1.0 + ? values.fontScale + : typeof values.textScale === "number" && values.textScale !== 1.0 + ? values.textScale + : undefined; + const fontSizeScale = + typeof values.fontSize === "number" && values.fontSize !== 100 + ? values.fontSize / 100 + : undefined; + const font = fontScaleValue ?? fontSizeScale ?? 1.0; const line = Number(values.lineHeight ?? 100) / 100; // Measure all baselines before writing, so nested elements never compound scaling. + // Protect forms, labels, code, icons, widget and explicitly ignored elements. const measurements = font !== 1 || line !== 1 - ? elements.map((el) => { - const computed = getComputedStyle(el); - return { - el, - font: parseFloat(computed.fontSize) || 16, - line: - parseFloat(computed.lineHeight) || - (parseFloat(computed.fontSize) || 16) * 1.2, - }; - }) + ? elements + .filter((el) => !isProtectedFromTextScale(el)) + .map((el) => { + const computed = getComputedStyle(el); + return { + el, + font: parseFloat(computed.fontSize) || 16, + line: + parseFloat(computed.lineHeight) || + (parseFloat(computed.fontSize) || 16) * 1.2, + }; + }) : []; for (const measured of measurements) { if (font !== 1) set(measured.el, "font-size", `${measured.font * font}px`); diff --git a/src/core/featureSchema.ts b/src/core/featureSchema.ts index 65127b8..e38432c 100644 --- a/src/core/featureSchema.ts +++ b/src/core/featureSchema.ts @@ -4,11 +4,19 @@ const oneOf = (...values: unknown[]) => (v: unknown) => values.includes(v); +/** + * Reflow-safe text scale steps: 1x baseline, 1.125x subtle, 1.25x medium, 1.5x large, 2.0x maximum. + */ +export const TEXT_SCALE_STEPS = [1.0, 1.125, 1.25, 1.5, 2.0] as const; +export type TextScaleStep = (typeof TEXT_SCALE_STEPS)[number]; + export const builtins: Record< string, { defaultValue: unknown; validate: (v: unknown) => boolean } > = Object.assign(Object.create(null), { fontSize: { defaultValue: 100, validate: numberIn(50, 200) }, + fontScale: { defaultValue: 1.0, validate: numberIn(0.5, 2.0) }, + textScale: { defaultValue: 1.0, validate: numberIn(0.5, 2.0) }, lineHeight: { defaultValue: 100, validate: numberIn(50, 200) }, letterSpacing: { defaultValue: 0, validate: numberIn(0, 10) }, contrast: { diff --git a/src/features/FontSizeController.tsx b/src/features/FontSizeController.tsx index 635a963..4df4d45 100644 --- a/src/features/FontSizeController.tsx +++ b/src/features/FontSizeController.tsx @@ -8,13 +8,67 @@ const DEFAULT_VALUE = 100; const MIN = 50; const MAX = 200; const STEP = 10; +const DEFAULT_STEP_SCALE = [100, 112.5, 125, 150, 200] as const; -export function FontSizeController() { +export interface FontSizeControllerProps { + /** + * Optional custom scale steps (percentages or factors, e.g. [100, 112.5, 125, 150, 200] or [1.0, 1.125, 1.25, 1.5, 2.0]). + * If provided, the stepper snaps to the nearest configured step. + */ + steps?: readonly number[]; + /** + * Optional step scale mode: when true, snaps to standard WCAG reflow steps: 100%, 112.5%, 125%, 150%, 200%. + */ + stepScale?: boolean; +} + +export function FontSizeController({ + steps: customSteps, + stepScale, +}: FontSizeControllerProps = {}) { const { value, setValue } = useAccessibility( FEATURE_KEY, DEFAULT_VALUE, ); + const steps = customSteps + ? customSteps.map((s) => (s <= 2.5 ? Math.round(s * 1000) / 10 : s)) + : stepScale + ? DEFAULT_STEP_SCALE + : null; + + const handleDecrease = () => { + if (steps && steps.length > 0) { + const idx = steps.findIndex((s) => Math.abs(s - value) < 0.1); + if (idx > 0) { + setValue(steps[idx - 1]); + } else if (idx === -1) { + const lower = [...steps].reverse().find((s) => s < value); + setValue(lower ?? steps[0]); + } + } else { + setValue(Math.max(MIN, value - STEP)); + } + }; + + const handleIncrease = () => { + if (steps && steps.length > 0) { + const idx = steps.findIndex((s) => Math.abs(s - value) < 0.1); + if (idx !== -1 && idx < steps.length - 1) { + setValue(steps[idx + 1]); + } else if (idx === -1) { + const higher = steps.find((s) => s > value); + setValue(higher ?? steps[steps.length - 1]); + } + } else { + setValue(Math.min(MAX, value + STEP)); + } + }; + + const isMin = steps && steps.length > 0 ? value <= steps[0] : value <= MIN; + const isMax = + steps && steps.length > 0 ? value >= steps[steps.length - 1] : value >= MAX; + return (
- {value}% + {Math.round(value)}% + + {percentage}% + + +
+ + ); +} + +export default TextScaleController; diff --git a/src/index.ts b/src/index.ts index 438326e..185defd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,7 +54,19 @@ export { } from "./core/preferences"; // Prebuilt Features -export { FontSizeController } from "./features/FontSizeController"; +export { + FontSizeController, + type FontSizeControllerProps, +} from "./features/FontSizeController"; +export { + TextScaleController, + type TextScaleControllerProps, +} from "./features/TextScaleController"; +export { TEXT_SCALE_STEPS, type TextScaleStep } from "./core/featureSchema"; +export { + TEXT_SCALE_PROTECTED_SELECTOR, + isProtectedFromTextScale, +} from "./core/effects"; export { LineHeightController } from "./features/LineHeightController"; export { LetterSpacingController } from "./features/LetterSpacingController"; export { ContrastController } from "./features/ContrastController"; diff --git a/tests/browser/widget.spec.ts b/tests/browser/widget.spec.ts index 4163604..120e36f 100644 --- a/tests/browser/widget.spec.ts +++ b/tests/browser/widget.spec.ts @@ -392,3 +392,68 @@ test("quick actions stay visible and click controls move or reset placement", as await page.keyboard.press("Escape"); await expect(trigger).toBeFocused(); }); + +test("stepwise text scaling scales content without transform and protects forms/labels/widget", async ({ + page, +}) => { + await page.goto("http://127.0.0.1:7311/"); + const initialH1Size = await page + .locator("h1") + .evaluate((el) => parseFloat(getComputedStyle(el).fontSize)); + const initialSelectSize = await page + .locator("#host select") + .evaluate((el) => parseFloat(getComputedStyle(el).fontSize)); + const initialButtonSize = await page + .locator("#host-action") + .evaluate((el) => parseFloat(getComputedStyle(el).fontSize)); + + // Open widget + await page.getByLabel("Accessibility", { exact: true }).click(); + const increaseBtn = page.getByLabel("Increase font size"); + + // Step up 10 times to reach 200% (2.0x) + for (let i = 0; i < 10; i++) { + await increaseBtn.click(); + } + + // Heading scales to ~2x + await expect + .poll(async () => + page + .locator("h1") + .evaluate((el) => parseFloat(getComputedStyle(el).fontSize)), + ) + .toBeGreaterThanOrEqual(initialH1Size * 1.95); + + // Form controls (select, button) and labels must NOT have their font-size forced to 2x + const scaledSelectSize = await page + .locator("#host select") + .evaluate((el) => parseFloat(getComputedStyle(el).fontSize)); + expect(scaledSelectSize).toBe(initialSelectSize); + + const scaledButtonSize = await page + .locator("#host-action") + .evaluate((el) => parseFloat(getComputedStyle(el).fontSize)); + expect(scaledButtonSize).toBe(initialButtonSize); + + // No CSS transforms applied + const htmlTransform = await page + .locator("html") + .evaluate((el) => getComputedStyle(el).transform); + expect(htmlTransform).toBe("none"); + + const bodyTransform = await page + .locator("body") + .evaluate((el) => getComputedStyle(el).transform); + expect(bodyTransform).toBe("none"); + + // Reset restores heading to initial size + await page.getByRole("button", { name: "Reset All", exact: true }).click(); + await expect + .poll(async () => + page + .locator("h1") + .evaluate((el) => parseFloat(getComputedStyle(el).fontSize)), + ) + .toBeCloseTo(initialH1Size, 0.5); +}); diff --git a/tests/textScaling.test.tsx b/tests/textScaling.test.tsx new file mode 100644 index 0000000..f8c1e49 --- /dev/null +++ b/tests/textScaling.test.tsx @@ -0,0 +1,306 @@ +import React from "react"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + render, + screen, + fireEvent, + waitFor, + cleanup, +} from "@testing-library/react"; +import { + AccessibilityPlugin, + AccessibilityButton, + AccessibilityList, + FontSizeController, + TextScaleController, + ResetController, + TEXT_SCALE_STEPS, + TEXT_SCALE_PROTECTED_SELECTOR, + isProtectedFromTextScale, +} from "../src"; + +describe("A11Y-201: Bigger text and reflow-safe scaling", () => { + let hostContainer: HTMLElement; + + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + document.body.innerHTML = ""; + + hostContainer = document.createElement("main"); + hostContainer.id = "host-root"; + document.body.appendChild(hostContainer); + }); + + afterEach(() => { + cleanup(); + document.body.innerHTML = ""; + }); + + it("exports standard WCAG reflow scale steps [1.0, 1.125, 1.25, 1.5, 2.0]", () => { + expect(TEXT_SCALE_STEPS).toEqual([1.0, 1.125, 1.25, 1.5, 2.0]); + expect(TEXT_SCALE_PROTECTED_SELECTOR).toContain("input"); + expect(TEXT_SCALE_PROTECTED_SELECTOR).toContain("code"); + expect(TEXT_SCALE_PROTECTED_SELECTOR).toContain("svg"); + }); + + it("identifies protected form, label, code, icon, and exempt elements", () => { + const input = document.createElement("input"); + const textarea = document.createElement("textarea"); + const select = document.createElement("select"); + const button = document.createElement("button"); + const label = document.createElement("label"); + const legend = document.createElement("legend"); + const pre = document.createElement("pre"); + const code = document.createElement("code"); + pre.appendChild(code); + const kbd = document.createElement("kbd"); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const ignored = document.createElement("div"); + ignored.setAttribute("data-arap-ignore", ""); + + const p = document.createElement("p"); + const h1 = document.createElement("h1"); + const a = document.createElement("a"); + + expect(isProtectedFromTextScale(input)).toBe(true); + expect(isProtectedFromTextScale(textarea)).toBe(true); + expect(isProtectedFromTextScale(select)).toBe(true); + expect(isProtectedFromTextScale(button)).toBe(true); + expect(isProtectedFromTextScale(label)).toBe(true); + expect(isProtectedFromTextScale(legend)).toBe(true); + expect(isProtectedFromTextScale(pre)).toBe(true); + expect(isProtectedFromTextScale(code)).toBe(true); + expect(isProtectedFromTextScale(kbd)).toBe(true); + expect(isProtectedFromTextScale(svg as unknown as HTMLElement)).toBe(true); + expect(isProtectedFromTextScale(ignored)).toBe(true); + + expect(isProtectedFromTextScale(p)).toBe(false); + expect(isProtectedFromTextScale(h1)).toBe(false); + expect(isProtectedFromTextScale(a)).toBe(false); + }); + + it("TextScaleController steps up from 100% to 200% and clamps at boundaries", () => { + render( + + + + + + , + ); + + fireEvent.click(screen.getByLabelText("Accessibility")); + + const decreaseBtn = screen.getByLabelText("Decrease text size"); + const increaseBtn = screen.getByLabelText("Increase text size"); + const output = document.querySelector(".arap-value") as HTMLElement; + + // Initially at 100% (1.0), decrease should be disabled + expect(output).toHaveTextContent("100%"); + expect(decreaseBtn).toBeDisabled(); + expect(increaseBtn).not.toBeDisabled(); + + // Step 1: 1.125 -> 113% + fireEvent.click(increaseBtn); + expect(output).toHaveTextContent("113%"); + expect(decreaseBtn).not.toBeDisabled(); + + // Step 2: 1.25 -> 125% + fireEvent.click(increaseBtn); + expect(output).toHaveTextContent("125%"); + + // Step 3: 1.5 -> 150% + fireEvent.click(increaseBtn); + expect(output).toHaveTextContent("150%"); + + // Step 4: 2.0 -> 200% + fireEvent.click(increaseBtn); + expect(output).toHaveTextContent("200%"); + expect(increaseBtn).toBeDisabled(); + + // Step down back to 150% + fireEvent.click(decreaseBtn); + expect(output).toHaveTextContent("150%"); + expect(increaseBtn).not.toBeDisabled(); + }); + + it("scales headings and paragraphs without modifying protected forms, labels, code, and icons", () => { + hostContainer.innerHTML = ` +

Main Title

+

Readable body paragraph.

+
+ + + +
+
const a = 1;
+
+ +
+
Exempt panel
+ `; + + const h1 = hostContainer.querySelector("h1") as HTMLElement; + const p = hostContainer.querySelector("#content-para") as HTMLElement; + const label = hostContainer.querySelector("#test-label") as HTMLElement; + const input = hostContainer.querySelector("#test-input") as HTMLElement; + const button = hostContainer.querySelector("#test-btn") as HTMLElement; + const pre = hostContainer.querySelector("#test-pre") as HTMLElement; + const code = hostContainer.querySelector("#test-code") as HTMLElement; + const exempt = hostContainer.querySelector("#test-exempt") as HTMLElement; + + render( + + + + + + + , + ); + + fireEvent.click(screen.getByLabelText("Accessibility")); + + // Increase text size: 1.0 -> 1.125 -> 1.25 -> 1.5 (150%) + const increaseBtn = screen.getByLabelText("Increase text size"); + fireEvent.click(increaseBtn); // 1.125 + fireEvent.click(increaseBtn); // 1.25 + fireEvent.click(increaseBtn); // 1.5 + + // Eligible elements scale by 1.5x: + // h1: 20px * 1.5 = 30px + expect(h1.style.fontSize).toBe("30px"); + // p: 16px * 1.5 = 24px + expect(p.style.fontSize).toBe("24px"); + + // Protected elements MUST NOT have their authored font-size overwritten: + expect(label.style.fontSize).toBe("14px"); + expect(input.style.fontSize).toBe("14px"); + expect(button.style.fontSize).toBe("14px"); + expect(pre.style.fontSize).toBe("13px"); + expect(code.style.fontSize).toBe(""); + expect(exempt.style.fontSize).toBe("15px"); + + // Reset All restores authored styles on all modified elements + fireEvent.click(screen.getByText("Reset All")); + expect(h1.style.fontSize).toBe("20px"); + expect(p.style.fontSize).toBe("16px"); + expect(label.style.fontSize).toBe("14px"); + expect(input.style.fontSize).toBe("14px"); + expect(button.style.fontSize).toBe("14px"); + expect(pre.style.fontSize).toBe("13px"); + }); + + it("avoids nested compounding when scaling deeply nested text elements", () => { + hostContainer.innerHTML = ` +
+
+

+ Nested text +

+
+
+ `; + + const section = hostContainer.querySelector("#section-1") as HTMLElement; + const article = hostContainer.querySelector("#article-1") as HTMLElement; + const p = hostContainer.querySelector("#nested-p") as HTMLElement; + const span = hostContainer.querySelector("#nested-span") as HTMLElement; + + render( + + + + + + , + ); + + fireEvent.click(screen.getByLabelText("Accessibility")); + const increaseBtn = screen.getByLabelText("Increase text size"); + + // Step to 200% (2.0x): 1.0 -> 1.125 -> 1.25 -> 1.5 -> 2.0 + fireEvent.click(increaseBtn); + fireEvent.click(increaseBtn); + fireEvent.click(increaseBtn); + fireEvent.click(increaseBtn); + + // Each element should be 16px * 2.0 = 32px, NOT compounded exponentially (e.g. 16 * 2^4 = 256px) + expect(section.style.fontSize).toBe("32px"); + expect(article.style.fontSize).toBe("32px"); + expect(p.style.fontSize).toBe("32px"); + expect(span.style.fontSize).toBe("32px"); + }); + + it("FontSizeController with stepScale snaps to standard reflow scale steps", () => { + hostContainer.innerHTML = `

Content

`; + const p = hostContainer.querySelector("#test-p") as HTMLElement; + + render( + + + + + + + , + ); + + fireEvent.click(screen.getByLabelText("Accessibility")); + const increaseBtn = screen.getByLabelText("Increase font size"); + const output = document.querySelector(".arap-value") as HTMLElement; + + expect(output).toHaveTextContent("100%"); + + // First click snaps to 112.5% (displayed as 113%) + fireEvent.click(increaseBtn); + expect(output).toHaveTextContent("113%"); + expect(parseFloat(p.style.fontSize)).toBeCloseTo(22.5, 1); + + // Second click snaps to 125% + fireEvent.click(increaseBtn); + expect(output).toHaveTextContent("125%"); + expect(p.style.fontSize).toBe("25px"); + + // Reset restores authored 20px + fireEvent.click(screen.getByText("Reset All")); + expect(p.style.fontSize).toBe("20px"); + }); + + it("handles dynamic route content insertions with active text scaling", async () => { + hostContainer.innerHTML = `

First

`; + + render( + + + + + + , + ); + + fireEvent.click(screen.getByLabelText("Accessibility")); + // Step to 1.5x (150%) + const increaseBtn = screen.getByLabelText("Increase text size"); + fireEvent.click(increaseBtn); + fireEvent.click(increaseBtn); + fireEvent.click(increaseBtn); + + expect( + hostContainer.querySelector("#first-p")?.getAttribute("style"), + ).toContain("font-size: 30px"); + + // Simulate SPA dynamic route change + const newPara = document.createElement("p"); + newPara.id = "second-p"; + newPara.style.fontSize = "18px"; + newPara.textContent = "Second"; + hostContainer.appendChild(newPara); + + await waitFor(() => { + const second = hostContainer.querySelector("#second-p") as HTMLElement; + expect(second?.style.fontSize).toBe("27px"); // 18 * 1.5 = 27 + }); + }); +});