@edgeandnode/eds-react 1.0.1 → 1.1.0

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 (219) hide show
  1. package/README.md +50 -8
  2. package/dist/EDSContext.d.ts +4 -1
  3. package/dist/EDSContext.d.ts.map +1 -1
  4. package/dist/EDSContext.js +7 -0
  5. package/dist/EDSContext.js.map +1 -1
  6. package/dist/EDSProvider.d.ts +13 -6
  7. package/dist/EDSProvider.d.ts.map +1 -1
  8. package/dist/EDSProvider.js +60 -68
  9. package/dist/EDSProvider.js.map +1 -1
  10. package/dist/components/Address.d.ts +3 -3
  11. package/dist/components/Address.d.ts.map +1 -1
  12. package/dist/components/Address.js.map +1 -1
  13. package/dist/components/Avatar.d.ts +3 -3
  14. package/dist/components/Avatar.d.ts.map +1 -1
  15. package/dist/components/Avatar.js +3 -2
  16. package/dist/components/Avatar.js.map +1 -1
  17. package/dist/components/Breadcrumbs.parts.d.ts +7 -6
  18. package/dist/components/Breadcrumbs.parts.d.ts.map +1 -1
  19. package/dist/components/Breadcrumbs.parts.js +15 -14
  20. package/dist/components/Breadcrumbs.parts.js.map +1 -1
  21. package/dist/components/Button.d.ts.map +1 -1
  22. package/dist/components/Button.js +3 -2
  23. package/dist/components/Button.js.map +1 -1
  24. package/dist/components/Card.d.ts +8 -4
  25. package/dist/components/Card.d.ts.map +1 -1
  26. package/dist/components/Card.js +2 -1
  27. package/dist/components/Card.js.map +1 -1
  28. package/dist/components/Cluster.d.ts +3 -3
  29. package/dist/components/Cluster.d.ts.map +1 -1
  30. package/dist/components/Cluster.js.map +1 -1
  31. package/dist/components/CodeBlock.parts.d.ts.map +1 -1
  32. package/dist/components/CodeBlock.parts.js +16 -20
  33. package/dist/components/CodeBlock.parts.js.map +1 -1
  34. package/dist/components/CopyButton.d.ts.map +1 -1
  35. package/dist/components/CopyButton.js +3 -2
  36. package/dist/components/CopyButton.js.map +1 -1
  37. package/dist/components/Icon.d.ts.map +1 -1
  38. package/dist/components/Icon.js +3 -3
  39. package/dist/components/Icon.js.map +1 -1
  40. package/dist/components/Keyboard.js +1 -1
  41. package/dist/components/LocalTheme.d.ts +29 -0
  42. package/dist/components/LocalTheme.d.ts.map +1 -0
  43. package/dist/components/LocalTheme.js +28 -0
  44. package/dist/components/LocalTheme.js.map +1 -0
  45. package/dist/components/Menu.parts.d.ts +43 -9
  46. package/dist/components/Menu.parts.d.ts.map +1 -1
  47. package/dist/components/Menu.parts.js +121 -115
  48. package/dist/components/Menu.parts.js.map +1 -1
  49. package/dist/components/Modal.parts.d.ts +1 -1
  50. package/dist/components/Modal.parts.d.ts.map +1 -1
  51. package/dist/components/Modal.parts.js +22 -11
  52. package/dist/components/Modal.parts.js.map +1 -1
  53. package/dist/components/Pane.parts.d.ts +23 -22
  54. package/dist/components/Pane.parts.d.ts.map +1 -1
  55. package/dist/components/Pane.parts.js +3 -3
  56. package/dist/components/Pane.parts.js.map +1 -1
  57. package/dist/components/Prose.d.ts +1 -1
  58. package/dist/components/Prose.d.ts.map +1 -1
  59. package/dist/components/Prose.js +3 -3
  60. package/dist/components/Prose.js.map +1 -1
  61. package/dist/components/Prose.meta.d.ts +5 -0
  62. package/dist/components/Prose.meta.d.ts.map +1 -1
  63. package/dist/components/Prose.meta.js +10 -0
  64. package/dist/components/Prose.meta.js.map +1 -1
  65. package/dist/components/SegmentedControl.parts.d.ts.map +1 -1
  66. package/dist/components/SegmentedControl.parts.js +3 -2
  67. package/dist/components/SegmentedControl.parts.js.map +1 -1
  68. package/dist/components/Select.parts.d.ts +12 -7
  69. package/dist/components/Select.parts.d.ts.map +1 -1
  70. package/dist/components/Select.parts.js +28 -18
  71. package/dist/components/Select.parts.js.map +1 -1
  72. package/dist/components/TabSet.parts.d.ts.map +1 -1
  73. package/dist/components/TabSet.parts.js +3 -2
  74. package/dist/components/TabSet.parts.js.map +1 -1
  75. package/dist/components/Table.parts.d.ts.map +1 -1
  76. package/dist/components/Table.parts.js +3 -4
  77. package/dist/components/Table.parts.js.map +1 -1
  78. package/dist/components/ThemeSwitcher.d.ts +5 -5
  79. package/dist/components/ThemeSwitcher.d.ts.map +1 -1
  80. package/dist/components/ThemeSwitcher.js +6 -5
  81. package/dist/components/ThemeSwitcher.js.map +1 -1
  82. package/dist/components/Tooltip.parts.js +2 -2
  83. package/dist/components/Tooltip.parts.js.map +1 -1
  84. package/dist/components/base/Addon.d.ts.map +1 -1
  85. package/dist/components/base/Addon.js +2 -4
  86. package/dist/components/base/Addon.js.map +1 -1
  87. package/dist/components/base/ButtonOrLink.parts.d.ts.map +1 -1
  88. package/dist/components/base/ButtonOrLink.parts.js +2 -1
  89. package/dist/components/base/ButtonOrLink.parts.js.map +1 -1
  90. package/dist/components/base/MaybeButtonOrLink.d.ts +7 -5
  91. package/dist/components/base/MaybeButtonOrLink.d.ts.map +1 -1
  92. package/dist/components/base/MaybeButtonOrLink.js +7 -2
  93. package/dist/components/base/MaybeButtonOrLink.js.map +1 -1
  94. package/dist/components/base/Render.d.ts +25 -6
  95. package/dist/components/base/Render.d.ts.map +1 -1
  96. package/dist/components/base/Render.js +4 -3
  97. package/dist/components/base/Render.js.map +1 -1
  98. package/dist/components/base/index.d.ts +1 -1
  99. package/dist/components/base/index.d.ts.map +1 -1
  100. package/dist/components/base/index.js.map +1 -1
  101. package/dist/components/index.d.ts +1 -0
  102. package/dist/components/index.d.ts.map +1 -1
  103. package/dist/components/index.js +1 -0
  104. package/dist/components/index.js.map +1 -1
  105. package/dist/hooks/useCSSProp.js +1 -1
  106. package/dist/hooks/useCSSProp.js.map +1 -1
  107. package/dist/hooks/useEDS.d.ts +24 -1
  108. package/dist/hooks/useEDS.d.ts.map +1 -1
  109. package/dist/hooks/useEDS.js +26 -2
  110. package/dist/hooks/useEDS.js.map +1 -1
  111. package/dist/hooks/useStyleObserver.d.ts.map +1 -1
  112. package/dist/hooks/useStyleObserver.js +2 -1
  113. package/dist/hooks/useStyleObserver.js.map +1 -1
  114. package/dist/icons/CalendarDynamicIcon.d.ts.map +1 -1
  115. package/dist/icons/CalendarDynamicIcon.js +3 -1
  116. package/dist/icons/CalendarDynamicIcon.js.map +1 -1
  117. package/dist/index.d.ts +2 -1
  118. package/dist/index.d.ts.map +1 -1
  119. package/dist/index.js +1 -0
  120. package/dist/index.js.map +1 -1
  121. package/dist/theme-persistence.d.ts +18 -0
  122. package/dist/theme-persistence.d.ts.map +1 -0
  123. package/dist/theme-persistence.js +25 -0
  124. package/dist/theme-persistence.js.map +1 -0
  125. package/dist/utils/getReactNodeKey.d.ts.map +1 -1
  126. package/dist/utils/getReactNodeKey.js +31 -13
  127. package/dist/utils/getReactNodeKey.js.map +1 -1
  128. package/dist/utils/index.d.ts +1 -1
  129. package/dist/utils/index.d.ts.map +1 -1
  130. package/dist/utils/index.js +1 -1
  131. package/dist/utils/index.js.map +1 -1
  132. package/dist/utils/internal/getTooltipProps.d.ts +9 -0
  133. package/dist/utils/internal/getTooltipProps.d.ts.map +1 -0
  134. package/dist/utils/internal/getTooltipProps.js +18 -0
  135. package/dist/utils/internal/getTooltipProps.js.map +1 -0
  136. package/dist/utils/internal/index.d.ts +4 -0
  137. package/dist/utils/internal/index.d.ts.map +1 -0
  138. package/dist/utils/internal/index.js +4 -0
  139. package/dist/utils/internal/index.js.map +1 -0
  140. package/dist/utils/internal/resolveLazy.d.ts +29 -0
  141. package/dist/utils/internal/resolveLazy.d.ts.map +1 -0
  142. package/dist/utils/internal/resolveLazy.js +40 -0
  143. package/dist/utils/internal/resolveLazy.js.map +1 -0
  144. package/dist/utils/internal/styleScheduler.d.ts.map +1 -0
  145. package/dist/utils/internal/styleScheduler.js.map +1 -0
  146. package/dist/utils/isComponentType.d.ts +9 -0
  147. package/dist/utils/isComponentType.d.ts.map +1 -0
  148. package/dist/utils/isComponentType.js +22 -0
  149. package/dist/utils/isComponentType.js.map +1 -0
  150. package/dist/utils/isReactNode.d.ts +7 -0
  151. package/dist/utils/isReactNode.d.ts.map +1 -1
  152. package/dist/utils/isReactNode.js +11 -0
  153. package/dist/utils/isReactNode.js.map +1 -1
  154. package/dist/utils/reactNodeToString.d.ts +3 -0
  155. package/dist/utils/reactNodeToString.d.ts.map +1 -1
  156. package/dist/utils/reactNodeToString.js +6 -0
  157. package/dist/utils/reactNodeToString.js.map +1 -1
  158. package/dist/utils/styleObserver.js +1 -1
  159. package/dist/utils/trimReactNode.d.ts +3 -0
  160. package/dist/utils/trimReactNode.d.ts.map +1 -1
  161. package/dist/utils/trimReactNode.js +6 -0
  162. package/dist/utils/trimReactNode.js.map +1 -1
  163. package/package.json +24 -23
  164. package/scripts/update-skill.cjs +18 -5
  165. package/skill/SKILL.md +95 -57
  166. package/skill/references/tokens.md +2 -8
  167. package/src/EDSContext.ts +10 -1
  168. package/src/EDSProvider.tsx +111 -80
  169. package/src/components/Address.tsx +4 -3
  170. package/src/components/Avatar.tsx +10 -5
  171. package/src/components/Breadcrumbs.parts.tsx +35 -18
  172. package/src/components/Button.tsx +7 -2
  173. package/src/components/Card.tsx +14 -8
  174. package/src/components/Cluster.tsx +5 -3
  175. package/src/components/CodeBlock.parts.tsx +20 -24
  176. package/src/components/CopyButton.tsx +3 -2
  177. package/src/components/Icon.tsx +3 -9
  178. package/src/components/Keyboard.tsx +1 -1
  179. package/src/components/LocalTheme.tsx +47 -0
  180. package/src/components/Menu.parts.tsx +368 -245
  181. package/src/components/Modal.parts.tsx +28 -11
  182. package/src/components/Pane.parts.tsx +31 -26
  183. package/src/components/Prose.meta.ts +10 -0
  184. package/src/components/Prose.tsx +4 -3
  185. package/src/components/SegmentedControl.parts.tsx +3 -8
  186. package/src/components/Select.parts.tsx +43 -34
  187. package/src/components/TabSet.parts.tsx +3 -5
  188. package/src/components/Table.parts.tsx +3 -3
  189. package/src/components/ThemeSwitcher.tsx +13 -15
  190. package/src/components/Tooltip.parts.tsx +2 -2
  191. package/src/components/base/Addon.tsx +3 -4
  192. package/src/components/base/ButtonOrLink.parts.tsx +2 -1
  193. package/src/components/base/MaybeButtonOrLink.tsx +11 -6
  194. package/src/components/base/Render.tsx +28 -4
  195. package/src/components/base/index.ts +2 -0
  196. package/src/components/index.ts +8 -0
  197. package/src/hooks/useCSSProp.ts +1 -1
  198. package/src/hooks/useEDS.ts +26 -2
  199. package/src/hooks/useStyleObserver.ts +2 -1
  200. package/src/icons/CalendarDynamicIcon.tsx +1 -0
  201. package/src/index.ts +2 -1
  202. package/src/theme-persistence.ts +31 -0
  203. package/src/utils/getReactNodeKey.ts +30 -14
  204. package/src/utils/index.ts +1 -1
  205. package/src/utils/internal/getTooltipProps.ts +25 -0
  206. package/src/utils/internal/index.ts +3 -0
  207. package/src/utils/internal/resolveLazy.ts +44 -0
  208. package/src/utils/isComponentType.ts +27 -0
  209. package/src/utils/isReactNode.ts +11 -0
  210. package/src/utils/reactNodeToString.ts +7 -0
  211. package/src/utils/styleObserver.ts +1 -1
  212. package/src/utils/trimReactNode.tsx +7 -0
  213. package/dist/utils/styleScheduler.d.ts.map +0 -1
  214. package/dist/utils/styleScheduler.js.map +0 -1
  215. package/src/__snapshots__/compiled.css +0 -22499
  216. package/styles/tailwind.storybook.css +0 -19
  217. /package/dist/utils/{styleScheduler.d.ts → internal/styleScheduler.d.ts} +0 -0
  218. /package/dist/utils/{styleScheduler.js → internal/styleScheduler.js} +0 -0
  219. /package/src/utils/{styleScheduler.ts → internal/styleScheduler.ts} +0 -0
