From 90e9f4a2316f258e37e11dca585530c7c70e0428 Mon Sep 17 00:00:00 2001 From: Shipy4ka Date: Sat, 21 Feb 2026 20:00:58 +0400 Subject: [PATCH] feat: add accessibility support for screen readers (#232) --- site/components/DiffView/index.tsx | 1 + src/Diff/index.tsx | 8 ++ src/Hunk/CodeCell.tsx | 11 +- src/Hunk/SplitHunk/SplitChange.tsx | 109 ++++++++++------ src/Hunk/UnifiedHunk/UnifiedChange.tsx | 19 ++- src/Hunk/accessibility.ts | 44 +++++++ src/Hunk/interface.ts | 37 +++--- src/context/index.ts | 10 +- src/styles/index.css | 168 +++++++++++++------------ 9 files changed, 266 insertions(+), 141 deletions(-) create mode 100644 src/Hunk/accessibility.ts diff --git a/site/components/DiffView/index.tsx b/site/components/DiffView/index.tsx index 23d2cbd..db6770a 100644 --- a/site/components/DiffView/index.tsx +++ b/site/components/DiffView/index.tsx @@ -227,6 +227,7 @@ export default function DiffView(props: Props) { return ( string | undefined; @@ -67,6 +70,8 @@ function Diff(props: DiffProps) { const { diffType, hunks, + tableAriaLabel, + accessibility, optimizeSelection, className, hunkClassName = DEFAULT_CONTEXT_VALUE.hunkClassName, @@ -158,6 +163,7 @@ function Diff(props: DiffProps) { const settingsContextValue = useMemo( (): ContextProps => { return { + accessibility, hunkClassName, lineClassName, generateLineClassName, @@ -178,6 +184,7 @@ function Diff(props: DiffProps) { }; }, [ + accessibility, codeClassName, codeEvents, generateAnchorID, @@ -204,6 +211,7 @@ function Diff(props: DiffProps) { ref={root} className={classNames('diff', `diff-${viewType}`, className)} onMouseDown={onTableMouseDown} + aria-label={tableAriaLabel} > {cols} {children(hunks)} diff --git a/src/Hunk/CodeCell.tsx b/src/Hunk/CodeCell.tsx index a6b6361..2765480 100644 --- a/src/Hunk/CodeCell.tsx +++ b/src/Hunk/CodeCell.tsx @@ -48,16 +48,23 @@ export interface CodeCellProps extends HTMLAttributes { text: string; tokens: TokenNode[] | null; renderToken: RenderToken | undefined; + screenReaderLabel?: string; } function CodeCell(props: CodeCellProps) { - const {changeKey, text, tokens, renderToken, ...attributes} = props; + const {changeKey, text, tokens, renderToken, screenReaderLabel, ...attributes} = props; const actualRenderToken: DefaultRenderToken = renderToken ? (token, i) => renderToken(token, defaultRenderToken, i) : defaultRenderToken; + const srId = screenReaderLabel ? `diff-sr-${changeKey}` : undefined; return ( - + + {screenReaderLabel && ( + + {screenReaderLabel} + + )} { tokens ? (isEmptyToken(tokens) ? ' ' : tokens.map(actualRenderToken)) diff --git a/src/Hunk/SplitHunk/SplitChange.tsx b/src/Hunk/SplitHunk/SplitChange.tsx index ed943e2..345c198 100644 --- a/src/Hunk/SplitHunk/SplitChange.tsx +++ b/src/Hunk/SplitHunk/SplitChange.tsx @@ -4,10 +4,11 @@ import {mapValues} from 'lodash'; import {ChangeData, getChangeKey} from '../../utils'; import {TokenNode} from '../../tokenize'; import {Side} from '../../interface'; -import {RenderToken, RenderGutter, GutterOptions, EventMap, NativeEventMap} from '../../context'; +import {RenderToken, RenderGutter, GutterOptions, EventMap, NativeEventMap, AccessibilityType} from '../../context'; import {ChangeSharedProps} from '../interface'; import CodeCell from '../CodeCell'; import {composeCallback, renderDefaultBy, wrapInAnchorBy} from '../utils'; +import {getGutterAriaLabel, getCodeCellAriaLabel} from '../accessibility'; const SIDE_OLD = 0; const SIDE_NEW = 1; @@ -33,24 +34,53 @@ function useCallbackOnSide(side: Side, setHover: SetHover, change: ChangeData | } interface RenderCellArgs { - change: ChangeData | null; - side: typeof SIDE_OLD | typeof SIDE_NEW; - selected: boolean; - tokens: TokenNode[] | null; - gutterClassName: string; - codeClassName: string; - gutterEvents: NativeEventMap; - codeEvents: NativeEventMap; - anchorID: string | null | undefined; - gutterAnchor: boolean; - gutterAnchorTarget: string | null | undefined; - hideGutter: boolean; - hover: boolean; - renderToken: RenderToken | undefined; - renderGutter: RenderGutter; + change: ChangeData | null; + side: typeof SIDE_OLD | typeof SIDE_NEW; + selected: boolean; + tokens: TokenNode[] | null; + gutterClassName: string; + codeClassName: string; + gutterEvents: NativeEventMap; + codeEvents: NativeEventMap; + anchorID: string | null | undefined; + gutterAnchor: boolean; + gutterAnchorTarget: string | null | undefined; + hideGutter: boolean; + hover: boolean; + renderToken: RenderToken | undefined; + renderGutter: RenderGutter; + accessibility?: AccessibilityType; } -function renderCells(args: RenderCellArgs) { +type SideName = 'old' | 'new'; + +function getSideName(side: typeof SIDE_OLD | typeof SIDE_NEW): SideName { + return side === SIDE_OLD ? 'old' : 'new'; +} + +function getOmitCodeAriaLabel( + accessibility: RenderCellArgs['accessibility'], + sideName: SideName +): string | undefined { + if (accessibility !== 'screen-reader') { + return undefined; + } + return sideName === 'old' ? 'Not in old version' : 'Not in new version'; +} + +function renderOmitCells(args: RenderCellArgs): Array { + const {hideGutter, gutterClassName, codeClassName, accessibility, side} = args; + const gutterClassNameValue = classNames('diff-gutter', 'diff-gutter-omit', gutterClassName); + const codeClassNameValue = classNames('diff-code', 'diff-code-omit', codeClassName); + const codeAriaLabel = getOmitCodeAriaLabel(accessibility, getSideName(side)); + + return [ + !hideGutter && , + , + ]; +} + +function renderChangeCells(args: RenderCellArgs & {change: ChangeData}): Array { const { change, side, @@ -67,21 +97,12 @@ function renderCells(args: RenderCellArgs) { hover, renderToken, renderGutter, + accessibility, } = args; - if (!change) { - const gutterClassNameValue = classNames('diff-gutter', 'diff-gutter-omit', gutterClassName); - const codeClassNameValue = classNames('diff-code', 'diff-code-omit', codeClassName); - - return [ - !hideGutter && , - , - ]; - } - + const sideName = getSideName(side); const {type, content} = change; const changeKey = getChangeKey(change); - const sideName = side === SIDE_OLD ? 'old' : 'new'; const gutterClassNameValue = classNames( 'diff-gutter', `diff-gutter-${type}`, @@ -98,12 +119,16 @@ function renderCells(args: RenderCellArgs) { renderDefault: renderDefaultBy(change, sideName), wrapInAnchor: wrapInAnchorBy(gutterAnchor, gutterAnchorTarget), }; + const gutterAriaLabel + = accessibility === 'screen-reader' ? getGutterAriaLabel(change, sideName) : undefined; const gutterProps = { id: anchorID || undefined, className: gutterClassNameValue, children: renderGutter(gutterOptions), ...gutterEvents, + ...(gutterAriaLabel && {'aria-label': gutterAriaLabel}), }; + const codeClassNameValue = classNames( 'diff-code', `diff-code-${type}`, @@ -113,6 +138,8 @@ function renderCells(args: RenderCellArgs) { }, codeClassName ); + const codeAriaLabel + = accessibility === 'screen-reader' ? getCodeCellAriaLabel(change) : undefined; return [ !hideGutter && , @@ -123,20 +150,28 @@ function renderCells(args: RenderCellArgs) { text={content} tokens={tokens} renderToken={renderToken} + screenReaderLabel={codeAriaLabel} {...codeEvents} />, ]; } +function renderCells(args: RenderCellArgs): Array { + if (!args.change) { + return renderOmitCells(args); + } + return renderChangeCells(args as RenderCellArgs & {change: ChangeData}); +} + interface SplitChangeProps extends ChangeSharedProps { - className: string; - oldChange: ChangeData | null; - newChange: ChangeData | null; - oldSelected: boolean; - newSelected: boolean; - oldTokens: TokenNode[] | null; - newTokens: TokenNode[] | null; - monotonous: boolean; + className: string; + oldChange: ChangeData | null; + newChange: ChangeData | null; + oldSelected: boolean; + newSelected: boolean; + oldTokens: TokenNode[] | null; + newTokens: TokenNode[] | null; + monotonous: boolean; } function SplitChange(props: SplitChangeProps) { @@ -159,6 +194,7 @@ function SplitChange(props: SplitChangeProps) { gutterAnchor, renderToken, renderGutter, + accessibility, } = props; const [hover, setHover] = useState(''); @@ -183,6 +219,7 @@ function SplitChange(props: SplitChangeProps) { codeEvents, renderToken, renderGutter, + accessibility, }; const oldArgs: RenderCellArgs = { ...commons, diff --git a/src/Hunk/UnifiedHunk/UnifiedChange.tsx b/src/Hunk/UnifiedHunk/UnifiedChange.tsx index f4e6a20..4174a03 100644 --- a/src/Hunk/UnifiedHunk/UnifiedChange.tsx +++ b/src/Hunk/UnifiedHunk/UnifiedChange.tsx @@ -8,6 +8,7 @@ import {ChangeEventArgs, EventMap, GutterOptions, NativeEventMap, RenderGutter} import {ChangeSharedProps} from '../interface'; import CodeCell from '../CodeCell'; import {composeCallback, renderDefaultBy, wrapInAnchorBy} from '../utils'; +import {getGutterAriaLabel, getCodeCellAriaLabel} from '../accessibility'; interface UnifiedChangeProps extends ChangeSharedProps { change: ChangeData; @@ -44,7 +45,8 @@ function renderGutterCell( anchorTarget: string | undefined, events: NativeEventMap, inHoverState: boolean, - renderGutter: RenderGutter + renderGutter: RenderGutter, + gutterAriaLabel?: string ) { const gutterOptions: GutterOptions = { change, @@ -55,7 +57,12 @@ function renderGutterCell( }; return ( - + {renderGutter(gutterOptions)} ); @@ -77,6 +84,7 @@ function UnifiedChange(props: UnifiedChangeProps) { generateAnchorID, renderToken, renderGutter, + accessibility, } = props; const {type, content} = change; const changeKey = getChangeKey(change); @@ -117,7 +125,8 @@ function UnifiedChange(props: UnifiedChangeProps) { anchorID, boundGutterEvents, hover, - renderGutter + renderGutter, + accessibility === 'screen-reader' ? getGutterAriaLabel(change, 'old') : undefined ) } { @@ -130,7 +139,8 @@ function UnifiedChange(props: UnifiedChangeProps) { anchorID, boundGutterEvents, hover, - renderGutter + renderGutter, + accessibility === 'screen-reader' ? getGutterAriaLabel(change, 'new') : undefined ) } diff --git a/src/Hunk/accessibility.ts b/src/Hunk/accessibility.ts new file mode 100644 index 0000000..83bf1b8 --- /dev/null +++ b/src/Hunk/accessibility.ts @@ -0,0 +1,44 @@ +import { + computeOldLineNumber, + computeNewLineNumber, + isDelete, + isInsert, +} from '../utils'; +import type {ChangeData} from '../utils'; +import type {Side} from '../interface'; + +export function getGutterAriaLabel(change: ChangeData, side: Side): string { + const oldNum = computeOldLineNumber(change); + const newNum = computeNewLineNumber(change); + + if (side === 'old') { + if (oldNum === -1) { + return 'Not in old version'; + } + if (isDelete(change)) { + return `Deleted line ${oldNum}`; + } + return `Old line ${oldNum}`; + } + + if (newNum === -1) { + return 'Not in new version'; + } + if (isInsert(change)) { + return `Added line ${newNum}`; + } + return `New line ${newNum}`; +} + +export function getCodeCellAriaLabel(change: ChangeData): string { + const oldNum = computeOldLineNumber(change); + const newNum = computeNewLineNumber(change); + + if (isInsert(change)) { + return `Added line ${newNum}`; + } + if (isDelete(change)) { + return `Deleted line ${oldNum}`; + } + return `Line ${newNum} unchanged`; +} diff --git a/src/Hunk/interface.ts b/src/Hunk/interface.ts index 1672010..075d6a4 100644 --- a/src/Hunk/interface.ts +++ b/src/Hunk/interface.ts @@ -1,31 +1,32 @@ import {ReactNode} from 'react'; import {ChangeData, HunkData} from '../utils'; -import {EventMap, RenderGutter, RenderToken} from '../context'; +import {EventMap, RenderGutter, RenderToken, AccessibilityType} from '../context'; import {HunkTokens} from '../tokenize'; export interface SharedProps { - hideGutter: boolean; - gutterAnchor: boolean; - monotonous: boolean; - generateAnchorID: (change: ChangeData) => string | undefined; + hideGutter: boolean; + gutterAnchor: boolean; + monotonous: boolean; + generateAnchorID: (change: ChangeData) => string | undefined; generateLineClassName: (params: {changes: ChangeData[], defaultGenerate: () => string}) => string | undefined; - renderToken?: RenderToken; - renderGutter: RenderGutter; + renderToken?: RenderToken; + renderGutter: RenderGutter; } export interface ChangeSharedProps extends SharedProps { - gutterClassName: string; - codeClassName: string; - gutterEvents: EventMap; - codeEvents: EventMap; + accessibility?: AccessibilityType; + gutterClassName: string; + codeClassName: string; + gutterEvents: EventMap; + codeEvents: EventMap; } export interface ActualHunkProps extends ChangeSharedProps { - className: string; - lineClassName: string; - hunk: HunkData; - widgets: Record; - hideGutter: boolean; - selectedChanges: string[]; - tokens?: HunkTokens | null; + className: string; + lineClassName: string; + hunk: HunkData; + widgets: Record; + hideGutter: boolean; + selectedChanges: string[]; + tokens?: HunkTokens | null; } diff --git a/src/context/index.ts b/src/context/index.ts index b950885..3842e5f 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -19,6 +19,8 @@ export type RenderGutter = (options: GutterOptions) => ReactNode; export type ViewType = 'unified' | 'split'; +export type AccessibilityType = 'screen-reader'; + export type GutterType = 'default' | 'none' | 'anchor'; type IsEvent = T extends `on${string}` ? T : never; @@ -32,9 +34,9 @@ type ExtractEventHandler = Exclude = Parameters>[0]; export interface ChangeEventArgs { - // TODO: use union type on next major version - side?: Side; - change: ChangeData | null; + // TODO: use union type on next major version + side?: Side; + change: ChangeData | null; } type BindEvent = (args: ChangeEventArgs, event: ExtractEventType) => void; @@ -42,6 +44,7 @@ type BindEvent = (args: ChangeEventArgs, event: ExtractEven export type EventMap = Partial<{[K in EventKeys]: BindEvent}>; export interface ContextProps { + accessibility?: AccessibilityType; hunkClassName: string; lineClassName: string; gutterClassName: string; @@ -62,6 +65,7 @@ export interface ContextProps { } export const DEFAULT_CONTEXT_VALUE: ContextProps = { + accessibility: undefined, hunkClassName: '', lineClassName: '', gutterClassName: '', diff --git a/src/styles/index.css b/src/styles/index.css index 7a19cc6..b6daee4 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -1,149 +1,161 @@ :root { - --diff-background-color: initial; - --diff-text-color: initial; - --diff-font-family: Consolas, Courier, monospace; - --diff-selection-background-color: #b3d7ff; - --diff-selection-text-color: var(--diff-text-color);; - --diff-gutter-insert-background-color: #d6fedb; - --diff-gutter-insert-text-color: var(--diff-text-color); - --diff-gutter-delete-background-color: #fadde0; - --diff-gutter-delete-text-color: var(--diff-text-color); - --diff-gutter-selected-background-color: #fffce0; - --diff-gutter-selected-text-color: var(--diff-text-color); - --diff-code-insert-background-color: #eaffee; - --diff-code-insert-text-color: var(--diff-text-color); - --diff-code-delete-background-color: #fdeff0; - --diff-code-delete-text-color: var(--diff-text-color); - --diff-code-insert-edit-background-color: #c0dc91; - --diff-code-insert-edit-text-color: var(--diff-text-color); - --diff-code-delete-edit-background-color: #f39ea2; - --diff-code-delete-edit-text-color: var(--diff-text-color); - --diff-code-selected-background-color: #fffce0; - --diff-code-selected-text-color: var(--diff-text-color); - --diff-omit-gutter-line-color: #cb2a1d; + --diff-background-color: initial; + --diff-text-color: initial; + --diff-font-family: Consolas, Courier, monospace; + --diff-selection-background-color: #b3d7ff; + --diff-selection-text-color: var(--diff-text-color); + --diff-gutter-insert-background-color: #d6fedb; + --diff-gutter-insert-text-color: var(--diff-text-color); + --diff-gutter-delete-background-color: #fadde0; + --diff-gutter-delete-text-color: var(--diff-text-color); + --diff-gutter-selected-background-color: #fffce0; + --diff-gutter-selected-text-color: var(--diff-text-color); + --diff-code-insert-background-color: #eaffee; + --diff-code-insert-text-color: var(--diff-text-color); + --diff-code-delete-background-color: #fdeff0; + --diff-code-delete-text-color: var(--diff-text-color); + --diff-code-insert-edit-background-color: #c0dc91; + --diff-code-insert-edit-text-color: var(--diff-text-color); + --diff-code-delete-edit-background-color: #f39ea2; + --diff-code-delete-edit-text-color: var(--diff-text-color); + --diff-code-selected-background-color: #fffce0; + --diff-code-selected-text-color: var(--diff-text-color); + --diff-omit-gutter-line-color: #cb2a1d; } .diff { - background-color: var(--diff-background-color); - color: var(--diff-text-color); - table-layout: fixed; - border-collapse: collapse; - width: 100%; + background-color: var(--diff-background-color); + color: var(--diff-text-color); + table-layout: fixed; + border-collapse: collapse; + width: 100%; } .diff::selection { - background-color: var(--diff-selection-background-color); - color: var(--diff-selection-text-color); + background-color: var(--diff-selection-background-color); + color: var(--diff-selection-text-color); } .diff td { - vertical-align: top; - padding-top: 0; - padding-bottom: 0; + vertical-align: top; + padding-top: 0; + padding-bottom: 0; } .diff-line { - line-height: 1.5; - font-family: var(--diff-font-family); + line-height: 1.5; + font-family: var(--diff-font-family); } .diff-gutter > a { - color: inherit; - display: block; + color: inherit; + display: block; } .diff-gutter { - padding: 0 1ch; - text-align: right; - cursor: pointer; - user-select: none; + padding: 0 1ch; + text-align: right; + cursor: pointer; + user-select: none; } .diff-gutter-insert { - background-color: var(--diff-gutter-insert-background-color); - color: var(--diff-gutter-insert-text-color); + background-color: var(--diff-gutter-insert-background-color); + color: var(--diff-gutter-insert-text-color); } .diff-gutter-delete { - background-color: var(--diff-gutter-delete-background-color); - color: var(--diff-gutter-delete-text-color); + background-color: var(--diff-gutter-delete-background-color); + color: var(--diff-gutter-delete-text-color); } .diff-gutter-omit { - cursor: default; + cursor: default; } .diff-gutter-selected { - background-color: var(--diff-gutter-selected-background-color); - color: var(--diff-gutter-selected-text-color); + background-color: var(--diff-gutter-selected-background-color); + color: var(--diff-gutter-selected-text-color); } .diff-code { - white-space: pre-wrap; - word-wrap: break-word; - word-break: break-all; - padding: 0 0 0 0.5em; + white-space: pre-wrap; + word-wrap: break-word; + word-break: break-all; + padding: 0 0 0 0.5em; } .diff-code-edit { - color: inherit; + color: inherit; } .diff-code-insert { - background-color: var(--diff-code-insert-background-color); - color: var(--diff-code-insert-text-color); + background-color: var(--diff-code-insert-background-color); + color: var(--diff-code-insert-text-color); } .diff-code-insert .diff-code-edit { - background-color: var(--diff-code-insert-edit-background-color); - color: var(--diff-code-insert-edit-text-color); + background-color: var(--diff-code-insert-edit-background-color); + color: var(--diff-code-insert-edit-text-color); } .diff-code-delete { - background-color: var(--diff-code-delete-background-color); - color: var(--diff-code-delete-text-color); + background-color: var(--diff-code-delete-background-color); + color: var(--diff-code-delete-text-color); } .diff-code-delete .diff-code-edit { - background-color: var(--diff-code-delete-edit-background-color); - color: var(--diff-code-delete-edit-text-color); + background-color: var(--diff-code-delete-edit-background-color); + color: var(--diff-code-delete-edit-text-color); } .diff-code-selected { - background-color: var(--diff-code-selected-background-color); - color: var(--diff-code-selected-text-color); + background-color: var(--diff-code-selected-background-color); + color: var(--diff-code-selected-text-color); } .diff-widget-content { - vertical-align: top; + vertical-align: top; } .diff-gutter-col { - width: 7ch; + width: 7ch; } .diff-gutter-omit { - height: 0; + height: 0; } .diff-gutter-omit:before { - content: ' '; - display: block; - white-space: pre; - width: 2px; - height: 100%; - margin-left: 4.6ch; - overflow: hidden; - background-color: var(--diff-omit-gutter-line-color); + content: " "; + display: block; + white-space: pre; + width: 2px; + height: 100%; + margin-left: 4.6ch; + overflow: hidden; + background-color: var(--diff-omit-gutter-line-color); } .diff-decoration { - line-height: 1.5; - user-select: none; + line-height: 1.5; + user-select: none; } .diff-decoration-content { - font-family: var(--diff-font-family); - padding: 0; + font-family: var(--diff-font-family); + padding: 0; +} + +.diff-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + user-select: none; } -