Skip to content

Commit e2f6df2

Browse files
authored
feat: Dialog EDS 2.0 (#4956)
* feat: add Dialog component to next/ Native <dialog>-based modal with compound API: Dialog.Header, Dialog.Title, Dialog.Content, Dialog.Actions. Controlled via open + onOpenChange. Closes on Escape (native), backdrop click, and optional close affordance in the header. Includes a scrim prop (default true) toggling backdrop visibility via data-scrim; backdrop colour is a placeholder pending Figma spec and a future standalone Scrim component. * feat: make Dialog width content-driven with 300px minimum Removes the fixed default width per designer feedback. The dialog is at least 300px wide and grows with content; consumers can set a larger inlineSize via style or className for forms or longer copy. New SpecificWidth story documents the override pattern. Adds a TODO to bind 300px to a token once one exists in @equinor/eds-tokens. * feat: auto-id Dialog.Title, add button-pattern stories, suppress UA backdrop - Auto-generate Dialog.Title id via context + useId, default aria-labelledby to it so consumers don't need manual id wiring. Explicit aria-labelledby or aria-label on Dialog still wins. - Move story group from Surface to Feedback to match the Figma category for this component. - Add DangerAction and SingleAction stories illustrating common button arrangements (delete confirm, single acknowledge). - Force ::backdrop to transparent by default; the visible 40% scrim now only applies when [data-scrim] is set, so scrim={false} truly hides the backdrop instead of leaving Chrome's UA 10% overlay. - Tests: cover initial open=true, inline width override, Dialog.Actions, auto-aria-labelledby wiring, explicit aria-labelledby/label overrides (20 tests total, up from 14). * fix: align Dialog header/actions gap with Figma, conform story metadata - Header and actions gap was using horizontal-sm (12px) but Figma specifies 8px — switched to horizontal-xs. - Restructured the Dialog story description to follow the canonical order proposed in #4917 (description → import → usage). Removed the combined import-plus-JSX code block and pointed readers to the stories below for usage examples. * fix: address PR review — single onOpenChange call, safer backdrop, no dangling aria-labelledby Three correctness fixes from the @claude review on PR #4956: 1. onOpenChange fired twice per close interaction (consumer's setOpen plus the native close event handler). Track effect-driven closes via expectedCloseRef and suppress the close-event callback when we already know state will land on false. 2. aria-labelledby defaulted to the generated title id even when no Dialog.Title was rendered, dangling onto a missing element. Title now registers its id with the Dialog via context; aria-labelledby is only set when a title is actually present. 3. Backdrop click closed the dialog on a text-selection drag that started inside content and overshot the dialog edge. Track mousedown target so close fires only when both mousedown and click land on the dialog. API tweak: Dialog.Header.onClose -> closable boolean. The close button now invokes the context-exposed close() so all close paths converge on the native close event (single source of truth for onOpenChange). Tests: 5 new — drag-out guard, exactly-once for backdrop/close-button/ state-driven paths, and no aria-labelledby when neither title nor label is given. 25 tests total. * refactor: default Dialog.Header closable to true, add Figma trailing-space note closable now defaults to true — most dialogs want a close affordance, and decision-forcing dialogs opt out with closable={false}. Removes the closable prop from every story except WithoutCloseButton. Also adds the one-word comment from the second review explaining that 'Content ' in Dialog.figma.tsx intentionally keeps the trailing space to match the Figma layer name. * fix: address PR review for Dialog - drop closable prop, always render close button (Passive variant achieved by omitting Dialog.Actions) - make onOpenChange required - move Storybook h1-h6 font-family reset into @layer storybook; drop unlayered title override from dialog.css - stabilise setRef with useCallback * fix: address Dialog PR review from millus
1 parent 8a18fb6 commit e2f6df2

10 files changed

Lines changed: 958 additions & 2 deletions

File tree

packages/eds-core-react/.storybook/preview-head.html

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,13 @@
1616
}
1717
}
1818

