@graphprotocol/gds-react 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/GDSContext.d.ts +13 -0
- package/dist/GDSContext.d.ts.map +1 -0
- package/dist/GDSContext.js +4 -0
- package/dist/GDSContext.js.map +1 -0
- package/dist/GDSProvider.d.ts +1 -9
- package/dist/GDSProvider.d.ts.map +1 -1
- package/dist/GDSProvider.js +4 -3
- package/dist/GDSProvider.js.map +1 -1
- package/dist/components/Avatar.d.ts.map +1 -1
- package/dist/components/Avatar.js +2 -2
- package/dist/components/Avatar.js.map +1 -1
- package/dist/components/Breadcrumbs.parts.js +1 -1
- package/dist/components/Breadcrumbs.parts.js.map +1 -1
- package/dist/components/Button.d.ts.map +1 -1
- package/dist/components/Button.js +69 -69
- package/dist/components/Button.js.map +1 -1
- package/dist/components/Card.js +2 -2
- package/dist/components/Card.js.map +1 -1
- package/dist/components/CodeBlock.d.ts +1 -1
- package/dist/components/CodeBlock.parts.d.ts +1 -1
- package/dist/components/CopyButton.d.ts +1 -1
- package/dist/components/CopyButton.d.ts.map +1 -1
- package/dist/components/CopyButton.js +46 -19
- package/dist/components/CopyButton.js.map +1 -1
- package/dist/components/Input.js +2 -2
- package/dist/components/Input.js.map +1 -1
- package/dist/components/Link.js +2 -2
- package/dist/components/Link.js.map +1 -1
- package/dist/components/Menu.parts.d.ts +4 -5
- package/dist/components/Menu.parts.d.ts.map +1 -1
- package/dist/components/Menu.parts.js +52 -45
- package/dist/components/Menu.parts.js.map +1 -1
- package/dist/components/Modal.parts.d.ts.map +1 -1
- package/dist/components/Modal.parts.js +17 -21
- package/dist/components/Modal.parts.js.map +1 -1
- package/dist/components/Pane.d.ts +9 -0
- package/dist/components/Pane.d.ts.map +1 -0
- package/dist/components/Pane.js +8 -0
- package/dist/components/Pane.js.map +1 -0
- package/dist/components/Pane.meta.d.ts +20 -0
- package/dist/components/Pane.meta.d.ts.map +1 -0
- package/dist/components/Pane.meta.js +30 -0
- package/dist/components/Pane.meta.js.map +1 -0
- package/dist/components/Pane.parts.d.ts +77 -0
- package/dist/components/Pane.parts.d.ts.map +1 -0
- package/dist/components/Pane.parts.js +412 -0
- package/dist/components/Pane.parts.js.map +1 -0
- package/dist/components/Search.js +1 -1
- package/dist/components/Tooltip.parts.d.ts +13 -4
- package/dist/components/Tooltip.parts.d.ts.map +1 -1
- package/dist/components/Tooltip.parts.js +51 -63
- package/dist/components/Tooltip.parts.js.map +1 -1
- package/dist/components/base/ButtonOrLink.d.ts +1 -1
- package/dist/components/base/ButtonOrLink.d.ts.map +1 -1
- package/dist/components/base/ButtonOrLink.parts.d.ts +10 -3
- package/dist/components/base/ButtonOrLink.parts.d.ts.map +1 -1
- package/dist/components/base/ButtonOrLink.parts.js +27 -35
- package/dist/components/base/ButtonOrLink.parts.js.map +1 -1
- package/dist/components/base/MaybeButtonOrLink.d.ts +19 -2
- package/dist/components/base/MaybeButtonOrLink.d.ts.map +1 -1
- package/dist/components/base/MaybeButtonOrLink.js +5 -3
- package/dist/components/base/MaybeButtonOrLink.js.map +1 -1
- package/dist/components/base/Presence.d.ts +157 -0
- package/dist/components/base/Presence.d.ts.map +1 -0
- package/dist/components/base/Presence.js +808 -0
- package/dist/components/base/Presence.js.map +1 -0
- package/dist/components/base/index.d.ts +1 -0
- package/dist/components/base/index.d.ts.map +1 -1
- package/dist/components/base/index.js +1 -0
- package/dist/components/base/index.js.map +1 -1
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +2 -0
- package/dist/components/index.js.map +1 -1
- package/dist/hooks/useCSSProp.js +1 -1
- package/dist/hooks/useCSSProp.js.map +1 -1
- package/dist/hooks/useControlled.d.ts.map +1 -1
- package/dist/hooks/useControlled.js +6 -4
- package/dist/hooks/useControlled.js.map +1 -1
- package/dist/hooks/useGDS.js +1 -1
- package/dist/hooks/useGDS.js.map +1 -1
- package/dist/hooks/useStyleObserver.js +1 -1
- package/dist/hooks/useStyleObserver.js.map +1 -1
- package/dist/tailwind-plugin.d.ts.map +1 -1
- package/dist/tailwind-plugin.js +3 -0
- package/dist/tailwind-plugin.js.map +1 -1
- package/dist/utils/InlineCounter.d.ts +3 -0
- package/dist/utils/InlineCounter.d.ts.map +1 -0
- package/dist/utils/InlineCounter.js +7 -0
- package/dist/utils/InlineCounter.js.map +1 -0
- package/dist/utils/RenderCount.d.ts +3 -0
- package/dist/utils/RenderCount.d.ts.map +1 -0
- package/dist/utils/RenderCount.js +7 -0
- package/dist/utils/RenderCount.js.map +1 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +2 -0
- package/dist/utils/index.js.map +1 -1
- package/package.json +14 -14
- package/src/GDSContext.ts +16 -0
- package/src/GDSProvider.tsx +20 -31
- package/src/components/Avatar.tsx +3 -2
- package/src/components/Breadcrumbs.parts.tsx +1 -1
- package/src/components/Button.tsx +113 -107
- package/src/components/Card.tsx +2 -2
- package/src/components/CopyButton.tsx +49 -25
- package/src/components/Input.tsx +1 -1
- package/src/components/Link.tsx +2 -2
- package/src/components/Menu.parts.tsx +78 -73
- package/src/components/Modal.parts.tsx +26 -31
- package/src/components/Pane.meta.ts +31 -0
- package/src/components/Pane.parts.tsx +713 -0
- package/src/components/Pane.tsx +17 -0
- package/src/components/Search.tsx +1 -1
- package/src/components/Tooltip.parts.tsx +95 -80
- package/src/components/base/ButtonOrLink.parts.tsx +71 -51
- package/src/components/base/ButtonOrLink.tsx +1 -0
- package/src/components/base/MaybeButtonOrLink.tsx +26 -5
- package/src/components/base/Presence.tsx +1375 -0
- package/src/components/base/index.ts +1 -0
- package/src/components/index.ts +10 -0
- package/src/hooks/useCSSProp.ts +1 -1
- package/src/hooks/useControlled.ts +16 -8
- package/src/hooks/useGDS.ts +1 -1
- package/src/hooks/useStyleObserver.ts +1 -1
- package/src/tailwind-plugin.ts +3 -0
- package/src/utils/InlineCounter.tsx +17 -0
- package/src/utils/RenderCount.tsx +7 -0
- 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'
|