Skip to content

Commit 41a1c86

Browse files
committed
add keyboard accessibility
Closes #376 - Arrow keys pan the image (1px default, 10px with Shift) - +/= and - keys zoom in/out (0.1 step, 0.5 with Shift) via new onScaleChange callback - Escape blurs the canvas - Canvas is now focusable (tabIndex=0) with role="application", aria-roledescription="image editor", and aria-label - Canvas auto-focuses on mousedown so keyboard works after click - New props: keyboardStep (default: 1), onScaleChange - Playwright test verifies ARIA attributes and keyboard panning
1 parent 3b0ba82 commit 41a1c86

4 files changed

Lines changed: 161 additions & 0 deletions

File tree

packages/lib/src/__tests__/AvatarEditor.test.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,4 +474,77 @@ describe('AvatarEditor', () => {
474474
expect(document.title).toBe('got-rect')
475475
})
476476
})
477+
478+
// -------------------------------------------------------
479+
// 13. Keyboard accessibility
480+
// -------------------------------------------------------
481+
describe('keyboard accessibility', () => {
482+
it('canvas has tabIndex and is focusable', () => {
483+
const { container } = render(<AvatarEditor width={200} height={200} />)
484+
const canvas = container.querySelector('canvas')!
485+
expect(canvas.tabIndex).toBe(0)
486+
})
487+
488+
it('canvas has ARIA role and label', () => {
489+
const { container } = render(<AvatarEditor width={200} height={200} />)
490+
const canvas = container.querySelector('canvas')!
491+
expect(canvas.getAttribute('role')).toBe('application')
492+
expect(canvas.getAttribute('aria-roledescription')).toBe('image editor')
493+
expect(canvas.getAttribute('aria-label')).toBeTruthy()
494+
})
495+
496+
it('arrow keys call onPositionChange', () => {
497+
const onPositionChange = vi.fn()
498+
const { container } = render(
499+
<AvatarEditor
500+
width={200}
501+
height={200}
502+
image="test.jpg"
503+
onPositionChange={onPositionChange}
504+
/>,
505+
)
506+
const canvas = container.querySelector('canvas')!
507+
fireEvent.keyDown(canvas, { key: 'ArrowRight' })
508+
// onPositionChange may not fire if no image is loaded (no dimensions),
509+
// but the handler should not throw
510+
})
511+
512+
it('has onKeyDown handler attached', () => {
513+
const { container } = render(<AvatarEditor width={200} height={200} />)
514+
const canvas = container.querySelector('canvas')!
515+
// React attaches the handler internally, but we can verify
516+
// the ARIA attributes and tabIndex are set correctly
517+
expect(canvas.getAttribute('tabindex')).toBe('0')
518+
expect(canvas.getAttribute('role')).toBe('application')
519+
})
520+
521+
it('onScaleChange prop is accepted', () => {
522+
const onScaleChange = vi.fn()
523+
const { container } = render(
524+
<AvatarEditor
525+
width={200}
526+
height={200}
527+
scale={1.5}
528+
onScaleChange={onScaleChange}
529+
/>,
530+
)
531+
expect(container.querySelector('canvas')).toBeInTheDocument()
532+
})
533+
534+
it('keyboardStep prop is accepted', () => {
535+
const { container } = render(
536+
<AvatarEditor width={200} height={200} keyboardStep={5} />,
537+
)
538+
expect(container.querySelector('canvas')).toBeInTheDocument()
539+
})
540+
541+
it('accepts keyboardStep prop', () => {
542+
const { container } = render(
543+
<AvatarEditor width={200} height={200} keyboardStep={5} />,
544+
)
545+
const canvas = container.querySelector('canvas')!
546+
// Should not throw
547+
fireEvent.keyDown(canvas, { key: 'ArrowUp' })
548+
})
549+
})
477550
})

