Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/date-input-touch-clear-preventscroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@astryxdesign/core': patch
---

[fix] DateInput: clearing on touch no longer jumps the page to the top

On the touch surface, tapping the clear (✕) threw the user to the top of the
page. Clearing unmounts the clear button, and `handleClear` focused the field
in that same task — on iOS Safari, focusing an element as the focused button is
removed scrolls the whole document to 0. The focus handoff is now deferred past
the unmount, which keeps the page where it was and still returns focus to the
field.

Measured on the iOS 26 simulator against the live docsite (DateInput —
Clearable, page at scrollY 2055): synchronous focus → 0, deferred focus → 2055.
`preventScroll` alone does not fix it; it is kept for the ordinary
scroll-into-view nudge, which is unwanted for the same reason.

@imdreamrunner
36 changes: 36 additions & 0 deletions packages/core/src/DateInput/DateInputTouch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,42 @@ describe('DateInput — field parity', () => {
expect(onChange).toHaveBeenCalledWith(undefined);
});

it('returns focus to the field without letting the page scroll', async () => {
// Clearing unmounts the clear button, and focusing another element in the
// same task as that unmount makes iOS Safari scroll the document to the
// top. Measured on the iOS 26 simulator against the live docsite, field at
// scrollY 2055: synchronous focus lands at 0, deferred focus stays at
// 2055. `preventScroll` alone does not fix it, so both halves are
// asserted: the focus is deferred past the unmount, and it is passed
// preventScroll. jsdom implements no scrolling, so the guard is asserted
// at the call.
vi.useFakeTimers();
try {
render(
<DateInput
label="Ship date"
value="2026-03-21"
hasClear
onChange={() => {}}
/>,
);
const input = field();
const focus = vi.spyOn(input, 'focus');

fireEvent.click(screen.getByRole('button', {name: /Clear Ship date/}));

// Not synchronous — that is the whole point.
expect(focus).not.toHaveBeenCalled();

vi.runAllTimers();

expect(focus).toHaveBeenCalledWith({preventScroll: true});
focus.mockRestore();
} finally {
vi.useRealTimers();
}
});

it('does not open the picker until the field is tapped', () => {
withLayout(() => {
render(<DateInput label="Ship date" onChange={() => {}} />);
Expand Down
36 changes: 35 additions & 1 deletion packages/core/src/DateInput/TouchDateField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,16 @@ export function TouchDateField({
const [isSheetOpen, setIsSheetOpen] = useState(false);
const [isWheelOpen, setIsWheelOpen] = useState(false);
const scrollerHandleRef = useRef<MonthScrollerHandle | null>(null);
// Pending focus handoff from the clear button; see handleClear.
const clearFocusTimerRef = useRef<number | null>(null);
useEffect(
() => () => {
if (clearFocusTimerRef.current != null) {
clearTimeout(clearFocusTimerRef.current);
}
},
[],
);

const today = useMemo(() => plainDateToday(), []);
const selectedDate = useMemo(
Expand Down Expand Up @@ -708,7 +718,31 @@ export function TouchDateField({

const handleClear = useCallback(() => {
fireChange(undefined);
inputRef.current?.focus();
// Focus goes back to the field on the NEXT task, not synchronously.
//
// Clearing unmounts this button (it only renders while there is a value),
// and focusing another element in the same task as that unmount makes iOS
// Safari scroll the whole document to the top — the user is thrown from
// wherever the field sat to the start of the page. Measured on the iOS 26
// simulator against the live docsite, field at scrollY 2055: synchronous
// focus lands at 0, deferred focus stays at 2055.
//
// `preventScroll` alone does NOT fix it (verified: still 0) — this is not
// the browser's ordinary scroll-the-focused-element-into-view step, so the
// deferral is the load-bearing half. It is kept because the reveal scroll
// is real too, and unwanted for the same reason: the field the user just
// tapped is already on screen (+12px on a plain page without it).
//
// Skipping the focus entirely would also stop the scroll, but then focus
// dies with the unmounting button and lands on <body>.
const field = inputRef.current;
if (field == null) {
return;
}
clearFocusTimerRef.current = window.setTimeout(() => {
clearFocusTimerRef.current = null;
field.focus({preventScroll: true});
}, 0);
}, [fireChange]);

/**
Expand Down
Loading