@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.32",
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",
32
- "@podoba/tailwind": "^0.0.32",
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 an inline
12
- * create-hub panel. We port that interaction to React Aria + Tailwind + the
13
- * teal accent token (`brand-secondary`), dropping gs-platform's mobile
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). When
48
- * set it REPLACES the collapsed-pill ExpandableCTA use this for the
49
- * gs-faithful "Let's create something" hero. `ctaLabel`/`createHub` are ignored.
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 sm:grid sm:grid-cols-3 sm:items-stretch sm:gap-4">
104
- <div className="flex min-w-0 flex-1 flex-col gap-1 sm:col-span-2">
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 className="h-full min-w-0">{cta}</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={PANEL_ID}
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
- <div
168
- id={PANEL_ID}
169
- role="region"
170
- aria-label={typeof ctaLabel === 'string' ? ctaLabel : undefined}
171
- hidden={!expanded}
172
- className="mt-4"
173
- >
174
- {expanded ? (
175
- <div className="relative rounded-lg border border-border bg-surface-card p-4 animate-expand-cta motion-reduce:animate-none">
176
- <Button
177
- variant="ghost"
178
- aria-label={closeLabel}
179
- onPress={() => setExpanded(false)}
180
- className="absolute right-2 top-2 h-7 w-7 rounded-full p-0 text-fg-muted data-[hovered]:bg-surface-muted data-[hovered]:text-fg"
181
- >
182
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
183
- <path
184
- d="M6 6l12 12M18 6L6 18"
185
- stroke="currentColor"
186
- strokeWidth="2"
187
- strokeLinecap="round"
188
- />
189
- </svg>
190
- </Button>
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
- ) : null}
194
- </div>
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. Presentational
9
- * only (hard rule #1): every string arrives via props — no i18n, no domain data.
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 className="flex h-full min-h-16 w-full items-center justify-between gap-nav-x rounded-lg bg-brand-green py-2.5 pr-3 pl-4.5">
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";