Skip to content

Conversation

@framitdavid
Copy link
Contributor

@framitdavid framitdavid commented Oct 30, 2025

Description

Fixes three WCAG/accessibility issues in the ImageUpload component related to VoiceOver focus management on macOS.

Focus management improvements

  • Canvas focus after file selection: Focus now returns to canvas element after selecting an image file
  • Delete button focus after save: Focus moves to delete button after successfully saving an image
  • Change Image button consistency: Fixed inconsistent focus behavior by removing problematic Button asChild + Label pattern
  • Dropzone focus after delete: Focus returns to dropzone input after deleting/canceling an image for easy re-upload

New custom hooks

Created three reusable focus management hooks following React best practices:

  1. useFocusOnChange: Focuses an element when a value changes to a new truthy value
  2. useFocusWhenUploaded: Focuses an element when an attachment completes uploading (domain-specific)
  3. useFocusWhenRemoved: Focuses an element when a value is removed/deleted (truthy → falsy)

Technical details

Ref merging

Implemented proper ref merging to combine react-dropzone's internal ref with our custom ref without breaking file dialog functionality.

Async timing

Used requestAnimationFrame to ensure focus calls happen after DOM updates, accounting for async operations like HTTP uploads.

Related Issue(s)

Verification/QA

  • Manual functionality testing
    • I have tested these changes manually
    • Creator of the original issue (or service owner) has been contacted for manual testing (or will be contacted when released in alpha)
    • No testing done/necessary
  • Automated tests
    • Unit test(s) have been added/updated
    • Cypress E2E test(s) have been added/updated
    • No automatic tests are needed here (no functional changes/additions)
    • I want someone to help me make some tests
  • UU/WCAG (follow these guidelines until we have our own)
    • I have tested with a screen reader/keyboard navigation/automated wcag validator
    • No testing done/necessary (no DOM/visual changes)
    • I want someone to help me perform accessibility testing
  • User documentation @ altinn-studio-docs
    • Has been added/updated
    • No functionality has been changed/added, so no documentation is needed
    • I will do that later/have created an issue
  • Support in Altinn Studio
    • Issue(s) created for support in Studio
    • This change/feature does not require any changes to Altinn Studio
  • Sprint board
    • The original issue (or this PR itself) has been added to the Team Apps project and to the current sprint board
    • I don't have permissions to do that, please help me out
  • Labels
    • I have added a kind/* and backport* label to this PR for proper release notes grouping
    • I don't have permissions to add labels, please help me out

Summary by CodeRabbit

Release Notes

  • New Features

    • Improved focus management throughout the image upload flow—delete button receives focus after upload, canvas receives focus on image load, and dropzone input receives focus when image is removed.
  • Accessibility

    • Added aria labels to file input for better screen reader support.
  • Refactoring

    • Simplified file input trigger button interaction and removed keyboard shortcut handling for file selection.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 30, 2025

📝 Walkthrough

Walkthrough

This PR introduces ref forwarding and focus management enhancements to the ImageUpload component to address WCAG accessibility issues. It adds a utility for merging multiple refs, implements three custom hooks for focus control during image upload and deletion, and refactors the component hierarchy to properly restore focus after user interactions.

Changes

Cohort / File(s) Summary
Ref Merging Utility
src/utils/refs/mergeRefs.ts
Introduces RefsUtils class with a static merge method that aggregates multiple React refs (callback and object types) into a single ref callback for applying to DOM elements.
Ref Merging Tests
src/utils/refs/mergeRefs.test.ts
Adds comprehensive unit tests covering callback ref invocation, RefObject updates, multiple ref handling, undefined/null ref handling, empty ref arrays, and element changes.
Focus Management Hooks
src/layout/ImageUpload/hooks/useFocusOnChange.tsx, useFocusWhenRemoved.tsx, useFocusWhenUploaded.tsx
Adds three new custom hooks: useFocusOnChange detects value changes and focuses an element; useFocusWhenRemoved focuses when a value is deleted; useFocusWhenUploaded focuses when an attachment is uploaded. All use requestAnimationFrame for deferred focus.
Dropzone Component
src/app-components/Dropzone/Dropzone.tsx
Adds optional inputRef prop to forward external refs to the internal file input element; imports and uses RefsUtils.merge to combine internal and external refs.
ImageUpload Component Hierarchy
src/layout/ImageUpload/ImageControllers.tsx, ImageCropper.tsx, ImageDropzone.tsx
ImageControllers wires deleteButtonRef with useFocusWhenUploaded and replaces keyboard shortcut handling with handleFileSelectClick; ImageCropper introduces focus hooks and dropzoneInputRef; ImageDropzone accepts and forwards dropzoneInputRef as inputRef to Dropzone.
E2E Test Update
test/e2e/integration/component-library/image-upload.ts
Updates image replacement test to directly target file input element instead of using label selector for more reliable selection.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Focus management timing: Verify requestAnimationFrame usage is appropriate for focus scheduling across different lifecycle scenarios
  • Ref forwarding chain: Validate the ref propagation path from Dropzone through ImageDropzone, ImageCropper, and ImageControllers
  • Hook edge cases: Confirm useFocusWhenUploaded attachment state detection and previous value tracking handle all transitions correctly
  • Accessibility compliance: Ensure focus restoration matches WCAG expectations for screen reader users and matches FileUpload component behavior
  • Test coverage: Verify ref merge tests adequately cover callback and object ref combinations and null element cases

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Title Check ✅ Passed The title "fix(ImageUpload): Improve accessibility with focus management for VoiceOver" clearly and concisely summarizes the main change in the pull request. It uses a conventional prefix (fix), identifies the component (ImageUpload), and describes the primary improvement (focus management) with contextual information (VoiceOver). The title directly reflects the PR's objective of addressing WCAG accessibility issues and is specific enough to be meaningful without being overly verbose.
Linked Issues Check ✅ Passed The pull request successfully addresses all three primary coding objectives from the linked issue #3815. The implementation provides focus management to return focus to the canvas after image selection, moves focus to the delete button after saving, and removes the problematic Button asChild pattern to fix inconsistent focus behavior on the "Change Image" button. Additionally, the PR implements a fourth improvement not explicitly required by the issue—returning focus to the dropzone input after image deletion. The technical approach using new focus management hooks and ref merging infrastructure aligns with the issue's requirements and expected behavior parity with the FileUpload component.
Out of Scope Changes Check ✅ Passed All code changes are directly scoped to addressing the accessibility and focus management objectives from issue #3815. The Dropzone component enhancement, new focus management hooks, ref merging utility, and related infrastructure changes are all necessary enablers for the focus improvements in the ImageUpload component. The E2E test update reflects the changed implementation and is aligned with the refactoring. No extraneous changes unrelated to the stated objectives were identified.
Description Check ✅ Passed The pull request description comprehensively follows the provided template. It includes a detailed description of the changes with both technical and non-technical explanations, clearly links to the related issue (#3815), and thoroughly addresses all verification and QA sections with appropriate checkboxes marked. The author indicates manual testing was performed, unit and E2E tests were added, accessibility testing was conducted, and the change was added to the sprint board with proper labels.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/3815_VoiceOverImageUploadA11y

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sonarqubecloud
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
41.67% Condition Coverage on New Code (required ≥ 45%)

See analysis details on SonarQube Cloud

@framitdavid framitdavid added kind/bug Something isn't working backport This PR should be cherry-picked onto older release branches labels Oct 31, 2025
@framitdavid framitdavid linked an issue Oct 31, 2025 that may be closed by this pull request
@framitdavid framitdavid changed the title fix: ensure correct focus and aria voice over fix(ImageUpload): Improve accessibility with focus management for VoiceOver Oct 31, 2025
@framitdavid framitdavid marked this pull request as ready for review October 31, 2025 07:58
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/app-components/Dropzone/Dropzone.tsx (1)

60-62: Avoid type cast; access ref property safely.

Line 61 uses a type cast (as) which violates the coding guidelines. The getInputProps() return type from react-dropzone should provide proper typing for the ref property.

Consider refactoring to avoid the type cast:

  const inputProps = getInputProps();
- const dropzoneRef = (inputProps as { ref?: React.Ref<HTMLInputElement> }).ref;
+ const dropzoneRef = 'ref' in inputProps ? inputProps.ref : undefined;
  const combinedRef = React.useMemo(() => RefsUtils.merge(dropzoneRef, inputRef), [dropzoneRef, inputRef]);

Alternatively, if react-dropzone provides proper types, you could import and use them directly.

As per coding guidelines.

src/layout/ImageUpload/hooks/useFocusWhenUploaded.tsx (1)

42-44: Simplify redundant type guard.

The isAttachmentUploaded function checks the uploaded field on a value already typed as UploadedAttachment. By definition, UploadedAttachment always has uploaded: true, so this check is redundant—the function is effectively just checking for undefined.

Consider simplifying:

-function isAttachmentUploaded(attachment: UploadedAttachment | undefined): attachment is UploadedAttachment {
-  return !!attachment?.uploaded;
-}
+function isAttachmentDefined(attachment: UploadedAttachment | undefined): attachment is UploadedAttachment {
+  return attachment !== undefined;
+}

Or inline the check directly in the call sites (lines 16 and 25) as if (attachment).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d0baa59 and 7ab363e.

📒 Files selected for processing (10)
  • src/app-components/Dropzone/Dropzone.tsx (5 hunks)
  • src/layout/ImageUpload/ImageControllers.tsx (4 hunks)
  • src/layout/ImageUpload/ImageCropper.tsx (4 hunks)
  • src/layout/ImageUpload/ImageDropzone.tsx (2 hunks)
  • src/layout/ImageUpload/hooks/useFocusOnChange.tsx (1 hunks)
  • src/layout/ImageUpload/hooks/useFocusWhenRemoved.tsx (1 hunks)
  • src/layout/ImageUpload/hooks/useFocusWhenUploaded.tsx (1 hunks)
  • src/utils/refs/mergeRefs.test.ts (1 hunks)
  • src/utils/refs/mergeRefs.ts (1 hunks)
  • test/e2e/integration/component-library/image-upload.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Avoid using any and unnecessary type casts (as Type) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options via queryOptions

Files:

  • test/e2e/integration/component-library/image-upload.ts
  • src/layout/ImageUpload/hooks/useFocusWhenUploaded.tsx
  • src/layout/ImageUpload/hooks/useFocusWhenRemoved.tsx
  • src/utils/refs/mergeRefs.test.ts
  • src/layout/ImageUpload/hooks/useFocusOnChange.tsx
  • src/layout/ImageUpload/ImageControllers.tsx
  • src/utils/refs/mergeRefs.ts
  • src/app-components/Dropzone/Dropzone.tsx
  • src/layout/ImageUpload/ImageCropper.tsx
  • src/layout/ImageUpload/ImageDropzone.tsx
**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

In tests, use renderWithProviders from src/test/renderWithProviders.tsx to supply required form layout context

Files:

  • src/utils/refs/mergeRefs.test.ts
🧠 Learnings (1)
📚 Learning: 2025-09-03T14:26:18.627Z
Learnt from: olemartinorg
Repo: Altinn/app-frontend-react PR: 3645
File: src/components/wrappers/ProcessWrapper.tsx:78-83
Timestamp: 2025-09-03T14:26:18.627Z
Learning: In ProcessWrapper.tsx, the useIsRunningProcessNext() hook intentionally uses a non-reactive pattern with queryClient.isMutating and local state instead of useIsMutating hook. This design choice is deliberate and should not be changed to a reactive pattern.

Applied to files:

  • src/layout/ImageUpload/hooks/useFocusOnChange.tsx
🧬 Code graph analysis (7)
test/e2e/integration/component-library/image-upload.ts (1)
test/e2e/support/apps/component-library/uploadImageAndVerify.ts (1)
  • makeTestFile (1-6)
src/layout/ImageUpload/hooks/useFocusWhenUploaded.tsx (1)
src/features/attachments/index.ts (2)
  • UploadedAttachment (20-20)
  • isAttachmentUploaded (29-31)
src/utils/refs/mergeRefs.test.ts (1)
src/utils/refs/mergeRefs.ts (1)
  • RefsUtils (3-34)
src/layout/ImageUpload/ImageControllers.tsx (1)
src/layout/ImageUpload/hooks/useFocusWhenUploaded.tsx (1)
  • useFocusWhenUploaded (6-40)
src/app-components/Dropzone/Dropzone.tsx (1)
src/utils/refs/mergeRefs.ts (1)
  • RefsUtils (3-34)
src/layout/ImageUpload/ImageCropper.tsx (2)
src/layout/ImageUpload/hooks/useFocusOnChange.tsx (1)
  • useFocusOnChange (4-22)
src/layout/ImageUpload/hooks/useFocusWhenRemoved.tsx (1)
  • useFocusWhenRemoved (4-20)
src/layout/ImageUpload/ImageDropzone.tsx (1)
src/app-components/Dropzone/Dropzone.tsx (1)
  • IDropzoneProps (16-29)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Install
🔇 Additional comments (16)
src/layout/ImageUpload/hooks/useFocusWhenRemoved.tsx (1)

4-20: LGTM! Clean focus-on-removal implementation.

The hook correctly tracks value changes and focuses the element when removed. The requestAnimationFrame ensures focus occurs after DOM updates, and the dependency array is appropriate since RefObject has stable identity.

test/e2e/integration/component-library/image-upload.ts (1)

62-62: LGTM! Improved selector specificity.

The change from label-based selection to direct input targeting is more robust and aligns with the component refactor that removed the Button asChild + Label pattern.

src/layout/ImageUpload/ImageDropzone.tsx (1)

16-19: LGTM! Clean ref forwarding implementation.

The new dropzoneInputRef prop is properly typed and forwarded to the Dropzone component, enabling external focus control as intended for the accessibility improvements.

Also applies to: 38-38

src/layout/ImageUpload/ImageCropper.tsx (1)

34-34: LGTM! Proper focus management implementation.

The new hooks and ref forwarding correctly implement the accessibility requirements:

  • Focus returns to the canvas after selecting an image (Line 49)
  • Focus returns to the dropzone input after deleting/canceling (Lines 51-53)
  • The dropzoneInputRef is properly created and forwarded (Lines 34, 100)

Also applies to: 48-53, 100-100

src/utils/refs/mergeRefs.test.ts (1)

7-107: LGTM! Comprehensive test coverage.

The test suite thoroughly validates all scenarios:

  • Callback refs and RefObjects
  • Multiple refs (mixed types)
  • Undefined/null edge cases
  • Empty refs array
  • Element updates and transitions
src/utils/refs/mergeRefs.ts (1)

3-34: LGTM! Solid ref merging utility.

The implementation correctly handles both callback refs and RefObjects. The type cast on Line 23 is necessary and follows the standard pattern for ref utilities, as RefObject.current is readonly in its type definition but needs to be mutated here.

src/layout/ImageUpload/ImageControllers.tsx (2)

45-48: LGTM! Proper focus management for delete button.

The new deleteButtonRef and useFocusWhenUploaded hook correctly implement the requirement to focus the delete button after successfully saving an image.


67-69: LGTM! Improved accessibility and removed problematic pattern.

The refactored button implementation:

  • Removes the problematic Button asChild + Label pattern that caused VoiceOver issues (issue #3815, point 3)
  • Adds proper aria-label to the hidden file input (Line 136)
  • Uses aria-hidden on the icon (Line 145)
  • Implements programmatic file selection via handleFileSelectClick (Line 67-69)

This directly addresses the reported VoiceOver inconsistencies.

Also applies to: 136-147

src/app-components/Dropzone/Dropzone.tsx (3)

9-9: LGTM!

Clean import of the new ref merging utility.


28-28: LGTM!

The inputRef prop addition enables external components to control and focus the dropzone input, which is essential for the accessibility improvements in this PR.


98-102: LGTM!

The input element correctly spreads inputProps first, then overrides the ref with the combinedRef. This ensures the merged ref (combining react-dropzone's internal ref with the external inputRef) is applied while preserving all other input properties.

src/layout/ImageUpload/hooks/useFocusWhenUploaded.tsx (5)

6-11: LGTM!

The hook signature and state initialization are well-structured. Using useRef for tracking the previous attachment ID and initial mount flag is appropriate since these values don't need to trigger re-renders.


13-39: LGTM!

The effect logic correctly handles both initial mount (storing the attachment ID without focusing) and subsequent uploads (focusing when a new attachment is uploaded). The separation into handleInitialMount and handleAttachmentUpload improves readability.


46-48: LGTM!

The getAttachmentId accessor is straightforward and handles the optional attachment correctly.


50-52: LGTM!

The hasAttachmentIdChanged function correctly checks that a new ID exists and differs from the previous one.


54-58: LGTM!

Using requestAnimationFrame to defer the focus call is the right approach here. It ensures the focus occurs after DOM updates and any asynchronous operations (like HTTP uploads) complete, which is critical for the accessibility improvements in this PR.

Copy link
Contributor

@lassopicasso lassopicasso left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superb 🚀 Also tested it on windows.

@lassopicasso lassopicasso merged commit d3cd6ed into main Oct 31, 2025
26 of 34 checks passed
@lassopicasso lassopicasso deleted the fix/3815_VoiceOverImageUploadA11y branch October 31, 2025 09:44
@github-actions
Copy link
Contributor

⚠️ Automatic backport failed due to conflicts

The automatic backport to release/v4.22 failed because of merge conflicts.

The release branch release/v4.22 already existed and was updated.

Manual backport required:

# Checkout the release branch
git checkout release/v4.22
git pull origin release/v4.22

# Create backport branch
git checkout -b backport/3820

# Cherry-pick the merge commit
git cherry-pick d3cd6ed2d8fcc47a3049c486a8e4999a22ba6275

# Resolve conflicts, then:
git add .
git cherry-pick --continue

# Push and create PR
git push origin backport/3820

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport This PR should be cherry-picked onto older release branches kind/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

VoiceOver (macOS) wcag issues for ImageUpload component

2 participants