Skip to content

fix(Datepicker): fix Datepicker selection behavior on iOS with VoiceOver enabled#1106

Merged
kheinrich-eightfold merged 4 commits intomainfrom
kheinrich/fix-datepicker-voiceover-ios
Mar 24, 2026
Merged

fix(Datepicker): fix Datepicker selection behavior on iOS with VoiceOver enabled#1106
kheinrich-eightfold merged 4 commits intomainfrom
kheinrich/fix-datepicker-voiceover-ios

Conversation

@kheinrich-eightfold
Copy link
Contributor

@kheinrich-eightfold kheinrich-eightfold commented Mar 17, 2026

SUMMARY:

This PR fixes some issues with date selection using the DatePicker component on iOS when VoiceOver is enabled.

Problem

On iPhone with VoiceOver, the DatePicker behaved incorrectly:

Popover closed on first tap: Tapping a day cell to explore closed the popover instead of selecting the date.
Focus lost after selection: After successfully choosing a date (e.g. by double-tap), focus did not return to the input.
Without VoiceOver, opening the picker and selecting a date worked as expected.

Root cause

  1. Popover closing on explore

The popover’s close logic ran on blur of the input. When the user moved the VoiceOver cursor to a day cell, the input blurred and document.activeElement briefly became body. The existing guards (mousedown preventDefault on the popover, global mousedown setting preventBlurRef) never ran because VoiceOver does not fire mousedown for that interaction. The blur handler therefore treated the interaction as “click outside” and closed the popover immediately, before the user could activate the cell.

  1. Focus lost after selection

With VoiceOver, focus moved to the day cell button before the user double-tapped to select. Selection closed the popover and unmounted that button. Focus restoration was only done in the FocusTrap cleanup, and the trap is only enabled after Tab (not used in the touch/VoiceOver flow), so restoreFocusRef was never set and focus ended up on body.

Fixes

  1. Defer blur-based close (usePickerInput.ts)

For the non–blurToCancel path (default date picker), the close decision is no longer made synchronously in onBlur.
It is deferred with requestAnimationFrame. In the callback we re-check document.activeElement and only call triggerOpen(false) (and submit if needed) if isClickOutside(activeElement) is true.
Effect: When VoiceOver moves focus to a day cell, the initial blur still fires with activeElement === body, but one tick later focus has settled on the cell; the deferred check sees focus inside the picker and does not close. The popover stays open so the user can double-tap to select. This matches the existing pattern used for the blurToCancel path.

  1. Restore focus on selection (OcPicker.tsx)

In onContextSelect, after triggerChange, triggerOpen(false), and setTrap(false), we call inputRef.current?.focus().
Effect: When the user selects a date (mouse, keyboard, or VoiceOver), focus returns to the input that now shows the selected date, instead of being lost when the popover unmounts.

  1. Tests (picker.test.tsx, range.test.tsx)

Tests that rely on “blur → popover closes” now use fake timers: after closePicker() or simulate('blur'), they run act(() => { jest.runAllTimers(); }) and wrapper.update() before asserting closed state or input value. This accounts for the new deferred close (driven by requestAnimationFrame). New unit tests added to cover focus returning to input after selecting a date and keeping popup open when focus moves inside after blur.

Files changed

DateTimePicker/Internal/Hooks/usePickerInput.ts — deferred blur-close logic.
DateTimePicker/Internal/OcPicker.tsx — focus restore in onContextSelect.
DateTimePicker/Internal/Tests/picker.test.tsx — fake timers for blur-close tests.
DateTimePicker/Internal/Tests/range.test.tsx — same for range picker blur-close tests.

GITHUB ISSUE (Open Source Contributors)

JIRA TASK (Eightfold Employees Only):

https://eightfoldai.atlassian.net/browse/ENG-185529

CHANGE TYPE:

  • Bugfix Pull Request
  • Feature Pull Request

TEST COVERAGE:

  • Tests for this change already exist
  • I have added unittests for this change

TEST PLAN:

