@edgeandnode/eds-react 1.0.0 → 1.0.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.
Files changed (160) hide show
  1. package/README.md +21 -4
  2. package/dist/EDSContext.d.ts +3 -0
  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 +1 -1
  7. package/dist/EDSProvider.d.ts.map +1 -1
  8. package/dist/EDSProvider.js +15 -8
  9. package/dist/EDSProvider.js.map +1 -1
  10. package/dist/components/Avatar.d.ts.map +1 -1
  11. package/dist/components/Avatar.js +3 -2
  12. package/dist/components/Avatar.js.map +1 -1
  13. package/dist/components/Breadcrumbs.parts.d.ts +4 -3
  14. package/dist/components/Breadcrumbs.parts.d.ts.map +1 -1
  15. package/dist/components/Breadcrumbs.parts.js +15 -14
  16. package/dist/components/Breadcrumbs.parts.js.map +1 -1
  17. package/dist/components/Button.d.ts.map +1 -1
  18. package/dist/components/Button.js +3 -2
  19. package/dist/components/Button.js.map +1 -1
  20. package/dist/components/CodeBlock.parts.d.ts.map +1 -1
  21. package/dist/components/CodeBlock.parts.js +16 -20
  22. package/dist/components/CodeBlock.parts.js.map +1 -1
  23. package/dist/components/CopyButton.d.ts.map +1 -1
  24. package/dist/components/CopyButton.js +3 -2
  25. package/dist/components/CopyButton.js.map +1 -1
  26. package/dist/components/Icon.d.ts.map +1 -1
  27. package/dist/components/Icon.js +3 -3
  28. package/dist/components/Icon.js.map +1 -1
  29. package/dist/components/LocalTheme.d.ts +16 -0
  30. package/dist/components/LocalTheme.d.ts.map +1 -0
  31. package/dist/components/LocalTheme.js +22 -0
  32. package/dist/components/LocalTheme.js.map +1 -0
  33. package/dist/components/Menu.parts.d.ts.map +1 -1
  34. package/dist/components/Menu.parts.js +6 -4
  35. package/dist/components/Menu.parts.js.map +1 -1
  36. package/dist/components/Modal.parts.d.ts.map +1 -1
  37. package/dist/components/Modal.parts.js +21 -10
  38. package/dist/components/Modal.parts.js.map +1 -1
  39. package/dist/components/SegmentedControl.parts.d.ts.map +1 -1
  40. package/dist/components/SegmentedControl.parts.js +3 -2
  41. package/dist/components/SegmentedControl.parts.js.map +1 -1
  42. package/dist/components/Select.parts.d.ts +4 -4
  43. package/dist/components/Select.parts.d.ts.map +1 -1
  44. package/dist/components/Select.parts.js +2 -1
  45. package/dist/components/Select.parts.js.map +1 -1
  46. package/dist/components/TabSet.parts.d.ts.map +1 -1
  47. package/dist/components/TabSet.parts.js +3 -2
  48. package/dist/components/TabSet.parts.js.map +1 -1
  49. package/dist/components/Table.parts.d.ts.map +1 -1
  50. package/dist/components/Table.parts.js +3 -4
  51. package/dist/components/Table.parts.js.map +1 -1
  52. package/dist/components/ThemeSwitcher.js +2 -2
  53. package/dist/components/ThemeSwitcher.js.map +1 -1
  54. package/dist/components/Tooltip.parts.js +2 -2
  55. package/dist/components/Tooltip.parts.js.map +1 -1
  56. package/dist/components/base/Addon.d.ts.map +1 -1
  57. package/dist/components/base/Addon.js +2 -4
  58. package/dist/components/base/Addon.js.map +1 -1
  59. package/dist/components/base/ButtonOrLink.parts.d.ts.map +1 -1
  60. package/dist/components/base/ButtonOrLink.parts.js +2 -1
  61. package/dist/components/base/ButtonOrLink.parts.js.map +1 -1
  62. package/dist/components/index.d.ts +1 -0
  63. package/dist/components/index.d.ts.map +1 -1
  64. package/dist/components/index.js +1 -0
  65. package/dist/components/index.js.map +1 -1
  66. package/dist/hooks/useCSSProp.js +1 -1
  67. package/dist/hooks/useCSSProp.js.map +1 -1
  68. package/dist/hooks/useEDS.d.ts +16 -1
  69. package/dist/hooks/useEDS.d.ts.map +1 -1
  70. package/dist/hooks/useEDS.js +18 -2
  71. package/dist/hooks/useEDS.js.map +1 -1
  72. package/dist/hooks/useStyleObserver.d.ts.map +1 -1
  73. package/dist/hooks/useStyleObserver.js +2 -1
  74. package/dist/hooks/useStyleObserver.js.map +1 -1
  75. package/dist/icons/CalendarDynamicIcon.d.ts.map +1 -1
  76. package/dist/icons/CalendarDynamicIcon.js +3 -1
  77. package/dist/icons/CalendarDynamicIcon.js.map +1 -1
  78. package/dist/utils/getReactNodeKey.d.ts.map +1 -1
  79. package/dist/utils/getReactNodeKey.js +31 -13
  80. package/dist/utils/getReactNodeKey.js.map +1 -1
  81. package/dist/utils/index.d.ts +1 -1
  82. package/dist/utils/index.d.ts.map +1 -1
  83. package/dist/utils/index.js +1 -1
  84. package/dist/utils/index.js.map +1 -1
  85. package/dist/utils/internal/getTooltipProps.d.ts +9 -0
  86. package/dist/utils/internal/getTooltipProps.d.ts.map +1 -0
  87. package/dist/utils/internal/getTooltipProps.js +18 -0
  88. package/dist/utils/internal/getTooltipProps.js.map +1 -0
  89. package/dist/utils/internal/index.d.ts +4 -0
  90. package/dist/utils/internal/index.d.ts.map +1 -0
  91. package/dist/utils/internal/index.js +4 -0
  92. package/dist/utils/internal/index.js.map +1 -0
  93. package/dist/utils/internal/resolveLazy.d.ts +29 -0
  94. package/dist/utils/internal/resolveLazy.d.ts.map +1 -0
  95. package/dist/utils/internal/resolveLazy.js +40 -0
  96. package/dist/utils/internal/resolveLazy.js.map +1 -0
  97. package/dist/utils/internal/styleScheduler.d.ts.map +1 -0
  98. package/dist/utils/internal/styleScheduler.js.map +1 -0
  99. package/dist/utils/isComponentType.d.ts +9 -0
  100. package/dist/utils/isComponentType.d.ts.map +1 -0
  101. package/dist/utils/isComponentType.js +22 -0
  102. package/dist/utils/isComponentType.js.map +1 -0
  103. package/dist/utils/isReactNode.d.ts +7 -0
  104. package/dist/utils/isReactNode.d.ts.map +1 -1
  105. package/dist/utils/isReactNode.js +11 -0
  106. package/dist/utils/isReactNode.js.map +1 -1
  107. package/dist/utils/reactNodeToString.d.ts +3 -0
  108. package/dist/utils/reactNodeToString.d.ts.map +1 -1
  109. package/dist/utils/reactNodeToString.js +6 -0
  110. package/dist/utils/reactNodeToString.js.map +1 -1
  111. package/dist/utils/styleObserver.js +1 -1
  112. package/dist/utils/trimReactNode.d.ts +3 -0
  113. package/dist/utils/trimReactNode.d.ts.map +1 -1
  114. package/dist/utils/trimReactNode.js +6 -0
  115. package/dist/utils/trimReactNode.js.map +1 -1
  116. package/package.json +9 -8
  117. package/scripts/update-skill.cjs +18 -5
  118. package/skill/SKILL.md +17 -10
  119. package/skill/references/tokens.md +26 -20
  120. package/src/EDSContext.ts +9 -0
  121. package/src/EDSProvider.tsx +34 -9
  122. package/src/components/Avatar.tsx +3 -2
  123. package/src/components/Breadcrumbs.parts.tsx +31 -15
  124. package/src/components/Button.tsx +3 -2
  125. package/src/components/CodeBlock.parts.tsx +20 -24
  126. package/src/components/CopyButton.tsx +3 -2
  127. package/src/components/Icon.tsx +3 -9
  128. package/src/components/LocalTheme.tsx +39 -0
  129. package/src/components/Menu.parts.tsx +5 -3
  130. package/src/components/Modal.parts.tsx +20 -10
  131. package/src/components/SegmentedControl.parts.tsx +3 -8
  132. package/src/components/Select.parts.tsx +6 -5
  133. package/src/components/TabSet.parts.tsx +3 -5
  134. package/src/components/Table.parts.tsx +3 -3
  135. package/src/components/ThemeSwitcher.tsx +2 -2
  136. package/src/components/Tooltip.parts.tsx +2 -2
  137. package/src/components/base/Addon.tsx +3 -4
  138. package/src/components/base/ButtonOrLink.parts.tsx +2 -1
  139. package/src/components/index.ts +1 -0
  140. package/src/hooks/useCSSProp.ts +1 -1
  141. package/src/hooks/useEDS.ts +18 -2
  142. package/src/hooks/useStyleObserver.ts +2 -1
  143. package/src/icons/CalendarDynamicIcon.tsx +1 -0
  144. package/src/utils/getReactNodeKey.ts +30 -14
  145. package/src/utils/index.ts +1 -1
  146. package/src/utils/internal/getTooltipProps.ts +25 -0
  147. package/src/utils/internal/index.ts +3 -0
  148. package/src/utils/internal/resolveLazy.ts +44 -0
  149. package/src/utils/isComponentType.ts +27 -0
  150. package/src/utils/isReactNode.ts +11 -0
  151. package/src/utils/reactNodeToString.ts +7 -0
  152. package/src/utils/styleObserver.ts +1 -1
  153. package/src/utils/trimReactNode.tsx +7 -0
  154. package/dist/utils/styleScheduler.d.ts.map +0 -1
  155. package/dist/utils/styleScheduler.js.map +0 -1
  156. package/src/__snapshots__/compiled.css +0 -22525
  157. package/styles/tailwind.storybook.css +0 -19
  158. /package/dist/utils/{styleScheduler.d.ts → internal/styleScheduler.d.ts} +0 -0
  159. /package/dist/utils/{styleScheduler.js → internal/styleScheduler.js} +0 -0
  160. /package/src/utils/{styleScheduler.ts → internal/styleScheduler.ts} +0 -0
