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:
2026-09-27 01:33:37 +02:00
parent 4c63d6660d
commit 3ef2c38eb4
34 changed files with 505 additions and 113 deletions
+7 -1
View File
@@ -2,7 +2,13 @@
<html lang="en" data-theme="dark" data-scheme="midnight">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- resizes-content: the on-screen keyboard shrinks the layout viewport, not only the visual one,
so a dialog's pinned footer (DESIGN C4) sits above the keyboard instead of under it. Chrome's
default since 108 only shrinks the visual viewport; Safari ignores the key. -->
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- OG:START — the server replaces this block for /watch/{token} so shared links unfurl with
the video's title/channel/thumbnail; every other route keeps these generic defaults. -->
+1
View File
@@ -45,6 +45,7 @@ export default function About({
return (
<Modal
initialFocus="close"
title={
<span className="flex items-center gap-2">
<Info className="w-5 h-5 text-accent" /> {t("about.title")}
+8 -2
View File
@@ -105,8 +105,14 @@ export default function AccountPanel({
const a: AdminUserAccess | undefined = q.data;
return (
<Modal title={displayName(user)} onClose={onClose} placement="drawer" maxWidth="max-w-md">
<div className="text-2xs text-muted truncate -mt-2 mb-4">{user.email}</div>
<Modal
initialFocus="close"
title={displayName(user)}
onClose={onClose}
placement="drawer"
maxWidth="max-w-md"
>
<div className="text-2xs text-muted truncate mb-4">{user.email}</div>
{q.isError && !a ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
+5 -4
View File
@@ -1,6 +1,6 @@
import { useCallback, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./ui/Modal";
import Modal, { ModalFooter } from "./ui/Modal";
import { Button } from "./ui/Button";
import { ChoiceContext, type ChoiceFn, type ChoiceOptions } from "./confirmContext";
@@ -35,6 +35,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
{children}
{state && (
<Modal
initialFocus="action"
title={state.opts.title ?? t("common.confirmTitle")}
onClose={() => close(null)}
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
@@ -43,8 +44,8 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
{/* One action: the familiar Cancel/Confirm row. Two or more: a column, because side by
side they wrap raggedly and a wrapped button reads as a second, lesser group — and
these are peers. Ordered least to most destructive, top to bottom. */}
<div
className={multi ? "flex flex-col gap-2 mt-5" : "flex justify-end gap-2 mt-5 flex-wrap"}
<ModalFooter
className={multi ? "flex flex-col gap-2" : "flex flex-wrap justify-end gap-2"}
>
<Button
variant="outline"
@@ -66,7 +67,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
{c.label || t("common.confirm")}
</Button>
))}
</div>
</ModalFooter>
</Modal>
)}
</ChoiceContext.Provider>
@@ -143,7 +143,7 @@ function DialogToggle() {
<>
<button onClick={() => setOpen((v) => !v)}>toggle dialog</button>
{open && (
<Modal title="dialog" onClose={() => setOpen(false)}>
<Modal initialFocus="close" title="dialog" onClose={() => setOpen(false)}>
<p>inside</p>
</Modal>
)}
@@ -197,7 +197,7 @@ describe("the recorder pill", () => {
const onClose = vi.fn();
mount(
{ debug_session: session() },
<Modal title="dialog" onClose={onClose}>
<Modal initialFocus="close" title="dialog" onClose={onClose}>
<p>inside</p>
</Modal>,
);
@@ -215,7 +215,7 @@ describe("the recorder pill", () => {
it("Tab from the step prompt stays inside the dialog the pill sits in", () => {
mount(
{ debug_session: session() },
<Modal title="dialog" onClose={() => {}}>
<Modal initialFocus="close" title="dialog" onClose={() => {}}>
<p>inside</p>
</Modal>,
);
+4 -4
View File
@@ -23,7 +23,7 @@ import { qk } from "../lib/queryKeys";
import { dockUnderToasts } from "../lib/toastDock";
import { useMe } from "../lib/useMe";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import Modal, { ModalFooter } from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
import IssueComposer from "./IssueComposer";
@@ -97,7 +97,7 @@ function StartDialog() {
const minutes = me.diagnostics.session_minutes;
const levels: readonly DebugSessionLevel[] = ["DEBUG", "TRACE"];
return (
<Modal title={t("recorder.start.title")} onClose={closeRecorderStart}>
<Modal initialFocus="field" title={t("recorder.start.title")} onClose={closeRecorderStart}>
<div className="flex flex-col gap-4">
<p className="text-sm text-muted leading-relaxed">{t("recorder.start.intro")}</p>
<div className="flex flex-col items-start gap-1.5">
@@ -122,7 +122,7 @@ function StartDialog() {
{t("recorder.start.cap", { count: minutes })}
</p>
</div>
<div className="flex justify-end gap-2">
<ModalFooter>
<Button variant="ghost" onClick={closeRecorderStart}>
{t("common.cancel")}
</Button>
@@ -134,7 +134,7 @@ function StartDialog() {
>
{t("recorder.start.confirm")}
</Button>
</div>
</ModalFooter>
</div>
</Modal>
);
+5 -5
View File
@@ -2,7 +2,7 @@ import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Modal from "./ui/Modal";
import Modal, { ModalFooter } from "./ui/Modal";
import { Button } from "./ui/Button";
import { api } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
@@ -63,7 +63,7 @@ export default function DownloadDialog({
};
return (
<Modal title={t("downloads.dialog.title")} onClose={onClose}>
<Modal initialFocus="field" title={t("downloads.dialog.title")} onClose={onClose}>
{title && <p className="text-sm text-muted mb-4 line-clamp-2">{title}</p>}
<span className="block text-sm font-medium mb-1" aria-hidden="true">
@@ -83,20 +83,20 @@ export default function DownloadDialog({
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.nameLabel")}</label>
<Input
className="w-full mb-5"
className="w-full"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("downloads.dialog.namePlaceholder")}
/>
<div className="flex justify-end gap-2">
<ModalFooter>
<Button variant="ghost" size="sm" onClick={onClose}>
{t("downloads.actions.cancel")}
</Button>
<Button variant="primary" size="sm" onClick={submit} disabled={busy || !chosen}>
{t("downloads.dialog.submit")}
</Button>
</div>
</ModalFooter>
{manage && (
<Suspense fallback={null}>
+4 -4
View File
@@ -1,7 +1,7 @@
import { useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { dismissError, getError, subscribeError } from "../lib/errorDialog";
import Modal from "./ui/Modal";
import Modal, { ModalFooter } from "./ui/Modal";
import { Button } from "./ui/Button";
// Renders the current app error (if any) as a modal the user must acknowledge.
@@ -10,11 +10,11 @@ export default function ErrorDialog() {
const err = useSyncExternalStore(subscribeError, getError, getError);
if (!err) return null;
return (
<Modal title={err.title} onClose={dismissError} maxWidth="max-w-md">
<Modal initialFocus="close" title={err.title} onClose={dismissError} maxWidth="max-w-md">
<p className="text-sm leading-relaxed text-fg/90">{err.message}</p>
<div className="flex justify-end mt-5">
<ModalFooter>
<Button onClick={dismissError}>{t("errors.ok")}</Button>
</div>
</ModalFooter>
</Modal>
);
}
+9 -4
View File
@@ -22,7 +22,7 @@ import { qk } from "../lib/queryKeys";
import { LEVEL_TAG, logClock } from "./logLineParts";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import Modal from "./ui/Modal";
import Modal, { ModalFooter } from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
import { Textarea, Input } from "./ui/Input";
@@ -61,7 +61,12 @@ export default function IssueComposer({ id }: { id: string }) {
[],
);
return (
<Modal title={t("recorder.composer.title")} onClose={closeComposer} maxWidth="max-w-5xl">
<Modal
initialFocus="field"
title={t("recorder.composer.title")}
onClose={closeComposer}
maxWidth="max-w-5xl"
>
{failed && !(sessionQ.data && eventsQ.data) ? (
<StateMessage
tone="error"
@@ -410,7 +415,7 @@ function ComposerBody({
/>
</div>
)}
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-3">
<ModalFooter>
<span className="mr-auto flex flex-col text-xs text-muted">
{target && <span>{t("recorder.composer.target", { repo: target.repo })}</span>}
<span aria-live="polite">
@@ -447,7 +452,7 @@ function ComposerBody({
{t("recorder.composer.send")}
</Button>
)}
</div>
</ModalFooter>
</div>
);
}
+1 -1
View File
@@ -41,7 +41,7 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
);
return (
<Modal title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
<Modal initialFocus="close" title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
{isAudio ? (
<div className="flex flex-col items-center gap-4 py-2">
{poster ? (
@@ -40,7 +40,7 @@ describe("the onboarding wizard's Escape", () => {
>
<OnboardingWizard me={me} onClose={onClose} />
{dialog && (
<Modal title="boom" onClose={() => {}}>
<Modal initialFocus="close" title="boom" onClose={() => {}}>
<p>the sync failed</p>
</Modal>
)}
@@ -191,7 +191,12 @@ export default function PersonalViewsModal() {
const saveCurrentDisabled = selected != null && selected.id === activeViewId;
return (
<Modal title={t("views.manageTitle")} onClose={closeModal} maxWidth="max-w-2xl">
<Modal
initialFocus="close"
title={t("views.manageTitle")}
onClose={closeModal}
maxWidth="max-w-2xl"
>
<div className="grid min-h-[20rem] grid-cols-[minmax(0,14rem)_1fr] gap-4">
{/* Left: the list + create */}
<div className="flex flex-col border-r border-border/60 pr-4">
@@ -144,7 +144,11 @@ export default function PlexCollectionEditor({
}
return (
<Modal title={t("plex.collEditor.title", { title: item.title })} onClose={onClose}>
<Modal
initialFocus="field"
title={t("plex.collEditor.title", { title: item.title })}
onClose={onClose}
>
{/* Create new */}
<div className="mb-3 flex gap-2">
<Input
+5 -1
View File
@@ -113,7 +113,11 @@ export default function PlexPlaylistAdd({
}
return (
<Modal title={t("plex.playlistAdd.title", { title: target.title })} onClose={onClose}>
<Modal
initialFocus="field"
title={t("plex.playlistAdd.title", { title: target.title })}
onClose={onClose}
>
<div className="mb-3 flex gap-2">
<Input
className="flex-1"
+6 -1
View File
@@ -106,7 +106,12 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
const patch = (p: Partial<DownloadSpec>) => setSpec((s) => ({ ...s, ...p }));
return (
<Modal title={t("downloads.profiles.title")} onClose={onClose} maxWidth="max-w-xl">
<Modal
initialFocus="close"
title={t("downloads.profiles.title")}
onClose={onClose}
maxWidth="max-w-xl"
>
{/* Existing formats */}
<div className="space-y-1.5 mb-4">
<SectionLabel level={3}>{t("downloads.profiles.builtin")}</SectionLabel>
+6 -1
View File
@@ -45,7 +45,12 @@ export default function ReleaseNotes({
const active = at >= 0 ? highlight : notes[0]?.version;
const shown = showAll || at >= RECENT ? notes : notes.slice(0, RECENT);
return (
<Modal title={t("about.releaseNotes")} onClose={onClose} maxWidth="max-w-2xl">
<Modal
initialFocus="close"
title={t("about.releaseNotes")}
onClose={onClose}
maxWidth="max-w-2xl"
>
{q.isError ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("about.notesError")}
+1 -1
View File
@@ -349,7 +349,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
export default function ShareDialog({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
const { t } = useTranslation();
return (
<Modal title={t("downloads.share.title")} onClose={onClose}>
<Modal initialFocus="close" title={t("downloads.share.title")} onClose={onClose}>
<div className="space-y-5">
<UserShare job={job} />
<div className="border-t border-border" />
+1 -1
View File
@@ -160,7 +160,7 @@ export default function TagManager({
};
return (
<Modal title={t("tagManager.title")} onClose={onClose}>
<Modal initialFocus="close" title={t("tagManager.title")} onClose={onClose}>
<div className="flex flex-col gap-2">
{userTags.length === 0 ? (
<p className="text-sm text-muted">{t("tagManager.empty")}</p>
+6 -6
View File
@@ -2,7 +2,7 @@ import { useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { UploadCloud, File as FileIcon } from "lucide-react";
import Modal from "./ui/Modal";
import Modal, { ModalFooter } from "./ui/Modal";
import { Button } from "./ui/Button";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
@@ -98,7 +98,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
};
return (
<Modal title={t("downloads.upload.title")} onClose={onClose}>
<Modal initialFocus="field" title={t("downloads.upload.title")} onClose={onClose}>
<p className="text-sm text-muted mb-4">{t("downloads.upload.hint")}</p>
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */}
@@ -180,7 +180,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
{t("downloads.upload.nameLabel")}
</label>
<Input
className="w-full mb-5"
className="w-full"
id="upload-display-name"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -194,7 +194,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
/>
{busy && (
<div className="mb-5" aria-live="polite">
<div className="mt-5" aria-live="polite">
<div
className="h-2 rounded-full bg-surface overflow-hidden"
role="progressbar"
@@ -216,7 +216,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
</div>
)}
<div className="flex justify-end gap-2">
<ModalFooter>
<Button
variant="ghost"
size="sm"
@@ -239,7 +239,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
>
{t("downloads.upload.submit")}
</Button>
</div>
</ModalFooter>
</Modal>
);
}
+9 -4
View File
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, X } from "lucide-react";
import clsx from "clsx";
import Modal from "./ui/Modal";
import Modal, { ModalFooter } from "./ui/Modal";
import { notify } from "../lib/notifications";
import { api, type DownloadJob, type EditSpec } from "../lib/api";
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
@@ -384,7 +384,12 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const selIdx = segments.findIndex((s) => s.id === sel?.id);
return (
<Modal title={t("editor.title", { name: defaultName })} onClose={onClose} maxWidth="max-w-3xl">
<Modal
initialFocus="field"
title={t("editor.title", { name: defaultName })}
onClose={onClose}
maxWidth="max-w-3xl"
>
<div className="space-y-3">
{/* video + crop overlay */}
<div className="relative bg-black rounded-lg overflow-hidden select-none">
@@ -681,7 +686,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
/>
</div>
<div className="flex items-center justify-between pt-1">
<ModalFooter className="flex items-center justify-between gap-2">
<span className="text-xs text-muted">
{reencode ? t("editor.willReencode") : t("editor.willCopy")}
</span>
@@ -704,7 +709,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
: t("editor.create")}
</Button>
</div>
</div>
</ModalFooter>
</div>
</Modal>
);
+8 -4
View File
@@ -3,7 +3,7 @@ import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Settings2 } from "lucide-react";
import Modal from "./ui/Modal";
import Modal, { ModalFooter } from "./ui/Modal";
import { useLiveQuery } from "../lib/useLiveQuery";
import { api } from "../lib/api";
import { formatBytes } from "../lib/format";
@@ -77,7 +77,11 @@ function QuotaModal({
if (!state) return null;
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
return (
<Modal title={t("downloads.admin.quotaTitle", { email })} onClose={onClose}>
<Modal
initialFocus="field"
title={t("downloads.admin.quotaTitle", { email })}
onClose={onClose}
>
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<label className="text-sm">
@@ -116,7 +120,7 @@ function QuotaModal({
>
{t("downloads.admin.unlimited")}
</CheckRow>
<div className="flex justify-between pt-1">
<ModalFooter className="flex justify-between gap-2">
<Button variant="ghost" size="sm" onClick={() => reset.mutate()}>
{t("downloads.admin.reset")}
</Button>
@@ -128,7 +132,7 @@ function QuotaModal({
>
{t("downloads.admin.save")}
</Button>
</div>
</ModalFooter>
</div>
</Modal>
);
+8 -13
View File
@@ -3,7 +3,7 @@ import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react";
import Modal from "./ui/Modal";
import Modal, { ModalFooter } from "./ui/Modal";
import { api, type DownloadJob } from "../lib/api";
import { Button } from "./ui/Button";
import { IconBtn } from "./downloadRow";
@@ -46,16 +46,11 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
});
return (
<Modal title={t("downloads.edit.title")} onClose={onClose}>
<Modal initialFocus="field" title={t("downloads.edit.title")} onClose={onClose}>
<div className="space-y-3">
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.name")}</span>
<Input
className="w-full"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
<Input className="w-full" value={name} onChange={(e) => setName(e.target.value)} />
</label>
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
@@ -96,7 +91,7 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
</Button>
</div>
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
<div className="flex justify-end">
<ModalFooter>
<Button
variant="primary"
size="sm"
@@ -105,7 +100,7 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
>
{t("downloads.edit.save")}
</Button>
</div>
</ModalFooter>
</div>
</Modal>
);
@@ -135,7 +130,7 @@ export function ErrorModal({
const [showRaw, setShowRaw] = useState(false);
const retryable = RETRYABLE_ERRORS.includes(code);
return (
<Modal title={t("downloads.errors.fail.title")} onClose={onClose}>
<Modal initialFocus="close" title={t("downloads.errors.fail.title")} onClose={onClose}>
<div className="space-y-3">
<div className="font-medium">{t(failCopyKeys(job, "short"))}</div>
<p className="text-sm text-muted">{t(failCopyKeys(job, "body"))}</p>
@@ -156,7 +151,7 @@ export function ErrorModal({
)}
</div>
)}
<div className="flex justify-end gap-2 pt-1">
<ModalFooter>
{retryable && (
<Button
variant="primary"
@@ -172,7 +167,7 @@ export function ErrorModal({
<Button variant="ghost" size="sm" onClick={onClose}>
{t("common.close")}
</Button>
</div>
</ModalFooter>
</div>
</Modal>
);
@@ -194,11 +194,16 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
};
return (
<Modal title={t("palette.title")} onClose={onClose} maxWidth="max-w-xl" placement="top">
<Modal
initialFocus="field"
title={t("palette.title")}
onClose={onClose}
maxWidth="max-w-xl"
placement="top"
>
<div className={clsx(fieldClass({ size: "lg" }), "flex items-center gap-2")}>
<Search className="w-4 h-4 shrink-0 text-muted" />
<InnerInput
autoFocus
value={query}
onChange={(e) => {
setQuery(e.target.value);
+6 -1
View File
@@ -49,7 +49,12 @@ export default function KeyboardHelp() {
const lines = helpLines(live, pages).filter((l) => !l.when || !!conditions[l.when]);
const words = { Space: t("keys.cap.space"), ContextMenu: t("keys.cap.menu") };
return (
<Modal title={t("keys.title")} onClose={closeKeyboardHelp} maxWidth="max-w-md">
<Modal
initialFocus="close"
title={t("keys.title")}
onClose={closeKeyboardHelp}
maxWidth="max-w-md"
>
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
{lines.map((line) => (
<Fragment key={line.labelKey}>
+21 -21
View File
@@ -66,7 +66,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
it("moves initial focus to the first control on open", () => {
render(
<Modal title="t" onClose={() => {}}>
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
@@ -75,7 +75,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
it("Tab from the last control wraps to the first", () => {
render(
<Modal title="t" onClose={() => {}}>
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
@@ -86,7 +86,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
it("Shift+Tab from the first control wraps to the last", () => {
render(
<Modal title="t" onClose={() => {}}>
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
@@ -99,7 +99,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
// takes Escape first, but deferring the Tab trap to it let Shift+Tab walk out behind the scrim.
it("keeps trapping while only a hover card is open above it", () => {
render(
<Modal title="t" onClose={() => {}}>
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
@@ -117,7 +117,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
// The dialog's toast host sits beside the panel; trapped to the panel, a toast's Undo or dismiss
// could not be reached from the keyboard at all.
render(
<Modal title="t" onClose={() => {}}>
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
@@ -143,7 +143,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
const outside = document.createElement("button");
document.body.appendChild(outside);
render(
<Modal title="t" onClose={() => {}}>
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
@@ -156,7 +156,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
it("Shift+Tab from the panel container itself goes to the last control (no leak to the background)", () => {
render(
<Modal title="t" onClose={() => {}}>
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
@@ -167,7 +167,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
it("does not trap a Tab in the middle — only the edges wrap", () => {
render(
<Modal title="t" onClose={() => {}}>
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
@@ -180,7 +180,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
it("Escape calls onClose", () => {
const onClose = vi.fn();
render(
<Modal title="t" onClose={onClose}>
<Modal initialFocus="close" title="t" onClose={onClose}>
<Body />
</Modal>,
);
@@ -192,12 +192,12 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
const outerClose = vi.fn();
const innerClose = vi.fn();
render(
<Modal title="outer" onClose={outerClose}>
<Modal initialFocus="close" title="outer" onClose={outerClose}>
<button>outer-body</button>
</Modal>,
);
render(
<Modal title="inner" onClose={innerClose}>
<Modal initialFocus="close" title="inner" onClose={innerClose}>
<button>inner-body</button>
</Modal>,
);
@@ -211,7 +211,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
document.body.appendChild(trigger);
trigger.focus(); // captured as restoreTo during Modal's first render
const { unmount } = render(
<Modal title="t" onClose={() => {}}>
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
@@ -230,7 +230,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
document.body.appendChild(trigger);
trigger.focus();
const { unmount } = render(
<Modal title="t" onClose={() => {}}>
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
@@ -256,10 +256,10 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
function Nested() {
const [open, setOpen] = useState(false);
return (
<Modal title="outer" onClose={() => {}}>
<Modal initialFocus="close" title="outer" onClose={() => {}}>
<button onClick={() => setOpen(true)}>open inner</button>
{open && (
<Modal title="inner" onClose={() => {}}>
<Modal initialFocus="close" title="inner" onClose={() => {}}>
<button>inner-body</button>
</Modal>
)}
@@ -291,11 +291,11 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
return (
<>
{lower && (
<Modal title="lower" onClose={() => {}}>
<Modal initialFocus="close" title="lower" onClose={() => {}}>
<button onClick={() => setLower(false)}>close lower</button>
</Modal>
)}
<Modal title="upper" onClose={() => {}}>
<Modal initialFocus="close" title="upper" onClose={() => {}}>
<button>upper-body</button>
</Modal>
</>
@@ -319,7 +319,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
document.body.append(trigger, elsewhere);
trigger.focus();
const { unmount } = render(
<Modal title="t" onClose={() => {}}>
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
@@ -336,7 +336,7 @@ describe("Modal focus trap (raw jsdom — every offsetParent null)", () => {
it("with no visible focusables: initial focus falls to the panel and Tab is a safe no-op", () => {
render(
<Modal title="t" onClose={() => {}}>
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
@@ -357,7 +357,7 @@ describe("Modal — toasts raised while it is open", () => {
// The app's Toaster is in a stacking context under this body-level portal: a failed share or
// rename was drawn under the scrim, and clicking it landed on the backdrop and closed the dialog.
render(
<Modal title="Share" onClose={() => {}}>
<Modal initialFocus="close" title="Share" onClose={() => {}}>
<Body />
</Modal>,
);
@@ -390,7 +390,7 @@ describe("Modal — an Escape a layer above it already took", () => {
const onClose = vi.fn();
const listClosed = vi.fn();
render(
<Modal title="Share" onClose={onClose}>
<Modal initialFocus="close" title="Share" onClose={onClose}>
<List onClosed={listClosed} />
</Modal>,
);
@@ -33,7 +33,7 @@ describe("a dialog open while fullscreen is left", () => {
document.body.appendChild(stage);
setFullscreen(stage);
render(
<Modal title="Download" onClose={() => {}}>
<Modal initialFocus="close" title="Download" onClose={() => {}}>
<input aria-label="name" defaultValue="" />
</Modal>,
);
@@ -60,7 +60,7 @@ describe("a dialog with an autoFocus child", () => {
// command palette, the download rename dialog and every non-danger confirm shipped with after
// deep-review round 4 (found in round 5).
render(
<Modal title="Search" onClose={() => {}}>
<Modal initialFocus="close" title="Search" onClose={() => {}}>
<input aria-label="query" autoFocus />
</Modal>,
);
@@ -71,7 +71,7 @@ describe("a dialog with an autoFocus child", () => {
const stage = document.createElement("div");
document.body.appendChild(stage);
render(
<Modal title="Search" onClose={() => {}}>
<Modal initialFocus="close" title="Search" onClose={() => {}}>
<input aria-label="query" autoFocus />
</Modal>,
);
@@ -0,0 +1,209 @@
// @vitest-environment jsdom
//
// C4 (DESIGN, Dialogs): a dialog opens focused by its PURPOSE, and its commit row is a footer pinned
// outside the scrolling body. offsetParent is stubbed "visible" as in Modal.dom.test.tsx (jsdom never
// lays out, and the focus rules skip unrendered controls).
import { readFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
import Modal, { ModalFooter } from "./Modal";
const closeBtn = () => screen.getByRole("button", { name: "common.close" });
const press = (key: string, shiftKey = false) =>
act(() => {
window.dispatchEvent(
new KeyboardEvent("keydown", { key, shiftKey, bubbles: true, cancelable: true }),
);
});
let origDesc: PropertyDescriptor | undefined;
beforeEach(() => {
origDesc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetParent");
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
configurable: true,
get(this: HTMLElement) {
return this.isConnected ? document.body : null;
},
});
});
afterEach(() => {
cleanup();
if (origDesc) Object.defineProperty(HTMLElement.prototype, "offsetParent", origDesc);
else delete (HTMLElement.prototype as unknown as Record<string, unknown>).offsetParent;
});
// A body that owns its footer, one component down — the issue composer's shape.
function Form() {
return (
<div>
<input aria-label="name" />
<input aria-label="channel" />
<ModalFooter>
<button>cancel</button>
<button>save</button>
</ModalFooter>
</div>
);
}
describe("Modal initial focus by purpose (C4)", () => {
it("field: the body's first control", () => {
render(
<Modal initialFocus="field" title="t" onClose={() => {}}>
<Form />
</Modal>,
);
expect(document.activeElement).toBe(screen.getByLabelText("name"));
});
it("field with nothing to fill in: the ✕", () => {
render(
<Modal initialFocus="field" title="t" onClose={() => {}}>
<p>nothing here</p>
</Modal>,
);
expect(document.activeElement).toBe(closeBtn());
});
// The issue composer's shape: a spinner first, the form when its query lands.
function Late({ ready }: { ready: boolean }) {
return ready ? <input aria-label="title" /> : <p>loading…</p>;
}
it("field that loads late: the focus moves to it when it arrives", async () => {
const { rerender } = render(
<Modal initialFocus="field" title="t" onClose={() => {}}>
<Late ready={false} />
</Modal>,
);
expect(document.activeElement).toBe(closeBtn());
rerender(
<Modal initialFocus="field" title="t" onClose={() => {}}>
<Late ready />
</Modal>,
);
await act(async () => {});
expect(document.activeElement).toBe(screen.getByLabelText("title"));
});
it("field that loads late: a focus the user has moved stays put", async () => {
const { rerender } = render(
<Modal initialFocus="field" title="t" onClose={() => {}}>
<Late ready={false} />
<ModalFooter>
<button>cancel</button>
</ModalFooter>
</Modal>,
);
const cancel = screen.getByRole("button", { name: "cancel" });
act(() => cancel.focus());
rerender(
<Modal initialFocus="field" title="t" onClose={() => {}}>
<Late ready />
<ModalFooter>
<button>cancel</button>
</ModalFooter>
</Modal>,
);
await act(async () => {});
expect(document.activeElement).toBe(cancel);
});
it("close: the ✕, even with a field in the body", () => {
render(
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Form />
</Modal>,
);
expect(document.activeElement).toBe(closeBtn());
});
it("action: the footer's autoFocus button (a confirm's least destructive)", () => {
render(
<Modal initialFocus="action" title="t" onClose={() => {}}>
<p>Delete it?</p>
<ModalFooter>
<button autoFocus>cancel</button>
<button>delete</button>
</ModalFooter>
</Modal>,
);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "cancel" }));
});
});
describe("Modal footer (C4)", () => {
it("renders outside the scrolling body, inside the dialog, after it", () => {
render(
<Modal initialFocus="field" title="t" onClose={() => {}}>
<Form />
</Modal>,
);
const save = screen.getByRole("button", { name: "save" });
const body = screen.getByLabelText("name").closest(".panel-scroller");
expect(body).not.toBeNull();
expect(body!.contains(save)).toBe(false);
expect(screen.getByRole("dialog").contains(save)).toBe(true);
expect(body!.compareDocumentPosition(save) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
// The trap only acts at its ends (a middle Tab is the browser's, which jsdom does not move): the
// footer's last button IS the dialog's last stop, both ways.
it("the Tab trap counts the footer: its last button wraps to the ✕ and back", () => {
render(
<Modal initialFocus="field" title="t" onClose={() => {}}>
<Form />
</Modal>,
);
const save = screen.getByRole("button", { name: "save" });
act(() => save.focus());
press("Tab");
expect(document.activeElement).toBe(closeBtn());
press("Tab", true);
expect(document.activeElement).toBe(save);
});
it("no footer: the slot stays empty (and `empty:hidden` takes it out)", () => {
render(
<Modal initialFocus="close" title="t" onClose={() => {}}>
<p>read me</p>
</Modal>,
);
const slot = document.querySelector(".fade-above-panel");
expect(slot).not.toBeNull();
expect(slot!.childElementCount).toBe(0);
expect(slot!.className).toContain("empty:hidden");
});
});
// The fades live in CSS, which jsdom cannot evaluate: the pairing is held here instead. Header and
// footer sit OUTSIDE the scroller, so they reach its timeline only through the panel's scope.
describe("the panel fades follow the body's scroll (C4 + B9)", () => {
const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8");
const css = read("../../index.css");
const modal = read("./Modal.tsx");
it("the Modal wires the scope, the scroller and both fades", () => {
expect(modal).toMatch(/const PANEL = "[^"]*\bpanel-scroll-scope\b/);
expect(modal).toMatch(/className="[^"]*\bpanel-scroller\b[^"]*"/);
expect(modal).toMatch(/className="[^"]*\bfade-below-panel\b[^"]*"/);
expect(modal).toMatch(/className="[^"]*\bfade-above-panel\b[^"]*"/);
});
it("index.css names the timeline and drives both fades from it, off at rest", () => {
expect(css).toMatch(/@utility panel-scroll-scope \{\s*timeline-scope: --panel-scroll;/);
expect(css).toMatch(/@utility panel-scroller \{\s*scroll-timeline: --panel-scroll block;/);
for (const util of ["fade-below-panel", "fade-above-panel"]) {
const block = css.slice(css.indexOf(`@utility ${util} {`)).split(/\n}\n/)[0];
expect(block, util).toMatch(/opacity: 0;/);
expect(block, util).toMatch(/animation-timeline: --panel-scroll;/);
}
});
});
@@ -43,7 +43,7 @@ describe("Modal focus under StrictMode", () => {
it("keeps the focus its autoFocus child asked for across the double mount", () => {
render(
<StrictMode>
<Modal title="t" onClose={() => {}}>
<Modal initialFocus="close" title="t" onClose={() => {}}>
<input aria-label="field" autoFocus />
</Modal>
</StrictMode>,
+86 -8
View File
@@ -1,16 +1,53 @@
import { useEffect, useId, useRef, type ReactNode } from "react";
import {
createContext,
useContext,
useEffect,
useId,
useRef,
useState,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { useBackToClose } from "../../lib/history";
import { anyBlockingLayerAbove, pushLayer, removeLayer, takeEscape } from "../../lib/layerStack";
import { useFullscreenPortalHost } from "../../lib/fullscreen";
import { trapTab, useDialogChrome } from "./dialogFocus";
import { focusablesIn, trapTab, useDialogChrome } from "./dialogFocus";
import { IconButton } from "./IconButton";
import Toaster from "./Toaster";
import { useOverlayOrder } from "./overlayOrder";
import { SURFACE } from "./surfaces";
/** Where a dialog opens focused (C4, by purpose — WAI-ARIA APG): `field` = the body's first control
* (a form), `close` = the header's ✕ (anything to read or browse), `action` = the child that takes
* `autoFocus` itself (a confirm's least destructive button; the ✕ if none does). Required, so every
* dialog states its purpose instead of inheriting "the first focusable", which is always the ✕. */
export type ModalFocus = "field" | "close" | "action";
// The footer slot of the open dialog. `ModalFooter` portals into it from any depth, so a body that
// owns its own state (the issue composer) pins its buttons without lifting that state into the caller.
const FooterSlot = createContext<HTMLElement | null>(null);
/** The dialog's commit row (Cancel / Save…). It renders OUTSIDE the scrolling body, pinned under it
* (C4): the primary action stays on screen however long the body is, or however much of the screen
* the on-screen keyboard takes. A section's own buttons (a sub-form inside a manager) stay in the body. */
export function ModalFooter({
children,
className = "flex flex-wrap items-center justify-end gap-2",
}: {
children: ReactNode;
className?: string;
}) {
const slot = useContext(FooterSlot);
return slot ? createPortal(<div className={className}>{children}</div>, slot) : null;
}
// A column: header, the scrolling body, the footer. `overflow-hidden` clips the body to the rounded
// panel; the panel itself never scrolls any more. The scope names the body's scroll timeline for the
// header's and the footer's fades, which sit outside that scroller.
const PANEL = "relative flex flex-col overflow-hidden outline-none panel-scroll-scope";
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
// dismisses just the error and returns to the editor underneath, and a player below defers to it
@@ -23,8 +60,10 @@ export default function Modal({
children,
maxWidth = "max-w-lg",
placement = "center",
initialFocus,
}: {
title: ReactNode;
initialFocus: ModalFocus;
onClose: () => void;
children: ReactNode;
maxWidth?: string;
@@ -49,7 +88,31 @@ export default function Modal({
// Focus lands in the panel; OWNERSHIP is the whole dialog — the same element the Tab trap below
// is scoped to, toast host included. Tracking ownership over the panel alone meant tabbing to an
// in-dialog toast counted as leaving, and the close-time restore was then skipped (round 6).
useDialogChrome(panelRef, rootRef);
const bodyRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const [footerSlot, setFooterSlot] = useState<HTMLDivElement | null>(null);
useDialogChrome(panelRef, rootRef, {
// Only a rendered target (the trap's own `offsetParent` test); none falls back to the hook's
// first-focusable rule. `action` lands on the ✕ here and its child's autoFocus then takes over.
initial: () =>
(initialFocus === "field" && bodyRef.current ? focusablesIn(bodyRef.current)[0] : null) ??
(closeRef.current?.offsetParent != null ? closeRef.current : null),
});
// A form that loads after the dialog opens (the issue composer shows a spinner first) has no field
// to take the focus at open, so it lands on the ✕. When the first control arrives the focus moves
// there — but only while it still rests on the ✕: a user who has moved it keeps it where it is.
useEffect(() => {
const body = bodyRef.current;
if (initialFocus !== "field" || !body || focusablesIn(body).length > 0) return;
const watch = new MutationObserver(() => {
const first = focusablesIn(body)[0];
if (!first) return;
watch.disconnect();
if (document.activeElement === closeRef.current) first.focus();
});
watch.observe(body, { childList: true, subtree: true });
return () => watch.disconnect();
}, [initialFocus]);
const overlayOrder = useOverlayOrder();
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
@@ -115,19 +178,34 @@ export default function Modal({
tabIndex={-1}
className={
drawer
? `${SURFACE.drawer} relative w-full ${maxWidth} h-full overflow-y-auto rounded-none sm:rounded-l-2xl outline-none overlay-drawer`
: `${SURFACE.panel} relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`
? `${SURFACE.drawer} ${PANEL} w-full ${maxWidth} h-full rounded-none sm:rounded-l-2xl overlay-drawer`
: `${SURFACE.panel} ${PANEL} w-full ${maxWidth} max-h-full rounded-2xl`
}
>
<div className="flex items-start justify-between gap-3 p-5 pb-3">
{/* C4: the header and the footer stay put and only the body scrolls, with the B9 fade at
each edge — shown only while content is actually under it (the body's scroll timeline,
see index.css), so a dialog that fits draws no fade over its first or last line. */}
<div className="relative z-base flex shrink-0 items-start justify-between gap-3 p-5 pb-3 fade-below-panel">
<h2 id={titleId} className="text-lg font-semibold leading-snug">
{title}
</h2>
<IconButton label={t("common.close")} size="sm" variant="ghost" onClick={onClose}>
<IconButton
ref={closeRef}
label={t("common.close")}
size="sm"
variant="ghost"
onClick={onClose}
>
<X className="w-4 h-4" />
</IconButton>
</div>
<div className="px-5 pb-5">{children}</div>
<div ref={bodyRef} className="min-h-0 flex-auto overflow-y-auto px-5 pb-5 panel-scroller">
<FooterSlot value={footerSlot}>{children}</FooterSlot>
</div>
<div
ref={setFooterSlot}
className="relative z-base shrink-0 px-5 pb-5 fade-above-panel empty:hidden"
/>
</div>
{/* Toasts raised while a dialog is open show inside it: the app's own host is under this
scrim, where a failed share or rename was unreadable and a click on it closed the dialog
+4 -4
View File
@@ -36,10 +36,10 @@ domain-free.
## What lives here
| area | components |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the |
| area | components |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the |
B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a
link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
+11 -4
View File
@@ -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();
}
+8 -4
View File
@@ -410,17 +410,21 @@ function Fades() {
<ScrollBody />
</Frame>
<Sample label="2 · Tabs over a scrolling panel">
<div className={`${SURFACE.panel} relative flex h-44 flex-col overflow-hidden`}>
<div
className={`${SURFACE.panel} relative flex h-44 flex-col overflow-hidden panel-scroll-scope`}
>
<div className={clsx("relative z-base px-3 pb-2 pt-3", FADE_BELOW_PANEL)}>
<Tabs />
</div>
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar">
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar panel-scroller">
<ScrollBody />
</div>
</div>
</Sample>
<Sample label="3 · A dialog's header and footer">
<div className={`${SURFACE.panel} relative flex h-44 flex-col overflow-hidden`}>
<div
className={`${SURFACE.panel} relative flex h-44 flex-col overflow-hidden panel-scroll-scope`}
>
<div
className={clsx(
"relative z-base flex items-center gap-2 px-3 py-2 text-sm font-semibold",
@@ -429,7 +433,7 @@ function Fades() {
>
<Lock className="h-4 w-4 text-accent" /> Edit details
</div>
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar">
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar panel-scroller">
<ScrollBody />
</div>
<div
+4 -2
View File
@@ -169,7 +169,9 @@ function C4() {
title="Dialog conventions"
rule="Modal takes an initialFocus by purpose (APG): a form focuses its first field, a confirm its least destructive button, anything else Close. The header and the footer are fixed, only the body scrolls, with the B9 fade at both edges, so Save stays one tap away with the keyboard up. Shown at a keyboard-up height."
>
<div className={`${SURFACE.panel} relative flex h-52 flex-col overflow-hidden`}>
<div
className={`${SURFACE.panel} relative flex h-52 flex-col overflow-hidden panel-scroll-scope`}
>
<div
className={clsx(
"relative z-base flex items-center justify-between px-3 py-2 text-sm font-semibold",
@@ -181,7 +183,7 @@ function C4() {
<X className={ico} />
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar">
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar panel-scroller">
<div className="flex flex-col gap-2 px-3 pb-3 pt-2">
<Field label="Title" value="Building a home lab" focused />
<Field label="Channel" value="Techno Tim" />
+36
View File
@@ -184,6 +184,26 @@
}
}
}
/* A panel's fixed header and footer (C4: a dialog's). The scroller between them is the
`panel-scroller`; the panel names its timeline for both edges with `panel-scroll-scope`, since they
sit outside it. The header's fade shows once content has slid under it (the first 1rem of scroll);
the footer's while there is more below (it fades out over the last 1rem). A body that fits has no
scroll range, its timeline is inactive and neither fade draws — nor does an engine without scroll
timelines: no fade rather than one over a resting first or last line. */
@utility panel-scroll-scope {
timeline-scope: --panel-scroll;
}
@utility panel-scroller {
scroll-timeline: --panel-scroll block;
}
@keyframes fade-edge-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@utility fade-below-panel {
&::after {
content: "";
@@ -193,6 +213,14 @@
top: 100%;
height: 1rem;
background: linear-gradient(to bottom, var(--surface), transparent);
opacity: 0;
}
@supports (timeline-scope: none) {
&::after {
animation: fade-edge-in linear both;
animation-timeline: --panel-scroll;
animation-range: 0 1rem;
}
}
}
@utility fade-above-panel {
@@ -204,6 +232,14 @@
bottom: 100%;
height: 1rem;
background: linear-gradient(to top, var(--surface), transparent);
opacity: 0;
}
@supports (timeline-scope: none) {
&::before {
animation: fade-edge-out linear both;
animation-timeline: --panel-scroll;
animation-range: calc(100% - 1rem) 100%;
}
}
}
/* B9: the clipped ends of an overflowing chip row. */