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:
@@ -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")}
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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