Note: This fixes an issue specific to iOS with VoiceOver enabled. In order to test the fix, you'll need to expose the Octuple Storybook to your network so that it can be accessed via iPhone with VoiceOver running. Also, with VoiceOver enabled, touch behavior works differently for focus and selection; see details below.

  • Pull branch and run yarn storybook
  • Expose the app to local network (using ngrok, expose port 2022. See ngrok setup guide for additional details.)
  • From your iOS browser, navigate to the ngrok URL. Go to the first DatePicker story in Storybook (Open Sidebar -> Date Picker -> Single Picker -> Back to Canvas).
  • Enable VoiceOver. Move focus to the first input ("Select date") using a single-tap, then open it using double-tap. Focus on a day cell in the calendar popup using single-tap, then select it using double-tap. Ensure the day is selectable and that the input gets populated with the selected day. Also ensure focus returns to the input when the popup closes.

@codesandbox-ci
Copy link

codesandbox-ci bot commented Mar 17, 2026

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

@kheinrich-eightfold kheinrich-eightfold marked this pull request as ready for review March 17, 2026 20:29
Copy link
Contributor

@factory-droid factory-droid bot left a comment

Choose a reason for hiding this comment

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

PR Description:
The PR enhances DatePicker accessibility on iOS with VoiceOver by modifying blur handling in usePickerInput.ts to defer popup closing decisions and improving focus management in OcPicker.tsx with explicit input focus restoration. Test files were updated with jest fake timers to properly test the new asynchronous behavior. These changes allow VoiceOver users to properly explore and select dates while maintaining focus context.

Review:
LGTM

Open in Factory.
Click For Summary of Files

Summary of Files

Files Modified

src/components/DateTimePicker/Internal/OcPicker.tsx
Added explicit focus management in DatePicker for iOS VoiceOver accessibility:

- Enhanced focus handling in InnerPicker component to restore input focus after interaction
src/components/DateTimePicker/Internal/Tests/range.test.tsx
Updated Range Picker tests to properly handle asynchronous behavior for iOS VoiceOver accessibility:

- Added jest timer management to control async operations in blur and focus tests
- Enhanced test coverage for picker closing behavior by wrapping operations in act() blocks and adding explicit wrapper updates
- Updated auto-open functionality tests with proper timer handling for both empty and valued states
src/components/DateTimePicker/Internal/Tests/picker.test.tsx
Updated DatePicker tests to handle new asynchronous behavior for iOS VoiceOver accessibility:

- Added Jest timer mocking (useFakeTimers/useRealTimers) to multiple test cases
- Implemented proper timer management with act() and runAllTimers() to handle delayed state updates
- Updated component re-rendering assertions to account for asynchronous blur handling
src/components/DateTimePicker/Internal/Hooks/usePickerInput.ts
Enhanced DatePicker blur handling for iOS VoiceOver accessibility:

- Modified blur handler to defer popup closing decisions using setTimeout
- Added shadow DOM traversal to properly detect active element focus
- Implemented conditional popup closing based on click target validation
Tips
Review Droid is highly customizable and comes with powerful features for augmenting your organization's code review process. Here are some tips to get the most out of it.

Table of contents

⌨️ Droid Fill

Contextual PR Body Replacement

When you create a PR with the @droid fill command anywhere in your PR body, Review Droid will fill in the PR description for your pull request based on it's PR analysis. This will also take into account your pull request templates.

Review Droid can also analyze your project management system. If you have a project management system integrated with Factory (e.g. Linear, Jira) Review Droid will also integrate information from linked and related tickets.

At Factory, we typically create our PR's with this command. For example, let's say I'm creating a PR which addresses the jira ticket FAC-123. I would write the following PR description:

@droid fill FAC-123

and your Review Droid fills in the rest!

📚 Review Guidelines

Creating guidelines for Droid to follow

You can configure guidelines that Droid will follow when reviewing your PRs. Droid will focus on these aspects of your code and aim to leave in-line comments if any guidelines are violated.