@@ -50,15 +50,6 @@ import { TabSetContext } from './TabSet.context.ts'
50
50
  import { TabSet, type TabSetTabProps } from './TabSet.tsx'
51
51
  import { ToggleButton } from './ToggleButton.tsx'
52
52
 
53
- // Can't import `@shikijs/vscode-textmate`'s `FontStyle` enum due to `verbatimModuleSyntax`
54
- const FontStyle = {
55
- NotSet: -1,
56
- None: 0,
57
- Italic: 1,
58
- Bold: 2,
59
- Underline: 4,
60
- } as const
61
-
62
53
  const THEMES = {
63
54
  dark: 'night-owl',
64
55
  light: 'min-light',
@@ -166,7 +157,7 @@ export function CodeBlockRoot({
166
157
  children,
167
158
  ...props
168
159
  }: CodeBlockProps) {
169
- const { activeTheme } = useEDS()
160
+ useEDS()
170
161
  const tabsContext = useContext(CodeBlockTabsContext)
171
162
  const hasAncestorTabs = tabsContext !== null
172
163
  const hasAncestorHeader = useContext(CodeBlockHeaderContext) !== false
@@ -209,7 +200,6 @@ export function CodeBlockRoot({
209
200
  highlights,
210
201
  secrets,
211
202
  language,
212
- activeTheme,
213
203
  highlighter,
214
204
  hasAncestorHeader,
215
205
  })
@@ -363,7 +353,18 @@ export function CodeBlockRoot({
363
353
  <span className="select-none"> </span>
364
354
  ) : (
365
355
  <span
356
+ /**
357
+ * `light-dark()` only works for colors, so the font styles use the `dark:`
358
+ * variant (and highlighted tokens need `i:font-bold` to override the weight).
359
+ */
366
360
  className="
361
+ *:font-(--shiki-light-font-weight)
362
+ *:text-[light-dark(var(--shiki-light),var(--shiki-dark))]
363
+ *:[font-style:var(--shiki-light-font-style)]
364
+ *:[text-decoration:var(--shiki-light-text-decoration)]
365
+ dark:*:font-(--shiki-dark-font-weight)
366
+ dark:*:[font-style:var(--shiki-dark-font-style)]
367
+ dark:*:[text-decoration:var(--shiki-dark-text-decoration)]
367
368
  @prop-wrap-true/code-block:wrap-anywhere
368
369
  @prop-wrap-true/code-block:whitespace-pre-wrap
369
370
  "
@@ -379,23 +380,18 @@ export function CodeBlockRoot({
379
380
  data-whitespace={chunk.content.trim() === '' || undefined}
380
381
  data-highlighted={chunk.highlighted || undefined}
381
382
  className={cn(
382
- chunk.fontStyle === FontStyle.Bold ? 'font-bold' : undefined,
383
- chunk.fontStyle === FontStyle.Italic ? 'italic' : undefined,
384
- chunk.fontStyle === FontStyle.Underline ? 'underline' : undefined,
385
- chunk.highlighted ? `z-10 bg-code-highlighted py-1 font-bold
383
+ chunk.highlighted ? `z-10 bg-code-highlighted py-1
386
384
  not-has-[+[data-highlighted]]:-me-1
387
385
  not-has-[+[data-highlighted]]:rounded-e-4
388
386
  not-has-[+[data-highlighted]]:pe-1
389
387
  not-[[data-highlighted]+*]:-ms-1
390
388
  not-[[data-highlighted]+*]:rounded-s-4
391
- not-[[data-highlighted]+*]:ps-1` : 'z-20',
389
+ not-[[data-highlighted]+*]:ps-1
390
+ i:font-bold` : 'z-20',
392
391
  // Prevent wrapping between the indentation and the code
393
392
  chunkIndex === 1 ? '[[data-whitespace]+*]:break-all' : undefined,
394
393
  )}
395
- style={{
396
- color: chunk.color,
397
- backgroundColor: chunk.bgColor,
398
- }}
394
+ style={chunk.htmlStyle}
399
395
  >
400
396
  {chunkContent}
401
397
  </span>
@@ -423,7 +419,6 @@ function useCodeLines({
423
419
  highlights,
424
420
  secrets,
425
421
  language,
426
- activeTheme,
427
422
  highlighter,
428
423
  hasAncestorHeader,
429
424
  }: {
@@ -432,7 +427,6 @@ function useCodeLines({
432
427
  highlights: string[]
433
428
  secrets: string[]
434
429
  language: Language
435
- activeTheme: 'dark' | 'light'
436
430
  highlighter: Highlighter
437
431
  hasAncestorHeader: boolean
438
432
  }): { lines: CodeLine[]; codeToCopy: string } {
@@ -461,7 +455,9 @@ function useCodeLines({
461
455
  if (hasAncestorHeader) return []
462
456
 
463
457
  const { tokens } = highlighter.codeToTokens(code, {
464
- theme: THEMES[activeTheme],
458
+ // Both themes' styles become CSS variables, so tokens follow the local color scheme
459
+ themes: THEMES,
460
+ defaultColor: false,
465
461
  lang: LANGUAGES.includes(language) ? language : 'text',
466
462
  })
467
463
 
@@ -526,7 +522,7 @@ function useCodeLines({
526
522
 
527
523
  return { chunks, style: lineStyle }
528
524
  })
529
- }, [highlighter, passedCode, activeTheme, language, highlights, secrets, diff])
525
+ }, [highlighter, passedCode, language, highlights, secrets, diff])
530
526
 
531
527
  return { lines, codeToCopy }
532
528
  }
@@ -8,7 +8,8 @@ import type { EDSComponentProps } from '@edgeandnode/eds-css'
8
8
  import { CopyInteractiveIcon } from '@edgeandnode/eds-react/icons'
9
9
 
10
10
  import { useEDS } from '../hooks/index.ts'
11
- import { cn, isReactNode } from '../utils/index.ts'
11
+ import { cn } from '../utils/index.ts'
12
+ import { getTooltipProps } from '../utils/internal/index.ts'
12
13
  import { Button, type ButtonProps } from './Button.tsx'
13
14
  import type { CopyButtonMeta } from './CopyButton.meta.ts'
14
15
  import { Tooltip } from './Tooltip.tsx'
@@ -49,7 +50,7 @@ export function CopyButton({
49
50
 
50
51
  const hasChildren = children != null
51
52
  const copyIcon = <CopyInteractiveIcon copied={copied} />
52
- const tooltipProps = isReactNode(tooltip) ? { content: tooltip } : tooltip
53
+ const tooltipProps = getTooltipProps(tooltip)
53
54
  const [tooltipOpen, setTooltipOpen] = useState(false)
54
55
 
55
56
  return (
@@ -1,18 +1,12 @@
1
1
  'use client'
2
2
 
3
- import {
4
- createElement,
5
- isValidElement,
6
- type ComponentProps,
7
- type ComponentType,
8
- type ReactElement,
9
- } from 'react'
3
+ import { createElement, type ComponentProps, type ComponentType, type ReactElement } from 'react'
10
4
  import { mergeProps } from '@base-ui/react'
11
5
 
12
6
  import type { EDSComponentProps } from '@edgeandnode/eds-css'
13
7
 
14
8
  import { useEDS } from '../hooks/index.ts'
15
- import { cn, getCSSPropsAttributes } from '../utils/index.ts'
9
+ import { cn, getCSSPropsAttributes, isComponentType } from '../utils/index.ts'
16
10
  import { IconMeta } from './Icon.meta.ts'
17
11
  import { useTooltipCollector } from './Tooltip.tsx'
18
12
 
@@ -37,7 +31,7 @@ export interface IconPropsWithSrc extends IconProps {
37
31
  export function Icon({ src, alt, size, color, ...props }: IconPropsWithSrc) {
38
32
  useEDS()
39
33
 
40
- const iconContent = isValidElement(src) ? src : createElement(src)
34
+ const iconContent = isComponentType(src) ? createElement(src) : src
41
35
  const collectTooltipContent = useTooltipCollector()
42
36
 
43
37
  if (collectTooltipContent && alt) {
@@ -0,0 +1,39 @@
1
+ 'use client'
2
+
3
+ import type { ComponentProps, ElementType } from 'react'
4
+ import { mergeProps } from '@base-ui/react'
5
+
6
+ import { LocalThemeContext, THEME_CLASSES, type ResolvedTheme } from '../EDSContext.ts'
7
+
8
+ export interface LocalThemeProps extends ComponentProps<'div'> {
9
+ /** @default 'div' */
10
+ as?: ElementType | undefined
11
+ }
12
+
13
+ function LocalTheme({
14
+ theme,
15
+ as: Element = 'div',
16
+ ...props
17
+ }: LocalThemeProps & { theme: ResolvedTheme }) {
18
+ return (
19
+ <LocalThemeContext.Provider value={theme}>
20
+ <Element {...mergeProps({ className: THEME_CLASSES[theme] }, props)} />
21
+ </LocalThemeContext.Provider>
22
+ )
23
+ }
24
+
25
+ /**
26
+ * Renders its content in the dark theme, whatever the page's theme is. Components that need to know
27
+ * the theme in JavaScript can read it with `useEDS().localTheme`.
28
+ */
29
+ export function Dark(props: LocalThemeProps) {
30
+ return <LocalTheme theme="dark" {...props} />
31
+ }
32
+
33
+ /**
34
+ * Renders its content in the light theme, whatever the page's theme is. Components that need to
35
+ * know the theme in JavaScript can read it with `useEDS().localTheme`.
36
+ */
37
+ export function Light(props: LocalThemeProps) {
38
+ return <LocalTheme theme="light" {...props} />
39
+ }
@@ -166,7 +166,7 @@ export function MenuRoot<T extends OptionValue>({
166
166
  children,
167
167
  ...props
168
168
  }: MenuProps<T>) {
169
- const { dirProps } = useEDS()
169
+ const { dirProps, localThemeClass } = useEDS()
170
170
 
171
171
  const isSubmenu = useContext(MenuGroupContext) !== null
172
172
  const isContextMenu = triggerMode === 'right-click' && !isSubmenu
@@ -307,6 +307,8 @@ export function MenuRoot<T extends OptionValue>({
307
307
  ? document.body
308
308
  : undefined
309
309
  }
310
+ // Inline menus are portalled in place, so they already inherit the local theme
311
+ className={hasTrigger ? localThemeClass : undefined}
310
312
  >
311
313
  <Menu.Positioner
312
314
  ref={passedRef}
@@ -535,8 +537,8 @@ function MenuInlineFallback({
535
537
  {...mergeProps(
536
538
  {
537
539
  className: cn(`
538
- pointer-events-none absolute inset-[anchor(inside)] size-auto! [position-anchor:--eds-menu]
539
- not-open:hidden
540
+ pointer-events-none! absolute! inset-[anchor(inside)]! z-auto! m-0! size-auto! [position-anchor:--eds-menu]!
541
+ not-open:hidden!
540
542
  u:rounded-12
541
543
  `),
542
544
  },
@@ -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'
@@ -275,7 +276,13 @@ export const ModalRoot = <
275
276
  hidden={undefined}
276
277
  />
277
278
  </Portal>
278
- <Dialog.Portal>
279
+ <Dialog.Portal
280
+ /**
281
+ * Modals cover the whole page, so they're rendered in `<body>`, even when opened from
282
+ * inside another portal (e.g. a `Menu`), where Base UI would put them otherwise.
283
+ */
284
+ container={typeof document !== 'undefined' ? document.body : undefined}
285
+ >
279
286
  <div
280
287
  ref={passedRef}
281
288
  /**
@@ -355,15 +362,18 @@ export const ModalRoot = <
355
362
  has-nested/modal-slot:p-0
356
363
  "
357
364
  >
358
- {typeof children === 'function'
359
- ? children({
360
- open,
361
- setOpen,
362
- closeModal: () => setOpen(false),
363
- currentStep,
364
- setCurrentStep,
365
- })
366
- : children}
365
+ {/* Modals cover the whole page, so they use its theme rather than the local one */}
366
+ <LocalThemeContext.Provider value={undefined}>
367
+ {typeof children === 'function'
368
+ ? children({
369
+ open,
370
+ setOpen,
371
+ closeModal: () => setOpen(false),
372
+ currentStep,
373
+ setCurrentStep,
374
+ })
375
+ : children}
376
+ </LocalThemeContext.Provider>
367
377
  </div>
368
378
  </Dialog.Popup>
369
379
  </div>
@@ -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
@@ -18,6 +18,7 @@ import { CaretDownInteractiveIcon } from '@edgeandnode/eds-react/icons'
18
18
 
19
19
  import { useControlled } from '../hooks/index.ts'
20
20
  import { cn, reactNodeToString, splitProps, type OptionValue } from '../utils/index.ts'
21
+ import { resolveLazy } from '../utils/internal/index.ts'
21
22
  import { renderAddon, type AddonValue } from './base/Addon.tsx'
22
23
  import { Field, type FieldProps } from './base/Field.tsx'
23
24
  import { Render, type RenderFnHandlers, type RenderFnProps } from './base/Render.tsx'
@@ -292,10 +293,10 @@ export declare namespace SelectProps {
292
293
  options?: never
293
294
  /**
294
295
  * `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.
296
+ * Wrapping options here (e.g., `<Tooltip><Select.Option/></Tooltip>`) is unsupported: options
297
+ * are identified by their type, which only matches direct children, so a wrapped option
298
+ * wouldn't appear in `optionsList`. Use the data API for that — its `children` render fn is
299
+ * opaque to `Select`, so wrapping is free.
299
300
  */
300
301
  children?: ReactNode
301
302
  }
@@ -727,7 +728,7 @@ function useSelectOptions<T extends OptionValue>({
727
728
  const flatChildren = flattenChildren(children as ReactNode)
728
729
  let autoValueCounter = 0
729
730
  augmentedChildren = flatChildren.flatMap((child) => {
730
- if (isValidElement(child) && child.type === SelectOption) {
731
+ if (isValidElement(child) && resolveLazy(child.type) === SelectOption) {
731
732
  const childProps = child.props as SelectOptionProps<T>
732
733
  const hasExplicitValue = childProps.value !== undefined
733
734
  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'
@@ -50,7 +50,7 @@ export function ThemeSwitcher({
50
50
  <Tooltip
51
51
  content={`Switch to ${activeTheme === 'dark' ? 'light' : 'dark'} theme`}
52
52
  closeOnClick={false}
53
- {...(isReactNode(tooltip) && tooltip !== undefined ? { content: tooltip } : tooltip)}
53
+ {...getTooltipProps(tooltip)}
54
54
  triggerProps="forward"
55
55
  >
56
56
  <SegmentedControl<'dark' | 'light'>
@@ -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
 
@@ -44,6 +44,7 @@ export { Label, type LabelProps } from './Label.tsx'
44
44
  export { LabelMeta } from './Label.meta.ts'
45
45
  export { Link, type LinkProps } from './Link.tsx'
46
46
  export { LinkMeta } from './Link.meta.ts'
47
+ export { Dark, Light, type LocalThemeProps } from './LocalTheme.tsx'
47
48
  export {
48
49
  Menu,
49
50
  type MenuEmptyProps,
@@ -8,7 +8,7 @@ import {
8
8
  type EDSComponent,
9
9
  } from '@edgeandnode/eds-css'
10
10
 
11
- import { styleScheduler } from '../utils/index.ts'
11
+ import { styleScheduler } from '../utils/internal/index.ts'
12
12
 
13
13
  /**
14
14
  * Returns a `ref` to set on an element, and an async function to get the current value of a CSS
@@ -1,19 +1,35 @@
1
1
  import { useContext } from 'react'
2
2
 
3
- import { EDSContext } from '../EDSContext.ts'
3
+ import { EDSContext, LocalThemeContext, THEME_CLASSES } from '../EDSContext.ts'
4
4
 
5
5
  /** Throws an error if used outside of a `EDSProvider`. */
6
6
  export function useEDS() {
7
7
  const context = useContext(EDSContext)
8
+ const localTheme = useContext(LocalThemeContext)
8
9
 
9
10
  if (context === null) {
10
11
  throw new Error('EDS components must be wrapped in a `EDSProvider`')
11
12
  }
12
13
 
13
14
  return {
15
+ /** The page's theme: `'dark'`, `'light'`, or `'system'` (follows the OS preference) */
14
16
  theme: context.theme,
15
- activeTheme: context.activeTheme,
17
+ /** Changes the page's theme (use `ThemeSwitcher` for the UI) */
16
18
  setTheme: context.setTheme,
19
+ /** The page's theme, with `'system'` resolved to `'dark'` or `'light'` */
20
+ activeTheme: context.activeTheme,
21
+ /**
22
+ * The theme of the nearest `Dark` or `Light` ancestor, or `undefined` if there's none. Use
23
+ * `localTheme ?? activeTheme` to get the theme content is rendered in.
24
+ */
25
+ localTheme,
26
+ /**
27
+ * The class of the nearest `Dark` or `Light` ancestor, or `undefined` if there's none. Add it
28
+ * to content rendered outside of that ancestor in the DOM (e.g. with `createPortal`) so it
29
+ * keeps its theme.
30
+ */
31
+ localThemeClass: localTheme !== undefined ? THEME_CLASSES[localTheme] : undefined,
32
+ /** The `dir` and `lang` of `EDSProvider`, to spread on content rendered outside of it. */
17
33
  dirProps: {
18
34
  dir: context.direction,
19
35
  lang: context.language,
@@ -1,7 +1,8 @@
1
1
  import { useCallback, useLayoutEffect, useRef, type Ref } from 'react'
2
2
  import { useMergedRefs } from '@base-ui/utils/useMergedRefs'
3
3
 
4
- import { styleObserver, styleScheduler, type StyleObserverRecord } from '../utils/index.ts'
4
+ import { styleObserver, type StyleObserverRecord } from '../utils/index.ts'
5
+ import { styleScheduler } from '../utils/internal/index.ts'
5
6
  import { useEffectWithRefDeps } from './useEffectWithRefDeps.ts'
6
7
 
7
8
  /**
@@ -16,6 +16,7 @@ export interface CalendarDynamicIconProps extends Omit<IconProps, 'alt'> {
16
16
 
17
17
  export function CalendarDynamicIcon({
18
18
  alt,
19
+ // oxlint-disable-next-line react/purity -- `useState(() => new Date())` would need `'use client'`, which would cause hydration mismatches (time zone, locale) where this is rendered by a server component
19
20
  date = new Date(),
20
21
  ...props
21
22
  }: CalendarDynamicIconProps) {
@@ -2,6 +2,7 @@ import { Children, isValidElement, type ReactNode } from 'react'
2
2
 
3
3
  import { getKey } from '@edgeandnode/eds-utils'
4
4
 
5
+ import { resolveLazy } from './internal/index.ts'
5
6
  import { reactNodeToString } from './reactNodeToString.ts'
6
7
 
7
8
  /**
@@ -12,18 +13,33 @@ import { reactNodeToString } from './reactNodeToString.ts'
12
13
  * @returns A string key that uniquely identifies the node structure and content.
13
14
  */
14
15
  export function getReactNodeKey(reactNode: ReactNode): string {
15
- const keys =
16
- Children.map(reactNode, (child) => {
17
- if (!child || typeof child === 'boolean') {
18
- return 'empty'
19
- }
20
- if (!isValidElement(child)) {
21
- return getKey(child)
22
- }
23
- if (child.key !== null) {
24
- return `key:${child.key}`
25
- }
26
- return `${getKey(child.type)}:${reactNodeToString(child)}`
27
- }) ?? []
28
- return keys.length > 0 ? keys.join('|') : 'empty'
16
+ try {
17
+ const keys =
18
+ Children.map(reactNode, (child) => {
19
+ if (!child || typeof child === 'boolean') {
20
+ return 'empty'
21
+ }
22
+ if (!isValidElement(child)) {
23
+ return getKey(child)
24
+ }
25
+ if (child.key !== null) {
26
+ return `key:${child.key}`
27
+ }
28
+ /**
29
+ * Key the type by the component it resolves to, so that identical content created by a
30
+ * server component gets the same key in every server response (each of which has new lazy
31
+ * types)
32
+ */
33
+ return `${getKey(resolveLazy(child.type))}:${reactNodeToString(child)}`
34
+ }) ?? []
35
+ return keys.length > 0 ? keys.join('|') : 'empty'
36
+ } catch (error) {
37
+ /**
38
+ * Resolving a lazy value (e.g. from a server component) suspends (throws a thenable) while it's
39
+ * still loading, which isn't possible in effects, where this is also called. Fall back to the
40
+ * node's identity then, but let any other error through.
41
+ */
42
+ if (typeof error !== 'object' || error === null || !('then' in error)) throw error
43
+ return getKey(reactNode)
44
+ }
29
45
  }
@@ -3,12 +3,12 @@ export { getAssetUrl } from './getAssetUrl.ts'
3
3
  export { getCSSPropsAttributes } from './getCSSPropsAttributes.ts'
4
4
  export { getReactNodeKey } from './getReactNodeKey.ts'
5
5
  export { InlineCounter } from './InlineCounter.tsx'
6
+ export { isComponentType } from './isComponentType.ts'
6
7
  export { isReactNode } from './isReactNode.ts'
7
8
  export { reactNodeToString } from './reactNodeToString.ts'
8
9
  export { RenderCount } from './RenderCount.tsx'
9
10
  export { setInputValue } from './setInputValue.ts'
10
11
  export { splitProps } from './splitProps.ts'
11
12
  export { styleObserver, type StyleObserverRecord } from './styleObserver.ts'
12
- export { styleScheduler } from './styleScheduler.ts'
13
13
  export { trimReactNode } from './trimReactNode.tsx'
14
14
  export type { OptionValue, Stringifiable } from './types.ts'
@@ -0,0 +1,25 @@
1
+ import type { TooltipProps } from '../../components/Tooltip.tsx'
2
+
3
+ /**
4
+ * Normalizes a component's `tooltip` prop, which accepts either the tooltip's content or its props.
5
+ * Checks for props (a plain object that isn't a React node) rather than for content, so that any
6
+ * kind of node is treated as content, including ones that `isReactNode` doesn't recognize (e.g.
7
+ * portals or promises).
8
+ */
9
+ export function getTooltipProps(
10
+ tooltip: TooltipProps['content'] | Omit<TooltipProps, 'children'>,
11
+ ): Omit<TooltipProps, 'children'> {
12
+ if (tooltip === undefined) return {}
13
+ return isTooltipProps(tooltip) ? tooltip : { content: tooltip }
14
+ }
15
+
16
+ function isTooltipProps(
17
+ tooltip: Exclude<TooltipProps['content'], undefined> | Omit<TooltipProps, 'children'>,
18
+ ): tooltip is Omit<TooltipProps, 'children'> {
19
+ return (
20
+ typeof tooltip === 'object' &&
21
+ tooltip !== null &&
22
+ !('$$typeof' in tooltip) &&
23
+ [Object.prototype, null].includes(Object.getPrototypeOf(tooltip))
24
+ )
25
+ }
@@ -0,0 +1,3 @@
1
+ export { getTooltipProps } from './getTooltipProps.ts'
2
+ export { isLazy, resolveLazy } from './resolveLazy.ts'
3
+ export { styleScheduler } from './styleScheduler.ts'