@duro-app/ui 5.1.0 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Table-C70zdSkM.js +1419 -0
- package/dist/Table-C70zdSkM.js.map +1 -0
- package/dist/components/Button/Button.d.ts +11 -2
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/Button/Button.stories.d.ts +1 -0
- package/dist/components/Button/Button.stories.d.ts.map +1 -1
- package/dist/components/Combobox/Combobox.d.ts +1 -1
- package/dist/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/components/Combobox/styles.css.d.ts +0 -64
- package/dist/components/Combobox/styles.css.d.ts.map +1 -1
- package/dist/components/Dialog/Dialog.d.ts +11 -3
- package/dist/components/Dialog/Dialog.d.ts.map +1 -1
- package/dist/components/Dialog/Dialog.stories.d.ts +4 -0
- package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -1
- package/dist/components/Dialog/styles.css.d.ts +1 -0
- package/dist/components/Dialog/styles.css.d.ts.map +1 -1
- package/dist/components/DragDrop/styles.css.d.ts +10 -2
- package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
- package/dist/components/Grid/Grid.d.ts +11 -2
- package/dist/components/Grid/Grid.d.ts.map +1 -1
- package/dist/components/Grid/Grid.stories.d.ts +2 -0
- package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
- package/dist/components/Input/Input.d.ts +6 -1
- package/dist/components/Input/Input.d.ts.map +1 -1
- package/dist/components/Input/Input.meta.d.ts.map +1 -1
- package/dist/components/Input/Input.stories.d.ts +1 -0
- package/dist/components/Input/Input.stories.d.ts.map +1 -1
- package/dist/components/Input/styles.css.d.ts +18 -0
- package/dist/components/Input/styles.css.d.ts.map +1 -1
- package/dist/components/InputGroup/InputGroup.d.ts +6 -1
- package/dist/components/InputGroup/InputGroup.d.ts.map +1 -1
- package/dist/components/InputGroup/InputGroup.stories.d.ts +1 -0
- package/dist/components/InputGroup/InputGroup.stories.d.ts.map +1 -1
- package/dist/components/Listbox/Listbox.d.ts +75 -0
- package/dist/components/Listbox/Listbox.d.ts.map +1 -0
- package/dist/components/Listbox/Listbox.meta.d.ts +3 -0
- package/dist/components/Listbox/Listbox.meta.d.ts.map +1 -0
- package/dist/components/Listbox/Listbox.stories.d.ts +7 -0
- package/dist/components/Listbox/Listbox.stories.d.ts.map +1 -0
- package/dist/components/Listbox/styles.css.d.ts +75 -0
- package/dist/components/Listbox/styles.css.d.ts.map +1 -0
- package/dist/components/Menu/Menu.d.ts +18 -5
- package/dist/components/Menu/Menu.d.ts.map +1 -1
- package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
- package/dist/components/Menu/Menu.stories.d.ts +7 -0
- package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
- package/dist/components/Menu/MenuContext.d.ts +3 -0
- package/dist/components/Menu/MenuContext.d.ts.map +1 -1
- package/dist/components/Menu/styles.css.d.ts +24 -15
- package/dist/components/Menu/styles.css.d.ts.map +1 -1
- package/dist/components/Menu/useMenuRoot.d.ts +14 -1
- package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
- package/dist/components/TextLink/TextLink.d.ts +24 -0
- package/dist/components/TextLink/TextLink.d.ts.map +1 -0
- package/dist/components/TextLink/TextLink.meta.d.ts +3 -0
- package/dist/components/TextLink/TextLink.meta.d.ts.map +1 -0
- package/dist/components/TextLink/TextLink.stories.d.ts +9 -0
- package/dist/components/TextLink/TextLink.stories.d.ts.map +1 -0
- package/dist/components/TextLink/styles.css.d.ts +28 -0
- package/dist/components/TextLink/styles.css.d.ts.map +1 -0
- package/dist/components/ThemeProvider/ThemeProvider.stories.d.ts +1 -0
- package/dist/components/ThemeProvider/ThemeProvider.stories.d.ts.map +1 -1
- package/dist/components/VisuallyHidden/VisuallyHidden.d.ts +14 -0
- package/dist/components/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
- package/dist/components/VisuallyHidden/VisuallyHidden.meta.d.ts +3 -0
- package/dist/components/VisuallyHidden/VisuallyHidden.meta.d.ts.map +1 -0
- package/dist/components/VisuallyHidden/VisuallyHidden.stories.d.ts +7 -0
- package/dist/components/VisuallyHidden/VisuallyHidden.stories.d.ts.map +1 -0
- package/dist/docs/FlexGrow.stories.d.ts +7 -0
- package/dist/docs/FlexGrow.stories.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2943 -2778
- package/dist/index.js.map +1 -1
- package/dist/index.native.d.ts +1 -0
- package/dist/index.native.d.ts.map +1 -1
- package/dist/styles/visually-hidden.css.d.ts +6 -0
- package/dist/styles/visually-hidden.css.d.ts.map +1 -1
- package/dist/table.js +1 -1
- package/package.json +2 -2
- package/src/components/Button/Button.stories.tsx +41 -0
- package/src/components/Button/Button.tsx +16 -1
- package/src/components/Combobox/Combobox.tsx +25 -79
- package/src/components/Combobox/styles.css.ts +0 -74
- package/src/components/Dialog/Dialog.meta.ts +1 -1
- package/src/components/Dialog/Dialog.stories.tsx +109 -3
- package/src/components/Dialog/Dialog.tsx +63 -9
- package/src/components/Dialog/styles.css.ts +2 -0
- package/src/components/DragDrop/styles.css.ts +16 -3
- package/src/components/Grid/Grid.meta.ts +1 -1
- package/src/components/Grid/Grid.stories.tsx +53 -1
- package/src/components/Grid/Grid.tsx +23 -11
- package/src/components/Input/Input.meta.ts +10 -3
- package/src/components/Input/Input.stories.tsx +52 -2
- package/src/components/Input/Input.tsx +12 -3
- package/src/components/Input/styles.css.ts +29 -0
- package/src/components/InputGroup/InputGroup.stories.tsx +20 -0
- package/src/components/InputGroup/InputGroup.tsx +20 -2
- package/src/components/Listbox/Listbox.meta.ts +52 -0
- package/src/components/Listbox/Listbox.stories.tsx +170 -0
- package/src/components/Listbox/Listbox.tsx +278 -0
- package/src/components/Listbox/styles.css.ts +89 -0
- package/src/components/Menu/Menu.meta.ts +2 -1
- package/src/components/Menu/Menu.stories.tsx +289 -8
- package/src/components/Menu/Menu.tsx +100 -29
- package/src/components/Menu/MenuContext.ts +3 -0
- package/src/components/Menu/styles.css.ts +31 -15
- package/src/components/Menu/useMenuRoot.ts +105 -48
- package/src/components/TextLink/TextLink.meta.ts +33 -0
- package/src/components/TextLink/TextLink.stories.tsx +63 -0
- package/src/components/TextLink/TextLink.tsx +46 -0
- package/src/components/TextLink/styles.css.ts +58 -0
- package/src/components/ThemeProvider/ThemeProvider.stories.tsx +50 -0
- package/src/components/VisuallyHidden/VisuallyHidden.meta.ts +19 -0
- package/src/components/VisuallyHidden/VisuallyHidden.stories.tsx +30 -0
- package/src/components/VisuallyHidden/VisuallyHidden.tsx +22 -0
- package/src/docs/FlexGrow.stories.tsx +83 -0
- package/src/index.native.ts +1 -0
- package/src/index.ts +5 -0
- package/src/styles/visually-hidden.css.ts +8 -0
- package/dist/Table-BkrgH0jb.js +0 -1199
- package/dist/Table-BkrgH0jb.js.map +0 -1
package/dist/index.native.d.ts
CHANGED
|
@@ -28,5 +28,6 @@ export { Stack, type SpacingKey } from './components/Stack/Stack';
|
|
|
28
28
|
export { Inline } from './components/Inline/Inline';
|
|
29
29
|
export { Cluster } from './components/Cluster/Cluster';
|
|
30
30
|
export { Grid } from './components/Grid/Grid';
|
|
31
|
+
export type { GridTrack } from './shared/length';
|
|
31
32
|
export { ThemeProvider, usePortalMount, type ThemeName, } from './components/ThemeProvider/ThemeProvider';
|
|
32
33
|
//# sourceMappingURL=index.native.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.native.d.ts","sourceRoot":"","sources":["../src/index.native.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,EAAC,KAAK,EAAE,KAAK,YAAY,EAAE,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAA;AACjF,OAAO,EAAC,MAAM,EAAE,KAAK,aAAa,EAAE,KAAK,UAAU,EAAC,MAAM,4BAA4B,CAAA;AACtF,OAAO,EAAC,IAAI,EAAE,KAAK,WAAW,EAAE,KAAK,QAAQ,EAAC,MAAM,wBAAwB,CAAA;AAC5E,OAAO,EAAC,QAAQ,EAAC,MAAM,gCAAgC,CAAA;AACvD,OAAO,EAAC,OAAO,EAAE,KAAK,cAAc,EAAC,MAAM,8BAA8B,CAAA;AACzE,OAAO,EAAC,KAAK,EAAE,KAAK,YAAY,EAAC,MAAM,0BAA0B,CAAA;AACjE,OAAO,EAAC,MAAM,EAAC,MAAM,4BAA4B,CAAA;AACjD,OAAO,EAAC,IAAI,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAC,MAAM,wBAAwB,CAAA;AAC7E,OAAO,EAAC,QAAQ,EAAE,KAAK,eAAe,EAAC,MAAM,gCAAgC,CAAA;AAG7E,OAAO,EAAC,KAAK,EAAC,MAAM,0BAA0B,CAAA;AAC9C,OAAO,EAAC,UAAU,EAAC,MAAM,oCAAoC,CAAA;AAG7D,OAAO,EAAC,KAAK,EAAE,KAAK,UAAU,EAAC,MAAM,0BAA0B,CAAA;AAC/D,OAAO,EAAC,MAAM,EAAC,MAAM,4BAA4B,CAAA;AACjD,OAAO,EAAC,OAAO,EAAC,MAAM,8BAA8B,CAAA;AACpD,OAAO,EAAC,IAAI,EAAC,MAAM,wBAAwB,CAAA;
|
|
1
|
+
{"version":3,"file":"index.native.d.ts","sourceRoot":"","sources":["../src/index.native.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,EAAC,KAAK,EAAE,KAAK,YAAY,EAAE,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAA;AACjF,OAAO,EAAC,MAAM,EAAE,KAAK,aAAa,EAAE,KAAK,UAAU,EAAC,MAAM,4BAA4B,CAAA;AACtF,OAAO,EAAC,IAAI,EAAE,KAAK,WAAW,EAAE,KAAK,QAAQ,EAAC,MAAM,wBAAwB,CAAA;AAC5E,OAAO,EAAC,QAAQ,EAAC,MAAM,gCAAgC,CAAA;AACvD,OAAO,EAAC,OAAO,EAAE,KAAK,cAAc,EAAC,MAAM,8BAA8B,CAAA;AACzE,OAAO,EAAC,KAAK,EAAE,KAAK,YAAY,EAAC,MAAM,0BAA0B,CAAA;AACjE,OAAO,EAAC,MAAM,EAAC,MAAM,4BAA4B,CAAA;AACjD,OAAO,EAAC,IAAI,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAC,MAAM,wBAAwB,CAAA;AAC7E,OAAO,EAAC,QAAQ,EAAE,KAAK,eAAe,EAAC,MAAM,gCAAgC,CAAA;AAG7E,OAAO,EAAC,KAAK,EAAC,MAAM,0BAA0B,CAAA;AAC9C,OAAO,EAAC,UAAU,EAAC,MAAM,oCAAoC,CAAA;AAG7D,OAAO,EAAC,KAAK,EAAE,KAAK,UAAU,EAAC,MAAM,0BAA0B,CAAA;AAC/D,OAAO,EAAC,MAAM,EAAC,MAAM,4BAA4B,CAAA;AACjD,OAAO,EAAC,OAAO,EAAC,MAAM,8BAA8B,CAAA;AACpD,OAAO,EAAC,IAAI,EAAC,MAAM,wBAAwB,CAAA;AAC3C,YAAY,EAAC,SAAS,EAAC,MAAM,iBAAiB,CAAA;AAI9C,OAAO,EACL,aAAa,EACb,cAAc,EACd,KAAK,SAAS,GACf,MAAM,0CAA0C,CAAA"}
|
|
@@ -6,5 +6,11 @@ export declare const visuallyHidden: Readonly<{
|
|
|
6
6
|
readonly height: css.StyleXClassNameFor<"height", 1>;
|
|
7
7
|
readonly overflow: css.StyleXClassNameFor<"overflow", "hidden">;
|
|
8
8
|
}>;
|
|
9
|
+
readonly text: Readonly<{
|
|
10
|
+
readonly clip: css.StyleXClassNameFor<"clip", "rect(0, 0, 0, 0)">;
|
|
11
|
+
readonly whiteSpace: css.StyleXClassNameFor<"whiteSpace", "nowrap">;
|
|
12
|
+
readonly borderWidth: css.StyleXClassNameFor<"borderWidth", 0>;
|
|
13
|
+
readonly padding: css.StyleXClassNameFor<"padding", 0>;
|
|
14
|
+
}>;
|
|
9
15
|
}>;
|
|
10
16
|
//# sourceMappingURL=visually-hidden.css.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"visually-hidden.css.d.ts","sourceRoot":"","sources":["../../src/styles/visually-hidden.css.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,GAAG,EAAC,MAAM,kBAAkB,CAAA;AAQpC,eAAO,MAAM,cAAc
|
|
1
|
+
{"version":3,"file":"visually-hidden.css.d.ts","sourceRoot":"","sources":["../../src/styles/visually-hidden.css.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,GAAG,EAAC,MAAM,kBAAkB,CAAA;AAQpC,eAAO,MAAM,cAAc;;;;;;;;;;;;;EAezB,CAAA"}
|
package/dist/table.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { M as F,
|
|
1
|
+
import { M as F, g as xt, R as rt, H as kt, h as pt, B as bt, j as ft, C as mt, e as ht, k as yt, r as St, l as Tt } from "./Table-C70zdSkM.js";
|
|
2
2
|
import { jsx as u, jsxs as q } from "react/jsx-runtime";
|
|
3
3
|
import { c as H, m as p, d as b } from "./compiler-runtime-B2IcI8_O.js";
|
|
4
4
|
import { flexRender as E, useReactTable as it, getPaginationRowModel as Ct, getFilteredRowModel as vt, getSortedRowModel as at, getCoreRowModel as st } from "@tanstack/react-table";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"@tanstack/react-virtual": "^3.14.6",
|
|
69
|
-
"@duro-app/tokens": "^5.
|
|
69
|
+
"@duro-app/tokens": "^5.2.0"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
2
|
import {expect, fn} from 'storybook/test'
|
|
3
|
+
import {useRef, useState} from 'react'
|
|
3
4
|
import {css, html} from 'react-strict-dom'
|
|
4
5
|
import {Button} from './Button'
|
|
5
6
|
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
@@ -129,3 +130,43 @@ export const AllVariants: Story = {
|
|
|
129
130
|
await expect(disabledButtons.length).toBe(3)
|
|
130
131
|
},
|
|
131
132
|
}
|
|
133
|
+
|
|
134
|
+
// A disclosure: aria-expanded / aria-controls pass through, and the ref
|
|
135
|
+
// reaches the <button> (to return focus to it).
|
|
136
|
+
function DisclosureDemo() {
|
|
137
|
+
const [open, setOpen] = useState(false)
|
|
138
|
+
const ref = useRef<HTMLButtonElement>(null)
|
|
139
|
+
return (
|
|
140
|
+
<html.div>
|
|
141
|
+
<Button
|
|
142
|
+
ref={ref}
|
|
143
|
+
variant="secondary"
|
|
144
|
+
aria-expanded={open}
|
|
145
|
+
aria-controls="filters-panel"
|
|
146
|
+
onClick={() => setOpen(!open)}
|
|
147
|
+
>
|
|
148
|
+
Filters
|
|
149
|
+
</Button>
|
|
150
|
+
{open && (
|
|
151
|
+
<html.div id="filters-panel" role="region" aria-label="Filters panel">
|
|
152
|
+
<Button variant="link" onClick={() => ref.current?.focus()}>
|
|
153
|
+
Back to Filters
|
|
154
|
+
</Button>
|
|
155
|
+
</html.div>
|
|
156
|
+
)}
|
|
157
|
+
</html.div>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const RefAndDisclosure: Story = {
|
|
162
|
+
render: () => <DisclosureDemo />,
|
|
163
|
+
play: async ({canvas, userEvent}) => {
|
|
164
|
+
const toggle = canvas.getByRole('button', {name: 'Filters'})
|
|
165
|
+
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
|
166
|
+
await userEvent.click(toggle)
|
|
167
|
+
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
|
168
|
+
await expect(toggle).toHaveAttribute('aria-controls', 'filters-panel')
|
|
169
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Back to Filters'}))
|
|
170
|
+
await expect(toggle).toHaveFocus()
|
|
171
|
+
},
|
|
172
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {ReactNode} from 'react'
|
|
1
|
+
import type {ReactNode, Ref} from 'react'
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
3
|
import {isNative} from '../../platform'
|
|
4
4
|
import {styles} from './styles.css'
|
|
@@ -15,6 +15,15 @@ interface ButtonProps {
|
|
|
15
15
|
onClick?: () => void
|
|
16
16
|
/** Accessible name override — e.g. for buttons whose visible text isn't unique. */
|
|
17
17
|
'aria-label'?: string
|
|
18
|
+
/** For a button that shows or hides another element (a disclosure, a panel). */
|
|
19
|
+
'aria-expanded'?: boolean
|
|
20
|
+
/** Id of the element this button controls. */
|
|
21
|
+
'aria-controls'?: string
|
|
22
|
+
/**
|
|
23
|
+
* The underlying <button>, e.g. to anchor a Popover or return focus to it.
|
|
24
|
+
* Web only: on native the ref is not attached.
|
|
25
|
+
*/
|
|
26
|
+
ref?: Ref<HTMLButtonElement>
|
|
18
27
|
children: ReactNode
|
|
19
28
|
}
|
|
20
29
|
|
|
@@ -31,14 +40,20 @@ export function Button({
|
|
|
31
40
|
type = 'button',
|
|
32
41
|
onClick,
|
|
33
42
|
'aria-label': ariaLabel,
|
|
43
|
+
'aria-expanded': ariaExpanded,
|
|
44
|
+
'aria-controls': ariaControls,
|
|
45
|
+
ref,
|
|
34
46
|
children,
|
|
35
47
|
}: ButtonProps) {
|
|
36
48
|
return (
|
|
37
49
|
<html.button
|
|
50
|
+
ref={isNative ? undefined : ref}
|
|
38
51
|
type={type}
|
|
39
52
|
disabled={disabled}
|
|
40
53
|
onClick={onClick}
|
|
41
54
|
aria-label={ariaLabel}
|
|
55
|
+
aria-expanded={ariaExpanded}
|
|
56
|
+
aria-controls={ariaControls}
|
|
42
57
|
style={[
|
|
43
58
|
styles.base,
|
|
44
59
|
isNative && styles.nativeFlex,
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import {type ReactNode, useRef, useId, useEffect,
|
|
2
|
-
import {createPortal} from 'react-dom'
|
|
1
|
+
import {type ReactNode, useCallback, useRef, useId, useEffect, useState} from 'react'
|
|
3
2
|
import {html} from 'react-strict-dom'
|
|
4
3
|
import {styles} from './styles.css'
|
|
5
4
|
import {ComboboxContext, useCombobox} from './ComboboxContext'
|
|
6
5
|
import {useComboboxRoot} from './useComboboxRoot'
|
|
7
|
-
import {
|
|
8
|
-
import {usePopoverLayer} from '../Popover/PopoverLayerContext'
|
|
6
|
+
import {Listbox, getAnchorProps} from '../Listbox/Listbox'
|
|
9
7
|
|
|
10
8
|
// --- Root ---
|
|
11
9
|
interface RootProps {
|
|
@@ -54,6 +52,7 @@ function Input({placeholder, children}: {placeholder?: string; children?: ReactN
|
|
|
54
52
|
useCombobox()
|
|
55
53
|
const localRef = useRef<HTMLInputElement>(null)
|
|
56
54
|
const [focused, setFocused] = useState(false)
|
|
55
|
+
const anchor = getAnchorProps({id: listboxId, open, highlightedId})
|
|
57
56
|
|
|
58
57
|
useEffect(() => {
|
|
59
58
|
inputRef.current = localRef.current
|
|
@@ -67,10 +66,10 @@ function Input({placeholder, children}: {placeholder?: string; children?: ReactN
|
|
|
67
66
|
role={'combobox' as 'listbox'}
|
|
68
67
|
value={inputValue}
|
|
69
68
|
placeholder={placeholder}
|
|
70
|
-
aria-expanded={
|
|
69
|
+
aria-expanded={anchor['aria-expanded']}
|
|
71
70
|
aria-haspopup="listbox"
|
|
72
|
-
aria-controls={
|
|
73
|
-
aria-activedescendant={
|
|
71
|
+
aria-controls={anchor['aria-controls']}
|
|
72
|
+
aria-activedescendant={anchor['aria-activedescendant']}
|
|
74
73
|
aria-autocomplete={'list' as 'none'}
|
|
75
74
|
autoComplete="off"
|
|
76
75
|
style={styles.input}
|
|
@@ -132,58 +131,27 @@ function Trigger({children}: {children?: ReactNode}) {
|
|
|
132
131
|
}
|
|
133
132
|
|
|
134
133
|
// --- Popup ---
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
// containing blocks (notably Dialog and Drawer).
|
|
134
|
+
// A Listbox placed under the input, as wide as the field: rendered into the
|
|
135
|
+
// ThemeProvider portal mount so it escapes ancestor `overflow: hidden` /
|
|
136
|
+
// `transform` containing blocks (notably Dialog and Drawer).
|
|
138
137
|
function Popup({children}: {children: ReactNode}) {
|
|
139
|
-
const {open, listboxId, rootRef} = useCombobox()
|
|
140
|
-
const
|
|
141
|
-
const listboxRef = useRef<HTMLDivElement>(null)
|
|
142
|
-
const [coords, setCoords] = useState<{top: number; left: number; width: number} | null>(null)
|
|
138
|
+
const {open, listboxId, rootRef, highlightedId, setHighlightedId, setValue} = useCombobox()
|
|
139
|
+
const anchor = useCallback(() => rootRef.current?.getBoundingClientRect(), [rootRef])
|
|
143
140
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
// scroll on any ancestor (e.g. Dialog body).
|
|
147
|
-
useLayoutEffect(() => {
|
|
148
|
-
if (!open) {
|
|
149
|
-
setCoords(null)
|
|
150
|
-
return
|
|
151
|
-
}
|
|
152
|
-
const update = () => {
|
|
153
|
-
const el = rootRef.current
|
|
154
|
-
if (!el) return
|
|
155
|
-
const rect = el.getBoundingClientRect()
|
|
156
|
-
// Position immediately below the input with a small gap. `xs` token = 4px.
|
|
157
|
-
setCoords({top: rect.bottom + 4, left: rect.left, width: rect.width})
|
|
158
|
-
}
|
|
159
|
-
update()
|
|
160
|
-
window.addEventListener('resize', update)
|
|
161
|
-
window.addEventListener('scroll', update, true)
|
|
162
|
-
return () => {
|
|
163
|
-
window.removeEventListener('resize', update)
|
|
164
|
-
window.removeEventListener('scroll', update, true)
|
|
165
|
-
}
|
|
166
|
-
}, [open, rootRef])
|
|
167
|
-
|
|
168
|
-
usePopoverLayer(listboxRef, {active: open && coords !== null})
|
|
169
|
-
|
|
170
|
-
if (!open || !coords) return null
|
|
171
|
-
|
|
172
|
-
const node = (
|
|
173
|
-
<html.div
|
|
174
|
-
ref={listboxRef}
|
|
141
|
+
return (
|
|
142
|
+
<Listbox.Root
|
|
175
143
|
id={listboxId}
|
|
176
|
-
|
|
177
|
-
|
|
144
|
+
open={open}
|
|
145
|
+
anchor={anchor}
|
|
146
|
+
matchAnchorWidth
|
|
147
|
+
highlightedId={highlightedId}
|
|
148
|
+
onHighlight={setHighlightedId}
|
|
149
|
+
onSelect={setValue}
|
|
150
|
+
maxHeight="listMaxHSm"
|
|
178
151
|
>
|
|
179
152
|
{children}
|
|
180
|
-
</
|
|
153
|
+
</Listbox.Root>
|
|
181
154
|
)
|
|
182
|
-
|
|
183
|
-
// SSR / pre-mount fallback: render inline (will be invisible under
|
|
184
|
-
// overflow:hidden parents but won't crash). Once the mount is available,
|
|
185
|
-
// portal into it.
|
|
186
|
-
return mount ? createPortal(node, mount) : node
|
|
187
155
|
}
|
|
188
156
|
|
|
189
157
|
// --- Item ---
|
|
@@ -193,20 +161,10 @@ interface ItemProps {
|
|
|
193
161
|
}
|
|
194
162
|
|
|
195
163
|
function Item({value: itemValue, children}: ItemProps) {
|
|
196
|
-
const {
|
|
197
|
-
value: selectedValue,
|
|
198
|
-
setValue,
|
|
199
|
-
inputValue,
|
|
200
|
-
labels,
|
|
201
|
-
registerLabel,
|
|
202
|
-
highlightedId,
|
|
203
|
-
setHighlightedId,
|
|
204
|
-
registerItem,
|
|
205
|
-
} = useCombobox()
|
|
164
|
+
const {value: selectedValue, inputValue, labels, registerLabel, registerItem} = useCombobox()
|
|
206
165
|
const id = useId()
|
|
207
166
|
const ref = useRef<HTMLDivElement>(null)
|
|
208
167
|
const isSelected = selectedValue === itemValue
|
|
209
|
-
const isHighlighted = highlightedId === id
|
|
210
168
|
|
|
211
169
|
// Filter: check if this item matches the input
|
|
212
170
|
const label = labels[itemValue] ?? itemValue
|
|
@@ -229,21 +187,9 @@ function Item({value: itemValue, children}: ItemProps) {
|
|
|
229
187
|
if (!isVisible) return null
|
|
230
188
|
|
|
231
189
|
return (
|
|
232
|
-
<
|
|
233
|
-
ref={ref}
|
|
234
|
-
id={id}
|
|
235
|
-
role="option"
|
|
236
|
-
aria-selected={isSelected}
|
|
237
|
-
onClick={() => setValue(itemValue)}
|
|
238
|
-
onPointerEnter={() => setHighlightedId(id)}
|
|
239
|
-
style={[
|
|
240
|
-
styles.item,
|
|
241
|
-
isSelected && styles.itemSelected,
|
|
242
|
-
isHighlighted && styles.itemHighlighted,
|
|
243
|
-
]}
|
|
244
|
-
>
|
|
190
|
+
<Listbox.Option ref={ref} id={id} value={itemValue} selected={isSelected}>
|
|
245
191
|
{children}
|
|
246
|
-
</
|
|
192
|
+
</Listbox.Option>
|
|
247
193
|
)
|
|
248
194
|
}
|
|
249
195
|
|
|
@@ -266,7 +212,7 @@ function Empty({children}: {children: ReactNode}) {
|
|
|
266
212
|
|
|
267
213
|
if (hasVisibleItems) return null
|
|
268
214
|
|
|
269
|
-
return <
|
|
215
|
+
return <Listbox.Empty>{children}</Listbox.Empty>
|
|
270
216
|
}
|
|
271
217
|
|
|
272
218
|
export const Combobox = {
|
|
@@ -2,9 +2,7 @@ import {css} from 'react-strict-dom'
|
|
|
2
2
|
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
3
|
import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
4
|
import {typography} from '@duro-app/tokens/tokens/typography.css'
|
|
5
|
-
import {shadows} from '@duro-app/tokens/tokens/shadows.css'
|
|
6
5
|
import {duration} from '@duro-app/tokens/tokens/motion.css'
|
|
7
|
-
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
8
6
|
import {borders} from '@duro-app/tokens/tokens/borders.css'
|
|
9
7
|
|
|
10
8
|
export const styles = css.create({
|
|
@@ -57,76 +55,4 @@ export const styles = css.create({
|
|
|
57
55
|
color: colors.textMuted,
|
|
58
56
|
cursor: 'pointer',
|
|
59
57
|
},
|
|
60
|
-
backdrop: {
|
|
61
|
-
position: 'fixed',
|
|
62
|
-
top: 0,
|
|
63
|
-
left: 0,
|
|
64
|
-
right: 0,
|
|
65
|
-
bottom: 0,
|
|
66
|
-
// Above Dialog/Drawer (1000/1001): they portal into the same mount, so a
|
|
67
|
-
// popup opened from inside a dialog must out-rank the dialog itself.
|
|
68
|
-
zIndex: 1049,
|
|
69
|
-
},
|
|
70
|
-
popup: {
|
|
71
|
-
// Position is `fixed` and the top/left/width are applied inline by Popup
|
|
72
|
-
// (computed from the root's bounding rect) so the dropdown can escape any
|
|
73
|
-
// ancestor with `overflow: hidden` or `transform` (e.g. inside a Dialog).
|
|
74
|
-
position: 'fixed',
|
|
75
|
-
backgroundColor: colors.bgCard,
|
|
76
|
-
borderWidth: borders.hairline,
|
|
77
|
-
borderStyle: 'solid',
|
|
78
|
-
borderColor: colors.border,
|
|
79
|
-
borderRadius: radii.sm,
|
|
80
|
-
boxShadow: shadows.md,
|
|
81
|
-
paddingTop: spacing.xs,
|
|
82
|
-
paddingBottom: spacing.xs,
|
|
83
|
-
maxHeight: sizes.listMaxHSm,
|
|
84
|
-
overflowY: 'auto',
|
|
85
|
-
zIndex: 1050,
|
|
86
|
-
// Re-enable pointer events: the portal mount is pointer-events: none so
|
|
87
|
-
// clicks fall through except on the popup itself.
|
|
88
|
-
pointerEvents: 'auto',
|
|
89
|
-
},
|
|
90
|
-
item: {
|
|
91
|
-
display: 'flex',
|
|
92
|
-
alignItems: 'center',
|
|
93
|
-
paddingTop: spacing.sm,
|
|
94
|
-
paddingBottom: spacing.sm,
|
|
95
|
-
paddingLeft: spacing.md,
|
|
96
|
-
paddingRight: spacing.md,
|
|
97
|
-
fontSize: typography.fontSizeSm,
|
|
98
|
-
fontFamily: typography.fontFamily,
|
|
99
|
-
color: colors.text,
|
|
100
|
-
cursor: 'pointer',
|
|
101
|
-
backgroundColor: 'transparent',
|
|
102
|
-
transitionProperty: 'background-color',
|
|
103
|
-
transitionDuration: duration.fast,
|
|
104
|
-
},
|
|
105
|
-
itemSelected: {
|
|
106
|
-
color: colors.accent,
|
|
107
|
-
fontWeight: typography.fontWeightMedium,
|
|
108
|
-
},
|
|
109
|
-
itemHighlighted: {
|
|
110
|
-
backgroundColor: colors.bgCardHover,
|
|
111
|
-
},
|
|
112
|
-
itemHidden: {
|
|
113
|
-
display: 'none',
|
|
114
|
-
},
|
|
115
|
-
empty: {
|
|
116
|
-
paddingTop: spacing.sm,
|
|
117
|
-
paddingBottom: spacing.sm,
|
|
118
|
-
paddingLeft: spacing.md,
|
|
119
|
-
paddingRight: spacing.md,
|
|
120
|
-
fontSize: typography.fontSizeSm,
|
|
121
|
-
color: colors.textMuted,
|
|
122
|
-
},
|
|
123
|
-
hidden: {
|
|
124
|
-
display: 'none',
|
|
125
|
-
},
|
|
126
|
-
// Dynamic position — applied at runtime from the input's bounding rect.
|
|
127
|
-
popupPosition: (top: number, left: number, width: number) => ({
|
|
128
|
-
top,
|
|
129
|
-
left,
|
|
130
|
-
width,
|
|
131
|
-
}),
|
|
132
58
|
})
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Modal dialog with backdrop overlay. Centered on screen with scale+fade animation.
|
|
5
|
+
'Modal dialog with backdrop overlay. Centered on screen with scale+fade animation. Moves focus in on open (initialFocus, else the first focusable element, else the dialog) and back on close; dismisses on backdrop click (dismissable) or Escape (closeOnEscape, independent of dismissable). Use for confirmations, forms, or content that requires user attention before continuing.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Confirmation prompts (delete, revoke, destructive actions)',
|
|
8
8
|
'Short forms or data entry that blocks the main flow',
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
-
import {expect, within, userEvent} from 'storybook/test'
|
|
2
|
+
import {expect, screen, waitFor, within, userEvent} from 'storybook/test'
|
|
3
3
|
import {css, html} from 'react-strict-dom'
|
|
4
|
-
import {useState} from 'react'
|
|
4
|
+
import {useRef, useState} from 'react'
|
|
5
5
|
import {Dialog} from './Dialog'
|
|
6
6
|
import {Button} from '../Button/Button'
|
|
7
7
|
import {Text} from '../Text/Text'
|
|
@@ -260,7 +260,7 @@ export const DangerConfirmation: Story = {
|
|
|
260
260
|
|
|
261
261
|
export const NonDismissable: Story = {
|
|
262
262
|
render: () => (
|
|
263
|
-
<Dialog.Root dismissable={false}>
|
|
263
|
+
<Dialog.Root dismissable={false} closeOnEscape={false}>
|
|
264
264
|
<Dialog.Trigger>
|
|
265
265
|
<Button>Open non-dismissable</Button>
|
|
266
266
|
</Dialog.Trigger>
|
|
@@ -454,3 +454,109 @@ export const BackdropClickDismisses: Story = {
|
|
|
454
454
|
await expect(await canvas.findByText('Last action: Dismissed')).toBeInTheDocument()
|
|
455
455
|
},
|
|
456
456
|
}
|
|
457
|
+
|
|
458
|
+
// --- Focus and Escape (5.2) ---
|
|
459
|
+
|
|
460
|
+
// dismissable only governs the backdrop: Escape still closes.
|
|
461
|
+
export const EscapeClosesNonDismissable: Story = {
|
|
462
|
+
render: () => (
|
|
463
|
+
<Dialog.Root dismissable={false}>
|
|
464
|
+
<Dialog.Trigger>
|
|
465
|
+
<Button>Open terms</Button>
|
|
466
|
+
</Dialog.Trigger>
|
|
467
|
+
<Dialog.Portal size="sm">
|
|
468
|
+
<Dialog.Header>
|
|
469
|
+
<Dialog.Title>Terms</Dialog.Title>
|
|
470
|
+
</Dialog.Header>
|
|
471
|
+
<Dialog.Body>
|
|
472
|
+
<Text>Read before continuing.</Text>
|
|
473
|
+
</Dialog.Body>
|
|
474
|
+
</Dialog.Portal>
|
|
475
|
+
</Dialog.Root>
|
|
476
|
+
),
|
|
477
|
+
play: async ({canvas, userEvent}) => {
|
|
478
|
+
const opener = canvas.getByRole('button', {name: 'Open terms'})
|
|
479
|
+
await userEvent.click(opener)
|
|
480
|
+
const dialog = await screen.findByRole('dialog', {name: 'Terms'})
|
|
481
|
+
// Nothing focusable inside: the dialog itself takes focus.
|
|
482
|
+
await waitFor(() => expect(dialog).toHaveFocus())
|
|
483
|
+
await userEvent.keyboard('{Escape}')
|
|
484
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
|
485
|
+
await waitFor(() => expect(opener).toHaveFocus())
|
|
486
|
+
},
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function InitialFocusDemo() {
|
|
490
|
+
const nameRef = useRef<HTMLInputElement>(null)
|
|
491
|
+
return (
|
|
492
|
+
<Dialog.Root initialFocus={nameRef}>
|
|
493
|
+
<Dialog.Trigger>
|
|
494
|
+
<Button>Rename</Button>
|
|
495
|
+
</Dialog.Trigger>
|
|
496
|
+
<Dialog.Portal size="sm">
|
|
497
|
+
<Dialog.Header>
|
|
498
|
+
<Dialog.Title>Rename file</Dialog.Title>
|
|
499
|
+
<Dialog.Close />
|
|
500
|
+
</Dialog.Header>
|
|
501
|
+
<Dialog.Body>
|
|
502
|
+
<Field.Root>
|
|
503
|
+
<Field.Label>Name</Field.Label>
|
|
504
|
+
<Input ref={nameRef} defaultValue="notes.md" />
|
|
505
|
+
</Field.Root>
|
|
506
|
+
</Dialog.Body>
|
|
507
|
+
</Dialog.Portal>
|
|
508
|
+
</Dialog.Root>
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export const InitialFocus: Story = {
|
|
513
|
+
render: () => <InitialFocusDemo />,
|
|
514
|
+
play: async ({canvas, userEvent}) => {
|
|
515
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Rename'}))
|
|
516
|
+
const input = await screen.findByRole('textbox', {name: 'Name'})
|
|
517
|
+
await waitFor(() => expect(input).toHaveFocus())
|
|
518
|
+
},
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export const FirstFocusableByDefault: Story = {
|
|
522
|
+
render: () => (
|
|
523
|
+
<Dialog.Root>
|
|
524
|
+
<Dialog.Trigger>
|
|
525
|
+
<Button>Open</Button>
|
|
526
|
+
</Dialog.Trigger>
|
|
527
|
+
<Dialog.Portal size="sm">
|
|
528
|
+
<Dialog.Header>
|
|
529
|
+
<Dialog.Title>Share</Dialog.Title>
|
|
530
|
+
<Dialog.Close aria-label="Close share" />
|
|
531
|
+
</Dialog.Header>
|
|
532
|
+
</Dialog.Portal>
|
|
533
|
+
</Dialog.Root>
|
|
534
|
+
),
|
|
535
|
+
play: async ({canvas, userEvent}) => {
|
|
536
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Open'}))
|
|
537
|
+
const close = await screen.findByRole('button', {name: 'Close share'})
|
|
538
|
+
await waitFor(() => expect(close).toHaveFocus())
|
|
539
|
+
},
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export const CloseOnEscapeOff: Story = {
|
|
543
|
+
render: () => (
|
|
544
|
+
<Dialog.Root defaultOpen closeOnEscape={false}>
|
|
545
|
+
<Dialog.Portal size="sm">
|
|
546
|
+
<Dialog.Header>
|
|
547
|
+
<Dialog.Title>Required step</Dialog.Title>
|
|
548
|
+
</Dialog.Header>
|
|
549
|
+
<Dialog.Footer>
|
|
550
|
+
<Dialog.Close>
|
|
551
|
+
<Button>Done</Button>
|
|
552
|
+
</Dialog.Close>
|
|
553
|
+
</Dialog.Footer>
|
|
554
|
+
</Dialog.Portal>
|
|
555
|
+
</Dialog.Root>
|
|
556
|
+
),
|
|
557
|
+
play: async ({userEvent}) => {
|
|
558
|
+
await screen.findByRole('dialog', {name: 'Required step'})
|
|
559
|
+
await userEvent.keyboard('{Escape}')
|
|
560
|
+
await expect(screen.getByRole('dialog', {name: 'Required step'})).toBeInTheDocument()
|
|
561
|
+
},
|
|
562
|
+
}
|
|
@@ -14,9 +14,13 @@ import {html} from 'react-strict-dom'
|
|
|
14
14
|
import {DURATION_MS, type DurationToken} from '@duro-app/tokens/keys'
|
|
15
15
|
import {styles} from './styles.css'
|
|
16
16
|
import {usePortalMount} from '../ThemeProvider/ThemeProvider'
|
|
17
|
+
import {devWarnOnce} from '../../shared/devWarnOnce'
|
|
17
18
|
|
|
18
19
|
// --- Types ---
|
|
19
20
|
|
|
21
|
+
const FOCUSABLE =
|
|
22
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
23
|
+
|
|
20
24
|
export type DialogSize = 'sm' | 'md' | 'lg'
|
|
21
25
|
|
|
22
26
|
const closeDurationMap = {
|
|
@@ -41,6 +45,8 @@ interface DialogContextValue {
|
|
|
41
45
|
titleId: string
|
|
42
46
|
descriptionId: string
|
|
43
47
|
popupRef: RefObject<HTMLDivElement | null>
|
|
48
|
+
/** Dialog.Close parts currently rendered (for the closeOnEscape dev warning). */
|
|
49
|
+
registerClose: () => () => void
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
const DialogContext = createContext<DialogContextValue | null>(null)
|
|
@@ -61,8 +67,16 @@ interface RootProps {
|
|
|
61
67
|
defaultOpen?: boolean
|
|
62
68
|
/** Called when dialog open state changes */
|
|
63
69
|
onOpenChange?: (open: boolean) => void
|
|
64
|
-
/** Whether clicking backdrop closes the dialog. Default: true */
|
|
70
|
+
/** Whether clicking the backdrop closes the dialog. Default: true */
|
|
65
71
|
dismissable?: boolean
|
|
72
|
+
/**
|
|
73
|
+
* Whether Escape closes the dialog. Default: true, also when `dismissable`
|
|
74
|
+
* is false. Turning it off leaves the keyboard no way out but a
|
|
75
|
+
* Dialog.Close, so render one.
|
|
76
|
+
*/
|
|
77
|
+
closeOnEscape?: boolean
|
|
78
|
+
/** Element to focus on open. Default: the first focusable element, else the dialog itself. */
|
|
79
|
+
initialFocus?: RefObject<HTMLElement | null>
|
|
66
80
|
/** Motion token for the close animation. Default: 'fast' (150ms) */
|
|
67
81
|
closeAnimationDuration?: DurationToken
|
|
68
82
|
}
|
|
@@ -73,6 +87,8 @@ function Root({
|
|
|
73
87
|
defaultOpen = false,
|
|
74
88
|
onOpenChange,
|
|
75
89
|
dismissable = true,
|
|
90
|
+
closeOnEscape = true,
|
|
91
|
+
initialFocus,
|
|
76
92
|
closeAnimationDuration = 'fast',
|
|
77
93
|
}: RootProps) {
|
|
78
94
|
const isControlled = controlledOpen !== undefined
|
|
@@ -120,20 +136,55 @@ function Root({
|
|
|
120
136
|
}
|
|
121
137
|
}, [])
|
|
122
138
|
|
|
123
|
-
// Handle Escape key
|
|
139
|
+
// Handle Escape key. A layer inside the dialog (a Menu, a Select) that
|
|
140
|
+
// already handled it marks the event, and the dialog stays open.
|
|
124
141
|
useEffect(() => {
|
|
125
142
|
if (!isOpen && !closing) return
|
|
126
|
-
if (!
|
|
143
|
+
if (!closeOnEscape) return
|
|
127
144
|
|
|
128
145
|
function onKeyDown(e: KeyboardEvent) {
|
|
129
|
-
if (e.key
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
146
|
+
if (e.key !== 'Escape' || e.defaultPrevented) return
|
|
147
|
+
e.stopPropagation()
|
|
148
|
+
requestClose()
|
|
133
149
|
}
|
|
134
150
|
document.addEventListener('keydown', onKeyDown)
|
|
135
151
|
return () => document.removeEventListener('keydown', onKeyDown)
|
|
136
|
-
}, [isOpen, closing,
|
|
152
|
+
}, [isOpen, closing, closeOnEscape, requestClose])
|
|
153
|
+
|
|
154
|
+
// Focus: into the dialog on open (initialFocus, else the first focusable
|
|
155
|
+
// element, else the dialog itself), back to what had it on close. The
|
|
156
|
+
// Portal is a descendant, so its popup is mounted when this effect runs.
|
|
157
|
+
const closeCountRef = useRef(0)
|
|
158
|
+
const registerClose = useCallback(() => {
|
|
159
|
+
closeCountRef.current += 1
|
|
160
|
+
return () => {
|
|
161
|
+
closeCountRef.current -= 1
|
|
162
|
+
}
|
|
163
|
+
}, [])
|
|
164
|
+
const previousFocusRef = useRef<HTMLElement | null>(null)
|
|
165
|
+
const wasOpenRef = useRef(false)
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (isOpen && !wasOpenRef.current) {
|
|
168
|
+
previousFocusRef.current = document.activeElement as HTMLElement | null
|
|
169
|
+
const popup = popupRef.current
|
|
170
|
+
if (popup) {
|
|
171
|
+
const target = initialFocus?.current ?? popup.querySelector<HTMLElement>(FOCUSABLE) ?? popup
|
|
172
|
+
target.focus({preventScroll: true})
|
|
173
|
+
}
|
|
174
|
+
if (!closeOnEscape && closeCountRef.current === 0) {
|
|
175
|
+
devWarnOnce(
|
|
176
|
+
'Dialog',
|
|
177
|
+
'no-escape-no-close',
|
|
178
|
+
'Dialog.Root has closeOnEscape={false} and no Dialog.Close: a keyboard user cannot leave this dialog. Render a Dialog.Close, or keep Escape.',
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
} else if (!isOpen && !closing && wasOpenRef.current) {
|
|
182
|
+
const target = previousFocusRef.current
|
|
183
|
+
previousFocusRef.current = null
|
|
184
|
+
if (target?.isConnected) target.focus({preventScroll: true})
|
|
185
|
+
}
|
|
186
|
+
wasOpenRef.current = isOpen || closing
|
|
187
|
+
}, [isOpen, closing, initialFocus, closeOnEscape])
|
|
137
188
|
|
|
138
189
|
// Lock body scroll when open
|
|
139
190
|
useEffect(() => {
|
|
@@ -158,6 +209,7 @@ function Root({
|
|
|
158
209
|
titleId,
|
|
159
210
|
descriptionId,
|
|
160
211
|
popupRef,
|
|
212
|
+
registerClose,
|
|
161
213
|
}}
|
|
162
214
|
>
|
|
163
215
|
{children}
|
|
@@ -229,6 +281,7 @@ function Portal({children, size = 'md'}: PortalProps) {
|
|
|
229
281
|
ref={popupRef}
|
|
230
282
|
role="dialog"
|
|
231
283
|
aria-modal={true}
|
|
284
|
+
tabIndex={-1}
|
|
232
285
|
aria-labelledby={titleId}
|
|
233
286
|
aria-describedby={descriptionId}
|
|
234
287
|
style={[
|
|
@@ -301,7 +354,8 @@ interface CloseProps {
|
|
|
301
354
|
}
|
|
302
355
|
|
|
303
356
|
function Close({children, 'aria-label': ariaLabel = 'Close'}: CloseProps) {
|
|
304
|
-
const {requestClose} = useDialog()
|
|
357
|
+
const {requestClose, registerClose} = useDialog()
|
|
358
|
+
useEffect(() => registerClose(), [registerClose])
|
|
305
359
|
|
|
306
360
|
if (children) {
|
|
307
361
|
return (
|