Compare commits
2 Commits
b1589fe4e7
...
6d60994078
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d60994078 | |||
| 0413fec3f7 |
@@ -12,11 +12,14 @@ import { useEffect } from "react";
|
|||||||
* The field therefore stays wherever it sat in the dialog, often
|
* The field therefore stays wherever it sat in the dialog, often
|
||||||
* behind the keyboard.
|
* behind the keyboard.
|
||||||
*
|
*
|
||||||
* Fix: when focus lands on an input/textarea/select/contenteditable
|
* Fix: when focus lands on an editable inside a `[role="dialog"]`,
|
||||||
* that lives inside a `[role="dialog"]`, scroll the target into the
|
* walk up to the nearest scrollable ancestor (overflow-y auto/scroll)
|
||||||
* centre of the dialog. Run twice (300ms / 600ms) because iPad's
|
* inside the dialog and manually set its `scrollTop` so the target
|
||||||
* keyboard animation finishes ~250-500ms after focus and the dialog's
|
* sits in the middle of the visible band. We do NOT rely on
|
||||||
* own `max-height: 100dvh` recalc happens during that window.
|
* `scrollIntoView` because Safari iPadOS ignores it for descendants
|
||||||
|
* of `position: fixed` containers — the scroll never happens. We run
|
||||||
|
* the reveal twice (300ms / 600ms) to cover the iPad keyboard
|
||||||
|
* animation window and the dialog's own `max-height: 100dvh` recalc.
|
||||||
*
|
*
|
||||||
* One global listener covers every dialog kind we use (Radix
|
* One global listener covers every dialog kind we use (Radix
|
||||||
* DialogContent, the bespoke AdminFormDialog, ad-hoc role="dialog"
|
* DialogContent, the bespoke AdminFormDialog, ad-hoc role="dialog"
|
||||||
@@ -24,14 +27,17 @@ import { useEffect } from "react";
|
|||||||
*/
|
*/
|
||||||
export function useScrollFocusedDialogField(): void {
|
export function useScrollFocusedDialogField(): void {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Gate strictly to touch devices. Desktop browsers (mouse pointer)
|
// Gate to devices with any touch input. `pointer: coarse` alone
|
||||||
// never open a soft keyboard, so scrolling the focused field would
|
// misses iPads connected to a Magic Keyboard / trackpad (their
|
||||||
// be unwanted movement. `pointer: coarse` matches touch-primary
|
// *primary* pointer is reported as fine, so coarse never
|
||||||
// devices (iPad, iPhone, Android) and is stable across orientation
|
// matches), which is exactly the device class hitting this bug.
|
||||||
// / split-view changes, unlike visualViewport heuristics.
|
// `any-pointer: coarse` catches "has touch at all", which is the
|
||||||
|
// condition under which a soft keyboard can appear.
|
||||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||||
const isTouch = window.matchMedia("(pointer: coarse)").matches;
|
const hasTouch =
|
||||||
if (!isTouch) return;
|
window.matchMedia("(pointer: coarse)").matches ||
|
||||||
|
window.matchMedia("(any-pointer: coarse)").matches;
|
||||||
|
if (!hasTouch) return;
|
||||||
|
|
||||||
const isEditable = (el: Element | null): el is HTMLElement => {
|
const isEditable = (el: Element | null): el is HTMLElement => {
|
||||||
if (!el || !(el instanceof HTMLElement)) return false;
|
if (!el || !(el instanceof HTMLElement)) return false;
|
||||||
@@ -40,6 +46,26 @@ export function useScrollFocusedDialogField(): void {
|
|||||||
return el.isContentEditable;
|
return el.isContentEditable;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Walk up from `target` to find the nearest ancestor that actually
|
||||||
|
// scrolls vertically. Stops at the dialog root (we never want to
|
||||||
|
// scroll outside the dialog). Falls back to the dialog itself if
|
||||||
|
// no scrollable inner container exists.
|
||||||
|
const findScrollableAncestor = (
|
||||||
|
target: HTMLElement,
|
||||||
|
dialog: Element,
|
||||||
|
): HTMLElement => {
|
||||||
|
let node: HTMLElement | null = target.parentElement;
|
||||||
|
while (node && node !== dialog) {
|
||||||
|
const style = window.getComputedStyle(node);
|
||||||
|
const oy = style.overflowY;
|
||||||
|
if ((oy === "auto" || oy === "scroll") && node.scrollHeight > node.clientHeight) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return dialog as HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
const scrollTimers = new Set<number>();
|
const scrollTimers = new Set<number>();
|
||||||
|
|
||||||
const onFocusIn = (e: FocusEvent) => {
|
const onFocusIn = (e: FocusEvent) => {
|
||||||
@@ -47,21 +73,32 @@ export function useScrollFocusedDialogField(): void {
|
|||||||
if (!isEditable(target)) return;
|
if (!isEditable(target)) return;
|
||||||
const dialog = target.closest('[role="dialog"]');
|
const dialog = target.closest('[role="dialog"]');
|
||||||
if (!dialog) return;
|
if (!dialog) return;
|
||||||
|
|
||||||
const reveal = () => {
|
const reveal = () => {
|
||||||
try {
|
const container = findScrollableAncestor(target, dialog);
|
||||||
target.scrollIntoView({ block: "center", behavior: "smooth" });
|
const cRect = container.getBoundingClientRect();
|
||||||
} catch {
|
const tRect = target.getBoundingClientRect();
|
||||||
try {
|
// Distance from the container's top to the target's top in
|
||||||
target.scrollIntoView();
|
// *content* coordinates: current scrollTop + (target top in
|
||||||
} catch {
|
// viewport - container top in viewport).
|
||||||
/* no-op */
|
const targetTopInContent = container.scrollTop + (tRect.top - cRect.top);
|
||||||
}
|
// Centre the target within the visible band of the container.
|
||||||
}
|
const desired = targetTopInContent - (container.clientHeight - tRect.height) / 2;
|
||||||
|
const max = container.scrollHeight - container.clientHeight;
|
||||||
|
container.scrollTop = Math.max(0, Math.min(desired, max));
|
||||||
};
|
};
|
||||||
const t1 = window.setTimeout(reveal, 300);
|
|
||||||
const t2 = window.setTimeout(reveal, 600);
|
const schedule = (delay: number) => {
|
||||||
scrollTimers.add(t1);
|
const id = window.setTimeout(() => {
|
||||||
scrollTimers.add(t2);
|
scrollTimers.delete(id);
|
||||||
|
reveal();
|
||||||
|
}, delay);
|
||||||
|
scrollTimers.add(id);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
const t1 = schedule(300);
|
||||||
|
const t2 = schedule(600);
|
||||||
|
|
||||||
const onBlur = () => {
|
const onBlur = () => {
|
||||||
window.clearTimeout(t1);
|
window.clearTimeout(t1);
|
||||||
window.clearTimeout(t2);
|
window.clearTimeout(t2);
|
||||||
|
|||||||
Reference in New Issue
Block a user