@graphprotocol/gds-react 0.2.0 → 0.2.1

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 (129) hide show
  1. package/dist/GDSContext.d.ts +13 -0
  2. package/dist/GDSContext.d.ts.map +1 -0
  3. package/dist/GDSContext.js +4 -0
  4. package/dist/GDSContext.js.map +1 -0
  5. package/dist/GDSProvider.d.ts +1 -9
  6. package/dist/GDSProvider.d.ts.map +1 -1
  7. package/dist/GDSProvider.js +4 -3
  8. package/dist/GDSProvider.js.map +1 -1
  9. package/dist/components/Avatar.d.ts.map +1 -1
  10. package/dist/components/Avatar.js +2 -2
  11. package/dist/components/Avatar.js.map +1 -1
  12. package/dist/components/Breadcrumbs.parts.js +1 -1
  13. package/dist/components/Breadcrumbs.parts.js.map +1 -1
  14. package/dist/components/Button.d.ts.map +1 -1
  15. package/dist/components/Button.js +69 -69
  16. package/dist/components/Button.js.map +1 -1
  17. package/dist/components/Card.js +2 -2
  18. package/dist/components/Card.js.map +1 -1
  19. package/dist/components/CodeBlock.d.ts +1 -1
  20. package/dist/components/CodeBlock.parts.d.ts +1 -1
  21. package/dist/components/CopyButton.d.ts +1 -1
  22. package/dist/components/CopyButton.d.ts.map +1 -1
  23. package/dist/components/CopyButton.js +46 -19
  24. package/dist/components/CopyButton.js.map +1 -1
  25. package/dist/components/Input.js +2 -2
  26. package/dist/components/Input.js.map +1 -1
  27. package/dist/components/Link.js +2 -2
  28. package/dist/components/Link.js.map +1 -1
  29. package/dist/components/Menu.parts.d.ts +4 -5
  30. package/dist/components/Menu.parts.d.ts.map +1 -1
  31. package/dist/components/Menu.parts.js +49 -44
  32. package/dist/components/Menu.parts.js.map +1 -1
  33. package/dist/components/Modal.parts.d.ts.map +1 -1
  34. package/dist/components/Modal.parts.js +17 -21
  35. package/dist/components/Modal.parts.js.map +1 -1
  36. package/dist/components/Pane.d.ts +9 -0
  37. package/dist/components/Pane.d.ts.map +1 -0
  38. package/dist/components/Pane.js +8 -0
  39. package/dist/components/Pane.js.map +1 -0
  40. package/dist/components/Pane.meta.d.ts +20 -0
  41. package/dist/components/Pane.meta.d.ts.map +1 -0
  42. package/dist/components/Pane.meta.js +30 -0
  43. package/dist/components/Pane.meta.js.map +1 -0
  44. package/dist/components/Pane.parts.d.ts +77 -0
  45. package/dist/components/Pane.parts.d.ts.map +1 -0
  46. package/dist/components/Pane.parts.js +412 -0
  47. package/dist/components/Pane.parts.js.map +1 -0
  48. package/dist/components/Search.js +1 -1
  49. package/dist/components/Tooltip.parts.d.ts +13 -4
  50. package/dist/components/Tooltip.parts.d.ts.map +1 -1
  51. package/dist/components/Tooltip.parts.js +51 -63
  52. package/dist/components/Tooltip.parts.js.map +1 -1
  53. package/dist/components/base/ButtonOrLink.d.ts +1 -1
  54. package/dist/components/base/ButtonOrLink.d.ts.map +1 -1
  55. package/dist/components/base/ButtonOrLink.parts.d.ts +10 -3
  56. package/dist/components/base/ButtonOrLink.parts.d.ts.map +1 -1
  57. package/dist/components/base/ButtonOrLink.parts.js +27 -35
  58. package/dist/components/base/ButtonOrLink.parts.js.map +1 -1
  59. package/dist/components/base/MaybeButtonOrLink.d.ts +19 -2
  60. package/dist/components/base/MaybeButtonOrLink.d.ts.map +1 -1
  61. package/dist/components/base/MaybeButtonOrLink.js +5 -3
  62. package/dist/components/base/MaybeButtonOrLink.js.map +1 -1
  63. package/dist/components/base/Presence.d.ts +157 -0
  64. package/dist/components/base/Presence.d.ts.map +1 -0
  65. package/dist/components/base/Presence.js +808 -0
  66. package/dist/components/base/Presence.js.map +1 -0
  67. package/dist/components/base/index.d.ts +1 -0
  68. package/dist/components/base/index.d.ts.map +1 -1
  69. package/dist/components/base/index.js +1 -0
  70. package/dist/components/base/index.js.map +1 -1
  71. package/dist/components/index.d.ts +2 -0
  72. package/dist/components/index.d.ts.map +1 -1
  73. package/dist/components/index.js +2 -0
  74. package/dist/components/index.js.map +1 -1
  75. package/dist/hooks/useCSSProp.js +1 -1
  76. package/dist/hooks/useCSSProp.js.map +1 -1
  77. package/dist/hooks/useControlled.d.ts.map +1 -1
  78. package/dist/hooks/useControlled.js +6 -4
  79. package/dist/hooks/useControlled.js.map +1 -1
  80. package/dist/hooks/useGDS.js +1 -1
  81. package/dist/hooks/useGDS.js.map +1 -1
  82. package/dist/hooks/useStyleObserver.js +1 -1
  83. package/dist/hooks/useStyleObserver.js.map +1 -1
  84. package/dist/tailwind-plugin.d.ts.map +1 -1
  85. package/dist/tailwind-plugin.js +3 -0
  86. package/dist/tailwind-plugin.js.map +1 -1
  87. package/dist/utils/InlineCounter.d.ts +3 -0
  88. package/dist/utils/InlineCounter.d.ts.map +1 -0
  89. package/dist/utils/InlineCounter.js +7 -0
  90. package/dist/utils/InlineCounter.js.map +1 -0
  91. package/dist/utils/RenderCount.d.ts +3 -0
  92. package/dist/utils/RenderCount.d.ts.map +1 -0
  93. package/dist/utils/RenderCount.js +7 -0
  94. package/dist/utils/RenderCount.js.map +1 -0
  95. package/dist/utils/index.d.ts +2 -0
  96. package/dist/utils/index.d.ts.map +1 -1
  97. package/dist/utils/index.js +2 -0
  98. package/dist/utils/index.js.map +1 -1
  99. package/package.json +14 -14
  100. package/src/GDSContext.ts +16 -0
  101. package/src/GDSProvider.tsx +20 -31
  102. package/src/components/Avatar.tsx +3 -2
  103. package/src/components/Breadcrumbs.parts.tsx +1 -1
  104. package/src/components/Button.tsx +113 -107
  105. package/src/components/Card.tsx +2 -2
  106. package/src/components/CopyButton.tsx +49 -25
  107. package/src/components/Input.tsx +1 -1
  108. package/src/components/Link.tsx +2 -2
  109. package/src/components/Menu.parts.tsx +75 -72
  110. package/src/components/Modal.parts.tsx +26 -31
  111. package/src/components/Pane.meta.ts +31 -0
  112. package/src/components/Pane.parts.tsx +713 -0
  113. package/src/components/Pane.tsx +17 -0
  114. package/src/components/Search.tsx +1 -1
  115. package/src/components/Tooltip.parts.tsx +95 -80
  116. package/src/components/base/ButtonOrLink.parts.tsx +71 -51
  117. package/src/components/base/ButtonOrLink.tsx +1 -0
  118. package/src/components/base/MaybeButtonOrLink.tsx +26 -5
  119. package/src/components/base/Presence.tsx +1375 -0
  120. package/src/components/base/index.ts +1 -0
  121. package/src/components/index.ts +10 -0
  122. package/src/hooks/useCSSProp.ts +1 -1
  123. package/src/hooks/useControlled.ts +16 -8
  124. package/src/hooks/useGDS.ts +1 -1
  125. package/src/hooks/useStyleObserver.ts +1 -1
  126. package/src/tailwind-plugin.ts +3 -0
  127. package/src/utils/InlineCounter.tsx +17 -0
  128. package/src/utils/RenderCount.tsx +7 -0
  129. package/src/utils/index.ts +2 -0