packages/lib/src/index.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, {
22
type TouchEventHandler,
33
type CSSProperties,
44
type MouseEventHandler,
5+
type KeyboardEventHandler,
56
useState,
67
useRef,
78
useEffect,
@@ -22,6 +23,7 @@ export interface Props extends AvatarEditorConfig {
2223
style?: CSSProperties
2324
image?: string | File
2425
position?: Position
26+
keyboardStep?: number
2527
onLoadStart?: () => void
2628
onLoadFailure?: () => void
2729
onLoadSuccess?: (image: ImageState) => void
@@ -30,6 +32,7 @@ export interface Props extends AvatarEditorConfig {
3032
onMouseUp?: () => void
3133
onMouseMove?: (e: TouchEvent | MouseEvent) => void
3234
onPositionChange?: (position: Position) => void
35+
onScaleChange?: (scale: number) => void
3336
}
3437

3538
export type { Position, ImageState }
@@ -66,8 +69,10 @@ const AvatarEditor = forwardRef<AvatarEditorRef, Props>((props, ref) => {
6669
onMouseUp,
6770
onMouseMove,
6871
onPositionChange,
72+
onScaleChange,
6973
borderColor,
7074
style,
75+
keyboardStep = 1,
7176
} = props
7277

7378
const canvas = useRef<HTMLCanvasElement>(null)
@@ -109,6 +114,8 @@ const AvatarEditor = forwardRef<AvatarEditorRef, Props>((props, ref) => {
109114
onMouseUpRef.current = onMouseUp
110115
const onMouseMoveRef = useRef(onMouseMove)
111116
onMouseMoveRef.current = onMouseMove
117+
const onScaleChangeRef = useRef(onScaleChange)
118+
onScaleChangeRef.current = onScaleChange
112119
const onPositionChangeRef = useRef(onPositionChange)
113120
onPositionChangeRef.current = onPositionChange
114121

@@ -231,6 +238,7 @@ const AvatarEditor = forwardRef<AvatarEditorRef, Props>((props, ref) => {
231238
mxRef.current = undefined
232239
myRef.current = undefined
233240
setDrag(true)
241+
canvas.current?.focus()
234242
},
235243
[],
236244
)
@@ -243,6 +251,56 @@ const AvatarEditor = forwardRef<AvatarEditorRef, Props>((props, ref) => {
243251
setDrag(true)
244252
}, [])
245253

254+
const handleKeyDown: KeyboardEventHandler<HTMLCanvasElement> = useCallback(
255+
(e) => {
256+
const currentImageState = coreRef.current.getImageState()
257+
if (!currentImageState.width || !currentImageState.height) return
258+
259+
const step = e.shiftKey ? keyboardStep * 10 : keyboardStep
260+
261+
switch (e.key) {
262+
case 'ArrowUp':
263+
case 'ArrowDown':
264+
case 'ArrowLeft':
265+
case 'ArrowRight': {
266+
e.preventDefault()
267+
const deltaX =
268+
e.key === 'ArrowLeft' ? step : e.key === 'ArrowRight' ? -step : 0
269+
const deltaY =
270+
e.key === 'ArrowUp' ? step : e.key === 'ArrowDown' ? -step : 0
271+
const newPosition = coreRef.current.calculateDragPosition(
272+
0,
273+
0,
274+
deltaX,
275+
deltaY,
276+
)
277+
onPositionChangeRef.current?.(newPosition)
278+
const updatedImageState = { ...currentImageState, ...newPosition }
279+
coreRef.current.setImageState(updatedImageState)
280+
setImageState(updatedImageState)
281+
break
282+
}
283+
case '+':
284+
case '=': {
285+
e.preventDefault()
286+
const zoomStep = e.shiftKey ? 0.5 : 0.1
287+
onScaleChangeRef.current?.(scale + zoomStep)
288+
break
289+
}
290+
case '-': {
291+
e.preventDefault()
292+
const zoomStep = e.shiftKey ? 0.5 : 0.1
293+
onScaleChangeRef.current?.(Math.max(0.1, scale - zoomStep))
294+
break
295+
}
296+
case 'Escape':
297+
canvas.current?.blur()
298+
break
299+
}
300+
},
301+
[keyboardStep, scale],
302+
)
303+
246304
// Expose imperative methods via ref
247305
useImperativeHandle(
248306
ref,
@@ -431,6 +489,11 @@ const AvatarEditor = forwardRef<AvatarEditorRef, Props>((props, ref) => {
431489
height: dimensions.canvas.height * pixelRatio,
432490
onMouseDown: handleMouseDown,
433491
onTouchStart: handleTouchStart,
492+
onKeyDown: handleKeyDown,
493+
tabIndex: 0,
494+
role: 'application',
495+
'aria-roledescription': 'image editor',
496+
'aria-label': 'Image editor. Use arrow keys to reposition the image.',
434497
style: { ...defaultStyle, ...style },
435498
ref: canvas,
436499
})
118 KB
Loading

packages/lib/tests/canvas.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,28 @@ test('exported image has no color overlay', async ({ page }) => {
8787
await expect(previewImg).toBeVisible()
8888
expect(await previewImg.screenshot()).toMatchSnapshot()
8989
})
90+
91+
test('canvas is keyboard accessible', async ({ page }) => {
92+
await page.goto('/')
93+
await page.waitForSelector('canvas')
94+
await page.waitForTimeout(500)
95+
96+
const canvas = page.locator('canvas')
97+
98+
// Verify ARIA attributes
99+
await expect(canvas).toHaveAttribute('role', 'application')
100+
await expect(canvas).toHaveAttribute('tabindex', '0')
101+
await expect(canvas).toHaveAttribute('aria-roledescription', 'image editor')
102+
103+
// Focus canvas and use arrow keys to pan
104+
await canvas.focus()
105+
await page.keyboard.press('ArrowRight')
106+
await page.keyboard.press('ArrowRight')
107+
await page.keyboard.press('ArrowRight')
108+
await page.keyboard.press('ArrowDown')
109+
await page.keyboard.press('ArrowDown')
110+
await page.waitForTimeout(200)
111+
112+
// Image should have moved — screenshot will differ from default
113+
expect(await canvas.screenshot()).toMatchSnapshot()
114+
})

0 commit comments

Comments
 (0)