@@ -17,6 +17,7 @@ import { Dialog } from '@base-ui/react/dialog'
17
17
  import type { EDSComponentProps } from '@edgeandnode/eds-css'
18
18
  import { ArrowLeftIcon, XIcon } from '@edgeandnode/eds-react/icons'
19
19
 
20
+ import { LocalThemeContext } from '../EDSContext.ts'
20
21
  import { useControlled, useCSSPropsPolyfill, useEDS } from '../hooks/index.ts'
21
22
  import { cn, getCSSPropsAttributes } from '../utils/index.ts'
22
23
  import { Portal } from './base/Portal.tsx'
@@ -113,10 +114,13 @@ export const ModalRoot = <
113
114
  defaultOpen,
114
115
  onOpenChange,
115
116
  dismissible: passedDismissible,
116
- role,
117
117
  steps: passedSteps = 1 as Steps,
118
118
  currentStep: controlledCurrentStep,
119
119
  onCurrentStepChange,
120
+ role,
121
+ 'aria-label': ariaLabel,
122
+ 'aria-labelledby': ariaLabelledBy,
123
+ 'aria-describedby': ariaDescribedBy,
120
124
  children,
121
125
  ...props
122
126
  }: ModalProps<Steps, Step>) => {
@@ -275,7 +279,13 @@ export const ModalRoot = <
275
279
  hidden={undefined}
276
280
  />
277
281
  </Portal>
278
- <Dialog.Portal>
282
+ <Dialog.Portal
283
+ /**
284
+ * Modals cover the whole page, so they're rendered in `<body>`, even when opened from
285
+ * inside another portal (e.g. a `Menu`), where Base UI would put them otherwise.
286
+ */
287
+ container={typeof document !== 'undefined' ? document.body : undefined}
288
+ >
279
289
  <div
280
290
  ref={passedRef}
281
291
  /**
@@ -318,6 +328,10 @@ export const ModalRoot = <
318
328
  ? 'dialog'
319
329
  : 'alertdialog')
320
330
  }
331
+ aria-label={ariaLabel}
332
+ // Only when passed, so Base UI's title and description wiring still applies
333
+ {...(ariaLabelledBy !== undefined && { 'aria-labelledby': ariaLabelledBy })}
334
+ {...(ariaDescribedBy !== undefined && { 'aria-describedby': ariaDescribedBy })}
321
335
  className="
322
336
  fixed inset-0 m-auto h-max
323
337
  max-h-[min(max(100cqb,100vh-100cqb*9999),var(--strict-max-height))]
@@ -355,15 +369,18 @@ export const ModalRoot = <
355
369
  has-nested/modal-slot:p-0
356
370
  "
357
371
  >
358
- {typeof children === 'function'
359
- ? children({
360
- open,
361
- setOpen,
362
- closeModal: () => setOpen(false),
363
- currentStep,
364
- setCurrentStep,
365
- })
366
- : children}
372
+ {/* Modals cover the whole page, so they use its theme rather than the local one */}
373
+ <LocalThemeContext.Provider value={undefined}>
374
+ {typeof children === 'function'
375
+ ? children({
376
+ open,
377
+ setOpen,
378
+ closeModal: () => setOpen(false),
379
+ currentStep,
380
+ setCurrentStep,
381
+ })
382
+ : children}
383
+ </LocalThemeContext.Provider>
367
384
  </div>
