feat(design): dialogs open by purpose and keep their commit row pinned (E28 S3f1)
Modal takes a required initialFocus (field / close / action), a field that loads late takes the focus while it still rests on the close button, and ModalFooter portals a dialog's commit row under the scrolling body from any depth. The header and footer fades follow the body's scroll timeline, and the viewport's interactive-widget=resizes-content keeps the footer above an on-screen keyboard.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { useEffect, useEffectEvent, useRef, useState, type RefObject } from "react";
|
||||
|
||||
// The focus half of the dialog contract, in ONE place. `ui/Modal` owns it for every dialog that goes
|
||||
// through the shell; the onboarding wizard renders its own shell (a bare `ui/Overlay`, because it is
|
||||
@@ -13,7 +13,7 @@ import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
const focusablesIn = (root: HTMLElement): HTMLElement[] =>
|
||||
export const focusablesIn = (root: HTMLElement): HTMLElement[] =>
|
||||
[...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => el.offsetParent !== null);
|
||||
|
||||
/** Keep Tab / Shift+Tab cycling inside `scope` instead of reaching the inert page behind the
|
||||
@@ -71,11 +71,15 @@ function lockBodyScroll(): () => void {
|
||||
* panel, is that whole dialog — it must be the SAME element `trapTab` is scoped to, or the two
|
||||
* disagree about what counts as "inside" (see `heldRef`). `focusScope` opens the dialog focused on
|
||||
* the scope element itself rather than its first control: the player keeps the focus on its card, so
|
||||
* Space and the arrows reach its shortcuts instead of activating a button. */
|
||||
* Space and the arrows reach its shortcuts instead of activating a button. `initial`, when given,
|
||||
* picks the first-open target instead (`ui/Modal`'s focus-by-purpose rule); null falls back. */
|
||||
export function useDialogChrome(
|
||||
scopeRef: RefObject<HTMLElement | null>,
|
||||
ownerRef?: RefObject<HTMLElement | null>,
|
||||
{ focusScope = false }: { focusScope?: boolean } = {},
|
||||
{
|
||||
focusScope = false,
|
||||
initial,
|
||||
}: { focusScope?: boolean; initial?: () => HTMLElement | null } = {},
|
||||
): void {
|
||||
// Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the
|
||||
// dialog's autoFocus (or our own initial focus) and moves activeElement into the dialog. Reading it
|
||||
@@ -101,6 +105,8 @@ export function useDialogChrome(
|
||||
// <body> instead of handing it back to the trigger. Measured in jsdom, and a regression: before
|
||||
// this hook existed the restore was unconditional (deep-review round 6).
|
||||
const heldRef = useRef(true);
|
||||
// Read at open, not a dependency: a caller's fresh function each render must not re-run the open.
|
||||
const pickInitial = useEffectEvent(() => initial?.() ?? null);
|
||||
useEffect(() => {
|
||||
const unlockScroll = lockBodyScroll();
|
||||
// Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move
|
||||
@@ -117,6 +123,7 @@ export function useDialogChrome(
|
||||
const back = focusedRef.current;
|
||||
const target =
|
||||
(back?.isConnected && scope.contains(back) ? back : null) ??
|
||||
pickInitial() ??
|
||||
(focusScope ? scope : (focusablesIn(scope)[0] ?? scope));
|
||||
target.focus();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user