@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,17 @@
1
+ import { PaneContainer, PaneProvider, PaneRoot, PaneToggleButton } from './Pane.parts.tsx'
2
+
3
+ export const Pane = Object.assign(PaneRoot, {
4
+ Provider: PaneProvider,
5
+ Container: PaneContainer,
6
+ ToggleButton: PaneToggleButton,
7
+ })
8
+
9
+ export { usePane } from './Pane.parts.tsx'
10
+
11
+ export type {
12
+ PaneContainerProps,
13
+ PaneProps,
14
+ PaneProviderProps,
15
+ PaneToggleButtonProps,
16
+ UsePaneOptions,
17
+ } from './Pane.parts.tsx'
@@ -102,7 +102,7 @@ export function Search({
102
102
  data-size={cssProps.size}
103
103
  data-layout={cssProps.layout}
104
104
  className={cn(
105
- `gds-search root-flex flex-col u:max-w-full
105
+ `gds-search root-flex flex-col u:h-max u:max-w-full
106
106
  u:hover:state-hover
107
107
  u:active:state-active
108
108
  u:data-[layout=compact]:data-[size=medium]:min-w-10
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {
4
4
  createContext,
5
+ useCallback,
5
6
  useContext,
6
7
  useEffect,
7
8
  useRef,
@@ -9,8 +10,10 @@ import {
9
10
  type ComponentProps,
10
11
  type ReactElement,
11
12
  type ReactNode,
13
+ type Ref,
12
14
  } from 'react'
13
- import { Tooltip } from '@base-ui/react/tooltip'
15
+ import { Tooltip, type TooltipRoot as BaseUITooltipRoot } from '@base-ui/react/tooltip'
16
+ import { useMergedRefs } from '@base-ui/utils/useMergedRefs'
14
17
  import type { SetReturnType } from 'type-fest'
15
18
 
16
19
  import { twToPx, type GDSComponentProps } from '@graphprotocol/gds-css'
@@ -23,12 +26,11 @@ import { TooltipMeta } from './Tooltip.meta.ts'
23
26
 
24
27
  type Content = Exclude<ReactNode, undefined>
25
28
  type SetDeferredContentFunction = (content: Content, priority?: number) => void
29
+ type OpenChangeReason = BaseUITooltipRoot.ChangeEventDetails['reason']
26
30
 
27
31
  const TooltipContext = createContext<{
28
32
  collectContent: SetDeferredContentFunction
29
- inheritContent: SetDeferredContentFunction
30
33
  disableDescendants: boolean
31
- registerDescendantSetOpen: (descendantSetOpen: (newOpen: boolean) => void) => void
32
34
  } | null>(null)
33
35
 
34
36
  const OPEN_DELAY = 300
@@ -43,9 +45,15 @@ export interface TooltipProps
43
45
  /** Force the tooltip to be open or closed. */
44
46
  open?: boolean | undefined
45
47
  /** Called when the tooltip is opened or closed. */
46
- onOpenChange?: ((open: boolean) => void) | undefined
48
+ onOpenChange?: ((open: boolean, reason: OpenChangeReason) => void) | undefined
47
49
  /** The element that triggers the tooltip. */
48
50
  children: ReactElement
51
+ /**
52
+ * Props to merge with the trigger element. Set to `'forward'` to have the trigger automatically
53
+ * receive applicable props passed to the `Tooltip` itself (excluding tooltip-specific props like
54
+ * `content`, `side` and other positioning props, `open`, `disabled`, etc.).
55
+ */
56
+ triggerProps?: ComponentProps<'button'> | 'forward' | undefined
49
57
  }
50
58
 
51
59
  export function TooltipRoot({
@@ -62,12 +70,19 @@ export function TooltipRoot({
62
70
  className,
63
71
  style,
64
72
  children,
65
- ...props
73
+ triggerProps: passedTriggerProps,
74
+ ...passedProps
66
75
  }: TooltipProps) {
67
76
  const { dirProps } = useGDS()
68
77
 
69
78
  const ancestorTooltip = useContext(TooltipContext)
70
- const triggerRef = useRef<HTMLButtonElement>(null)
79
+
80
+ const popupProps = passedTriggerProps !== 'forward' ? passedProps : {}
81
+ const popupRef = passedTriggerProps !== 'forward' ? passedRef : undefined
82
+
83
+ const triggerProps = passedTriggerProps === 'forward' ? passedProps : passedTriggerProps
84
+ const triggerDisabled =
85
+ triggerProps && 'disabled' in triggerProps ? triggerProps.disabled === true : false
71
86
 
72
87
  const [cssPropsPolyfillRef, cssPropsPolyfillAttributes, cssProps] = useCSSPropsPolyfill(
73
88
  TooltipMeta,
@@ -81,88 +96,76 @@ export function TooltipRoot({
81
96
  )
82
97
 
83
98
  const [setCollectedContent, collectedContent] = useDeferredContent()
84
- const [setInheritedContent, inheritedContent] = useDeferredContent()
85
-
86
- const content =
87
- passedContent !== undefined
88
- ? passedContent
89
- : collectedContent !== undefined
90
- ? collectedContent
91
- : inheritedContent
92
-
93
- const triggerDisabled =
94
- 'props' in children &&
95
- children.props &&
96
- typeof children.props === 'object' &&
97
- 'disabled' in children.props
98
- ? children.props.disabled === true
99
- : false
100
-
101
- // Propagate the passed/collected content up for the first tooltip ancestor that needs them
102
- if (passedContent !== undefined && !cssProps.disabled && !triggerDisabled) {
103
- ancestorTooltip?.inheritContent(passedContent, 3)
104
- }
99
+ const content = passedContent !== undefined ? passedContent : collectedContent
105
100
 
101
+ /**
102
+ * Propagate collected content up till it reaches `Tooltip.Collector`'s closest non-disabled
103
+ * tooltip ancestor with no passed content.
104
+ */
106
105
  const collectContent: SetDeferredContentFunction = (newCollectedContent, priority) => {
107
- const prioritizedCollectedContent = setCollectedContent(newCollectedContent, priority)
108
- if (!triggerDisabled) ancestorTooltip?.inheritContent(prioritizedCollectedContent, 2)
109
- }
110
-
111
- const inheritContent: SetDeferredContentFunction = (newInheritedContent, priority) => {
112
- void setInheritedContent(newInheritedContent, priority)
113
- if (!triggerDisabled) ancestorTooltip?.inheritContent(newInheritedContent, 1)
106
+ if (passedContent === undefined || cssProps.disabled) {
107
+ void setCollectedContent(newCollectedContent, priority)
108
+ ancestorTooltip?.collectContent(newCollectedContent, priority)
109
+ }
114
110
  }
115
111
 
116
112
  const disabledByAncestor = ancestorTooltip?.disableDescendants ?? false
117
113
  const disableDescendants =
118
114
  disabledByAncestor ||
119
- triggerDisabled ||
120
115
  (content !== undefined && !cssProps.disabled && cssProps.overrideDescendants)
121
- const enabled = Boolean(content) && !cssProps.disabled && !triggerDisabled && !disabledByAncestor
116
+ const enabled =
117
+ Boolean(content) &&
118
+ !cssProps.disabled &&
119
+ (!triggerDisabled || controlledOpen) &&
120
+ !disabledByAncestor
122
121
 
123
- const [open, ownSetOpen] = useControlled(controlledOpen, false, onOpenChange)
124
- const descendantSetOpen = useRef<(newOpen: boolean) => void>(null)
125
- const registerDescendantSetOpen = (newDescendantSetOpen: (newOpen: boolean) => void) => {
126
- descendantSetOpen.current = newDescendantSetOpen
127
- }
128
- const justOpenedRef = useRef(false)
129
- const setOpen = (newOpen: boolean) => {
130
- ownSetOpen(newOpen)
122
+ const triggerRef = useRef<HTMLButtonElement>(null)
123
+ const triggerPassedRef = useMergedRefs(
131
124
  /**
132
- * Notify overridden descendant tooltips of the open state change. This ensures, for example,
133
- * that moving the cursor away from a `CopyButton` while its "Copied!" tooltip is shown closes
134
- * the inner tooltip as well (which doesn't happen automatically because tooltips nested under
135
- * an enabled tooltip are disabled to prevent a lot of weirdness).
125
+ * Conditionally setting the ref to force the tooltip to refresh its position when `enabled`
126
+ * changes, preventing it from moving to the viewport's top-left corner in some cases (e.g. when
127
+ * `CopyButton` returns to its default tooltip).
136
128
  */
137
- if (cssProps.overrideDescendants) {
138
- descendantSetOpen.current?.(newOpen)
139
- }
140
- if (newOpen) {
141
- justOpenedRef.current = true
142
- window.setTimeout(() => {
143
- justOpenedRef.current = false
144
- }, 0)
145
- }
146
- }
147
- ancestorTooltip?.registerDescendantSetOpen(setOpen)
129
+ enabled ? triggerRef : undefined,
130
+ (passedTriggerProps === 'forward' ? passedRef : passedTriggerProps?.ref) as
131
+ | Ref<HTMLButtonElement>
132
+ | undefined,
133
+ )
134
+
135
+ const [open, privateSetOpen] = useControlled(controlledOpen, false, onOpenChange)
136
+ const justOpenedRef = useRef(false)
137
+ const setOpen = useCallback(
138
+ (newOpen: boolean, reason: OpenChangeReason) => {
139
+ privateSetOpen(newOpen, reason)
140
+ if (newOpen) {
141
+ justOpenedRef.current = true
142
+ window.setTimeout(() => {
143
+ justOpenedRef.current = false
144
+ }, 0)
145
+ }
146
+ },
147
+ [privateSetOpen],
148
+ )
148
149
 
149
150
  const openTimeoutRef = useRef<number>(undefined)
150
- const clearOpenTimeout = () => {
151
+ const clearOpenTimeout = useCallback(() => {
151
152
  if (!openTimeoutRef.current) return
152
153
  window.clearTimeout(openTimeoutRef.current)
153
154
  openTimeoutRef.current = undefined
154
- }
155
- if (!enabled) {
156
- clearOpenTimeout()
157
- }
155
+ }, [])
156
+
157
+ useEffect(() => {
158
+ if (!enabled) {
159
+ clearOpenTimeout()
160
+ if (open) setOpen(false, 'disabled')
161
+ }
162
+ }, [enabled, open, setOpen, clearOpenTimeout])
158
163
 
159
164
  return (
160
165
  <TooltipContext.Provider
161
166
  value={{
162
167
  collectContent,
163
- inheritContent,
164
168
  disableDescendants,
165
- registerDescendantSetOpen,
166
169
  }}
167
170
  >
168
171
  <Tooltip.Root
@@ -175,7 +178,10 @@ export function TooltipRoot({
175
178
  // Disable Base UI's default behavior of closing the tooltip when pressing the trigger
176
179
  if (!newOpen && eventDetails.reason === 'trigger-press') {
177
180
  // Ensure clicking on the trigger before the tooltip has a chance to open doesn't prevent it from opening
178
- openTimeoutRef.current = window.setTimeout(() => setOpen(true), OPEN_DELAY)
181
+ openTimeoutRef.current = window.setTimeout(
182
+ () => setOpen(true, eventDetails.reason),
183
+ OPEN_DELAY,
184
+ )
179
185
  return
180
186
  }
181
187
  // Make `overrideDescendants={false}` work as expected (see the `NestedSimultaneous` story)
@@ -187,19 +193,27 @@ export function TooltipRoot({
187
193
  ) {
188
194
  return
189
195
  }
190
- setOpen(newOpen)
196
+ setOpen(newOpen, eventDetails.reason)
191
197
  }}
192
198
  >
193
199
  <Tooltip.Trigger
194
- ref={triggerRef}
195
200
  delay={OPEN_DELAY}
196
201
  closeDelay={100}
197
- render={({ onClick: _onClick, ...renderProps }) => (
198
- <Render {...(enabled ? renderProps : {})} render={children} />
202
+ {...(triggerProps as ComponentProps<typeof Tooltip.Trigger>)}
203
+ ref={triggerPassedRef}
204
+ disabled={undefined} // Not passed through for some reason, so we set it in `render` and override it to `undefined` here just in case
205
+ render={(renderProps) => (
206
+ <Render
207
+ render={children}
208
+ {...renderProps}
209
+ disabled={triggerDisabled}
210
+ onClick={triggerProps?.onClick} // Prevent the tooltip re-opening after clicking e.g. a `Menu` trigger
211
+ />
199
212
  )}
200
213
  />
201
214
  <Tooltip.Portal>
202
215
  <Tooltip.Positioner
216
+ key={getReactNodeKey(content)} // Refresh the tooltip's position when the content changes (see `WithDynamicContent` story)
203
217
  side={
204
218
  cssProps.side === 'start'
205
219
  ? 'inline-start'
@@ -211,19 +225,19 @@ export function TooltipRoot({
211
225
  align={cssProps.align}
212
226
  alignOffset={twToPx(cssProps.alignOffset)}
213
227
  collisionPadding={8}
228
+ disableAnchorTracking // Prevent the tooltip from moving to the viewport's top-left corner when the trigger is unmounted
214
229
  {...dirProps}
215
230
  >
216
231
  <Tooltip.Popup
217
- ref={passedRef}
218
- data-side={cssProps.side}
232
+ ref={popupRef}
219
233
  className={cn(
220
234
  `gds-tooltip root-block u:max-w-(--available-width) u:rounded-4 u:bg-default u:px-2 u:py-1 u:text-12 u:transition
221
235
  i:origin-(--transform-origin)
222
236
  i:data-ending-style:opacity-0
223
237
  i:data-starting-style:opacity-0
224
238
  i:data-starting-style:data-[side=bottom]:-translate-y-1
225
- i:data-starting-style:data-[side=end]:-translate-x-1
226
- i:data-starting-style:data-[side=start]:translate-x-1
239
+ i:data-starting-style:data-[side=inline-end]:-translate-x-1
240
+ i:data-starting-style:data-[side=inline-start]:translate-x-1
227
241
  i:data-starting-style:data-[side=top]:translate-y-1
228
242
  i:rtl:data-starting-style:data-[side=end]:translate-x-1
229
243
  i:rtl:data-starting-style:data-[side=start]:-translate-x-1`,
@@ -231,7 +245,7 @@ export function TooltipRoot({
231
245
  )}
232
246
  {...cssPropsAttributes}
233
247
  {...cssPropsPolyfillAttributes}
234
- {...props}
248
+ {...popupProps}
235
249
  >
236
250
  {content}
237
251
  </Tooltip.Popup>
@@ -244,7 +258,7 @@ export function TooltipRoot({
244
258
  className={cn('gds-tooltip i:invisible', className)}
245
259
  {...cssPropsAttributes}
246
260
  {...cssPropsPolyfillAttributes}
247
- {...props}
261
+ {...popupProps}
248
262
  />
249
263
  </Portal>
250
264
  </Tooltip.Root>
@@ -288,8 +302,9 @@ function useDeferredContent() {
288
302
  }
289
303
 
290
304
  export interface TooltipContentProps {
291
- render?: RenderProp | undefined
292
305
  children: ReactNode
306
+ priority?: number | undefined
307
+ render?: RenderProp | undefined
293
308
  }
294
309
 
295
310
  /**
@@ -301,10 +316,10 @@ export interface TooltipContentProps {
301
316
  * where the information it provides is not otherwise available to screen readers (i.e. different
302
317
  * from the trigger's accessible name).
303
318
  */
304
- export function TooltipContent({ render, children }: TooltipContentProps) {
319
+ export function TooltipContent({ children, priority, render }: TooltipContentProps) {
305
320
  const ancestorTooltip = useContext(TooltipContext)
306
321
 
307
- ancestorTooltip?.collectContent(children || null)
322
+ ancestorTooltip?.collectContent(children || null, priority)
308
323
 
309
324
  return render ? <Render render={render}>{children}</Render> : children
310
325
  }
@@ -20,7 +20,7 @@ import { useButton } from 'react-aria'
20
20
  import type { Merge } from 'type-fest'
21
21
 
22
22
  import { cn } from '../../utils/index.ts'
23
- import { Render, type RenderFn } from './Render.tsx'
23
+ import { Render, type RenderFn, type RenderFnProps } from './Render.tsx'
24
24
 
25
25
  interface LinkComponentObject {
26
26
  component: ElementType
@@ -37,8 +37,7 @@ type LinkComponent =
37
37
  | null
38
38
 
39
39
  export type ButtonOrLinkState = {
40
- Element: ElementType
41
- category: 'button' | 'link' | 'other'
40
+ category: 'button' | 'link'
42
41
  role: NonNullable<InternalButtonOrLinkProps['role']>
43
42
  disabled: NonNullable<InternalButtonOrLinkProps['disabled']>
44
43
  type?: InternalButtonOrLinkProps['type']
@@ -47,6 +46,16 @@ export type ButtonOrLinkState = {
47
46
  target?: InternalButtonOrLinkProps['target']
48
47
  }
49
48
 
49
+ export type ButtonOrLinkRenderState = ButtonOrLinkState & {
50
+ Element: ElementType<ButtonOrLinkRenderElementProps>
51
+ elementProps: ButtonOrLinkRenderElementProps
52
+ }
53
+
54
+ interface ButtonOrLinkRenderElementProps {
55
+ buttonOrLinkState: ButtonOrLinkState
56
+ [key: string]: unknown
57
+ }
58
+
50
59
  export declare namespace InternalButtonOrLinkProps {
51
60
  interface DisableableProps {
52
61
  disabled?: boolean | 'focusable' | undefined
@@ -62,7 +71,7 @@ export declare namespace InternalButtonOrLinkProps {
62
71
  */
63
72
  inline?: boolean | undefined
64
73
  /** Custom render function to control the rendered element. */
65
- render?: RenderFn<ButtonOrLinkState> | undefined
74
+ render?: RenderFn<ButtonOrLinkRenderState> | undefined
66
75
  /** Custom component to use for rendering links (e.g. Next.js Link) */
67
76
  linkComponent?: LinkComponent | undefined
68
77
  }
@@ -116,12 +125,14 @@ export function useAncestorButtonOrLink() {
116
125
  return useContext(ButtonOrLinkContext)
117
126
  }
118
127
 
119
- interface WithButtonOrLinkContextProps {
120
- buttonOrLinkState: ButtonOrLinkState
121
- }
128
+ /**
129
+ * Creating "with context" components as opposed to wrapping `ButtonOrLink`'s returned element in a
130
+ * context provider to avoid a "Trying to render a nested `ButtonOrLink`" error when rendering
131
+ * `ButtonOrLink` siblings in the `render` function (like `Card` does with `interactiveContent`).
132
+ */
122
133
 
123
134
  type WithContextComponent<T extends ElementType> = (
124
- props: Merge<ComponentProps<T>, WithButtonOrLinkContextProps>,
135
+ props: Merge<ComponentProps<T>, ButtonOrLinkRenderElementProps>,
125
136
  ) => ReactElement
126
137
 
127
138
  const memoizedWithContextComponents = new Map<ElementType, WithContextComponent<ElementType>>()
@@ -188,7 +199,7 @@ export const ButtonOrLinkRoot = (passedProps: InternalButtonOrLinkProps) => {
188
199
  disabled = false,
189
200
  inline = false,
190
201
  linkComponent: passedLinkComponent,
191
- render,
202
+ render: passedRender,
192
203
  className: passedClassName,
193
204
  children,
194
205
  ...nonBaseProps
@@ -205,6 +216,25 @@ export const ButtonOrLinkRoot = (passedProps: InternalButtonOrLinkProps) => {
205
216
  onKeyUp: undefined,
206
217
  }
207
218
 
219
+ const render = (
220
+ Element: ElementType<ButtonOrLinkRenderElementProps>,
221
+ renderProps: RenderFnProps,
222
+ buttonOrLinkState: ButtonOrLinkState,
223
+ extraElementProps?: Record<string, unknown>,
224
+ ) => {
225
+ const elementProps: ButtonOrLinkRenderElementProps = { buttonOrLinkState, ...extraElementProps }
226
+ const renderState: ButtonOrLinkRenderState = {
227
+ ...buttonOrLinkState,
228
+ Element,
229
+ elementProps,
230
+ }
231
+ return passedRender ? (
232
+ passedRender(renderProps, renderState)
233
+ ) : (
234
+ <Element {...renderProps} {...elementProps} />
235
+ )
236
+ }
237
+
208
238
  const className = cn('gds-button-or-link', passedClassName)
209
239
 
210
240
  if (props.href === undefined) {
@@ -225,17 +255,6 @@ export const ButtonOrLinkRoot = (passedProps: InternalButtonOrLinkProps) => {
225
255
  )
226
256
  }
227
257
 
228
- const Element = inline ? SpanButtonWithContext : ButtonWithContext
229
-
230
- const state: ButtonOrLinkState = {
231
- Element,
232
- category: 'button',
233
- role,
234
- disabled,
235
- type,
236
- checked,
237
- }
238
-
239
258
  const checkedAttribute =
240
259
  checked === undefined
241
260
  ? {}
@@ -254,7 +273,7 @@ export const ButtonOrLinkRoot = (passedProps: InternalButtonOrLinkProps) => {
254
273
  return { 'aria-checked': checkedOrMixed }
255
274
  })(checked)
256
275
 
257
- const buttonProps: ComponentProps<typeof Element> = {
276
+ const buttonProps: ComponentProps<'button'> = {
258
277
  role,
259
278
  type: disabled === 'focusable' ? 'button' : type,
260
279
  disabled: disabled === true,
@@ -262,12 +281,23 @@ export const ButtonOrLinkRoot = (passedProps: InternalButtonOrLinkProps) => {
262
281
  ...checkedAttribute,
263
282
  className,
264
283
  children,
265
- buttonOrLinkState: state,
266
284
  ...(remainingProps as ComponentProps<'button'>),
267
285
  ...((disabled === 'focusable' || (disabled && inline)) && manuallyDisabledEventProps),
268
286
  }
269
287
 
270
- return render ? render(buttonProps, state) : <Element {...buttonProps} />
288
+ const buttonOrLinkState: ButtonOrLinkState = {
289
+ category: 'button',
290
+ role,
291
+ disabled,
292
+ type,
293
+ checked,
294
+ }
295
+
296
+ return render(
297
+ inline ? SpanButtonWithContext : ButtonWithContext,
298
+ buttonProps,
299
+ buttonOrLinkState,
300
+ )
271
301
  }
272
302
 
273
303
  const {
@@ -281,15 +311,6 @@ export const ButtonOrLinkRoot = (passedProps: InternalButtonOrLinkProps) => {
281
311
  ...remainingProps
282
312
  } = nonBaseProps
283
313
 
284
- const state: ButtonOrLinkState = {
285
- Element: 'a',
286
- category: 'link',
287
- role,
288
- disabled,
289
- href,
290
- target,
291
- }
292
-
293
314
  let linkProps: ComponentProps<'a'> = {
294
315
  role: disabled || role !== 'link' ? role : undefined,
295
316
  href: !disabled ? href : undefined,
@@ -317,29 +338,28 @@ export const ButtonOrLinkRoot = (passedProps: InternalButtonOrLinkProps) => {
317
338
  }
318
339
  }
319
340
 
320
- if (isValidElement(linkComponent)) {
321
- const Element = RenderWithContext
322
- state.Element = Element
323
-
324
- const elementProps = {
325
- ...linkProps,
326
- render: linkComponent,
327
- buttonOrLinkState: state,
328
- }
329
-
330
- return render ? render(elementProps, state) : <Element {...elementProps} />
341
+ const buttonOrLinkState: ButtonOrLinkState = {
342
+ category: 'link',
343
+ role,
344
+ disabled,
345
+ href,
346
+ target,
331
347
  }
332
348
 
333
- const Element =
334
- linkComponent && linkComponent !== 'a' ? withContext(linkComponent) : AnchorWithContext
335
- state.Element = Element
336
-
337
- const elementProps = {
338
- ...linkProps,
339
- buttonOrLinkState: state,
349
+ if (isValidElement(linkComponent)) {
350
+ return render(
351
+ RenderWithContext as ButtonOrLinkRenderState['Element'],
352
+ linkProps,
353
+ buttonOrLinkState,
354
+ { render: linkComponent },
355
+ )
340
356
  }
341
357
 
342
- return render ? render(elementProps, state) : <Element {...elementProps} />
358
+ return render(
359
+ linkComponent && linkComponent !== 'a' ? withContext(linkComponent) : AnchorWithContext,
360
+ linkProps,
361
+ buttonOrLinkState,
362
+ )
343
363
  }
344
364
  ButtonOrLinkRoot.displayName = 'ButtonOrLink'
345
365
 
@@ -14,6 +14,7 @@ export { useAncestorButtonOrLink } from './ButtonOrLink.parts.tsx'
14
14
  export type {
15
15
  ButtonOrLinkProps,
16
16
  ButtonOrLinkState,
17
+ ButtonOrLinkRenderState,
17
18
  ButtonOrLinkConfigProps,
18
19
  InternalButtonOrLinkProps,
19
20
  OmitInternalButtonOrLinkProps,
@@ -4,30 +4,49 @@ import type { ComponentProps, ElementType } from 'react'
4
4
 
5
5
  import {
6
6
  ButtonOrLink,
7
- type ButtonOrLinkState,
7
+ type ButtonOrLinkRenderState,
8
8
  type InternalButtonOrLinkProps,
9
9
  type OmitInternalButtonOrLinkProps,
10
10
  } from './ButtonOrLink.tsx'
11
+ import type { RenderFn } from './Render.tsx'
12
+
13
+ type OtherElementRenderState = {
14
+ category: 'other'
15
+ role: ButtonOrLinkRenderState['role']
16
+ disabled: false
17
+ type?: undefined
18
+ checked?: undefined
19
+ href?: undefined
20
+ target?: undefined
21
+ Element: ButtonOrLinkRenderState['Element']
22
+ elementProps: ButtonOrLinkRenderState['elementProps']
23
+ }
24
+
25
+ type MaybeButtonOrLinkRenderState = ButtonOrLinkRenderState | OtherElementRenderState
11
26
 
12
27
  export declare namespace MaybeButtonOrLinkProps {
13
28
  export type DisableableProps = InternalButtonOrLinkProps.DisableableProps
14
29
  export type ButtonProps = OmitInternalButtonOrLinkProps<InternalButtonOrLinkProps.ButtonProps> & {
15
30
  onClick: NonNullable<InternalButtonOrLinkProps.ButtonProps['onClick']>
16
31
  as?: undefined
32
+ render?: RenderFn<MaybeButtonOrLinkRenderState> | undefined
17
33
  }
18
34
  export type ToggleButtonProps =
19
35
  OmitInternalButtonOrLinkProps<InternalButtonOrLinkProps.ToggleButtonProps> & {
20
36
  onClick: NonNullable<InternalButtonOrLinkProps.ToggleButtonProps['onClick']>
21
37
  as?: undefined
38
+ render?: RenderFn<MaybeButtonOrLinkRenderState> | undefined
22
39
  }
23
40
  export type LinkProps = OmitInternalButtonOrLinkProps<InternalButtonOrLinkProps.LinkProps> & {
24
41
  as?: undefined
42
+ render?: RenderFn<MaybeButtonOrLinkRenderState> | undefined
25
43
  }
26
44
  export type OtherElementProps =
27
45
  OmitInternalButtonOrLinkProps<InternalButtonOrLinkProps.BaseProps> &
28
46
  ComponentProps<'span'> & {
29
47
  onClick?: InternalButtonOrLinkProps.ButtonProps['onClick'] // Not technically correct, but prevents the event from being `any` when `as` is not specified
30
48
  as?: ElementType | undefined
49
+ render?: RenderFn<MaybeButtonOrLinkRenderState> | undefined
31
50
  disabled?: undefined
32
51
  type?: undefined
33
52
  checked?: undefined
@@ -44,7 +63,7 @@ export type MaybeButtonOrLinkProps =
44
63
 
45
64
  type InternalMaybeButtonOrLinkProps = MaybeButtonOrLinkProps &
46
65
  InternalButtonOrLinkProps.DisableableProps &
47
- InternalButtonOrLinkProps.BaseProps
66
+ Omit<InternalButtonOrLinkProps.BaseProps, 'render'>
48
67
 
49
68
  /**
50
69
  * Renders a `ButtonOrLink` if one of `href` or `onClick` is passed along with no `as`. Otherwise,
@@ -67,13 +86,15 @@ export function MaybeButtonOrLink(props: InternalMaybeButtonOrLinkProps) {
67
86
  ...otherElementProps
68
87
  } = props
69
88
 
70
- const state: ButtonOrLinkState = {
71
- Element,
89
+ const renderState: OtherElementRenderState = {
72
90
  category: 'other',
73
91
  role: props.role ?? 'none',
74
92
  disabled: false,
93
+ // The types lie to ensure there's a type error if consumers forget to spread `elementProps` on `Element`
94
+ Element: Element as OtherElementRenderState['Element'],
95
+ elementProps: {} as OtherElementRenderState['elementProps'],
75
96
  }
76
97
 
77
- return render ? render(otherElementProps, state) : <Element {...otherElementProps} />
98
+ return render ? render(otherElementProps, renderState) : <Element {...otherElementProps} />
78
99
  }
79
100
  }