@duro-app/ui 0.19.0 → 0.20.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/components/DetailPanel/DetailPanel.d.ts +53 -0
- package/dist/components/DetailPanel/DetailPanel.d.ts.map +1 -0
- package/dist/components/DetailPanel/DetailPanel.meta.d.ts +3 -0
- package/dist/components/DetailPanel/DetailPanel.meta.d.ts.map +1 -0
- package/dist/components/DetailPanel/DetailPanel.stories.d.ts +9 -0
- package/dist/components/DetailPanel/DetailPanel.stories.d.ts.map +1 -0
- package/dist/components/DetailPanel/styles.css.d.ts +126 -0
- package/dist/components/DetailPanel/styles.css.d.ts.map +1 -0
- package/dist/components/Dialog/Dialog.meta.d.ts +3 -0
- package/dist/components/Dialog/Dialog.meta.d.ts.map +1 -0
- package/dist/components/Drawer/Drawer.meta.d.ts +3 -0
- package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -0
- package/dist/components/Panel/Panel.d.ts +27 -0
- package/dist/components/Panel/Panel.d.ts.map +1 -0
- package/dist/components/Panel/Panel.meta.d.ts +3 -0
- package/dist/components/Panel/Panel.meta.d.ts.map +1 -0
- package/dist/components/Panel/Panel.stories.d.ts +10 -0
- package/dist/components/Panel/Panel.stories.d.ts.map +1 -0
- package/dist/components/Panel/styles.css.d.ts +50 -0
- package/dist/components/Panel/styles.css.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2417 -2034
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/DetailPanel/DetailPanel.meta.ts +41 -0
- package/src/components/DetailPanel/DetailPanel.stories.tsx +270 -0
- package/src/components/DetailPanel/DetailPanel.tsx +312 -0
- package/src/components/DetailPanel/styles.css.ts +187 -0
- package/src/components/Dialog/Dialog.meta.ts +44 -0
- package/src/components/Drawer/Drawer.meta.ts +45 -0
- package/src/components/Panel/Panel.meta.ts +37 -0
- package/src/components/Panel/Panel.stories.tsx +124 -0
- package/src/components/Panel/Panel.tsx +56 -0
- package/src/components/Panel/styles.css.ts +55 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
}
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@duro-app/tokens": "^0.
|
|
55
|
+
"@duro-app/tokens": "^0.20.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Non-modal side panel for right-side inspection. Click a table row to show details, click another to update in place. Built on Panel internally. In-flow (flex child), not fixed/modal — content behind remains interactive.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Table row detail inspection (click row → see details)',
|
|
8
|
+
'Side-by-side master-detail layouts',
|
|
9
|
+
'Non-modal content inspection that keeps context visible',
|
|
10
|
+
],
|
|
11
|
+
whenNotToUse: [
|
|
12
|
+
'Modal overlays — use Dialog or Drawer',
|
|
13
|
+
'Mobile slide-in panels — compose with Drawer',
|
|
14
|
+
'Left-side navigation — use SideNav',
|
|
15
|
+
],
|
|
16
|
+
anatomy: {
|
|
17
|
+
required: ['Root', 'Content', 'Header', 'Body'],
|
|
18
|
+
optional: ['Title', 'Footer', 'Close'],
|
|
19
|
+
},
|
|
20
|
+
relatedTo: [
|
|
21
|
+
{component: 'Drawer', relationship: 'Drawer is modal with backdrop; DetailPanel is non-modal and in-flow'},
|
|
22
|
+
{component: 'Panel', relationship: 'DetailPanel uses Panel structure internally'},
|
|
23
|
+
{component: 'Table', relationship: 'Common pattern: Table + DetailPanel for master-detail'},
|
|
24
|
+
],
|
|
25
|
+
example: `<html.div style={layoutStyles.container}>
|
|
26
|
+
<html.div style={layoutStyles.main}>
|
|
27
|
+
<Table.Root columns={3}>...</Table.Root>
|
|
28
|
+
</html.div>
|
|
29
|
+
<DetailPanel.Root open={selectedId != null} onOpenChange={handleOpenChange}>
|
|
30
|
+
<DetailPanel.Content size="sm" label="User details">
|
|
31
|
+
<DetailPanel.Header>
|
|
32
|
+
<DetailPanel.Title>{user.name}</DetailPanel.Title>
|
|
33
|
+
<DetailPanel.Close />
|
|
34
|
+
</DetailPanel.Header>
|
|
35
|
+
<DetailPanel.Body>
|
|
36
|
+
<Stack gap="md">...</Stack>
|
|
37
|
+
</DetailPanel.Body>
|
|
38
|
+
</DetailPanel.Content>
|
|
39
|
+
</DetailPanel.Root>
|
|
40
|
+
</html.div>`,
|
|
41
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {useState} from 'react'
|
|
3
|
+
import {css, html} from 'react-strict-dom'
|
|
4
|
+
import {DetailPanel} from './DetailPanel'
|
|
5
|
+
import {Button} from '../Button/Button'
|
|
6
|
+
import {Text} from '../Text/Text'
|
|
7
|
+
import {Heading} from '../Heading/Heading'
|
|
8
|
+
import {Stack} from '../Stack/Stack'
|
|
9
|
+
import {Inline} from '../Inline/Inline'
|
|
10
|
+
import {Badge} from '../Badge/Badge'
|
|
11
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
12
|
+
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
13
|
+
|
|
14
|
+
const meta: Meta = {
|
|
15
|
+
title: 'Components/DetailPanel',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default meta
|
|
19
|
+
type Story = StoryObj
|
|
20
|
+
|
|
21
|
+
const layoutStyles = css.create({
|
|
22
|
+
container: {
|
|
23
|
+
display: 'flex',
|
|
24
|
+
height: 400,
|
|
25
|
+
borderWidth: 1,
|
|
26
|
+
borderStyle: 'solid',
|
|
27
|
+
borderColor: colors.border,
|
|
28
|
+
},
|
|
29
|
+
main: {
|
|
30
|
+
flex: 1,
|
|
31
|
+
padding: spacing.lg,
|
|
32
|
+
overflowY: 'auto',
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// --- Default ---
|
|
37
|
+
|
|
38
|
+
function DefaultDemo() {
|
|
39
|
+
const [open, setOpen] = useState(false)
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<html.div style={layoutStyles.container}>
|
|
43
|
+
<html.div style={layoutStyles.main}>
|
|
44
|
+
<Stack gap="md">
|
|
45
|
+
<Heading level={3}>Main Content</Heading>
|
|
46
|
+
<Text>Click the button to open the detail panel.</Text>
|
|
47
|
+
<Button onClick={() => setOpen(true)}>Open Detail Panel</Button>
|
|
48
|
+
</Stack>
|
|
49
|
+
</html.div>
|
|
50
|
+
|
|
51
|
+
<DetailPanel.Root open={open} onOpenChange={setOpen}>
|
|
52
|
+
<DetailPanel.Content label="Details">
|
|
53
|
+
<DetailPanel.Header>
|
|
54
|
+
<DetailPanel.Title>Details</DetailPanel.Title>
|
|
55
|
+
<DetailPanel.Close />
|
|
56
|
+
</DetailPanel.Header>
|
|
57
|
+
<DetailPanel.Body>
|
|
58
|
+
<Text>
|
|
59
|
+
This is a non-modal detail panel. The content behind it
|
|
60
|
+
remains interactive — you can still click, scroll, and
|
|
61
|
+
interact with the main area.
|
|
62
|
+
</Text>
|
|
63
|
+
</DetailPanel.Body>
|
|
64
|
+
<DetailPanel.Footer>
|
|
65
|
+
<Button variant="secondary" onClick={() => setOpen(false)}>
|
|
66
|
+
Done
|
|
67
|
+
</Button>
|
|
68
|
+
</DetailPanel.Footer>
|
|
69
|
+
</DetailPanel.Content>
|
|
70
|
+
</DetailPanel.Root>
|
|
71
|
+
</html.div>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const Default: Story = {
|
|
76
|
+
render: () => <DefaultDemo />,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- Table Selection ---
|
|
80
|
+
|
|
81
|
+
interface User {
|
|
82
|
+
id: number
|
|
83
|
+
name: string
|
|
84
|
+
role: string
|
|
85
|
+
status: string
|
|
86
|
+
email: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const users: User[] = [
|
|
90
|
+
{id: 1, name: 'Alice Johnson', role: 'Admin', status: 'Active', email: 'alice@example.com'},
|
|
91
|
+
{id: 2, name: 'Bob Smith', role: 'Editor', status: 'Inactive', email: 'bob@example.com'},
|
|
92
|
+
{id: 3, name: 'Carol Davis', role: 'Viewer', status: 'Active', email: 'carol@example.com'},
|
|
93
|
+
{id: 4, name: 'Dan Wilson', role: 'Editor', status: 'Active', email: 'dan@example.com'},
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
const tableStyles = css.create({
|
|
97
|
+
row: {
|
|
98
|
+
display: 'flex',
|
|
99
|
+
gap: spacing.lg,
|
|
100
|
+
paddingTop: spacing.sm,
|
|
101
|
+
paddingBottom: spacing.sm,
|
|
102
|
+
paddingLeft: spacing.md,
|
|
103
|
+
paddingRight: spacing.md,
|
|
104
|
+
cursor: 'pointer',
|
|
105
|
+
backgroundColor: {
|
|
106
|
+
default: 'transparent',
|
|
107
|
+
':hover': colors.bgCardHover,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
rowSelected: {
|
|
111
|
+
backgroundColor: colors.bgCardHover,
|
|
112
|
+
},
|
|
113
|
+
headerRow: {
|
|
114
|
+
display: 'flex',
|
|
115
|
+
gap: spacing.lg,
|
|
116
|
+
paddingTop: spacing.sm,
|
|
117
|
+
paddingBottom: spacing.sm,
|
|
118
|
+
paddingLeft: spacing.md,
|
|
119
|
+
paddingRight: spacing.md,
|
|
120
|
+
borderBottomWidth: 1,
|
|
121
|
+
borderBottomStyle: 'solid',
|
|
122
|
+
borderBottomColor: colors.border,
|
|
123
|
+
},
|
|
124
|
+
cellText: {
|
|
125
|
+
flex: 1,
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
function TableSelectionDemo() {
|
|
130
|
+
const [selectedId, setSelectedId] = useState<number | null>(null)
|
|
131
|
+
const selectedUser = users.find((u) => u.id === selectedId)
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<html.div style={layoutStyles.container}>
|
|
135
|
+
<html.div style={layoutStyles.main}>
|
|
136
|
+
<Stack gap="md">
|
|
137
|
+
<Heading level={3}>Users</Heading>
|
|
138
|
+
<html.div>
|
|
139
|
+
<html.div style={tableStyles.headerRow}>
|
|
140
|
+
<html.span style={tableStyles.cellText}><Text variant="label">Name</Text></html.span>
|
|
141
|
+
<html.span style={tableStyles.cellText}><Text variant="label">Role</Text></html.span>
|
|
142
|
+
<html.span style={tableStyles.cellText}><Text variant="label">Status</Text></html.span>
|
|
143
|
+
</html.div>
|
|
144
|
+
{users.map((user) => (
|
|
145
|
+
<html.div
|
|
146
|
+
key={user.id}
|
|
147
|
+
style={[tableStyles.row, selectedId === user.id && tableStyles.rowSelected]}
|
|
148
|
+
onClick={() => setSelectedId(selectedId === user.id ? null : user.id)}
|
|
149
|
+
>
|
|
150
|
+
<html.span style={tableStyles.cellText}><Text>{user.name}</Text></html.span>
|
|
151
|
+
<html.span style={tableStyles.cellText}><Text>{user.role}</Text></html.span>
|
|
152
|
+
<html.span style={tableStyles.cellText}><Text>{user.status}</Text></html.span>
|
|
153
|
+
</html.div>
|
|
154
|
+
))}
|
|
155
|
+
</html.div>
|
|
156
|
+
</Stack>
|
|
157
|
+
</html.div>
|
|
158
|
+
|
|
159
|
+
<DetailPanel.Root
|
|
160
|
+
open={selectedId != null}
|
|
161
|
+
onOpenChange={(open) => {
|
|
162
|
+
if (!open) setSelectedId(null)
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
<DetailPanel.Content size="sm" label="User details">
|
|
166
|
+
<DetailPanel.Header>
|
|
167
|
+
<DetailPanel.Title>{selectedUser?.name}</DetailPanel.Title>
|
|
168
|
+
<DetailPanel.Close />
|
|
169
|
+
</DetailPanel.Header>
|
|
170
|
+
<DetailPanel.Body>
|
|
171
|
+
{selectedUser && (
|
|
172
|
+
<Stack gap="md">
|
|
173
|
+
<Stack gap="xs">
|
|
174
|
+
<Text variant="label">Email</Text>
|
|
175
|
+
<Text variant="bodySm">{selectedUser.email}</Text>
|
|
176
|
+
</Stack>
|
|
177
|
+
<Stack gap="xs">
|
|
178
|
+
<Text variant="label">Role</Text>
|
|
179
|
+
<Text variant="bodySm">{selectedUser.role}</Text>
|
|
180
|
+
</Stack>
|
|
181
|
+
<Stack gap="xs">
|
|
182
|
+
<Text variant="label">Status</Text>
|
|
183
|
+
<Inline gap="sm">
|
|
184
|
+
<Badge variant={selectedUser.status === 'Active' ? 'success' : 'default'}>
|
|
185
|
+
{selectedUser.status}
|
|
186
|
+
</Badge>
|
|
187
|
+
</Inline>
|
|
188
|
+
</Stack>
|
|
189
|
+
</Stack>
|
|
190
|
+
)}
|
|
191
|
+
</DetailPanel.Body>
|
|
192
|
+
<DetailPanel.Footer>
|
|
193
|
+
<Button variant="danger">Revoke Access</Button>
|
|
194
|
+
</DetailPanel.Footer>
|
|
195
|
+
</DetailPanel.Content>
|
|
196
|
+
</DetailPanel.Root>
|
|
197
|
+
</html.div>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const TableSelection: Story = {
|
|
202
|
+
render: () => <TableSelectionDemo />,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// --- Medium Size ---
|
|
206
|
+
|
|
207
|
+
function MediumSizeDemo() {
|
|
208
|
+
const [open, setOpen] = useState(false)
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<html.div style={layoutStyles.container}>
|
|
212
|
+
<html.div style={layoutStyles.main}>
|
|
213
|
+
<Stack gap="md">
|
|
214
|
+
<Heading level={3}>Main Content</Heading>
|
|
215
|
+
<Text>This demo uses the medium (480px) panel width.</Text>
|
|
216
|
+
<Button onClick={() => setOpen(true)}>Open Medium Panel</Button>
|
|
217
|
+
</Stack>
|
|
218
|
+
</html.div>
|
|
219
|
+
|
|
220
|
+
<DetailPanel.Root open={open} onOpenChange={setOpen}>
|
|
221
|
+
<DetailPanel.Content size="md" label="Medium detail panel">
|
|
222
|
+
<DetailPanel.Header>
|
|
223
|
+
<DetailPanel.Title>Medium Panel</DetailPanel.Title>
|
|
224
|
+
<DetailPanel.Close />
|
|
225
|
+
</DetailPanel.Header>
|
|
226
|
+
<DetailPanel.Body>
|
|
227
|
+
<Text>
|
|
228
|
+
This panel is 480px wide, providing more room for complex
|
|
229
|
+
content like forms or detailed information.
|
|
230
|
+
</Text>
|
|
231
|
+
</DetailPanel.Body>
|
|
232
|
+
</DetailPanel.Content>
|
|
233
|
+
</DetailPanel.Root>
|
|
234
|
+
</html.div>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export const MediumSize: Story = {
|
|
239
|
+
render: () => <MediumSizeDemo />,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- Uncontrolled ---
|
|
243
|
+
|
|
244
|
+
function UncontrolledDemo() {
|
|
245
|
+
return (
|
|
246
|
+
<html.div style={layoutStyles.container}>
|
|
247
|
+
<html.div style={layoutStyles.main}>
|
|
248
|
+
<Stack gap="md">
|
|
249
|
+
<Heading level={3}>Uncontrolled Mode</Heading>
|
|
250
|
+
<Text>This panel manages its own open state internally.</Text>
|
|
251
|
+
<DetailPanel.Root defaultOpen>
|
|
252
|
+
<DetailPanel.Content label="Uncontrolled panel">
|
|
253
|
+
<DetailPanel.Header>
|
|
254
|
+
<DetailPanel.Title>Uncontrolled</DetailPanel.Title>
|
|
255
|
+
<DetailPanel.Close />
|
|
256
|
+
</DetailPanel.Header>
|
|
257
|
+
<DetailPanel.Body>
|
|
258
|
+
<Text>This panel started open and manages its own state.</Text>
|
|
259
|
+
</DetailPanel.Body>
|
|
260
|
+
</DetailPanel.Content>
|
|
261
|
+
</DetailPanel.Root>
|
|
262
|
+
</Stack>
|
|
263
|
+
</html.div>
|
|
264
|
+
</html.div>
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export const Uncontrolled: Story = {
|
|
269
|
+
render: () => <UncontrolledDemo />,
|
|
270
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ReactNode,
|
|
3
|
+
type RefObject,
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useState,
|
|
7
|
+
useEffect,
|
|
8
|
+
useCallback,
|
|
9
|
+
useId,
|
|
10
|
+
useRef,
|
|
11
|
+
} from 'react'
|
|
12
|
+
import {html} from 'react-strict-dom'
|
|
13
|
+
import {styles} from './styles.css'
|
|
14
|
+
|
|
15
|
+
// --- Types ---
|
|
16
|
+
|
|
17
|
+
export type DetailPanelSize = 'sm' | 'md'
|
|
18
|
+
|
|
19
|
+
// --- Context ---
|
|
20
|
+
|
|
21
|
+
interface DetailPanelContextValue {
|
|
22
|
+
present: boolean
|
|
23
|
+
closing: boolean
|
|
24
|
+
requestOpen: () => void
|
|
25
|
+
requestClose: () => void
|
|
26
|
+
titleId: string
|
|
27
|
+
labelId: string | undefined
|
|
28
|
+
registerTitle: (id: string) => void
|
|
29
|
+
unregisterTitle: () => void
|
|
30
|
+
panelRef: RefObject<HTMLDivElement | null>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DetailPanelContext = createContext<DetailPanelContextValue | null>(null)
|
|
34
|
+
|
|
35
|
+
function useDetailPanel() {
|
|
36
|
+
const ctx = useContext(DetailPanelContext)
|
|
37
|
+
if (!ctx) throw new Error('DetailPanel compound components must be used within DetailPanel.Root')
|
|
38
|
+
return ctx
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- Root ---
|
|
42
|
+
|
|
43
|
+
interface RootProps {
|
|
44
|
+
children: ReactNode
|
|
45
|
+
/** Controlled open state */
|
|
46
|
+
open?: boolean
|
|
47
|
+
/** Default open state (uncontrolled) */
|
|
48
|
+
defaultOpen?: boolean
|
|
49
|
+
/** Called when open state changes */
|
|
50
|
+
onOpenChange?: (open: boolean) => void
|
|
51
|
+
/** Duration of the close animation in ms. Default: 220 */
|
|
52
|
+
closeAnimationDuration?: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function Root({
|
|
56
|
+
children,
|
|
57
|
+
open: controlledOpen,
|
|
58
|
+
defaultOpen = false,
|
|
59
|
+
onOpenChange,
|
|
60
|
+
closeAnimationDuration = 220,
|
|
61
|
+
}: RootProps) {
|
|
62
|
+
const isControlled = controlledOpen !== undefined
|
|
63
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen)
|
|
64
|
+
const [present, setPresent] = useState(defaultOpen)
|
|
65
|
+
const [closing, setClosing] = useState(false)
|
|
66
|
+
const closingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
67
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
68
|
+
const previousFocusRef = useRef<Element | null>(null)
|
|
69
|
+
|
|
70
|
+
const titleId = useId()
|
|
71
|
+
const [labelId, setLabelId] = useState<string | undefined>(undefined)
|
|
72
|
+
|
|
73
|
+
const isOpen = isControlled ? controlledOpen : internalOpen
|
|
74
|
+
|
|
75
|
+
// Sync presence with open state
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (isOpen && !present) {
|
|
78
|
+
setPresent(true)
|
|
79
|
+
setClosing(false)
|
|
80
|
+
} else if (!isOpen && present && !closing) {
|
|
81
|
+
setClosing(true)
|
|
82
|
+
closingTimerRef.current = setTimeout(() => {
|
|
83
|
+
setClosing(false)
|
|
84
|
+
setPresent(false)
|
|
85
|
+
closingTimerRef.current = null
|
|
86
|
+
}, closeAnimationDuration)
|
|
87
|
+
}
|
|
88
|
+
}, [isOpen, present, closing, closeAnimationDuration])
|
|
89
|
+
|
|
90
|
+
// Focus management: move focus on open, restore on close
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (present && !closing) {
|
|
93
|
+
previousFocusRef.current = document.activeElement
|
|
94
|
+
panelRef.current?.focus()
|
|
95
|
+
}
|
|
96
|
+
}, [present]) // eslint-disable-line react-hooks/exhaustive-deps -- only on present change
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!present && previousFocusRef.current) {
|
|
100
|
+
const el = previousFocusRef.current as HTMLElement
|
|
101
|
+
if (typeof el.focus === 'function') {
|
|
102
|
+
el.focus()
|
|
103
|
+
}
|
|
104
|
+
previousFocusRef.current = null
|
|
105
|
+
}
|
|
106
|
+
}, [present])
|
|
107
|
+
|
|
108
|
+
// ESC handler
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!present || closing) return
|
|
111
|
+
|
|
112
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
113
|
+
if (e.key === 'Escape') {
|
|
114
|
+
e.stopPropagation()
|
|
115
|
+
requestClose()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
document.addEventListener('keydown', onKeyDown)
|
|
119
|
+
return () => document.removeEventListener('keydown', onKeyDown)
|
|
120
|
+
}, [present, closing]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
121
|
+
|
|
122
|
+
// Clean up timer on unmount
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
return () => {
|
|
125
|
+
if (closingTimerRef.current) clearTimeout(closingTimerRef.current)
|
|
126
|
+
}
|
|
127
|
+
}, [])
|
|
128
|
+
|
|
129
|
+
const requestOpen = useCallback(() => {
|
|
130
|
+
if (closingTimerRef.current) {
|
|
131
|
+
clearTimeout(closingTimerRef.current)
|
|
132
|
+
closingTimerRef.current = null
|
|
133
|
+
setClosing(false)
|
|
134
|
+
}
|
|
135
|
+
if (!isControlled) setInternalOpen(true)
|
|
136
|
+
onOpenChange?.(true)
|
|
137
|
+
}, [isControlled, onOpenChange])
|
|
138
|
+
|
|
139
|
+
const requestClose = useCallback(() => {
|
|
140
|
+
if (!isControlled) setInternalOpen(false)
|
|
141
|
+
onOpenChange?.(false)
|
|
142
|
+
}, [isControlled, onOpenChange])
|
|
143
|
+
|
|
144
|
+
const registerTitle = useCallback((id: string) => {
|
|
145
|
+
setLabelId(id)
|
|
146
|
+
}, [])
|
|
147
|
+
|
|
148
|
+
const unregisterTitle = useCallback(() => {
|
|
149
|
+
setLabelId(undefined)
|
|
150
|
+
}, [])
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<DetailPanelContext.Provider
|
|
154
|
+
value={{
|
|
155
|
+
present,
|
|
156
|
+
closing,
|
|
157
|
+
requestOpen,
|
|
158
|
+
requestClose,
|
|
159
|
+
titleId,
|
|
160
|
+
labelId,
|
|
161
|
+
registerTitle,
|
|
162
|
+
unregisterTitle,
|
|
163
|
+
panelRef,
|
|
164
|
+
}}
|
|
165
|
+
>
|
|
166
|
+
{children}
|
|
167
|
+
</DetailPanelContext.Provider>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- Content ---
|
|
172
|
+
|
|
173
|
+
interface ContentProps {
|
|
174
|
+
children: ReactNode
|
|
175
|
+
/** Width of the panel. Default: 'sm' (360px) */
|
|
176
|
+
size?: DetailPanelSize
|
|
177
|
+
/** Accessible name for the complementary landmark */
|
|
178
|
+
label: string
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const wrapperOpenMap = {
|
|
182
|
+
sm: styles.wrapperOpenSm,
|
|
183
|
+
md: styles.wrapperOpenMd,
|
|
184
|
+
} as const
|
|
185
|
+
|
|
186
|
+
const wrapperCloseMap = {
|
|
187
|
+
sm: styles.wrapperCloseSm,
|
|
188
|
+
md: styles.wrapperCloseMd,
|
|
189
|
+
} as const
|
|
190
|
+
|
|
191
|
+
function Content({children, size = 'sm', label}: ContentProps) {
|
|
192
|
+
const {present, closing, labelId, panelRef} = useDetailPanel()
|
|
193
|
+
|
|
194
|
+
if (!present) return null
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<html.div
|
|
198
|
+
style={[
|
|
199
|
+
styles.wrapper,
|
|
200
|
+
closing ? wrapperCloseMap[size] : wrapperOpenMap[size],
|
|
201
|
+
]}
|
|
202
|
+
>
|
|
203
|
+
<html.div
|
|
204
|
+
ref={panelRef}
|
|
205
|
+
role="complementary"
|
|
206
|
+
aria-label={!labelId ? label : undefined}
|
|
207
|
+
aria-labelledby={labelId || undefined}
|
|
208
|
+
tabIndex={-1}
|
|
209
|
+
style={[
|
|
210
|
+
styles.content,
|
|
211
|
+
size === 'sm' ? styles.contentSm : styles.contentMd,
|
|
212
|
+
closing ? styles.slideOut : styles.slideIn,
|
|
213
|
+
]}
|
|
214
|
+
>
|
|
215
|
+
{children}
|
|
216
|
+
</html.div>
|
|
217
|
+
</html.div>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- Header ---
|
|
222
|
+
|
|
223
|
+
function Header({children}: {children: ReactNode}) {
|
|
224
|
+
return <html.div style={styles.header}>{children}</html.div>
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- Title ---
|
|
228
|
+
|
|
229
|
+
function Title({children}: {children: ReactNode}) {
|
|
230
|
+
const {titleId, registerTitle, unregisterTitle} = useDetailPanel()
|
|
231
|
+
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
registerTitle(titleId)
|
|
234
|
+
return () => unregisterTitle()
|
|
235
|
+
}, [titleId, registerTitle, unregisterTitle])
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<html.h2 id={titleId} style={styles.title}>
|
|
239
|
+
{children}
|
|
240
|
+
</html.h2>
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- Body ---
|
|
245
|
+
|
|
246
|
+
interface BodyProps {
|
|
247
|
+
children: ReactNode
|
|
248
|
+
/** Adds inner padding. Default: true */
|
|
249
|
+
padded?: boolean
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function Body({children, padded = true}: BodyProps) {
|
|
253
|
+
return (
|
|
254
|
+
<html.div style={[styles.body, padded && styles.bodyPadded]}>
|
|
255
|
+
{children}
|
|
256
|
+
</html.div>
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// --- Footer ---
|
|
261
|
+
|
|
262
|
+
function Footer({children}: {children: ReactNode}) {
|
|
263
|
+
return <html.div style={styles.footer}>{children}</html.div>
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- Close ---
|
|
267
|
+
|
|
268
|
+
interface CloseProps {
|
|
269
|
+
children?: ReactNode
|
|
270
|
+
'aria-label'?: string
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function Close({children, 'aria-label': ariaLabel = 'Close'}: CloseProps) {
|
|
274
|
+
const {requestClose} = useDetailPanel()
|
|
275
|
+
|
|
276
|
+
if (children) {
|
|
277
|
+
return (
|
|
278
|
+
<html.div onClick={requestClose} style={styles.inlineWrapper}>
|
|
279
|
+
{children}
|
|
280
|
+
</html.div>
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<html.button
|
|
286
|
+
onClick={requestClose}
|
|
287
|
+
aria-label={ariaLabel}
|
|
288
|
+
style={styles.closeButton}
|
|
289
|
+
>
|
|
290
|
+
<svg width={16} height={16} viewBox="0 0 16 16" fill="none">
|
|
291
|
+
<path
|
|
292
|
+
d="M4 4l8 8M12 4l-8 8"
|
|
293
|
+
stroke="currentColor"
|
|
294
|
+
strokeWidth={1.5}
|
|
295
|
+
strokeLinecap="round"
|
|
296
|
+
/>
|
|
297
|
+
</svg>
|
|
298
|
+
</html.button>
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// --- Export ---
|
|
303
|
+
|
|
304
|
+
export const DetailPanel = {
|
|
305
|
+
Root,
|
|
306
|
+
Content,
|
|
307
|
+
Header,
|
|
308
|
+
Title,
|
|
309
|
+
Body,
|
|
310
|
+
Footer,
|
|
311
|
+
Close,
|
|
312
|
+
}
|