import { css, on, ref, type Handle, type Props, type RemixNode } from 'remix/component'
import { buttonVariants } from './button.tsx'
import { XIcon } from './icons.tsx'
export interface DialogProps {
/** Controlled open state. */
open?: boolean
/** Initial open state when uncontrolled. */
defaultOpen?: boolean
/** Called whenever the dialog requests to open or close. */
onOpenChange?: (open: boolean) => void
children?: RemixNode
}
/**
* State shared by every part of one dialog. `Sheet` (which extends Dialog, like shadcn's) reads it
* through `handle.context.get(Dialog)`.
*/
export interface DialogContext {
readonly open: boolean
readonly contentId: string
readonly titleId: string
readonly descriptionId: string
setOpen(open: boolean): void
/** The trigger that receives focus back when the dialog closes. */
trigger: HTMLElement | null
/** The content panel; pointer-downs outside of it dismiss the dialog. */
content: HTMLElement | null
}
export function Dialog(handle: Handle<DialogProps, DialogContext>) {
let uncontrolledOpen = handle.props.defaultOpen ?? false
let context: DialogContext = {
get open() {
return handle.props.open ?? uncontrolledOpen
},
contentId: `${handle.id}-content`,
titleId: `${handle.id}-title`,
descriptionId: `${handle.id}-description`,
setOpen(open) {
if (open === context.open) return
if (handle.props.open === undefined) {
uncontrolledOpen = open
void handle.update()
}
handle.props.onOpenChange?.(open)
},
trigger: null,
content: null,
}
handle.context.set(context)
return () => handle.props.children
}
export function DialogTrigger(handle: Handle<Props<'button'>>) {
let dialog = handle.context.get(Dialog)
return () => {
let { mix, ...props } = handle.props
return (
<button
type="button"
data-slot="dialog-trigger"
aria-haspopup="dialog"
aria-expanded={dialog.open ? 'true' : 'false'}
aria-controls={dialog.contentId}
data-state={dialog.open ? 'open' : 'closed'}
{...props}
mix={[
ref((node, signal) => {
dialog.trigger = node
signal.addEventListener('abort', () => {
if (dialog.trigger === node) dialog.trigger = null
})
}),
on('click', () => dialog.setOpen(!dialog.open)),
mix,
]}
/>
)
}
}
const portalStyles = css({
position: 'fixed',
inset: 0,
width: '100%',
height: '100%',
maxWidth: 'none',
maxHeight: 'none',
margin: 0,
padding: 0,
borderWidth: 0,
overflow: 'visible',
background: 'transparent',
color: 'inherit',
outlineStyle: 'none',
'&::backdrop': { background: 'transparent' },
':root:has(&:modal)': { overflow: 'hidden' },
})
/**
* Radix portals the overlay and content to `document.body`. Here they render inside a native modal
* `<dialog>` in the top layer instead: it escapes overflow/stacking contexts, traps focus, makes
* the rest of the page inert, and closes on Escape. The `<dialog>` stays open until the exit
* animations of its overlay and content have finished.
*/
export function DialogPortal(handle: Handle<Props<'dialog'>>) {
let dialog = handle.context.get(Dialog)
let node: HTMLDialogElement | null = null
let present = dialog.open
let closing = false
async function sync(signal: AbortSignal) {
if (!node) return
if (dialog.open) {
if (node.open) return
node.showModal()
// Radix focuses the first tabbable element and selects an input's text.
let active = document.activeElement
if (active instanceof HTMLInputElement && node.contains(active)) active.select()
return
}
if (!present || closing) return
closing = true
if (node.open) {
let animations = node
.getAnimations({ subtree: true })
.filter((animation) => animation.effect?.getComputedTiming().endTime !== Infinity)
await Promise.allSettled(animations.map((animation) => animation.finished))
}
closing = false
if (signal.aborted || dialog.open) return
let wasModalOpen = node.open
node.close()
present = false
void handle.update()
if (wasModalOpen && dialog.trigger?.isConnected) dialog.trigger.focus()
}
return () => {
if (dialog.open) present = true
if (!present) return null
let { mix, children, ...props } = handle.props
handle.queueTask((signal) => void sync(signal))
return (
<dialog
id={dialog.contentId}
aria-labelledby={dialog.titleId}
aria-describedby={dialog.descriptionId}
{...props}
mix={[
portalStyles,
ref((dialogNode, signal) => {
node = dialogNode
signal.addEventListener('abort', () => {
if (node === dialogNode) node = null
})
}),
on('keydown', (event) => {
if (event.key !== 'Escape' || event.defaultPrevented) return
event.preventDefault()
dialog.setOpen(false)
}),
on('cancel', (event) => {
event.preventDefault()
dialog.setOpen(false)
}),
on('close', () => {
// The browser closed the dialog without us (e.g. a repeated Escape).
if (dialog.open) dialog.setOpen(false)
else if (!closing && present) {
present = false
void handle.update()
}
}),
on('pointerdown', (event) => {
let target = event.target
if (target instanceof Node && dialog.content?.contains(target)) return
dialog.setOpen(false)
}),
mix,
]}
>
{children}
</dialog>
)
}
}
export function DialogClose(handle: Handle<Props<'button'>>) {
let dialog = handle.context.get(Dialog)
return () => {
let { mix, ...props } = handle.props
return (
<button
type="button"
data-slot="dialog-close"
{...props}
mix={[on('click', () => dialog.setOpen(false)), mix]}
/>
)
}
}
const overlayStyles = css({
position: 'fixed',
inset: 0,
zIndex: 50,
backgroundColor: 'rgb(0 0 0 / 0.5)',
'&[data-state="open"]': { animation: 'enter 150ms', '--tw-enter-opacity': 0 },
'&[data-state="closed"]': {
animation: 'exit 150ms',
animationFillMode: 'forwards',
'--tw-exit-opacity': 0,
},
})
export function DialogOverlay(handle: Handle<Props<'div'>>) {
let dialog = handle.context.get(Dialog)
return () => {
let { mix, ...props } = handle.props
return (
<div
data-slot="dialog-overlay"
data-state={dialog.open ? 'open' : 'closed'}
{...props}
mix={[overlayStyles, mix]}
/>
)
}
}
const contentStyles = css({
position: 'fixed',
top: '50%',
left: '50%',
zIndex: 50,
display: 'grid',
width: '100%',
maxWidth: 'calc(100% - 2rem)',
translate: '-50% -50%',
gap: '1rem',
borderRadius: 'var(--radius)',
borderWidth: '1px',
backgroundColor: 'var(--background)',
padding: '1.5rem',
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
transitionDuration: '200ms',
outlineStyle: 'none',
'&[data-state="open"]': {
animation: 'enter 200ms',
'--tw-enter-opacity': 0,
'--tw-enter-scale': 0.95,
},
'&[data-state="closed"]': {
animation: 'exit 200ms',
animationFillMode: 'forwards',
'--tw-exit-opacity': 0,
'--tw-exit-scale': 0.95,
},
'@media (min-width: 40rem)': { maxWidth: '32rem' },
})
const closeButtonStyles = css({
position: 'absolute',
top: '1rem',
right: '1rem',
borderRadius: '0.125rem',
opacity: 0.7,
transitionProperty: 'opacity',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '150ms',
'&:hover': { opacity: 1 },
'&:focus': {
boxShadow: '0 0 0 2px var(--background), 0 0 0 4px var(--ring)',
outlineStyle: 'none',
},
'&:disabled': { pointerEvents: 'none' },
'& svg': { pointerEvents: 'none', flexShrink: 0 },
'& svg:not([class*="size-"])': { width: '1rem', height: '1rem' },
})
export interface DialogContentProps extends Props<'div'> {
/** Render the close (X) button in the top-right corner. */
showCloseButton?: boolean
}
export function DialogContent(handle: Handle<DialogContentProps>) {
let dialog = handle.context.get(Dialog)
return () => {
let { showCloseButton = true, mix, children, ...props } = handle.props
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<div
data-slot="dialog-content"
data-state={dialog.open ? 'open' : 'closed'}
{...props}
mix={[
contentStyles,
ref((node, signal) => {
dialog.content = node
signal.addEventListener('abort', () => {
if (dialog.content === node) dialog.content = null
})
}),
mix,
]}
>
{children}
{showCloseButton && (
<DialogClose mix={closeButtonStyles}>
<XIcon />
<span mix={srOnly}>Close</span>
</DialogClose>
)}
</div>
</DialogPortal>
)
}
}
const srOnly = css({
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
borderWidth: 0,
})
const headerStyles = css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
textAlign: 'center',
'@media (min-width: 40rem)': { textAlign: 'left' },
})
export function DialogHeader(handle: Handle<Props<'div'>>) {
return () => {
let { mix, ...props } = handle.props
return <div data-slot="dialog-header" {...props} mix={[headerStyles, mix]} />
}
}
const footerStyles = css({
display: 'flex',
flexDirection: 'column-reverse',
gap: '0.5rem',
'@media (min-width: 40rem)': { flexDirection: 'row', justifyContent: 'flex-end' },
})
export interface DialogFooterProps extends Props<'div'> {
/** Append an outline "Close" button. */
showCloseButton?: boolean
}
export function DialogFooter(handle: Handle<DialogFooterProps>) {
return () => {
let { showCloseButton = false, mix, children, ...props } = handle.props
return (
<div data-slot="dialog-footer" {...props} mix={[footerStyles, mix]}>
{children}
{showCloseButton && (
<DialogClose data-slot="button" data-variant="outline" data-size="default" mix={buttonVariants({ variant: 'outline' })}>
Close
</DialogClose>
)}
</div>
)
}
}
const titleStyles = css({
fontSize: '1.125rem',
lineHeight: 1,
fontWeight: 600,
})
export function DialogTitle(handle: Handle<Props<'h2'>>) {
let dialog = handle.context.get(Dialog)
return () => {
let { mix, ...props } = handle.props
return <h2 id={dialog.titleId} data-slot="dialog-title" {...props} mix={[titleStyles, mix]} />
}
}
const descriptionStyles = css({
fontSize: '0.875rem',
lineHeight: 'calc(1.25 / 0.875)',
color: 'var(--muted-foreground)',
})
export function DialogDescription(handle: Handle<Props<'p'>>) {
let dialog = handle.context.get(Dialog)
return () => {
let { mix, ...props } = handle.props
return (
<p id={dialog.descriptionId} data-slot="dialog-description" {...props} mix={[descriptionStyles, mix]} />
)
}
}