Skip to content

api change proposal for dialog component - #5228

Open
FredrikMWold wants to merge 1 commit into
equinor:mainfrom
FredrikMWold:dialog-proposal
Open

api change proposal for dialog component#5228
FredrikMWold wants to merge 1 commit into
equinor:mainfrom
FredrikMWold:dialog-proposal

Conversation

@FredrikMWold

Copy link
Copy Markdown
Contributor

This PR proposes a change to the Dialog API.

The Dialog component becomes a pure context provider, while dialog-specific props are moved to a new Dialog.Popup component. It also introduces Dialog.Trigger and Dialog.Close, both of which support asChild and integrate with the internal dialog context to control the open state.

With this API, the dialog can manage its own state internally, removing the need for consumers to control it externally.

<Dialog>
  <Dialog.Trigger>Open dialog</Dialog.Trigger>
  <Dialog.Popup>
    <Dialog.Header>
      <Dialog.Title>Dialog title</Dialog.Title>
    </Dialog.Header>
    <Dialog.Content>
      This is a short description of the action the user is about to take.
    </Dialog.Content>
    <Dialog.Actions>
      <Dialog.Close>Close</Dialog.Close>
      <Button onClick={() => {}}>Confirm</Button>
    </Dialog.Actions>
  </Dialog.Popup>
</Dialog>

This pattern is even more beneficial for components such as Popover and Menu. In addition to managing their own state internally, they can also manage the ref used to anchor the popup. Popover.Trigger can own this logic and, through asChild, attach the required ref to any element without exposing that implementation detail to consumers.

Copilot AI review requested due to automatic review settings July 23, 2026 13:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR proposes a new compound-component API for the /next Dialog, refactoring Dialog into a context provider and moving native <dialog> rendering and dialog-specific props to Dialog.Popup, while adding Dialog.Trigger and Dialog.Close helpers to manage open/close behavior through context.

Changes:

  • Made Dialog a context provider with optional open / onOpenChange for controlled or uncontrolled usage.
  • Introduced Dialog.Popup (native <dialog> + scrim/backdrop handling), plus Dialog.Trigger and Dialog.Close with asChild support via Slot.
  • Updated the primary Storybook introduction story to demonstrate the new API.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
packages/eds-core-react/src/components/next/Dialog/Dialog.types.ts Adjusts public types to support the new compound API and moves dialog element props to DialogPopupProps.
packages/eds-core-react/src/components/next/Dialog/Dialog.tsx Refactors implementation into a provider + Dialog.Popup renderer, and adds trigger/close compound components.
packages/eds-core-react/src/components/next/Dialog/Dialog.stories.tsx Updates the introduction story to use Dialog.Trigger + Dialog.Popup + Dialog.Close.

Comment on lines 4 to +15
/**
* Controlled open state. When true, the dialog is shown via the native
* `HTMLDialogElement.showModal()` (modal mode: focus trap + inert background).
*/
open: boolean
open?: boolean
/**
* Called when the dialog requests to close — via Escape key, backdrop click,
* or the header close button. Update your `open` state in response; the
* component is fully controlled.
*/
onOpenChange: (open: boolean) => void
onOpenChange?: (open: boolean) => void
children: ReactNode
Comment on lines 42 to +46
const dialogRef = useRef<HTMLDialogElement | null>(null)
// Suppresses the onOpenChange call fired by the native `close` event when
// we close the dialog in response to a consumer flipping `open` to false.
// Without this, the consumer's setter would be called once externally and
// once from handleClose — see review thread on PR #4956.
const expectedCloseRef = useRef(false)
// Records whether the mousedown that started a click landed on the dialog
// element itself. Click fires on the common ancestor of mousedown/up, so a
// text-selection drag that overshoots into the backdrop would otherwise be
// mistaken for a backdrop click and close the dialog.
const mouseDownOnDialogRef = useRef(false)
const [titleId, setTitleId] = useState<string | undefined>(undefined)
const [open, setOpen] = useState(openProp ?? false)

useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (open && !dialog.open) {
dialog.showModal()
} else if (!open && dialog.open) {
expectedCloseRef.current = true
dialog.close()
}
}, [open])

const setRef = useCallback(
(node: HTMLDialogElement | null) => {
dialogRef.current = node
if (typeof ref === 'function') ref(node)
else if (ref) ref.current = node
},
[ref],
)

const handleClose = () => {
if (expectedCloseRef.current) {
expectedCloseRef.current = false
return
}
onOpenChange?.(false)
}

