@podoba/react 0.0.32 → 0.0.34
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podoba/react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.34",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
|
|
6
6
|
"repository": {
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"typecheck": "tsc --build"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@podoba/tokens": "^0.0.
|
|
32
|
-
"@podoba/tailwind": "^0.0.
|
|
31
|
+
"@podoba/tokens": "^0.0.34",
|
|
32
|
+
"@podoba/tailwind": "^0.0.34",
|
|
33
33
|
"react-aria-components": "1.18.0",
|
|
34
34
|
"class-variance-authority": "0.7.1",
|
|
35
35
|
"clsx": "2.1.1",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ReactNode, useState } from 'react'
|
|
1
|
+
import { type ReactNode, useEffect, useId, useRef, useState } from 'react'
|
|
2
2
|
import { Button } from './button'
|
|
3
3
|
import { DisplayHeading } from './text'
|
|
4
4
|
|
|
@@ -8,10 +8,9 @@ import { DisplayHeading } from './text'
|
|
|
8
8
|
*
|
|
9
9
|
* gs-platform's header is a two-column grid: a left "welcome" section
|
|
10
10
|
* (optional breadcrumbs + a large greeting line) and a right section holding an
|
|
11
|
-
* `ExpandableCTA` — a collapsed teal pill that expands into
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* fixed-sheet behaviour for a simpler inline disclosure.
|
|
11
|
+
* `ExpandableCTA` — a collapsed teal pill that expands into a create-hub panel.
|
|
12
|
+
* The supplied hero `CtaPill` owns the source mobile fixed-bar treatment; this
|
|
13
|
+
* header switches to the desktop 2/3 + 1/3 grid at the matching 768px breakpoint.
|
|
15
14
|
*
|
|
16
15
|
* The expandable CTA is a controlled disclosure: the collapsed teal pill is a
|
|
17
16
|
* React Aria `Button` (keyboard + focus ring + press handling) wired to a
|
|
@@ -28,6 +27,12 @@ export type BrandPageHeaderCrumb = {
|
|
|
28
27
|
onPress?: () => void
|
|
29
28
|
}
|
|
30
29
|
|
|
30
|
+
export type BrandPageHeaderCtaRenderProps = {
|
|
31
|
+
expanded: boolean
|
|
32
|
+
controls: string
|
|
33
|
+
toggle: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
31
36
|
export type BrandPageHeaderProps = {
|
|
32
37
|
/** The large greeting / page-title slot (e.g. "Good morning Jonas 👋"). */
|
|
33
38
|
greeting: ReactNode
|
|
@@ -44,11 +49,12 @@ export type BrandPageHeaderProps = {
|
|
|
44
49
|
/** Optional breadcrumb trail rendered above the greeting. */
|
|
45
50
|
breadcrumbs?: BrandPageHeaderCrumb[]
|
|
46
51
|
/**
|
|
47
|
-
* Arbitrary right-column CTA node (e.g. the gs hero `CtaPill` banner)
|
|
48
|
-
*
|
|
49
|
-
*
|
|
52
|
+
* Arbitrary right-column CTA node (e.g. the gs hero `CtaPill` banner), or a
|
|
53
|
+
* render function receiving the disclosure state and generated controls id.
|
|
54
|
+
* Prefer the render function when the CTA opens `createHub`, so its trigger can
|
|
55
|
+
* expose `aria-expanded` and `aria-controls`.
|
|
50
56
|
*/
|
|
51
|
-
cta?: ReactNode
|
|
57
|
+
cta?: ReactNode | ((state: BrandPageHeaderCtaRenderProps) => ReactNode)
|
|
52
58
|
/** Label on the collapsed teal "Create" pill. Required to render the CTA. */
|
|
53
59
|
ctaLabel?: ReactNode
|
|
54
60
|
/** Inline content revealed when the CTA expands (the create hub). */
|
|
@@ -60,11 +66,14 @@ export type BrandPageHeaderProps = {
|
|
|
60
66
|
closeLabel?: string
|
|
61
67
|
/** Sticky header on scroll. */
|
|
62
68
|
sticky?: boolean
|
|
69
|
+
/**
|
|
70
|
+
* Dock the supplied CTA to the safe bottom edge below 768px, matching the
|
|
71
|
+
* source Manager's collapsed ExpandableCTA. Enabled by default.
|
|
72
|
+
*/
|
|
73
|
+
mobileCtaDocked?: boolean
|
|
63
74
|
className?: string
|
|
64
75
|
}
|
|
65
76
|
|
|
66
|
-
const PANEL_ID = 'brand-page-header-create-hub'
|
|
67
|
-
|
|
68
77
|
export function BrandPageHeader({
|
|
69
78
|
greeting,
|
|
70
79
|
headingLevel = 1,
|
|
@@ -77,18 +86,152 @@ export function BrandPageHeader({
|
|
|
77
86
|
onExpandedChange,
|
|
78
87
|
closeLabel = 'Close',
|
|
79
88
|
sticky = false,
|
|
89
|
+
mobileCtaDocked = true,
|
|
80
90
|
className,
|
|
81
91
|
}: BrandPageHeaderProps) {
|
|
82
92
|
const [internalExpanded, setInternalExpanded] = useState(false)
|
|
93
|
+
const [mobileSheet, setMobileSheet] = useState(false)
|
|
94
|
+
const [mobileFull, setMobileFull] = useState(false)
|
|
83
95
|
const isControlled = expandedProp !== undefined
|
|
84
96
|
const expanded = isControlled ? expandedProp : internalExpanded
|
|
85
97
|
const hasExpandable = Boolean(createHub)
|
|
86
98
|
const HeadingTag = `h${headingLevel}` as const
|
|
99
|
+
const panelId = useId()
|
|
100
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
101
|
+
const contentRef = useRef<HTMLDivElement>(null)
|
|
102
|
+
const returnFocusRef = useRef<HTMLElement | null>(null)
|
|
103
|
+
const touchStartYRef = useRef(0)
|
|
104
|
+
const touchStartScrollTopRef = useRef(0)
|
|
87
105
|
|
|
88
106
|
const setExpanded = (next: boolean) => {
|
|
89
107
|
if (!isControlled) setInternalExpanded(next)
|
|
90
108
|
onExpandedChange?.(next)
|
|
91
109
|
}
|
|
110
|
+
const renderedCta =
|
|
111
|
+
typeof cta === 'function'
|
|
112
|
+
? cta({
|
|
113
|
+
expanded,
|
|
114
|
+
controls: panelId,
|
|
115
|
+
toggle: () => setExpanded(!expanded),
|
|
116
|
+
})
|
|
117
|
+
: cta
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (!expanded || !hasExpandable || typeof window === 'undefined') return
|
|
121
|
+
|
|
122
|
+
returnFocusRef.current =
|
|
123
|
+
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
124
|
+
|
|
125
|
+
let lockedScrollY = 0
|
|
126
|
+
let restoreBody: (() => void) | undefined
|
|
127
|
+
|
|
128
|
+
const updateComposition = () => {
|
|
129
|
+
const isMobile = window.innerWidth < 768
|
|
130
|
+
setMobileSheet(isMobile)
|
|
131
|
+
|
|
132
|
+
const content = contentRef.current
|
|
133
|
+
setMobileFull(Boolean(isMobile && content && content.scrollHeight >= window.innerHeight * 0.4))
|
|
134
|
+
|
|
135
|
+
if (isMobile && !restoreBody) {
|
|
136
|
+
lockedScrollY = window.scrollY
|
|
137
|
+
const previous = {
|
|
138
|
+
overflow: document.body.style.overflow,
|
|
139
|
+
position: document.body.style.position,
|
|
140
|
+
top: document.body.style.top,
|
|
141
|
+
width: document.body.style.width,
|
|
142
|
+
}
|
|
143
|
+
document.body.style.overflow = 'hidden'
|
|
144
|
+
document.body.style.position = 'fixed'
|
|
145
|
+
document.body.style.top = `-${lockedScrollY}px`
|
|
146
|
+
document.body.style.width = '100%'
|
|
147
|
+
restoreBody = () => {
|
|
148
|
+
document.body.style.overflow = previous.overflow
|
|
149
|
+
document.body.style.position = previous.position
|
|
150
|
+
document.body.style.top = previous.top
|
|
151
|
+
document.body.style.width = previous.width
|
|
152
|
+
window.scrollTo(0, lockedScrollY)
|
|
153
|
+
}
|
|
154
|
+
} else if (!isMobile && restoreBody) {
|
|
155
|
+
restoreBody()
|
|
156
|
+
restoreBody = undefined
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const keepFocusInsideMobileSheet = (event: KeyboardEvent) => {
|
|
161
|
+
if (event.key === 'Escape') {
|
|
162
|
+
event.preventDefault()
|
|
163
|
+
setExpanded(false)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
if (event.key !== 'Tab' || window.innerWidth >= 768 || !panelRef.current) return
|
|
167
|
+
|
|
168
|
+
const focusable = Array.from(
|
|
169
|
+
panelRef.current.querySelectorAll<HTMLElement>(
|
|
170
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
171
|
+
),
|
|
172
|
+
).filter((node) => {
|
|
173
|
+
const style = window.getComputedStyle(node)
|
|
174
|
+
return (
|
|
175
|
+
!node.hidden &&
|
|
176
|
+
!node.closest('[hidden]') &&
|
|
177
|
+
node.getAttribute('aria-hidden') !== 'true' &&
|
|
178
|
+
style.display !== 'none' &&
|
|
179
|
+
style.visibility !== 'hidden'
|
|
180
|
+
)
|
|
181
|
+
})
|
|
182
|
+
if (focusable.length === 0) {
|
|
183
|
+
event.preventDefault()
|
|
184
|
+
panelRef.current.focus()
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
const first = focusable[0]
|
|
188
|
+
const last = focusable[focusable.length - 1]
|
|
189
|
+
if (!panelRef.current.contains(document.activeElement)) {
|
|
190
|
+
event.preventDefault()
|
|
191
|
+
const boundary = event.shiftKey ? last : first
|
|
192
|
+
boundary?.focus()
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
196
|
+
event.preventDefault()
|
|
197
|
+
last?.focus()
|
|
198
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
199
|
+
event.preventDefault()
|
|
200
|
+
first?.focus()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
updateComposition()
|
|
205
|
+
const observer =
|
|
206
|
+
typeof ResizeObserver === 'undefined' || !contentRef.current
|
|
207
|
+
? undefined
|
|
208
|
+
: new ResizeObserver(updateComposition)
|
|
209
|
+
if (contentRef.current) observer?.observe(contentRef.current)
|
|
210
|
+
window.addEventListener('resize', updateComposition)
|
|
211
|
+
document.addEventListener('keydown', keepFocusInsideMobileSheet)
|
|
212
|
+
|
|
213
|
+
return () => {
|
|
214
|
+
observer?.disconnect()
|
|
215
|
+
window.removeEventListener('resize', updateComposition)
|
|
216
|
+
document.removeEventListener('keydown', keepFocusInsideMobileSheet)
|
|
217
|
+
restoreBody?.()
|
|
218
|
+
returnFocusRef.current?.focus()
|
|
219
|
+
returnFocusRef.current = null
|
|
220
|
+
setMobileSheet(false)
|
|
221
|
+
setMobileFull(false)
|
|
222
|
+
}
|
|
223
|
+
}, [expanded, hasExpandable])
|
|
224
|
+
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
if (!expanded || !hasExpandable || typeof window === 'undefined') return
|
|
227
|
+
const frame = window.requestAnimationFrame(() => {
|
|
228
|
+
const selector = mobileSheet
|
|
229
|
+
? '[data-create-hub-focus="mobile"]'
|
|
230
|
+
: '[data-create-hub-focus="desktop"]'
|
|
231
|
+
panelRef.current?.querySelector<HTMLElement>(selector)?.focus()
|
|
232
|
+
})
|
|
233
|
+
return () => window.cancelAnimationFrame(frame)
|
|
234
|
+
}, [expanded, hasExpandable, mobileSheet])
|
|
92
235
|
|
|
93
236
|
return (
|
|
94
237
|
<div
|
|
@@ -100,8 +243,8 @@ export function BrandPageHeader({
|
|
|
100
243
|
.filter(Boolean)
|
|
101
244
|
.join(' ')}
|
|
102
245
|
>
|
|
103
|
-
<div className="flex flex-col gap-2
|
|
104
|
-
<div className="flex min-w-0 flex-1 flex-col gap-1
|
|
246
|
+
<div className="flex flex-col gap-2 md:grid md:grid-cols-3 md:items-stretch md:gap-4">
|
|
247
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1 md:col-span-2">
|
|
105
248
|
{breadcrumbs && breadcrumbs.length > 0 ? (
|
|
106
249
|
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-compact text-fg-muted">
|
|
107
250
|
{breadcrumbs.map((crumb, i) => (
|
|
@@ -142,14 +285,25 @@ export function BrandPageHeader({
|
|
|
142
285
|
|
|
143
286
|
{cta ? (
|
|
144
287
|
// Hero CTA spans 4 of 12 columns (one third) — the greeting takes the rest.
|
|
145
|
-
<div
|
|
288
|
+
<div
|
|
289
|
+
className={
|
|
290
|
+
[
|
|
291
|
+
mobileCtaDocked
|
|
292
|
+
? 'fixed inset-x-0 bottom-0 z-40 min-w-0 px-3 pb-mobile-cta-bottom md:static md:inset-auto md:z-auto md:h-full md:p-0'
|
|
293
|
+
: 'h-full min-w-0',
|
|
294
|
+
expanded ? 'hidden' : '',
|
|
295
|
+
].join(' ')
|
|
296
|
+
}
|
|
297
|
+
>
|
|
298
|
+
{renderedCta}
|
|
299
|
+
</div>
|
|
146
300
|
) : ctaLabel ? (
|
|
147
301
|
<div className="shrink-0">
|
|
148
302
|
{hasExpandable ? (
|
|
149
303
|
<Button
|
|
150
304
|
onPress={() => setExpanded(!expanded)}
|
|
151
305
|
aria-expanded={expanded}
|
|
152
|
-
aria-controls={
|
|
306
|
+
aria-controls={panelId}
|
|
153
307
|
className="h-10 rounded-full bg-brand-secondary px-5 text-small font-medium text-fg data-[hovered]:opacity-90 data-[pressed]:opacity-80"
|
|
154
308
|
>
|
|
155
309
|
{ctaLabel}
|
|
@@ -163,35 +317,80 @@ export function BrandPageHeader({
|
|
|
163
317
|
) : null}
|
|
164
318
|
</div>
|
|
165
319
|
|
|
166
|
-
{hasExpandable ? (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
320
|
+
{hasExpandable && expanded ? (
|
|
321
|
+
<>
|
|
322
|
+
<div
|
|
323
|
+
aria-hidden="true"
|
|
324
|
+
onClick={() => setExpanded(false)}
|
|
325
|
+
className="fixed inset-0 z-40 bg-black/50 animate-create-hub-backdrop md:hidden motion-reduce:animate-none"
|
|
326
|
+
/>
|
|
327
|
+
<div
|
|
328
|
+
ref={panelRef}
|
|
329
|
+
id={panelId}
|
|
330
|
+
role={mobileSheet ? 'dialog' : 'region'}
|
|
331
|
+
aria-modal={mobileSheet || undefined}
|
|
332
|
+
aria-label={typeof ctaLabel === 'string' ? ctaLabel : undefined}
|
|
333
|
+
tabIndex={-1}
|
|
334
|
+
onTouchStart={(event) => {
|
|
335
|
+
const touch = event.touches[0]
|
|
336
|
+
if (!touch || !contentRef.current) return
|
|
337
|
+
touchStartYRef.current = touch.clientY
|
|
338
|
+
touchStartScrollTopRef.current = contentRef.current.scrollTop
|
|
339
|
+
}}
|
|
340
|
+
onTouchMove={(event) => {
|
|
341
|
+
const touch = event.touches[0]
|
|
342
|
+
if (
|
|
343
|
+
!touch ||
|
|
344
|
+
touchStartScrollTopRef.current > 10 ||
|
|
345
|
+
touch.clientY - touchStartYRef.current < 80
|
|
346
|
+
) {
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
event.preventDefault()
|
|
350
|
+
setExpanded(false)
|
|
351
|
+
}}
|
|
352
|
+
className={[
|
|
353
|
+
'fixed inset-x-0 bottom-0 z-50 flex flex-col overflow-hidden bg-brand-green',
|
|
354
|
+
'outline-none motion-reduce:animate-none',
|
|
355
|
+
'md:relative md:inset-auto md:z-auto md:mt-4 md:max-h-none md:origin-top-right md:rounded-lg md:animate-create-hub-desktop',
|
|
356
|
+
mobileFull
|
|
357
|
+
? 'top-0 max-h-create-hub-full animate-create-hub-full'
|
|
358
|
+
: 'max-h-create-hub-partial animate-create-hub-sheet',
|
|
359
|
+
].join(' ')}
|
|
360
|
+
>
|
|
361
|
+
<Button
|
|
362
|
+
variant="ghost"
|
|
363
|
+
aria-label={closeLabel}
|
|
364
|
+
data-create-hub-focus="mobile"
|
|
365
|
+
onPress={() => setExpanded(false)}
|
|
366
|
+
className="h-10 w-full shrink-0 rounded-none p-0 md:hidden data-[hovered]:bg-transparent"
|
|
367
|
+
>
|
|
368
|
+
<span className="h-1 w-10 rounded-full bg-fg/25" aria-hidden="true" />
|
|
369
|
+
</Button>
|
|
370
|
+
<Button
|
|
371
|
+
variant="ghost"
|
|
372
|
+
aria-label={closeLabel}
|
|
373
|
+
data-create-hub-focus="desktop"
|
|
374
|
+
onPress={() => setExpanded(false)}
|
|
375
|
+
className="absolute right-4 top-14 z-10 hidden h-8 w-8 rounded-md p-0 text-fg md:flex data-[hovered]:bg-black/5"
|
|
376
|
+
>
|
|
377
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
378
|
+
<path
|
|
379
|
+
d="M6 6l12 12M18 6L6 18"
|
|
380
|
+
stroke="currentColor"
|
|
381
|
+
strokeWidth="2"
|
|
382
|
+
strokeLinecap="round"
|
|
383
|
+
/>
|
|
384
|
+
</svg>
|
|
385
|
+
</Button>
|
|
386
|
+
<div
|
|
387
|
+
ref={contentRef}
|
|
388
|
+
className="min-h-0 flex-1 overflow-y-auto overscroll-contain md:overflow-visible"
|
|
389
|
+
>
|
|
191
390
|
{createHub}
|
|
192
391
|
</div>
|
|
193
|
-
|
|
194
|
-
|
|
392
|
+
</div>
|
|
393
|
+
</>
|
|
195
394
|
) : null}
|
|
196
395
|
</div>
|
|
197
396
|
)
|
|
@@ -5,8 +5,11 @@ import type { ReactNode } from 'react'
|
|
|
5
5
|
* something" (the middle word emphasised) with an action control on the right.
|
|
6
6
|
*
|
|
7
7
|
* The copy is three fragments (`lead` · `emphasis` · `tail`) so a consumer can
|
|
8
|
-
* translate each per-locale while keeping the middle-word highlight.
|
|
9
|
-
*
|
|
8
|
+
* translate each per-locale while keeping the middle-word highlight. With
|
|
9
|
+
* `mobileHeader`, it adopts the source ExpandableCTA's 99px mobile touch surface;
|
|
10
|
+
* `BrandPageHeader` owns the safe-bottom positioning and returns it to the one-third
|
|
11
|
+
* desktop grid at the `md` breakpoint. Presentational only (hard rule #1): every
|
|
12
|
+
* string arrives via props — no i18n, no domain data.
|
|
10
13
|
*
|
|
11
14
|
* a11y NOTE: the background is the FIXED light brand-secondary (#6eddb1) — it does
|
|
12
15
|
* not flip with the theme, so every sentence fragment sits on `fg-on-brand` (stable
|
|
@@ -23,11 +26,23 @@ export interface CtaPillProps {
|
|
|
23
26
|
tail: ReactNode
|
|
24
27
|
/** Right-side action control (e.g. a Create button). */
|
|
25
28
|
children: ReactNode
|
|
29
|
+
/**
|
|
30
|
+
* Use the source mobile header density (99px minimum height and doubled
|
|
31
|
+
* horizontal content padding). Positioning stays with `BrandPageHeader`.
|
|
32
|
+
*/
|
|
33
|
+
mobileHeader?: boolean
|
|
26
34
|
}
|
|
27
35
|
|
|
28
|
-
export function CtaPill({ lead, emphasis, tail, children }: CtaPillProps) {
|
|
36
|
+
export function CtaPill({ lead, emphasis, tail, children, mobileHeader = false }: CtaPillProps) {
|
|
29
37
|
return (
|
|
30
|
-
<div
|
|
38
|
+
<div
|
|
39
|
+
className={[
|
|
40
|
+
'flex h-full w-full items-center justify-between gap-nav-x rounded-lg bg-brand-green',
|
|
41
|
+
mobileHeader
|
|
42
|
+
? 'min-h-mobile-cta px-12 py-5 shadow-mobile-cta md:min-h-16 md:py-2.5 md:pr-3 md:pl-4.5 md:shadow-none'
|
|
43
|
+
: 'min-h-16 py-2.5 pr-3 pl-4.5',
|
|
44
|
+
].join(' ')}
|
|
45
|
+
>
|
|
31
46
|
<p className="min-w-0 text-heading4 font-medium leading-5 tracking-tight text-fg-on-brand">
|
|
32
47
|
{lead} <span className="font-semibold">{emphasis}</span> {tail}
|
|
33
48
|
</p>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { Button } from './button'
|
|
4
|
+
import { SidePanel } from './side-panel'
|
|
5
|
+
|
|
6
|
+
function DefaultExample(): React.ReactNode {
|
|
7
|
+
const [open, setOpen] = useState(false)
|
|
8
|
+
return (
|
|
9
|
+
<>
|
|
10
|
+
<Button onPress={() => setOpen(true)}>Open panel</Button>
|
|
11
|
+
<SidePanel
|
|
12
|
+
isOpen={open}
|
|
13
|
+
onOpenChange={setOpen}
|
|
14
|
+
title="Assistant"
|
|
15
|
+
description="Acme Robotics"
|
|
16
|
+
closeLabel="Close assistant"
|
|
17
|
+
footer={<Button className="w-full">Send</Button>}
|
|
18
|
+
>
|
|
19
|
+
<p className="text-small text-fg-muted">Ask a question about the current Brand.</p>
|
|
20
|
+
</SidePanel>
|
|
21
|
+
</>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function SizeExample({ size }: { size: 'sm' | 'md' | 'lg' }): React.ReactNode {
|
|
26
|
+
const [open, setOpen] = useState(false)
|
|
27
|
+
return (
|
|
28
|
+
<>
|
|
29
|
+
<Button variant="secondary" onPress={() => setOpen(true)}>
|
|
30
|
+
{size.toUpperCase()}
|
|
31
|
+
</Button>
|
|
32
|
+
<SidePanel
|
|
33
|
+
isOpen={open}
|
|
34
|
+
onOpenChange={setOpen}
|
|
35
|
+
title={`${size.toUpperCase()} panel`}
|
|
36
|
+
closeLabel="Close panel"
|
|
37
|
+
size={size}
|
|
38
|
+
>
|
|
39
|
+
<p className="text-small text-fg">Responsive owned-scroll content.</p>
|
|
40
|
+
</SidePanel>
|
|
41
|
+
</>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function PendingExample(): React.ReactNode {
|
|
46
|
+
const [open, setOpen] = useState(false)
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
<Button variant="secondary" onPress={() => setOpen(true)}>Pending state</Button>
|
|
50
|
+
<SidePanel
|
|
51
|
+
isOpen={open}
|
|
52
|
+
onOpenChange={setOpen}
|
|
53
|
+
title="Saving"
|
|
54
|
+
description="Dismissal is temporarily disabled."
|
|
55
|
+
closeLabel="Close panel"
|
|
56
|
+
isDismissable={false}
|
|
57
|
+
>
|
|
58
|
+
<div role="status" className="text-small text-fg-muted">Working…</div>
|
|
59
|
+
</SidePanel>
|
|
60
|
+
</>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const examples = {
|
|
65
|
+
default: () => <DefaultExample />,
|
|
66
|
+
sizes: () => (
|
|
67
|
+
<div className="flex gap-2">
|
|
68
|
+
<SizeExample size="sm" />
|
|
69
|
+
<SizeExample size="md" />
|
|
70
|
+
<SizeExample size="lg" />
|
|
71
|
+
</div>
|
|
72
|
+
),
|
|
73
|
+
states: () => <PendingExample />,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const meta = {
|
|
77
|
+
category: 'Layout',
|
|
78
|
+
description: 'Accessible right-side modal panel with responsive full-screen mobile behavior.',
|
|
79
|
+
}
|
|
80
|
+
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Button as RACButton,
|
|
4
|
+
Dialog as RACDialog,
|
|
5
|
+
Heading,
|
|
6
|
+
Modal as RACModal,
|
|
7
|
+
ModalOverlay as RACModalOverlay,
|
|
8
|
+
} from 'react-aria-components'
|
|
9
|
+
|
|
10
|
+
import { uic } from '../utils/uic'
|
|
11
|
+
|
|
12
|
+
export type SidePanelSize = 'sm' | 'md' | 'lg'
|
|
13
|
+
|
|
14
|
+
export interface SidePanelProps {
|
|
15
|
+
/** Controlled open state. */
|
|
16
|
+
isOpen: boolean
|
|
17
|
+
/** Called for Escape, backdrop dismissal and the close control. */
|
|
18
|
+
onOpenChange: (isOpen: boolean) => void
|
|
19
|
+
/** Accessible panel title. */
|
|
20
|
+
title: ReactNode
|
|
21
|
+
/** Optional supporting copy below the title. */
|
|
22
|
+
description?: ReactNode
|
|
23
|
+
/** App-supplied localized label for the close control. */
|
|
24
|
+
closeLabel: string
|
|
25
|
+
/** Panel width from the shared responsive scale. */
|
|
26
|
+
size?: SidePanelSize
|
|
27
|
+
/** Disable Escape/backdrop dismissal while a critical operation is pending. */
|
|
28
|
+
isDismissable?: boolean
|
|
29
|
+
/** Owned-scroll panel content. */
|
|
30
|
+
children: ReactNode | ((options: { close: () => void }) => ReactNode)
|
|
31
|
+
/** Optional pinned action area below the scroll region. */
|
|
32
|
+
footer?: ReactNode | ((options: { close: () => void }) => ReactNode)
|
|
33
|
+
/** Optional test id forwarded to the dialog element. */
|
|
34
|
+
'data-testid'?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SidePanelOverlay = uic(RACModalOverlay, {
|
|
38
|
+
displayName: 'SidePanel.Overlay',
|
|
39
|
+
baseClass:
|
|
40
|
+
'fixed inset-0 z-50 flex justify-end bg-modal-backdrop backdrop-blur-modal-backdrop ' +
|
|
41
|
+
'data-[entering]:animate-modal-overlay-in data-[exiting]:animate-modal-overlay-out ' +
|
|
42
|
+
'motion-reduce:data-[entering]:animate-none motion-reduce:data-[exiting]:animate-none',
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const SidePanelSurface = uic(RACModal, {
|
|
46
|
+
displayName: 'SidePanel.Surface',
|
|
47
|
+
baseClass:
|
|
48
|
+
'ml-auto flex h-dvh w-full flex-col overflow-hidden bg-surface outline-none shadow-modal-surface ' +
|
|
49
|
+
'sm:rounded-l-lg data-[entering]:animate-side-panel-in data-[exiting]:animate-side-panel-out ' +
|
|
50
|
+
'motion-reduce:data-[entering]:animate-none motion-reduce:data-[exiting]:animate-none',
|
|
51
|
+
variants: {
|
|
52
|
+
size: {
|
|
53
|
+
sm: 'sm:max-w-sm',
|
|
54
|
+
md: 'sm:max-w-md',
|
|
55
|
+
lg: 'sm:max-w-lg',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
defaultVariants: { size: 'md' },
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const SidePanelClose = uic(RACButton, {
|
|
62
|
+
displayName: 'SidePanel.Close',
|
|
63
|
+
baseClass:
|
|
64
|
+
'inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-fg-subtle outline-none ' +
|
|
65
|
+
'transition-colors hover:bg-surface-muted hover:text-fg ' +
|
|
66
|
+
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2',
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Responsive modal side panel.
|
|
71
|
+
*
|
|
72
|
+
* React Aria owns focus containment, Escape/backdrop dismissal, scroll locking
|
|
73
|
+
* and trigger focus return. The surface is a full-screen owned-scroll sheet on
|
|
74
|
+
* mobile and a right-aligned panel on larger viewports. All copy and product
|
|
75
|
+
* content are supplied by the consuming application.
|
|
76
|
+
*/
|
|
77
|
+
export function SidePanel({
|
|
78
|
+
isOpen,
|
|
79
|
+
onOpenChange,
|
|
80
|
+
title,
|
|
81
|
+
description,
|
|
82
|
+
closeLabel,
|
|
83
|
+
size = 'md',
|
|
84
|
+
isDismissable = true,
|
|
85
|
+
children,
|
|
86
|
+
footer,
|
|
87
|
+
'data-testid': testId,
|
|
88
|
+
}: SidePanelProps): React.ReactNode {
|
|
89
|
+
return (
|
|
90
|
+
<SidePanelOverlay
|
|
91
|
+
isOpen={isOpen}
|
|
92
|
+
onOpenChange={onOpenChange}
|
|
93
|
+
isDismissable={isDismissable}
|
|
94
|
+
>
|
|
95
|
+
<SidePanelSurface size={size}>
|
|
96
|
+
<RACDialog
|
|
97
|
+
data-testid={testId}
|
|
98
|
+
className="flex min-h-0 flex-1 flex-col outline-none"
|
|
99
|
+
>
|
|
100
|
+
{renderProps => (
|
|
101
|
+
<>
|
|
102
|
+
<header className="flex shrink-0 items-start justify-between gap-4 border-b border-border px-5 py-4">
|
|
103
|
+
<div className="min-w-0 pt-1">
|
|
104
|
+
<Heading slot="title" className="text-heading1 font-medium tracking-tight text-fg">
|
|
105
|
+
{title}
|
|
106
|
+
</Heading>
|
|
107
|
+
{description ? (
|
|
108
|
+
<p className="mt-1 text-small text-fg-muted">{description}</p>
|
|
109
|
+
) : null}
|
|
110
|
+
</div>
|
|
111
|
+
<SidePanelClose aria-label={closeLabel} onClick={() => onOpenChange(false)}>
|
|
112
|
+
<svg
|
|
113
|
+
width="20"
|
|
114
|
+
height="20"
|
|
115
|
+
viewBox="0 0 24 24"
|
|
116
|
+
fill="none"
|
|
117
|
+
stroke="currentColor"
|
|
118
|
+
strokeWidth="2"
|
|
119
|
+
strokeLinecap="round"
|
|
120
|
+
aria-hidden="true"
|
|
121
|
+
>
|
|
122
|
+
<path d="M6 6l12 12M18 6 6 18" />
|
|
123
|
+
</svg>
|
|
124
|
+
</SidePanelClose>
|
|
125
|
+
</header>
|
|
126
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
|
127
|
+
{typeof children === 'function' ? children(renderProps) : children}
|
|
128
|
+
</div>
|
|
129
|
+
{footer ? (
|
|
130
|
+
<footer className="shrink-0 border-t border-border px-5 pt-4 pb-mobile-cta-bottom sm:pb-4">
|
|
131
|
+
{typeof footer === 'function' ? footer(renderProps) : footer}
|
|
132
|
+
</footer>
|
|
133
|
+
) : null}
|
|
134
|
+
</>
|
|
135
|
+
)}
|
|
136
|
+
</RACDialog>
|
|
137
|
+
</SidePanelSurface>
|
|
138
|
+
</SidePanelOverlay>
|
|
139
|
+
)
|
|
140
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ export * from "./components/file-upload";
|
|
|
30
30
|
export * from "./components/date-field";
|
|
31
31
|
export * from "./components/date-picker";
|
|
32
32
|
export * from "./components/dialog";
|
|
33
|
+
export * from "./components/side-panel";
|
|
33
34
|
export * from "./components/dropdown-menu";
|
|
34
35
|
export * from "./components/context-menu";
|
|
35
36
|
export * from "./components/tooltip";
|