@@ -0,0 +1,713 @@
1
+ 'use client'
2
+
3
+ import {
4
+ createContext,
5
+ Fragment,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useId,
10
+ useRef,
11
+ useSyncExternalStore,
12
+ type ComponentProps,
13
+ type ElementType,
14
+ type MouseEvent,
15
+ type ReactNode,
16
+ } from 'react'
17
+ import { useMergedRefs } from '@base-ui/utils/useMergedRefs'
18
+ import { objectKeys } from 'ts-extras'
19
+
20
+ import type { GDSComponentProps } from '@graphprotocol/gds-css'
21
+
22
+ import { useCSSPropsPolyfill, useFirstRender, usePrevious, useStyle } from '../hooks/index.ts'
23
+ import { cn, getCSSPropsAttributes } from '../utils/index.ts'
24
+ import { Presence } from './base/Presence.tsx'
25
+ import { Render, type RenderProp } from './base/Render.tsx'
26
+ import { PaneContainerMeta, PaneMeta } from './Pane.meta.ts'
27
+ import { ToggleButton, type ToggleButtonProps } from './ToggleButton.tsx'
28
+
29
+ const DEFAULT_DURATION = 200
30
+
31
+ /** Parse a CSS duration string (e.g., "200ms", "0.2s") to milliseconds. */
32
+ function parseDuration(value: string | undefined) {
33
+ if (!value) return null
34
+ const match = value.trim().match(/^([\d.]+)(ms|s)?$/i)
35
+ if (!match) return null
36
+ const num = parseFloat(match[1]!)
37
+ if (Number.isNaN(num)) return null
38
+ const unit = match[2]?.toLowerCase()
39
+ return Math.round(unit === 's' ? num * 1000 : num)
40
+ }
41
+
42
+ type PaneData = {
43
+ id: string
44
+ layout: 'docked' | 'overlay'
45
+ collapsed: boolean
46
+ overlayOpen: boolean
47
+ defaultCollapsed: boolean
48
+ defaultOverlayOpen: boolean
49
+ registrants: Set<string>
50
+ }
51
+
52
+ type PaneCallbacks = {
53
+ onCollapsedChange?: ((collapsed: boolean) => void) | undefined
54
+ onOverlayOpenChange?: ((overlayOpen: boolean) => void) | undefined
55
+ }
56
+
57
+ function createPaneStore(storeId: string) {
58
+ const panes = new Map<string, PaneData>()
59
+ // Map of pane name -> registrant ID -> callbacks
60
+ const callbacksByRegistrant = new Map<string, Map<string, PaneCallbacks>>()
61
+ const listeners = new Set<() => void>()
62
+ const notify = () => listeners.forEach((l) => l())
63
+
64
+ return {
65
+ subscribe: (listener: () => void) => {
66
+ listeners.add(listener)
67
+ return () => listeners.delete(listener)
68
+ },
69
+ get: (name: string) => panes.get(name),
70
+ register: (
71
+ name: string,
72
+ registrantId: string,
73
+ initial: Omit<PaneData, 'id' | 'layout' | 'registrants'>,
74
+ cached: PaneData | null,
75
+ ) => {
76
+ const existing = panes.get(name)
77
+ if (existing) {
78
+ existing.registrants.add(registrantId)
79
+ return
80
+ }
81
+ const registrants = new Set<string>([registrantId])
82
+ const id = `gds-pane-${storeId}-${name}`
83
+ panes.set(
84
+ name,
85
+ cached
86
+ ? { ...cached, registrants }
87
+ : { ...initial, id, layout: PaneMeta.cssProps.layout.defaultValue, registrants },
88
+ )
89
+ // Defer notification to avoid updating components during render
90
+ window.queueMicrotask(notify)
91
+ },
92
+ unregister: (name: string, registrantId: string) => {
93
+ const pane = panes.get(name)
94
+ if (!pane) return
95
+ pane.registrants.delete(registrantId)
96
+ // Remove this registrant's callbacks
97
+ callbacksByRegistrant.get(name)?.delete(registrantId)
98
+ if (pane.registrants.size === 0) {
99
+ panes.delete(name)
100
+ callbacksByRegistrant.delete(name)
101
+ }
102
+ // No notify - subscribers will re-register on remount if needed
103
+ },
104
+ setCallbacks: (name: string, registrantId: string, cbs: PaneCallbacks) => {
105
+ let paneCallbacks = callbacksByRegistrant.get(name)
106
+ if (!paneCallbacks) {
107
+ paneCallbacks = new Map()
108
+ callbacksByRegistrant.set(name, paneCallbacks)
109
+ }
110
+ paneCallbacks.set(registrantId, cbs)
111
+ },
112
+ update: (name: string, updates: Partial<PaneData>) => {
113
+ const pane = panes.get(name)
114
+ if (!pane) return
115
+ const hasChanges = objectKeys(updates).some((key) => pane[key] !== updates[key])
116
+ if (!hasChanges) return
117
+ const collapsedChanged = 'collapsed' in updates && updates.collapsed !== pane.collapsed
118
+ const overlayOpenChanged =
119
+ 'overlayOpen' in updates && updates.overlayOpen !== pane.overlayOpen
120
+ panes.set(name, { ...pane, ...updates })
121
+ notify()
122
+ // Fire all registered callbacks for this pane
123
+ const paneCallbacks = callbacksByRegistrant.get(name)
124
+ if (paneCallbacks) {
125
+ paneCallbacks.forEach((cbs) => {
126
+ if (collapsedChanged) cbs.onCollapsedChange?.(updates.collapsed!)
127
+ if (overlayOpenChanged) cbs.onOverlayOpenChange?.(updates.overlayOpen!)
128
+ })
129
+ }
130
+ },
131
+ }
132
+ }
133
+
134
+ type PaneStore = ReturnType<typeof createPaneStore>
135
+
136
+ const PaneProviderContext = createContext<PaneStore | null>(null)
137
+
138
+ export interface PaneProviderProps {
139
+ children: ReactNode
140
+ }
141
+
142
+ export function PaneProvider({ children }: PaneProviderProps) {
143
+ const storeId = useId()
144
+ const storeRef = useRef<PaneStore | null>(null)
145
+ if (!storeRef.current) {
146
+ storeRef.current = createPaneStore(storeId)
147
+ }
148
+
149
+ return (
150
+ <PaneProviderContext.Provider value={storeRef.current}>{children}</PaneProviderContext.Provider>
151
+ )
152
+ }
153
+ PaneProvider.displayName = 'Pane.Provider'
154
+
155
+ export interface UsePaneOptions {
156
+ defaultCollapsed?: boolean | undefined
157
+ defaultOverlayOpen?: boolean | undefined
158
+ onCollapsedChange?: ((collapsed: boolean) => void) | undefined
159
+ onOverlayOpenChange?: ((overlayOpen: boolean) => void) | undefined
160
+ }
161
+
162
+ function usePaneInternal(name: string, options: UsePaneOptions = {}) {
163
+ const {
164
+ defaultCollapsed = false,
165
+ defaultOverlayOpen = false,
166
+ onCollapsedChange,
167
+ onOverlayOpenChange,
168
+ } = options
169
+
170
+ const store = useContext(PaneProviderContext)
171
+ if (!store) {
172
+ throw new Error('[Pane] Must be used within GDSProvider or Pane.Provider')
173
+ }
174
+
175
+ const registrantId = useId()
176
+
177
+ // Cache pane data locally so we can restore it after React strict mode's simulated unmount
178
+ const cachedPaneRef = useRef<PaneData | null>(null)
179
+
180
+ // Register synchronously during render so sibling components have access on first render
181
+ store.register(
182
+ name,
183
+ registrantId,
184
+ {
185
+ collapsed: defaultCollapsed,
186
+ overlayOpen: defaultOverlayOpen,
187
+ defaultCollapsed,
188
+ defaultOverlayOpen,
189
+ },
190
+ cachedPaneRef.current,
191
+ )
192
+
193
+ // Register callbacks with the store so they fire for any state change
194
+ store.setCallbacks(name, registrantId, { onCollapsedChange, onOverlayOpenChange })
195
+
196
+ // Unregister on unmount
197
+ useEffect(() => {
198
+ return () => {
199
+ // Save current state to cache before unregistering
200
+ cachedPaneRef.current = store.get(name) ?? null
201
+ store.unregister(name, registrantId)
202
+ }
203
+ }, [store, name, registrantId])
204
+
205
+ // Subscribe to pane state changes
206
+ const getSnapshot = useCallback(() => store.get(name), [store, name])
207
+ const pane = useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot)!
208
+
209
+ const setCollapsed = useCallback(
210
+ (collapsed: boolean) => store.update(name, { collapsed }),
211
+ [store, name],
212
+ )
213
+ const setOverlayOpen = useCallback(
214
+ (overlayOpen: boolean) => store.update(name, { overlayOpen }),
215
+ [store, name],
216
+ )
217
+ const toggle = useCallback(() => {
218
+ if (pane.layout === 'overlay') {
219
+ store.update(name, { overlayOpen: !pane.overlayOpen })
220
+ } else {
221
+ store.update(name, { collapsed: !pane.collapsed })
222
+ }
223
+ }, [store, name, pane.layout, pane.overlayOpen, pane.collapsed])
224
+
225
+ return { store, pane, setCollapsed, setOverlayOpen, toggle }
226
+ }
227
+
228
+ export function usePane(name: string, options: UsePaneOptions = {}) {
229
+ const { pane, setCollapsed, setOverlayOpen, toggle } = usePaneInternal(name, options)
230
+
231
+ return {
232
+ layout: pane.layout,
233
+ collapsed: pane.collapsed,
234
+ overlayOpen: pane.overlayOpen,
235
+ setCollapsed,
236
+ setOverlayOpen,
237
+ toggle,
238
+ }
239
+ }
240
+
241
+ export interface PaneContainerProps
242
+ extends ComponentProps<'div'>, GDSComponentProps<typeof PaneContainerMeta> {}
243
+
244
+ export function PaneContainer({
245
+ ref: passedRef,
246
+ orientation,
247
+ className,
248
+ style,
249
+ children,
250
+ ...props
251
+ }: PaneContainerProps) {
252
+ const [cssPropsPolyfillPassedRef, cssPropsPolyfillAttributes] = useCSSPropsPolyfill(
253
+ PaneContainerMeta,
254
+ { orientation },
255
+ { ref: passedRef },
256
+ )
257
+
258
+ return (
259
+ <div
260
+ ref={cssPropsPolyfillPassedRef}
261
+ className={cn('gds-pane-container flex u:overflow-clip', className)}
262
+ {...getCSSPropsAttributes(PaneContainerMeta, { orientation }, style)}
263
+ {...cssPropsPolyfillAttributes}
264
+ {...props}
265
+ >
266
+ <div
267
+ className={`
268
+ flex grow
269
+ @prop-orientation-horizontal/pane-container:flex-row
270
+ @prop-orientation-vertical/pane-container:flex-col
271
+ u:*:isolate
272
+ u:@prop-orientation-horizontal/pane-container:*:not-pane:w-0
273
+ u:@prop-orientation-horizontal/pane-container:*:not-pane:grow
274
+ u:@prop-orientation-horizontal/pane-container:*:pane:w-[clamp(--spacing(48),25%,--spacing(80))]
275
+ `}
276
+ >
277
+ {children}
278
+ </div>
279
+ </div>
280
+ )
281
+ }
282
+ PaneContainer.displayName = 'Pane.Container'
283
+
284
+ export interface PaneProps extends ComponentProps<'div'>, GDSComponentProps<typeof PaneMeta> {
285
+ /** @default 'div' */
286
+ as?: ElementType | undefined
287
+ /** Unique name for this pane. */
288
+ name: string
289
+ /**
290
+ * Whether the pane is pseudo-modal in overlay mode. When `true`, the overlay shows a backdrop,
291
+ * receives focus when opened, and can be closed by pressing Escape or interacting with elements
292
+ * covered by the backdrop.
293
+ *
294
+ * @default true
295
+ */
296
+ overlayModal?: boolean | undefined
297
+ }
298
+
299
+ export function PaneRoot({
300
+ ref: passedRef,
301
+ as: Element = 'div',
302
+ name,
303
+ layout,
304
+ side,
305
+ overlayModal = true,
306
+ className,
307
+ style,
308
+ children,
309
+ ...props
310
+ }: PaneProps) {
311
+ const { store, pane } = usePaneInternal(name)
312
+
313
+ const rootRef = useRef<HTMLDivElement>(null)
314
+ // Observe `--tw-duration` to allow customizing the transition duration via CSS
315
+ const [styleCallbackRef, styleValues] = useStyle(['--tw-duration'])
316
+ const duration = parseDuration(styleValues['--tw-duration']) ?? DEFAULT_DURATION
317
+ const rootPassedRef = useMergedRefs(rootRef, passedRef, styleCallbackRef)
318
+
319
+ const [cssPropsPolyfillRootPassedRef, cssPropsPolyfillAttributes, cssProps] = useCSSPropsPolyfill(
320
+ PaneMeta,
321
+ { layout, side },
322
+ { ref: rootPassedRef, returnPropValues: { layout, side } },
323
+ )
324
+
325
+ // Sync layout to store, resetting `overlayOpen` when switching from overlay to docked
326
+ const previousLayoutRef = useRef(cssProps.layout)
327
+ useEffect(() => {
328
+ const previousLayout = previousLayoutRef.current
329
+ previousLayoutRef.current = cssProps.layout
330
+ if (previousLayout === 'overlay' && cssProps.layout === 'docked') {
331
+ store.update(name, { layout: cssProps.layout, overlayOpen: pane.defaultOverlayOpen })
332
+ } else {
333
+ store.update(name, { layout: cssProps.layout })
334
+ }
335
+ }, [store, name, cssProps.layout, pane.defaultOverlayOpen])
336
+
337
+ // Pseudo-modal overlay: focus pane on open, Escape to close, restore focus on close
338
+ const focusLeftPaneRef = useRef(false)
339
+ const previouslyFocusedRef = useRef<Element | null>(null)
340
+ useEffect(() => {
341
+ if (!overlayModal || cssProps.layout !== 'overlay' || !pane.overlayOpen) return
342
+
343
+ focusLeftPaneRef.current = false
344
+
345
+ // Save the currently focused element and focus the pane content
346
+ setTimeout(() => {
347
+ previouslyFocusedRef.current = document.activeElement
348
+ rootRef.current
349
+ ?.querySelector<HTMLElement>('[data-key="content"]')
350
+ ?.focus({ preventScroll: true })
351
+ }, 0)
352
+
353
+ // Close overlay when focus moves to an element covered by the backdrop (i.e. inside the container, but outside the pane)
354
+ const handlePaneFocusOut = (event: FocusEvent) => {
355
+ const newTarget = event.relatedTarget as Node | null
356
+ // Focus intentionally left if it went somewhere other than body (which is where `inert` sends it)
357
+ if (newTarget && newTarget !== document.body && !rootRef.current?.contains(newTarget)) {
358
+ focusLeftPaneRef.current = true
359
+ if (parent?.contains(newTarget)) {
360
+ store.update(name, { overlayOpen: false })
361
+ }
362
+ }
363
+ }
364
+ const handlePaneFocusIn = () => {
365
+ focusLeftPaneRef.current = false
366
+ }
367
+ // Also listen on parent to catch focus entering the backdrop area from outside
368
+ const handleParentFocusIn = (event: FocusEvent) => {
369
+ if (!rootRef.current?.contains(event.target as Node)) {
370
+ focusLeftPaneRef.current = true
371
+ store.update(name, { overlayOpen: false })
372
+ }
373
+ }
374
+ const root = rootRef.current
375
+ const parent = root?.parentElement
376
+ root?.addEventListener('focusout', handlePaneFocusOut)
377
+ root?.addEventListener('focusin', handlePaneFocusIn)
378
+ parent?.addEventListener('focusin', handleParentFocusIn)
379
+
380
+ // Close on Escape when focus is inside the pane
381
+ const handleKeyDown = (event: KeyboardEvent) => {
382
+ if (event.key === 'Escape' && rootRef.current?.contains(document.activeElement)) {
383
+ store.update(name, { overlayOpen: false })
384
+ }
385
+ }
386
+ document.addEventListener('keydown', handleKeyDown)
387
+
388
+ // Cleanup: remove handlers and restore focus unless it intentionally left the pane
389
+ return () => {
390
+ root?.removeEventListener('focusout', handlePaneFocusOut)
391
+ root?.removeEventListener('focusin', handlePaneFocusIn)
392
+ parent?.removeEventListener('focusin', handleParentFocusIn)
393
+ document.removeEventListener('keydown', handleKeyDown)
394
+ if (!focusLeftPaneRef.current && previouslyFocusedRef.current instanceof HTMLElement) {
395
+ previouslyFocusedRef.current.focus()
396
+ }
397
+ previouslyFocusedRef.current = null
398
+ }
399
+ }, [store, name, overlayModal, cssProps.layout, pane.overlayOpen])
400
+
401
+ const open = cssProps.layout === 'overlay' ? pane.overlayOpen : !pane.collapsed
402
+ const presenceKeys = ['content', 'placeholder'] as const
403
+ type PresenceKey = (typeof presenceKeys)[number]
404
+ let visibleKey: PresenceKey = open ? 'content' : 'placeholder'
405
+ const beforeFirstTransition = useRef(true)
406
+ const skipTransition = useRef(false)
407
+
408
+ // Always show the content on the first render, and skip the transition if it's hidden on the next render
409
+ const firstRender = useFirstRender()
410
+ if (firstRender && visibleKey === 'placeholder') {
411
+ visibleKey = 'content'
412
+ skipTransition.current = true
413
+ }
414
+
415
+ // Do not transition if the open state changed due to a layout change
416
+ const previousVisibleKey = usePrevious(visibleKey)
417
+ const previousLayout = usePrevious(cssProps.layout)
418
+ if (previousVisibleKey.hasChanged && previousLayout.hasChanged) {
419
+ skipTransition.current = true
420
+ }
421
+
422
+ const setSize = (size: number | null) => {
423
+ if (!rootRef.current) return
424
+ if (size === null) {
425
+ rootRef.current.style.removeProperty('--pane-size')
426
+ } else {
427
+ rootRef.current.style.setProperty('--pane-size', `${size}px`)
428
+ }
429
+ }
430
+
431
+ return (
432
+ <Element
433
+ ref={cssPropsPolyfillRootPassedRef}
434
+ id={pane.id}
435
+ data-gds-exposed-open={open}
436
+ className={cn(
437
+ `gds-pane root-contents u:z-10 u:max-w-full u:[--tw-duration:200ms]
438
+ u:default-side-start
439
+ u:last:default-side-end
440
+ u:@prop-orientation-vertical/pane-container:default-side-top
441
+ u:@prop-orientation-vertical/pane-container:last:default-side-bottom`,
442
+ className,
443
+ )}
444
+ {...getCSSPropsAttributes(PaneMeta, { layout, side }, style)}
445
+ {...cssPropsPolyfillAttributes}
446
+ {...props}
447
+ >
448
+ {overlayModal ? (
449
+ <div
450
+ aria-hidden
451
+ data-open={pane.overlayOpen || undefined}
452
+ onClick={() => store.update(name, { overlayOpen: false })}
453
+ className={`
454
+ absolute inset-0 z-[inherit] bg-backdrop transition [--tw-duration:inherit]
455
+ not-data-open:pointer-events-none
456
+ not-data-open:opacity-0
457
+ @prop-layout-docked/pane:hidden
458
+ `}
459
+ />
460
+ ) : null}
461
+ <Presence
462
+ visibleKey={visibleKey}
463
+ enterMs={skipTransition.current ? 0 : duration}
464
+ exitMs={skipTransition.current ? 0 : duration}
465
+ onBeforeTransitionStart={({ exiting, previouslyTransitioning }) => {
466
+ beforeFirstTransition.current = false
467
+ if (
468
+ skipTransition.current ||
469
+ cssProps.layout === 'overlay' ||
470
+ previouslyTransitioning.size > 0
471
+ ) {
472
+ return
473
+ }
474
+ const exitingContent = exiting.get('content' satisfies PresenceKey)
475
+ if (!exitingContent) return
476
+ setSize(
477
+ cssProps.side === 'start' || cssProps.side === 'end'
478
+ ? exitingContent.offsetWidth
479
+ : exitingContent.offsetHeight,
480
+ )
481
+ }}
482
+ onTransitionStart={({ entering, previouslyTransitioning }) => {
483
+ if (
484
+ skipTransition.current ||
485
+ cssProps.layout === 'overlay' ||
486
+ previouslyTransitioning.size > 0
487
+ ) {
488
+ return
489
+ }
490
+ const enteringContent = entering.get('content' satisfies PresenceKey)
491
+ if (!enteringContent) return
492
+ enteringContent.style.setProperty('width', 'inherit')
493
+ enteringContent.style.setProperty('height', 'inherit')
494
+ enteringContent.style.setProperty('position', 'relative')
495
+ setSize(
496
+ cssProps.side === 'start' || cssProps.side === 'end'
497
+ ? enteringContent.offsetWidth
498
+ : enteringContent.offsetHeight,
499
+ )
500
+ enteringContent.style.removeProperty('width')
501
+ enteringContent.style.removeProperty('height')
502
+ enteringContent.style.removeProperty('position')
503
+ }}
504
+ onTransitionEnd={() => {
505
+ skipTransition.current = false
506
+ }}
507
+ renderChild={(renderProps, state) => {
508
+ const hiddenStyle =
509
+ state.status === 'exiting' ||
510
+ state.status === 'hidden' ||
511
+ (state.status === 'entering' && state.previousStatus !== null)
512
+ switch (state.key as PresenceKey) {
513
+ case 'content': {
514
+ return (
515
+ <div
516
+ {...renderProps}
517
+ tabIndex={overlayModal && cssProps.layout === 'overlay' ? -1 : undefined}
518
+ data-entering-or-exiting={
519
+ state.status === 'entering' || state.status === 'exiting' || undefined
520
+ }
521
+ data-hidden-style={hiddenStyle || undefined}
522
+ data-hidden-if-overlay={
523
+ (beforeFirstTransition.current && !pane.overlayOpen) || undefined
524
+ }
525
+ data-layout={cssProps.layout}
526
+ data-side={cssProps.side}
527
+ className={cn(
528
+ renderProps.className,
529
+ `inherit-position inherit-layout inherit-paint transition-transform [--tw-duration:inherit]
530
+ @prop-side-top/pane:inset-x-0
531
+ @prop-side-top/pane:top-0
532
+ @prop-side-top/pane:data-hidden-style:-translate-y-full
533
+ @prop-side-bottom/pane:inset-x-0
534
+ @prop-side-bottom/pane:bottom-0
535
+ @prop-side-bottom/pane:data-hidden-style:translate-y-full
536
+ @prop-side-start/pane:inset-y-0
537
+ @prop-side-start/pane:start-0
538
+ @prop-side-start/pane:data-hidden-style:translate-x-[calc(-100%*var(--gds-rtl-factor))]
539
+ @prop-side-end/pane:inset-y-0
540
+ @prop-side-end/pane:end-0
541
+ @prop-side-end/pane:data-hidden-style:translate-x-[calc(100%*var(--gds-rtl-factor))]
542
+ @prop-layout-docked/pane:data-entering-or-exiting:absolute
543
+ @prop-layout-docked/pane:data-entering-or-exiting:@prop-side-top/pane:h-(--pane-size)
544
+ @prop-layout-docked/pane:data-entering-or-exiting:@prop-side-bottom/pane:h-(--pane-size)
545
+ @prop-layout-docked/pane:data-entering-or-exiting:@prop-side-start/pane:w-(--pane-size)
546
+ @prop-layout-docked/pane:data-entering-or-exiting:@prop-side-end/pane:w-(--pane-size)
547
+ @prop-layout-overlay/pane:absolute
548
+ @prop-layout-overlay/pane:data-hidden-if-overlay:invisible`,
549
+ /**
550
+ * Same as the above but with the `@prop` variants replaced with `data-*` (which
551
+ * isn't as good because the first render might be incorrect) to work around a
552
+ * Safari issue where style queries are buggy inside `display: contents` (see
553
+ * https://bugs.webkit.org/show_bug.cgi?id=301871).
554
+ */
555
+ `safari:data-[layout=docked]:data-entering-or-exiting:absolute
556
+ safari:data-[layout=overlay]:absolute
557
+ safari:data-[layout=overlay]:data-hidden-if-overlay:invisible
558
+ safari:data-[side=bottom]:inset-x-0
559
+ safari:data-[side=bottom]:bottom-0
560
+ safari:data-[side=bottom]:data-hidden-style:translate-y-full
561
+ safari:data-[layout=docked]:data-entering-or-exiting:data-[side=bottom]:h-(--pane-size)
562
+ safari:data-[side=end]:inset-y-0
563
+ safari:data-[side=end]:end-0
564
+ safari:data-[side=end]:data-hidden-style:translate-x-[calc(100%*var(--gds-rtl-factor))]
565
+ safari:data-[layout=docked]:data-entering-or-exiting:data-[side=end]:w-(--pane-size)
566
+ safari:data-[side=start]:inset-y-0
567
+ safari:data-[side=start]:start-0
568
+ safari:data-[side=start]:data-hidden-style:translate-x-[calc(-100%*var(--gds-rtl-factor))]
569
+ safari:data-[layout=docked]:data-entering-or-exiting:data-[side=start]:w-(--pane-size)
570
+ safari:data-[side=top]:inset-x-0
571
+ safari:data-[side=top]:top-0
572
+ safari:data-[side=top]:data-hidden-style:-translate-y-full
573
+ safari:data-[layout=docked]:data-entering-or-exiting:data-[side=top]:h-(--pane-size)`,
574
+ )}
575
+ >
576
+ {children}
577
+ </div>
578
+ )
579
+ }
580
+ case 'placeholder': {
581
+ return (
582
+ <div
583
+ {...renderProps}
584
+ hidden={undefined} // `hidden` cannot be overridden: https://github.com/tailwindlabs/tailwindcss/issues/18653
585
+ data-hidden={renderProps.hidden || undefined}
586
+ data-layout={cssProps.layout}
587
+ data-side={cssProps.side}
588
+ className={cn(
589
+ renderProps.className,
590
+ `inherit-position shrink-0 transition-[width,height] [--size:0px] [--tw-duration:inherit]
591
+ data-hidden:hidden
592
+ data-[status=entering]:data-starting-style:[--size:var(--pane-size)]
593
+ data-[status=exiting]:[--size:var(--pane-size)]
594
+ data-[status=exiting]:data-starting-style:[--size:0px]
595
+ @prop-side-top/pane:h-(--size)
596
+ @prop-side-bottom/pane:h-(--size)
597
+ @prop-side-start/pane:w-(--size)
598
+ @prop-side-end/pane:w-(--size)`,
599
+ /**
600
+ * Since the content is always absolutely-positioned in overlay mode, we render
601
+ * the placeholder in flow (even when it's supposed to be hidden) for the amount
602
+ * of in-flow elements to remain stable across `layout` or `overlayOpen` changes
603
+ * (e.g. so that sibling elements don't suddenly get auto-placed in a different
604
+ * grid cell if the pane is in a grid).
605
+ */
606
+ `i:@prop-layout-overlay/pane:block
607
+ i:@prop-layout-overlay/pane:size-0`,
608
+ // Workaround for the Safari bug mentioned above
609
+ `safari:data-[side=bottom]:h-(--size)
610
+ safari:data-[side=end]:w-(--size)
611
+ safari:data-[side=start]:w-(--size)
612
+ safari:data-[side=top]:h-(--size)
613
+ i:safari:data-[layout=overlay]:block
614
+ i:safari:data-[layout=overlay]:size-0`,
615
+ )}
616
+ />
617
+ )
618
+ }
619
+ }
620
+ }}
621
+ >
622
+ {presenceKeys.map((key) => (
623
+ <Fragment key={key} />
624
+ ))}
625
+ </Presence>
626
+ </Element>
627
+ )
628
+ }
629
+ PaneRoot.displayName = 'Pane'
630
+
631
+ type PaneToggleButtonState = {
632
+ open: boolean
633
+ }
634
+
635
+ interface PaneToggleButtonBaseProps extends ComponentProps<'button'> {
636
+ /** Name of the pane to toggle. */
637
+ name: string
638
+ }
639
+
640
+ interface PaneToggleButtonWithRenderProps extends PaneToggleButtonBaseProps {
641
+ /** Element or render function for the toggle button. */
642
+ render: RenderProp<PaneToggleButtonState>
643
+ variant?: never
644
+ size?: never
645
+ hideLabel?: never
646
+ tooltip?: never
647
+ addonBefore?: never
648
+ addonAfter?: never
649
+ }
650
+
651
+ interface PaneToggleButtonWithoutRenderProps
652
+ extends
653
+ PaneToggleButtonBaseProps,
654
+ Pick<
655
+ ToggleButtonProps,
656
+ 'variant' | 'size' | 'hideLabel' | 'tooltip' | 'addonBefore' | 'addonAfter'
657
+ > {
658
+ render?: undefined
659
+ }
660
+
661
+ export type PaneToggleButtonProps =
662
+ | PaneToggleButtonWithRenderProps
663
+ | PaneToggleButtonWithoutRenderProps
664
+
665
+ export function PaneToggleButton({
666
+ name,
667
+ render,
668
+ variant,
669
+ size,
670
+ hideLabel,
671
+ tooltip,
672
+ addonBefore,
673
+ addonAfter,
674
+ onClick,
675
+ className,
676
+ children,
677
+ ...props
678
+ }: PaneToggleButtonProps) {
679
+ const { pane, toggle } = usePaneInternal(name)
680
+ const open = pane.layout === 'overlay' ? pane.overlayOpen : !pane.collapsed
681
+
682
+ return (
683
+ <Render
684
+ render={
685
+ render ?? (
686
+ <ToggleButton
687
+ aria-pressed={undefined}
688
+ variant={variant}
689
+ size={size}
690
+ hideLabel={hideLabel}
691
+ addonBefore={addonBefore}
692
+ addonAfter={addonAfter}
693
+ tooltip={tooltip}
694
+ className={cn('u:open:state-checked', className)}
695
+ />
696
+ )
697
+ }
698
+ state={{ open }}
699
+ type="button"
700
+ aria-expanded={open}
701
+ aria-controls={pane.id}
702
+ onClick={(event: MouseEvent<HTMLButtonElement>) => {
703
+ toggle()
704
+ onClick?.(event)
705
+ }}
706
+ className={render ? className : undefined}
707
+ {...props}
708
+ >
709
+ {children}
710
+ </Render>
711
+ )
712
+ }
713
+ PaneToggleButton.displayName = 'Pane.ToggleButton'