const handleMouseDown = (event: MouseEvent<HTMLDialogElement>) => {
mouseDownOnDialogRef.current = event.target === dialogRef.current
}

// Native <dialog> reports the dialog itself as the click target when the
// backdrop is clicked; children dispatch from their own elements. The
// additional mousedown check guards against drag-out from a selection.
const handleClick = (event: MouseEvent<HTMLDialogElement>) => {
const wasOnBackdrop =
event.target === dialogRef.current && mouseDownOnDialogRef.current
mouseDownOnDialogRef.current = false
if (wasOnBackdrop) dialogRef.current?.close()
}
const onOpenChange = onOpenChangeProp ?? setOpen
Comment on lines +143 to +153
<dialog
ref={setRef}
className={['eds-dialog', className].filter(Boolean).join(' ')}
data-scrim={scrim || undefined}
aria-labelledby={resolvedAriaLabelledBy}
aria-label={ariaLabel}
onClose={handleClose}
onMouseDown={handleMouseDown}
onClick={handleClick}
{...rest}
>
Comment on lines +38 to 52
<Dialog>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Popup>
<Dialog.Header>
<Dialog.Title>Dialog title</Dialog.Title>
</Dialog.Header>
<Dialog.Content>
This is a short description of the action the user is about to take.
</Dialog.Content>
<Dialog.Actions>
<Dialog.Close>Close</Dialog.Close>
<Button onClick={() => {}}>Confirm</Button>
</Dialog.Actions>
</Dialog.Popup>
</Dialog>
if (typeof ref === 'function') ref(node)
else if (ref) ref.current = node
},
[ref, ctx],
@pomfrida

Copy link
Copy Markdown
Collaborator

@claude review

@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown

Claude finished @pomfrida's task in 7m 40s —— View job


Review: Dialog compound API proposal

I like the direction — a provider + Dialog.Popup + Trigger/Close is the right shape for EDS 2.0, matches the asChild/Slot ADR, and the Popover/Menu argument in the description is convincing. But the branch is currently red and controlled mode is broken, so this needs work before it can land.

I ran the suite and some scratch tests locally to verify each claim below.


🔴 Blocking

1. Controlled mode no longer works at all
Dialog.tsx:44-46

const [open, setOpen] = useState(openProp ?? false)
const onOpenChange = onOpenChangeProp ?? setOpen

openProp is only read as an initial value, and as soon as a consumer passes onOpenChange, setOpen is never called again — so internal open is frozen forever. Dialog.Popup's effect keys off ctx.open, so the <dialog> never opens.

I verified with two scratch tests, both fail on this branch:

✕ controlled: opens when open flips to true
✕ controlled with state (open={open} onOpenChange={setOpen}): trigger opens

Uncontrolled (<Dialog> + Trigger + Close + reopen) does work.

The usual fix is the controllable-state pattern — keep internal state always, let the prop win when defined, and always call the consumer callback:

const isControlled = openProp !== undefined
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen ?? false)
const open = isControlled ? openProp : uncontrolledOpen
const onOpenChange = useCallback((next: boolean) => {
  if (!isControlled) setUncontrolledOpen(next)
  onOpenChangeProp?.(next)
}, [isControlled, onOpenChangeProp])

This also fixes the "notify-only" case (uncontrolled dialog + onOpenChange for analytics), which is impossible today. Worth adding a defaultOpen prop while you're here, and — since Popover/Menu will need exactly this — extracting a shared useControllableState into next/. Fix this →

2. The test suite is not updated — CI is red

pnpm run test:core-react fails at the tsc -p tsconfig.test.json step:

Dialog.test.tsx(61,46): Property 'ref' does not exist on type 'DialogProps'
Dialog.test.tsx(86,11): Property 'style' does not exist on type 'DialogProps'
Dialog.test.tsx(102,46): Property 'scrim' does not exist on type 'DialogProps'

and running jest directly: 18 of 25 tests fail (everything that queries role="dialog" — the root no longer renders one). The whole file needs porting to <Dialog><Dialog.Popup>…, plus new coverage for Trigger, Close, asChild, uncontrolled open/close/reopen, controlled sync, and a jest-axe pass on the new composition. Fix this →

3. Five of six stories still use the old API

Only Introduction was updated. Passive, DangerAction, SingleAction, WithoutScrim, SpecificWidth pass scrim / style / children directly to <Dialog>, which is now a bare provider — they render loose <div class="header"> / <div class="content"> into the canvas with no <dialog> at all. Note this is not caught by any type check: tsconfig.build.json and tsconfig.test.json both exclude *.stories.tsx, so it's a silent runtime break in Storybook. Fix this →