19-
:where(h1, h2, h3, h4, h5, h6) {
20-
font-family: Equinor, sans-serif;
19+
/* Wrapped in @layer so component CSS in @layer eds-components wins
20+
naturally (unlayered styles outrank any layer). The layer name is
21+
declared here first, so it is the lowest-priority layer in the cascade. */
22+
@layer storybook {
23+
:where(h1, h2, h3, h4, h5, h6) {
24+
font-family: Equinor, sans-serif;
25+
}
2126
}
2227

2328
.sbdocs.sbdocs-a {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import figma from '@figma/code-connect'
2+
import { Dialog } from '.'
3+
import { Button } from '../Button'
4+
5+
figma.connect(
6+
Dialog,
7+
'https://www.figma.com/design/dz0XQdc5j7AAtjXr1gTfVR/EDS-Core-Components?node-id=6458-254&m=dev',
8+
{
9+
props: {
10+
title: figma.string('Dialog Title'),
11+
// Trailing space matches the Figma layer name verbatim.
12+
content: figma.string('Content '),
13+
showPrimary: figma.boolean('Show Primary Button'),
14+
showSecondary: figma.boolean('Show Secondary Button'),
15+
},
16+
example: ({ title, content, showPrimary, showSecondary }) => (
17+
<Dialog open onOpenChange={() => {}}>
18+
<Dialog.Header>
19+
<Dialog.Title>{title}</Dialog.Title>
20+
</Dialog.Header>
21+
<Dialog.Content>{content}</Dialog.Content>
22+
<Dialog.Actions>
23+
{showSecondary && <Button variant="secondary">Label</Button>}
24+
{showPrimary && <Button>Label</Button>}
25+
</Dialog.Actions>
26+
</Dialog>
27+
),
28+
},
29+
)
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { useState } from 'react'
2+
import type { Meta, StoryFn } from '@storybook/react-vite'
3+
import { Dialog } from '.'
4+
import { Button } from '../Button'
5+
6+
const meta: Meta<typeof Dialog> = {
7+
title: 'EDS 2.0 (beta)/Feedback/Dialog',
8+
component: Dialog,
9+
tags: ['beta'],
10+
parameters: {
11+
docs: {
12+
description: {
13+
component: `
14+
⚠️ **Beta Component** - This component is under active development.
15+
16+
Built on the native \`<dialog>\` element with \`showModal()\` for free focus
17+
trapping, Escape-key handling and inert background. Backdrop clicks close
18+
the dialog.
19+
20+
### Import
21+
22+
\`\`\`tsx
23+
import { Dialog } from '@equinor/eds-core-react/next'
24+
\`\`\`
25+
26+
See the stories below for usage patterns.
27+
`,
28+
},
29+
},
30+
},
31+
}
32+
33+
export default meta
34+
35+
export const Introduction: StoryFn = () => {
36+
const [open, setOpen] = useState(false)
37+
return (
38+
<>
39+
<Button onClick={() => setOpen(true)}>Open dialog</Button>
40+
<Dialog open={open} onOpenChange={setOpen}>
41+
<Dialog.Header>
42+
<Dialog.Title>Dialog title</Dialog.Title>
43+
</Dialog.Header>
44+
<Dialog.Content>
45+
This is a short description of the action the user is about to take.
46+
</Dialog.Content>
47+
<Dialog.Actions>
48+
<Button variant="secondary" onClick={() => setOpen(false)}>
49+
Cancel
50+
</Button>
51+
<Button onClick={() => setOpen(false)}>Confirm</Button>
52+
</Dialog.Actions>
53+
</Dialog>
54+
</>
55+
)
56+
}
57+
58+
export const Passive: StoryFn = () => {
59+
const [open, setOpen] = useState(false)
60+
return (
61+
<>
62+
<Button onClick={() => setOpen(true)}>Open dialog</Button>
63+
<Dialog open={open} onOpenChange={setOpen}>
64+
<Dialog.Header>
65+
<Dialog.Title>You are now offline</Dialog.Title>
66+
</Dialog.Header>
67+
<Dialog.Content>
68+
A passive dialog — no action row. Dismiss via the close icon, Escape,
69+
or backdrop click.
70+
</Dialog.Content>
71+
</Dialog>
72+
</>
73+
)
74+
}
75+
76+
export const DangerAction: StoryFn = () => {
77+
const [open, setOpen] = useState(false)
78+
return (
79+
<>
80+
<Button tone="danger" onClick={() => setOpen(true)}>
81+
Delete project
82+
</Button>
83+
<Dialog open={open} onOpenChange={setOpen}>
84+
<Dialog.Header>
85+
<Dialog.Title>Delete this project?</Dialog.Title>
86+
</Dialog.Header>
87+
<Dialog.Content>
88+
This permanently removes the project and all of its data. This action
89+
cannot be undone.
90+
</Dialog.Content>
91+
<Dialog.Actions>
92+
<Button variant="secondary" onClick={() => setOpen(false)}>
93+
Cancel
94+
</Button>
95+
<Button tone="danger" onClick={() => setOpen(false)}>
96+
Delete
97+
</Button>
98+
</Dialog.Actions>
99+
</Dialog>
100+
</>
101+
)
102+
}
103+
104+
export const SingleAction: StoryFn = () => {
105+
const [open, setOpen] = useState(false)
106+
return (
107+
<>
108+
<Button onClick={() => setOpen(true)}>Show details</Button>
109+
<Dialog open={open} onOpenChange={setOpen}>
110+
<Dialog.Header>
111+
<Dialog.Title>You are now offline</Dialog.Title>
112+
</Dialog.Header>
113+
<Dialog.Content>
114+
Changes will sync automatically when your connection is restored.
115+
</Dialog.Content>
116+
<Dialog.Actions>
117+
<Button onClick={() => setOpen(false)}>Got it</Button>
118+
</Dialog.Actions>
119+
</Dialog>
120+
</>
121+
)
122+
}
123+
124+
export const WithoutScrim: StoryFn = () => {
125+
const [open, setOpen] = useState(false)
126+
return (
127+
<>
128+
<Button onClick={() => setOpen(true)}>Open dialog</Button>
129+
<Dialog open={open} onOpenChange={setOpen} scrim={false}>
130+
<Dialog.Header>
131+
<Dialog.Title>Dialog title</Dialog.Title>
132+
</Dialog.Header>
133+
<Dialog.Content>
134+
<p style={{ margin: 0 }}>
135+
With <code>scrim={'{false}'}</code> the backdrop is fully
136+
transparent — useful when the dialog is part of a flow that already
137+
dims the background, or when a standalone Scrim component is
138+
composed externally.
139+
</p>
140+
</Dialog.Content>
141+
<Dialog.Actions>
142+
<Button variant="secondary" onClick={() => setOpen(false)}>
143+
Cancel
144+
</Button>
145+
<Button onClick={() => setOpen(false)}>Confirm</Button>
146+
</Dialog.Actions>
147+
</Dialog>
148+
</>
149+
)
150+
}
151+
152+
export const SpecificWidth: StoryFn = () => {
153+
const [open, setOpen] = useState(false)
154+
return (
155+
<>
156+
<Button onClick={() => setOpen(true)}>Open dialog</Button>
157+
<Dialog
158+
open={open}
159+
onOpenChange={setOpen}
160+
style={{ inlineSize: '32rem' }}
161+
>
162+
<Dialog.Header>
163+
<Dialog.Title>Dialog with a specific width</Dialog.Title>
164+
</Dialog.Header>
165+
<Dialog.Content>
166+
<p style={{ margin: 0 }}>
167+
By default the dialog is at least 300px wide and grows with its
168+
content (capped to the viewport by the browser). For dialogs holding
169+
forms, tables, or longer body copy, set a wider{' '}
170+
<code>inlineSize</code> via <code>style</code> or a custom{' '}
171+
<code>className</code>.
172+
</p>
173+
<pre style={{ margin: 0 }}>
174+
{`<Dialog style={{ inlineSize: '32rem' }}>...</Dialog>`}
175+
</pre>
176+
</Dialog.Content>
177+
<Dialog.Actions>
178+
<Button variant="secondary" onClick={() => setOpen(false)}>
179+
Cancel
180+
</Button>
181+
<Button onClick={() => setOpen(false)}>Confirm</Button>
182+
</Dialog.Actions>
183+
</Dialog>
184+
</>
185+
)
186+
}

0 commit comments

Comments
 (0)