Guidelines are defined in your repository's .droid.yaml. Every week, Droid will automatically refine and edit these guidelines based on the feedback you leave on Droid's comments.

💬 Droid Chat

Ask questions on a PR

You can leave in-line comments on PR's by tagging @droid in-line. This can be helpful when reviewing other's PRs. Some examples include:

  • @droid this section looks sketchy, are there issues with it?
  • @droid can you show me some examples of what this regex matches?
  • @droid is this the most efficient way to do this? I'm concerned about performance.

Follow up with Review Droid's Comments

You can reply to Review Droid's in-line review comments directly to ask questions or provide feedback. Some examples include:

  • @droid I made the change you suggested, does that fix the issue?
  • @droid we don't actually need to do this because of X, Y, Z. Can you confirm?
  • @droid do we have any scripts that rely on this behavior?

🛠️ PR Healing

Diagnose & Fix Failures in CI

Review Droid is aware of the CI processes you utilize and proposes fixes in case of any failures. This allows it to promptly address issues in your pull requests before they escalate.

By default, PR Healing is activated. Your organization does not have advanced PR healing enabled, which involved Review Droid directly making a PR to your PR which fixes the issue. If you would like to enable this feature, you must have an Enterprise Plan.

🎓 Teaching Droid

Giving Droid feedback so it learns

You can give feedback to Review Droid by replying or reacting to its comments (👍 / 👎). This helps Review Droid learn from your preferences and improve its future reviews.

To send feedback directly to the Factory team, include @droid feedback in your comment. Droid will file a ticket with your feedback and provide a ticket ID so you can track it with our support team.

🔎 Review Usage

Re-Requesting Review

If you make changes to your PR and want Review Droid to re-review it, you can simply comment @droid review on the PR. This will trigger Droid to re-review the PR and update the review body.

.droid.yaml to Configure Review Droid

You can place a .droid.yaml file in the root of your repository. This file contains settings for a variety of features and settings including:

  • Guidelines - For defining the rules that Review Droid will enforce
  • Enabling/Disabling Per-file Summaries
  • Enabling/Disabling PR Healing
  • Path Filters (For ignoring certain files or directories)
  • Auto-Review Settings
  • Chat settings

To override a setting leave a comment on a PR with the setting to disable/enable/reset. For example @droid setting disable progress_comment. The current options are: progress_comment, lgtm_comment, and list.

list is a special setting that will list all the settings that you have set and will explain what each setting does.

For more information, you can view our documentation at https://docs.factory.ai - the password is factory.

Ignoring Reviews

If you want to have your PR ignored by Review Droid you can define Droid Ignored Title Words in your .droid.yaml file. If the title of your PR contains any of these words, Review Droid will ignore the PR.

Your organization currently has the following words in the Droid Ignored Title Words list:
None

@codecov
Copy link

codecov bot commented Mar 17, 2026

Codecov Report

❌ Patch coverage is 90.90909% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 84.13%. Comparing base (4311894) to head (f4440d0).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...ts/DateTimePicker/Internal/Hooks/usePickerInput.ts 90.00% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1106   +/-   ##
=======================================
  Coverage   84.13%   84.13%           
=======================================
  Files        1146     1146           
  Lines       21178    21185    +7     
  Branches     8052     8055    +3     
=======================================
+ Hits        17819    17825    +6     
- Misses       3272     3273    +1     
  Partials       87       87           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@kheinrich-eightfold kheinrich-eightfold force-pushed the kheinrich/fix-datepicker-voiceover-ios branch 2 times, most recently from 582c0e1 to e1b81c0 Compare March 23, 2026 02:56
@kheinrich-eightfold kheinrich-eightfold force-pushed the kheinrich/fix-datepicker-voiceover-ios branch from e1b81c0 to ce56103 Compare March 23, 2026 22:54
@kheinrich-eightfold kheinrich-eightfold merged commit a2f8a00 into main Mar 24, 2026
7 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants