@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.
Files changed (37) hide show
  1. package/dist/components/DetailPanel/DetailPanel.d.ts +53 -0
  2. package/dist/components/DetailPanel/DetailPanel.d.ts.map +1 -0
  3. package/dist/components/DetailPanel/DetailPanel.meta.d.ts +3 -0
  4. package/dist/components/DetailPanel/DetailPanel.meta.d.ts.map +1 -0
  5. package/dist/components/DetailPanel/DetailPanel.stories.d.ts +9 -0
  6. package/dist/components/DetailPanel/DetailPanel.stories.d.ts.map +1 -0
  7. package/dist/components/DetailPanel/styles.css.d.ts +126 -0
  8. package/dist/components/DetailPanel/styles.css.d.ts.map +1 -0
  9. package/dist/components/Dialog/Dialog.meta.d.ts +3 -0
  10. package/dist/components/Dialog/Dialog.meta.d.ts.map +1 -0
  11. package/dist/components/Drawer/Drawer.meta.d.ts +3 -0
  12. package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -0
  13. package/dist/components/Panel/Panel.d.ts +27 -0
  14. package/dist/components/Panel/Panel.d.ts.map +1 -0
  15. package/dist/components/Panel/Panel.meta.d.ts +3 -0
  16. package/dist/components/Panel/Panel.meta.d.ts.map +1 -0
  17. package/dist/components/Panel/Panel.stories.d.ts +10 -0
  18. package/dist/components/Panel/Panel.stories.d.ts.map +1 -0
  19. package/dist/components/Panel/styles.css.d.ts +50 -0
  20. package/dist/components/Panel/styles.css.d.ts.map +1 -0
  21. package/dist/index.css +1 -1
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2417 -2034
  25. package/dist/index.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/components/DetailPanel/DetailPanel.meta.ts +41 -0
  28. package/src/components/DetailPanel/DetailPanel.stories.tsx +270 -0
  29. package/src/components/DetailPanel/DetailPanel.tsx +312 -0
  30. package/src/components/DetailPanel/styles.css.ts +187 -0
  31. package/src/components/Dialog/Dialog.meta.ts +44 -0
  32. package/src/components/Drawer/Drawer.meta.ts +45 -0
  33. package/src/components/Panel/Panel.meta.ts +37 -0
  34. package/src/components/Panel/Panel.stories.tsx +124 -0
  35. package/src/components/Panel/Panel.tsx +56 -0
  36. package/src/components/Panel/styles.css.ts +55 -0
  37. 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.19.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.19.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
+ }