368
385
  </Dialog.Popup>
369
386
  </div>
@@ -11,7 +11,6 @@ import {
11
11
  useState,
12
12
  useSyncExternalStore,
13
13
  type ComponentProps,
14
- type ElementType,
15
14
  type MouseEvent,
16
15
  type ReactNode,
17
16
  type RefObject,
@@ -25,7 +24,12 @@ import type { EDSComponentProps } from '@edgeandnode/eds-css'
25
24
  import { useCSSPropsPolyfill, useFirstRender, usePrevious, useStyle } from '../hooks/index.ts'
26
25
  import { cn, getCSSPropsAttributes } from '../utils/index.ts'
27
26
  import { Presence } from './base/Presence.tsx'
28
- import { Render, type RenderProp } from './base/Render.tsx'
27
+ import {
28
+ Render,
29
+ type AsOrRenderProps,
30
+ type HTMLElementProps,
31
+ type RenderProp,
32
+ } from './base/Render.tsx'
29
33
  import { PaneContainerMeta, PaneMeta } from './Pane.meta.ts'
30
34
  import { ToggleButton, type ToggleButtonProps } from './ToggleButton.tsx'
31
35
 
@@ -282,30 +286,31 @@ export function PaneContainer({
282
286
  }
283
287
  PaneContainer.displayName = 'Pane.Container'
284
288
 
285
- export interface PaneProps extends ComponentProps<'div'>, EDSComponentProps<typeof PaneMeta> {
286
- /** @default 'div' */
287
- as?: ElementType | undefined
288
- /** Unique name for this pane. */
289
- name: string
290
- /**
291
- * Whether the pane is pseudo-modal in overlay mode. When `true`, the overlay shows a backdrop,
292
- * receives focus when opened, and can be closed by pressing Escape or interacting with elements
293
- * covered by the backdrop.
294
- *
295
- * @default true
296
- */
297
- overlayModal?: boolean | undefined
298
- /**
299
- * Whether the content should stay mounted when the pane is closed.
300
- *
301
- * @default true
302
- */
303
- keepMounted?: boolean | undefined
289
+ export declare namespace PaneProps {
290
+ interface CommonProps extends HTMLElementProps, EDSComponentProps<typeof PaneMeta> {
291
+ /** Unique name for this pane. */
292
+ name: string
293
+ /**
294
+ * Whether the pane is pseudo-modal in overlay mode. When `true`, the overlay shows a backdrop,
295
+ * receives focus when opened, and can be closed by pressing Escape or interacting with elements
296
+ * covered by the backdrop.
297
+ *
298
+ * @default true
299
+ */
300
+ overlayModal?: boolean | undefined
301
+ /**
302
+ * Whether the content should stay mounted when the pane is closed.
303
+ *
304
+ * @default true
305
+ */
306
+ keepMounted?: boolean | undefined
307
+ }
304
308
  }
305
309
 
310
+ export type PaneProps = PaneProps.CommonProps & AsOrRenderProps
311
+
306
312
  export function PaneRoot({
307
313
  ref: passedRef,
308
- as: Element = 'div',
309
314
  name,
310
315
  layout,
311
316
  side,
@@ -316,7 +321,7 @@ export function PaneRoot({
316
321
  }: PaneProps) {
317
322
  const { store, pane } = usePaneInternal(name)
318
323
 
319
- const rootRef = useRef<HTMLDivElement>(null)
324
+ const rootRef = useRef<HTMLElement>(null)
320
325
  const rootPassedRef = useMergedRefs(rootRef, passedRef)
321
326
 
322
327
  // Observe `--tw-duration` to allow customizing the transition duration via CSS
@@ -379,7 +384,7 @@ export function PaneRoot({
379
384
  }
380
385
 
381
386
  return (
382
- <Element
387
+ <Render
383
388
  ref={rootPassedRef}
384
389
  id={pane.id}
385
390
  data-eds-exposed-open={open}
@@ -587,7 +592,7 @@ export function PaneRoot({
587
592
  return <Fragment key={key} />
588
593
  })}
589
594
  </Presence>
590
- </Element>
595
+ </Render>
591
596
  )
592
597
  }
593
598
  PaneRoot.displayName = 'Pane'
@@ -604,7 +609,7 @@ function useOverlayModalFocus({
604
609
  layout,
605
610
  overlayOpen,
606
611
  }: {
607
- rootRef: RefObject<HTMLDivElement | null>
612
+ rootRef: RefObject<HTMLElement | null>
608
613
  store: PaneStore
609
614
  name: string
610
615
  enabled: boolean
@@ -8,5 +8,15 @@ export const ProseMeta = createComponent('Prose', {
8
8
  values: ['xsmall', 'small', 'medium'],
9
9
  defaultValue: 'medium',
10
10
  },
11
+ /**
12
+ * Use the serif font for headings, e.g. for editorial or marketing content.
13
+ *
14
+ * @default false
15
+ */
16
+ serif: {
17
+ type: 'values',
18
+ values: [false, true],
19
+ defaultValue: false,
20
+ },
11
21
  },
12
22
  })
@@ -20,12 +20,12 @@ export interface ProseProps
20
20
  children?: string | undefined
21
21
  }
22
22
 
23
- export function Prose({ ref: passedRef, size, children, ...props }: ProseProps) {
23
+ export function Prose({ ref: passedRef, size, serif, children, ...props }: ProseProps) {
24
24
  useEDS()
25
25
 
26
26
  const [cssPropsPolyfillPassedRef, cssPropsPolyfillAttributes] = useCSSPropsPolyfill(
27
27
  ProseMeta,
28
- { size },
28
+ { size, serif },
29
29
  { ref: passedRef },
30
30
  )
31
31
 
@@ -34,7 +34,7 @@ export function Prose({ ref: passedRef, size, children, ...props }: ProseProps)
34
34
  ref={cssPropsPolyfillPassedRef}
35
35
  {...mergeProps(
36
36
  { className: cn('eds-prose u:text-muted u:text-wrap-style-pretty') },
37
- getCSSPropsAttributes(ProseMeta, { size }),
37
+ getCSSPropsAttributes(ProseMeta, { size, serif }),
38
38
  cssPropsPolyfillAttributes,
39
39
  props,
40
40
  )}
@@ -45,6 +45,7 @@ export function Prose({ ref: passedRef, size, children, ...props }: ProseProps)
45
45
  @prop-size-xsmall/prose:prose-xsmall
46
46
  @prop-size-small/prose:prose-small
47
47
  @prop-size-medium/prose:prose-medium
48
+ @prop-serif-true/prose:prose-serif
48
49
  "
49
50
  >
50
51
  <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
@@ -22,13 +22,8 @@ import {
22
22
  useCSSState,
23
23
  useEDS,
24
24
  } from '../hooks/index.ts'
25
- import {
26
- cn,
27
- getCSSPropsAttributes,
28
- isReactNode,
29
- splitProps,
30
- type OptionValue,
31
- } from '../utils/index.ts'
25
+ import { cn, getCSSPropsAttributes, splitProps, type OptionValue } from '../utils/index.ts'
26
+ import { getTooltipProps } from '../utils/internal/index.ts'
32
27
  import { renderAddon, type AddonValue } from './base/Addon.tsx'
33
28
  import { SegmentedControlMeta, SegmentedControlOptionMeta } from './SegmentedControl.meta.ts'
34
29
  import { Tooltip, type TooltipProps } from './Tooltip.tsx'
@@ -193,7 +188,7 @@ export function SegmentedControlOption<T extends OptionValue>({
193
188
  }
194
189
 
195
190
  return (
196
- <Tooltip {...(isReactNode(tooltip) && tooltip !== undefined ? { content: tooltip } : tooltip)}>
191
+ <Tooltip {...getTooltipProps(tooltip)}>
197
192
  {/**
198
193
  * This wrapper is necessary to ensure this component returns a single element (which some Tailwind
199
194
  * classes might assume) because `Radio.Root` renders a `<input type="radio">` as a sibling of the
@@ -5,12 +5,14 @@ import {
5
5
  createContext,
6
6
  isValidElement,
7
7
  useContext,
8
+ useState,
8
9
  type ComponentProps,
9
10
  type ElementType,
10
11
  type ReactElement,
11
12
  type ReactNode,
12
13
  } from 'react'
13
14
  import { mergeProps } from '@base-ui/react'
15
+ import { Menu as BaseUIMenu } from '@base-ui/react/menu'
14
16
  import flattenChildren from 'react-keyed-flatten-children'
15
17
 
16
18
  import type { EDSComponentProps } from '@edgeandnode/eds-css'
@@ -18,11 +20,12 @@ import { CaretDownInteractiveIcon } from '@edgeandnode/eds-react/icons'
18
20
 
19
21
  import { useControlled } from '../hooks/index.ts'
20
22
  import { cn, reactNodeToString, splitProps, type OptionValue } from '../utils/index.ts'
23
+ import { resolveLazy } from '../utils/internal/index.ts'
21
24
  import { renderAddon, type AddonValue } from './base/Addon.tsx'
22
25
  import { Field, type FieldProps } from './base/Field.tsx'
23
26
  import { Render, type RenderFnHandlers, type RenderFnProps } from './base/Render.tsx'
24
27
  import type { MenuMeta } from './Menu.meta.ts'
25
- import { Menu } from './Menu.tsx'
28
+ import { Menu, type MenuProps } from './Menu.tsx'
26
29
  import type { SelectMeta, SelectOptionMeta } from './Select.meta.ts'
27
30
 
28
31
  type MenuCSSProps = EDSComponentProps<typeof MenuMeta>
@@ -281,21 +284,25 @@ export declare namespace SelectProps {
281
284
  /**
282
285
  * `true` for the default filter; a function for custom filtering. Auto-enables when
283
286
  * `searchValue` or `onSearchValueChange` is set. When truthy and `header` is unset,
284
- * `Menu.Search` becomes the default header.
287
+ * `Menu.Search` becomes the default header. A custom `header` must include `Menu.Search`.
285
288
  */
286
289
  searchable?: boolean | SelectSearchableFn<T>
287
290
  searchValue?: string
288
- onSearchValueChange?: (value: string) => void
291
+ /**
292
+ * Called when the search query changes, including when it's reset after the menu closes (then
293
+ * `eventDetails.reason` is `'popup-close'`).
294
+ */
295
+ onSearchValueChange?: MenuProps['onSearchValueChange']
289
296
  }
290
297
 
291
298
  interface DeclarativeProps {
292
299
  options?: never
293
300
  /**
294
301
  * `Select.Option` elements (fragments / arrays are fine — `flattenChildren` flattens them).
295
- * Wrapping options here (e.g., `<Tooltip><Select.Option/></Tooltip>`) is unsupported:
296
- * `child.type === SelectOption` only matches direct children, so a wrapped option wouldn't
297
- * appear in `optionsList`. Use the data API for that — its `children` render fn is opaque to
298
- * `Select`, so wrapping is free.
302
+ * Wrapping options here (e.g., `<Tooltip><Select.Option/></Tooltip>`) is unsupported: options
303
+ * are identified by their type, which only matches direct children, so a wrapped option
304
+ * wouldn't appear in `optionsList`. Use the data API for that — its `children` render fn is
305
+ * opaque to `Select`, so wrapping is free.
299
306
  */
300
307
  children?: ReactNode
301
308
  }
@@ -349,7 +356,7 @@ export function SelectRoot<T extends OptionValue = number>({
349
356
  alignOffset,
350
357
  matchTriggerWidth = 'at-least',
351
358
  matchTriggerHeight,
352
- header: passedHeader,
359
+ header,
353
360
  footer,
354
361
  empty,
355
362
  trigger: passedTrigger,
@@ -373,11 +380,12 @@ export function SelectRoot<T extends OptionValue = number>({
373
380
  onValueChange as ((value: T | T[] | undefined) => void) | undefined,
374
381
  )
375
382
 
376
- const [searchValue, setSearchValue] = useControlled(
377
- controlledSearchValue,
378
- '',
379
- onSearchValueChange,
380
- )
383
+ /**
384
+ * Not `useControlled`, which skips a change equal to the previous one even if the parent ignored
385
+ * it (e.g. the reset after the menu closes), so clearing the search would then do nothing.
386
+ */
387
+ const [uncontrolledSearchValue, setUncontrolledSearchValue] = useState('')
388
+ const searchValue = controlledSearchValue ?? uncontrolledSearchValue
381
389
 
382
390
  /**
383
391
  * `searchable` auto-enables when `searchValue` / `onSearchValueChange` is wired (matches how
@@ -423,16 +431,9 @@ export function SelectRoot<T extends OptionValue = number>({
423
431
  }
424
432
  : null
425
433
 
426
- /** Default `header` to a wired `<Menu.Search>` when search is enabled and none was provided. */
427
- const header =
428
- passedHeader !== undefined ? (
429
- passedHeader
430
- ) : searchEnabled ? (
431
- <Menu.Search value={searchValue} onValueChange={setSearchValue} />
432
- ) : undefined
433
-
434
434
  const renderMenuContent = () => {
435
- if (augmentedChildren.length === 0) {
435
+ // A searchable menu shows `empty` itself when no option matches
436
+ if (augmentedChildren.length === 0 && !searchEnabled) {
436
437
  if (empty === undefined) return <Menu.Empty />
437
438
  return typeof empty === 'string' ? <Menu.Empty>{empty}</Menu.Empty> : empty
438
439
  }
@@ -582,14 +583,7 @@ export function SelectRoot<T extends OptionValue = number>({
582
583
  {...radioGroupProps}
583
584
  open={open}
584
585
  defaultOpen={defaultOpen}
585
- onOpenChange={(newOpen) => {
586
- // Reset uncontrolled search on open (not close) — resetting on close would flash
587
- // the unfiltered list mid close-animation. Controlled consumers own their lifecycle.
588
- if (newOpen && searchEnabled && controlledSearchValue === undefined) {
589
- setSearchValue('')
590
- }
591
- onOpenChange?.(newOpen)
592
- }}
586
+ onOpenChange={onOpenChange}
593
587
  side={side}
594
588
  gap={gap}
595
589
  align={align}
@@ -598,6 +592,13 @@ export function SelectRoot<T extends OptionValue = number>({
598
592
  matchTriggerHeight={matchTriggerHeight}
599
593
  header={header}
600
594
  footer={footer}
595
+ searchable={searchEnabled && 'manual'}
596
+ searchValue={searchValue}
597
+ onSearchValueChange={(newSearchValue, eventDetails) => {
598
+ setUncontrolledSearchValue(newSearchValue)
599
+ onSearchValueChange?.(newSearchValue, eventDetails)
600
+ }}
601
+ empty={empty}
601
602
  highlightCheckedOnOpen
602
603
  trigger={menuTrigger}
603
604
  >
@@ -653,6 +654,8 @@ function useSelectOptions<T extends OptionValue>({
653
654
  addonBefore: ReactNode
654
655
  addonAfter: ReactNode
655
656
  }> = []
657
+ const { contains } = BaseUIMenu.useFilter()
658
+ const trimmedSearchValue = searchValue.trim()
656
659
 
657
660
  /**
658
661
  * Per-iteration helper: records the option in `optionsList` (always the full set) and returns the
@@ -667,12 +670,18 @@ function useSelectOptions<T extends OptionValue>({
667
670
  renderNode: () => ReactNode,
668
671
  ): ReactNode[] => {
669
672
  optionsList.push({ value, label, addonBefore, addonAfter })
670
- if (searchEnabled) {
673
+ if (searchEnabled && trimmedSearchValue !== '') {
671
674
  const stringLabel = reactNodeToString(label)
672
675
  const passes =
673
676
  typeof searchable === 'function'
674
- ? searchable({ value, label: stringLabel, addonBefore, addonAfter, searchValue })
675
- : stringLabel.toLowerCase().includes(searchValue.toLowerCase())
677
+ ? searchable({
678
+ value,
679
+ label: stringLabel,
680
+ addonBefore,
681
+ addonAfter,
682
+ searchValue: trimmedSearchValue,
683
+ })
684
+ : contains(stringLabel, trimmedSearchValue)
676
685
  if (!passes) return []
677
686
  }
678
687
  return [renderNode()]
@@ -727,7 +736,7 @@ function useSelectOptions<T extends OptionValue>({
727
736
  const flatChildren = flattenChildren(children as ReactNode)
728
737
  let autoValueCounter = 0
729
738
  augmentedChildren = flatChildren.flatMap((child) => {
730
- if (isValidElement(child) && child.type === SelectOption) {
739
+ if (isValidElement(child) && resolveLazy(child.type) === SelectOption) {
731
740
  const childProps = child.props as SelectOptionProps<T>
732
741
  const hasExplicitValue = childProps.value !== undefined
733
742
  const effectiveValue = (hasExplicitValue ? childProps.value : autoValueCounter) as T
@@ -15,7 +15,8 @@ import flattenChildren from 'react-keyed-flatten-children'
15
15
  import type { EDSComponentProps } from '@edgeandnode/eds-css'
16
16
 
17
17
  import { useControlled, useCSSPropsPolyfill, useCSSState, useEDS } from '../hooks/index.ts'
18
- import { cn, getCSSPropsAttributes, isReactNode } from '../utils/index.ts'
18
+ import { cn, getCSSPropsAttributes } from '../utils/index.ts'
19
+ import { getTooltipProps } from '../utils/internal/index.ts'
19
20
  import { renderAddon, type AddonValue } from './base/Addon.tsx'
20
21
  import { ButtonOrLink, type ButtonOrLinkProps } from './base/ButtonOrLink.tsx'
21
22
  import {
@@ -270,10 +271,7 @@ export function TabSetTab({
270
271
  tabRenderProps,
271
272
  )}
272
273
  render={(buttonOrLinkRenderProps, { Element, elementProps }) => (
273
- <Tooltip
274
- {...(isReactNode(tooltip) && tooltip !== undefined ? { content: tooltip } : tooltip)}
275
- triggerProps={buttonOrLinkRenderProps}
276
- >
274
+ <Tooltip {...getTooltipProps(tooltip)} triggerProps={buttonOrLinkRenderProps}>
277
275
  <Element {...elementProps}>
278
276
  {addonBefore ? <TabAddon>{renderAddon(addonBefore)}</TabAddon> : null}
279
277
  {children ? (
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import {
4
+ Children,
4
5
  createContext,
5
6
  Fragment,
6
7
  isValidElement,
@@ -753,9 +754,8 @@ export function TableCell({
753
754
  ) : null}
754
755
  </span>
755
756
  )
756
- const cellContentMayContainInteractiveElements = Array.isArray(children)
757
- ? children.some(isValidElement)
758
- : isValidElement(children)
757
+ // `Children.toArray` also resolves lazy nodes (elements passed from server components)
758
+ const cellContentMayContainInteractiveElements = Children.toArray(children).some(isValidElement)
759
759
 
760
760
  return (
761
761
  <td
@@ -5,7 +5,7 @@ import { MoonIcon, SunIcon } from '@edgeandnode/eds-react/icons'
5
5
 
6
6
  import type { ResolvedTheme } from '../EDSContext.ts'
7
7
  import { useEDS } from '../hooks/index.ts'
8
- import { isReactNode } from '../utils/index.ts'
8
+ import { getTooltipProps } from '../utils/internal/index.ts'
9
9
  import { Menu } from './Menu.tsx'
10
10
  import { SegmentedControl, type SegmentedControlProps } from './SegmentedControl.tsx'
11
11
  import type { ThemeSwitcherMeta } from './ThemeSwitcher.meta.ts'
@@ -18,19 +18,16 @@ export interface ThemeSwitcherProps
18
18
  Omit<SegmentedControlProps<'dark' | 'light'>, 'value' | 'defaultValue' | 'onValueChange'>,
19
19
  EDSComponentProps<typeof ThemeSwitcherMeta> {
20
20
  /**
21
- * Defaults to a label that flips with `activeTheme` (e.g. `Switch to light theme` when dark is
22
- * active). Accepts a function that receives `activeTheme` for dynamic content. Pass `null` to
23
- * disable.
21
+ * Defaults to a label that flips with the page's theme (e.g. `Switch to light theme` when it's
22
+ * dark). Accepts a function that receives the page's theme, resolved to `'dark'` or `'light'`,
23
+ * for dynamic content. Pass `null` to disable.
24
24
  */
25
- tooltip?:
26
- | ThemeSwitcherTooltip
27
- | ((activeTheme: ResolvedTheme) => ThemeSwitcherTooltip)
28
- | undefined
25
+ tooltip?: ThemeSwitcherTooltip | ((theme: ResolvedTheme) => ThemeSwitcherTooltip) | undefined
29
26
  }
30
27
 
31
28
  /**
32
29
  * Toggles the EDS theme between light and dark. Right-click for a context menu with a "Use system
33
- * appearance" toggle. Always uncontrolled — reads `activeTheme` from `useEDS()` and drives
30
+ * appearance" toggle. Always uncontrolled — reads the theme from `useEDS()` and drives
34
31
  * `setTheme()`. `theme="system"` (or `defaultTheme="system"`) on `EDSProvider` also opts into the
35
32
  * user's OS preference.
36
33
  */
@@ -39,23 +36,24 @@ export function ThemeSwitcher({
39
36
  tooltip: passedTooltip,
40
37
  ...props
41
38
  }: ThemeSwitcherProps) {
42
- const { theme, activeTheme, setTheme } = useEDS()
39
+ const { theme, setTheme, systemTheme } = useEDS()
40
+ const pageTheme = theme === 'system' ? systemTheme : theme
43
41
 
44
- const tooltip = typeof passedTooltip === 'function' ? passedTooltip(activeTheme) : passedTooltip
42
+ const tooltip = typeof passedTooltip === 'function' ? passedTooltip(pageTheme) : passedTooltip
45
43
 
46
44
  return (
47
45
  <Menu
48
46
  triggerMode="right-click"
49
47
  trigger={
50
48
  <Tooltip
51
- content={`Switch to ${activeTheme === 'dark' ? 'light' : 'dark'} theme`}
49
+ content={`Switch to ${pageTheme === 'dark' ? 'light' : 'dark'} theme`}
52
50
  closeOnClick={false}
53
- {...(isReactNode(tooltip) && tooltip !== undefined ? { content: tooltip } : tooltip)}
51
+ {...getTooltipProps(tooltip)}
54
52
  triggerProps="forward"
55
53
  >
56
54
  <SegmentedControl<'dark' | 'light'>
57
55
  aria-label={ariaLabel}
58
- value={activeTheme}
56
+ value={pageTheme}
59
57
  onValueChange={setTheme}
60
58
  {...props}
61
59
  >
@@ -72,7 +70,7 @@ export function ThemeSwitcher({
72
70
  <Menu.Item
73
71
  type="checkbox"
74
72
  checked={theme === 'system'}
75
- onCheckedChange={(checked) => setTheme(checked ? 'system' : activeTheme)}
73
+ onCheckedChange={(checked) => setTheme(checked ? 'system' : pageTheme)}
76
74
  >
77
75
  Use system appearance
78
76
  </Menu.Item>
@@ -75,7 +75,7 @@ export function TooltipRoot({
75
75
  triggerProps: passedTriggerProps,
76
76
  ...passedProps
77
77
  }: TooltipProps) {
78
- const { dirProps } = useEDS()
78
+ const { dirProps, localThemeClass } = useEDS()
79
79
 
80
80
  const ancestorTooltip = useContext(TooltipContext)
81
81
 
@@ -190,7 +190,7 @@ export function TooltipRoot({
190
190
  hidden={undefined}
191
191
  />
192
192
  </Portal>
193
- <Tooltip.Portal>
193
+ <Tooltip.Portal className={localThemeClass}>
194
194
  <Tooltip.Positioner
195
195
  key={getReactNodeKey(content)} // Refresh the tooltip's position when the content changes (see `WithDynamicContent` story)
196
196
  side={
@@ -1,5 +1,6 @@
1
1
  import { createElement, type ComponentType, type ReactNode } from 'react'
2
- import { isValidElementType } from 'react-is'
2
+
3
+ import { isComponentType } from '../../utils/index.ts'
3
4
 
4
5
  type AddonComponent = ComponentType<{
5
6
  __addonCompatible?: undefined
@@ -10,9 +11,7 @@ type AddonComponent = ComponentType<{
10
11
  export type AddonValue = ReactNode | AddonComponent
11
12
 
12
13
  export function renderAddon(value: AddonValue): ReactNode {
13
- return isValidElementType(value) && typeof value !== 'string'
14
- ? createElement(value)
15
- : (value as ReactNode)
14
+ return isComponentType(value) ? createElement(value) : value
16
15
  }
17
16
 
18
17
  /**
@@ -21,6 +21,7 @@ import { useButton } from 'react-aria'
21
21
  import type { Merge } from 'type-fest'
22
22
 
23
23
  import { cn } from '../../utils/index.ts'
24
+ import { resolveLazy } from '../../utils/internal/index.ts'
24
25
  import { Render, type RenderFn, type RenderFnProps } from './Render.tsx'
25
26
  import { ButtonOrLinkContext, useAncestorButtonOrLink } from './useAncestorButtonOrLink.ts'
26
27
 
@@ -345,7 +346,7 @@ export const ButtonOrLinkRoot = (passedProps: InternalButtonOrLinkProps): ReactE
345
346
  }
346
347
  }
347
348
  } else if (!disabled) {
348
- linkComponent = passedLinkComponent
349
+ linkComponent = resolveLazy(passedLinkComponent)
349
350
  }
350
351
  }
351
352
 
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import type { ComponentProps, ElementType } from 'react'
3
+ import type { ElementType } from 'react'
4
4
 
5
5
  import {
6
6
  ButtonOrLink,
@@ -8,7 +8,7 @@ import {
8
8
  type InternalButtonOrLinkProps,
9
9
  type OmitInternalButtonOrLinkProps,
10
10
  } from './ButtonOrLink.tsx'
11
- import type { RenderFn } from './Render.tsx'
11
+ import type { HTMLElementProps, RenderFn } from './Render.tsx'
12
12
 
13
13
  type OtherElementRenderState = {
14
14
  category: 'other'
@@ -43,9 +43,9 @@ export declare namespace MaybeButtonOrLinkProps {
43
43
  }
44
44
  export type OtherElementProps =
45
45
  OmitInternalButtonOrLinkProps<InternalButtonOrLinkProps.CommonProps> &
46
- ComponentProps<'span'> & {
46
+ HTMLElementProps & {
47
47
  onClick?: InternalButtonOrLinkProps.ButtonProps['onClick'] // Not technically correct, but prevents the event from being `any` when `as` is not specified
48
- as?: ElementType | undefined
48
+ as?: keyof HTMLElementTagNameMap | undefined
49
49
  render?: RenderFn<MaybeButtonOrLinkRenderState> | undefined
50
50
  disabled?: undefined
51
51
  type?: undefined
@@ -67,14 +67,17 @@ type InternalMaybeButtonOrLinkProps = MaybeButtonOrLinkProps &
67
67
 
68
68
  /**
69
69
  * Renders a `ButtonOrLink` if one of `href` or `onClick` is passed along with no `as`. Otherwise,
70
- * renders the element type passed in `as` (defaulting to `span`).
70
+ * renders the tag name passed in `as` (defaulting to `span`), so an element that isn't a button can
71
+ * still have an `onClick` (e.g. a `label`). Like `ButtonOrLink`'s (and unlike `Render`'s), the
72
+ * `render` function wraps that element instead of replacing it: it receives it as `Element`, along
73
+ * with the `elementProps` to spread on it.
71
74
  */
72
75
  export function MaybeButtonOrLink(props: InternalMaybeButtonOrLinkProps) {
73
76
  if ((props.href !== undefined || props.onClick !== undefined) && props.as === undefined) {
74
77
  return <ButtonOrLink {...(props as InternalButtonOrLinkProps)} />
75
78
  } else {
76
79
  const {
77
- as: Element = 'span',
80
+ as: tagName = 'span',
78
81
  render,
79
82
  disabled: _disabled,
80
83
  inline: _inline,
@@ -86,6 +89,8 @@ export function MaybeButtonOrLink(props: InternalMaybeButtonOrLinkProps) {
86
89
  ...otherElementProps
87
90
  } = props
88
91
 
92
+ // The union of every HTML tag name is too complex a type for JSX
93
+ const Element = tagName as ElementType
89
94
  const renderState: OtherElementRenderState = {
90
95
  category: 'other',
91
96
  role: props.role ?? 'none',