Files
Aarnav Tale 2584a4ef55 fix(dialog): stop clipping focus rings in scrollable content
#556 added overflow-y-auto to the dialog content container. Per the CSS
spec, when one overflow axis is non-visible the other resets from
visible to auto, so overflow-x was being silently clamped too — clipping
the ~2px focus ring on inputs/buttons against the left edge of the
container.

Add px-1/-mx-1 so the layout width is unchanged while focus rings get
room to render.

Amp-Thread-ID: https://ampcode.com/threads/T-019e7ae4-6862-760c-a3e7-239350eab71d
Co-authored-by: Amp <amp@ampcode.com>
2026-05-30 18:48:27 -04:00

134 lines
3.8 KiB
TypeScript

import { AlertDialog } from "@base-ui/react/alert-dialog";
import React, { cloneElement, useEffect, useRef } from "react";
import { Form, type HTMLFormMethod } from "react-router";
import Button, { type ButtonProps } from "~/components/button";
import cn from "~/utils/cn";
import { useLiveData } from "~/utils/live-data";
export interface DialogProps {
children:
| [React.ReactElement<ButtonProps>, React.ReactElement<DialogPanelProps>]
| React.ReactElement<DialogPanelProps>;
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
}
function Dialog(props: DialogProps) {
const { pause, resume } = useLiveData();
const { isOpen, onOpenChange } = props;
useEffect(() => {
if (isOpen) {
pause();
} else {
resume();
}
}, [isOpen]);
if (Array.isArray(props.children)) {
const [button, panel] = props.children;
return (
<AlertDialog.Root open={isOpen} onOpenChange={(open) => onOpenChange?.(open)}>
<AlertDialog.Trigger render={cloneElement(button)} />
<DialogOverlay>{panel}</DialogOverlay>
</AlertDialog.Root>
);
}
return (
<AlertDialog.Root open={isOpen} onOpenChange={(open) => onOpenChange?.(open)}>
<DialogOverlay>{props.children}</DialogOverlay>
</AlertDialog.Root>
);
}
export interface DialogPanelProps {
children: React.ReactNode;
variant?: "normal" | "destructive" | "unactionable";
onSubmit?: React.FormEventHandler<HTMLFormElement>;
method?: HTMLFormMethod;
isDisabled?: boolean;
}
function Panel(props: DialogPanelProps) {
const { children, onSubmit, isDisabled, variant, method = "POST" } = props;
const closeRef = useRef<HTMLButtonElement>(null);
return (
<AlertDialog.Popup
className={cn(
"flex w-full max-w-lg flex-col rounded-xl p-4",
"max-h-[90dvh]",
"outline-hidden",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
"shadow-overlay",
)}
>
<Form
method={method ?? "POST"}
className="flex min-h-0 flex-1 flex-col"
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
}
if (!event.defaultPrevented) {
closeRef.current?.click();
}
}}
>
{/* px-1 -mx-1 gives focus rings on inputs/buttons room to render
without being clipped by overflow-y-auto (which implicitly forces
overflow-x: auto per CSS spec). */}
<div className="-mx-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-1">
{children}
</div>
<div className="mt-5 flex shrink-0 justify-end gap-3">
{variant === "unactionable" ? (
<AlertDialog.Close render={<Button>Close</Button>} />
) : (
<>
<AlertDialog.Close render={<Button>Cancel</Button>} />
<AlertDialog.Close ref={closeRef} className="hidden" aria-hidden tabIndex={-1} />
<Button
disabled={isDisabled}
type="submit"
variant={variant === "destructive" ? "danger" : "heavy"}
>
Confirm
</Button>
</>
)}
</div>
</Form>
</AlertDialog.Popup>
);
}
function DialogOverlay({ children }: { children: React.ReactNode }) {
return (
<AlertDialog.Portal>
<AlertDialog.Backdrop
className={cn(
"fixed inset-0 z-20 h-screen w-screen",
"bg-mist-900/30 dark:bg-mist-950/60",
"transition-opacity duration-100",
)}
/>
<div
className={cn(
"fixed inset-0 z-20 h-screen w-screen",
"flex items-center justify-center p-4",
)}
>
{children}
</div>
</AlertDialog.Portal>
);
}
export { Panel as DialogPanel };
export default Dialog;