4. Dialog.figma.tsx still emits the old API

The Code Connect example renders <Dialog open onOpenChange={() => {}}> with Dialog.Header as a direct child. It still type-checks (both props survive on DialogProps) but the snippet handed to designers/devs in Figma Dev Mode now produces a dialog that renders nothing.


🟠 Correctness & API

5. {...rest} is spread after the handlersDialog.tsx:149-152

A consumer passing onClick or onMouseDown to Dialog.Popup silently disables backdrop dismissal. Spread {...rest} before onClose/onMouseDown/onClick, and compose the consumer handler inside yours (same as Trigger already does with onClick).

6. asChild on Trigger/Close throws away the Button styling

Both bypass Button and render a bare Slot. Verified output:

<a href="#x" class="dialog-button">Open link</a>

No eds-button, no data-variant, no data-selectable-space — and ButtonProps-only props (variant, tone, icon, round) would be spread straight onto the DOM node and warn. Since the props type is ButtonProps and the default render is a Button, consumers will reasonably expect <Dialog.Trigger asChild><Link/></Dialog.Trigger> to look like a button. Button already supports asChild via Slot, so just delegate:

return <Button asChild={asChild} {...sharedProps}>{children}</Button>

If the intent is genuinely "unstyled trigger", it needs a different props type and an explicit doc note — but the styled version seems more useful here. Fix this →

7. dialog-button is a dead classDialog.tsx:168, :194

grep -r dialog-button matches only those two lines — nothing in dialog.css. Drop it or add the rule.

8. Two different close paths

Trigger goes through state (ctx.onOpenChange(true)), while Close, the header button and the backdrop go through the DOM (dialogRef.current.close()) and rely on the native close event bouncing back into onOpenChange(false). It works today, but it means a Dialog.Close rendered outside a mounted Dialog.Popup is a silent no-op, and it makes controlled mode harder to reason about. Cleaner: every intent calls onOpenChange(next), and the effect in Popup is the single owner of showModal()/close().

9. One dialogRef per provider

ctx.dialogRef is a singleton, so two Dialog.Popups under one Dialog clobber each other (last mounted wins) and closing targets the wrong element. Fine if single-popup is the contract — but that should be documented, or the ref should live in Popup with registration.

10. Root no longer forwards a ref to the <dialog> — a real breaking change for existing consumers, worth an explicit line in the migration notes alongside scrim/style/className/aria-* moving to Popup.


🟡 Minor

  • Dialog.tsx:102-109useCallback depends on the whole ctx; it only needs ctx.dialogRef (stable). Same for DialogTitle's effect at :252-254: because ctx identity now changes with open, the title unregisters and re-registers on every open/close. Destructure registerTitle instead. (I checked — it doesn't loop, just churns.)
  • Dialog.tsx:136-156 — the <>…</> wrapper around a single <dialog> is redundant.
  • useDialogContext is declared at the bottom of the file, below every consumer. It works (TDZ resolves before render), but per AGENTS.md module-scope helpers belong up top, next to the context.
  • Types (AGENTS.md: types live in Dialog.types.ts, exports are named and complete):
    • DialogProps JSDoc still says "the component is fully controlled" — now inaccurate on both counts.
    • Trigger/Close reuse ButtonProps inline; declare DialogTriggerProps / DialogCloseProps in Dialog.types.ts.
    • index.ts doesn't export DialogPopupProps — consumers need it now that all the dialog props live there.
  • documentation/AI-COMPONENT-INDEX.md:25 still lists scrim on Dialog and no Popup/Trigger/Close. Regenerated by pnpm run generate:component-index.
  • A11y: consider aria-haspopup="dialog" on the trigger. The header close button's hardcoded aria-label="Close" is pre-existing, but a compound API is a good moment to make it overridable for non-English apps.

Summary

The API is worth pursuing — my main asks before merge are: fix controlled mode, port the tests and the remaining stories, fix the {...rest} ordering, and decide deliberately what asChild means for Trigger/Close. Since Popover and Menu will copy this shape, the controllable-state hook is worth getting right once and sharing.

Verified locally: pnpm exec jest --testPathPatterns next/Dialog → 18 failed / 7 passed; tsc -p tsconfig.test.json → 3 errors. Review only — no changes pushed.
· branch dialog-proposal

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants