@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
@@ -1,32 +1,38 @@
1
1
  'use client'
2
2
 
3
- import { useCallback, useLayoutEffect, useMemo, useRef, useState, type ComponentProps } from 'react'
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ useSyncExternalStore,
10
+ type ComponentProps,
11
+ } from 'react'
4
12
  import { mergeProps } from '@base-ui/react'
5
13
  import { DirectionProvider } from '@base-ui/react/direction-provider'
6
14
  import { Tooltip } from '@base-ui/react/tooltip'
7
15
 
8
16
  import { ButtonOrLink, type ButtonOrLinkConfigProps } from './components/base/ButtonOrLink.tsx'
9
17
  import { Pane } from './components/Pane.tsx'
10
- import { EDSContext, type ResolvedTheme, type Theme, type ThemePersistence } from './EDSContext.ts'
18
+ import {
19
+ EDSContext,
20
+ THEME_CLASSES,
21
+ type ResolvedTheme,
22
+ type Theme,
23
+ type ThemePersistence,
24
+ } from './EDSContext.ts'
11
25
  import { useControlled } from './hooks/useControlled.ts'
12
26
  import { useIsHydrated } from './hooks/useIsHydrated.ts'
27
+ import {
28
+ COOKIE_REGEX,
29
+ isValidTheme,
30
+ parseThemeCookie,
31
+ PERSISTENCE_KEY,
32
+ VALID_THEMES,
33
+ } from './theme-persistence.ts'
13
34
  import { cn } from './utils/index.ts'
14
35
 
15
- const PERSISTENCE_KEY = 'eds-active-theme'
16
- const COOKIE_REGEX = new RegExp(`(?:^|; )${PERSISTENCE_KEY}=([^;]+)`)
17
-
18
- const THEME_CLASSES: Record<Theme, string> = {
19
- dark: 'eds-dark',
20
- light: 'eds-light',
21
- system: 'eds-system',
22
- }
23
-
24
- const VALID_THEMES: readonly Theme[] = ['dark', 'light', 'system']
25
-
26
- function isValidTheme(value: unknown): value is Theme {
27
- return typeof value === 'string' && (VALID_THEMES as readonly string[]).includes(value)
28
- }
29
-
30
36
  interface PersistenceAdapter {
31
37
  get: () => Theme | null
32
38
  set: (value: Theme) => void
@@ -59,17 +65,14 @@ function resolvePersistence(persistTheme: ThemePersistence | undefined): Persist
59
65
  return {
60
66
  get: () => {
61
67
  try {
62
- const match = document.cookie.match(COOKIE_REGEX)
63
- if (match === null) return null
64
- const value = decodeURIComponent(match[1] as string)
65
- return isValidTheme(value) ? value : null
68
+ return parseThemeCookie(document.cookie) ?? null
66
69
  } catch {
67
70
  return null
68
71
  }
69
72
  },
70
73
  set: (value) => {
71
74
  try {
72
- document.cookie = `${PERSISTENCE_KEY}=${encodeURIComponent(value)}; path=/; max-age=31536000; samesite=lax`
75
+ document.cookie = `${PERSISTENCE_KEY}=${value}; path=/; max-age=31536000; samesite=lax`
73
76
  } catch {
74
77
  // ignore restrictive environments
75
78
  }
@@ -99,12 +102,13 @@ function resolvePersistence(persistTheme: ThemePersistence | undefined): Persist
99
102
  const prefersDarkMediaQuery =
100
103
  typeof window !== 'undefined' ? window.matchMedia('(prefers-color-scheme: dark)') : null
101
104
 
102
- function resolveTheme(theme: Theme, systemFallback: ResolvedTheme = 'dark'): ResolvedTheme {
103
- if (theme === 'system') {
104
- if (prefersDarkMediaQuery === null) return systemFallback
105
- return prefersDarkMediaQuery.matches ? 'dark' : 'light'
106
- }
107
- return theme
105
+ function subscribeToSystemTheme(onChange: () => void) {
106
+ prefersDarkMediaQuery?.addEventListener('change', onChange)
107
+ return () => prefersDarkMediaQuery?.removeEventListener('change', onChange)
108
+ }
109
+
110
+ function getSystemTheme(): ResolvedTheme {
111
+ return prefersDarkMediaQuery?.matches === false ? 'light' : 'dark'
108
112
  }
109
113
 
110
114
  export interface EDSProviderProps extends Omit<ComponentProps<'div'>, 'dir'> {
@@ -130,16 +134,22 @@ export interface EDSProviderProps extends Omit<ComponentProps<'div'>, 'dir'> {
130
134
  * - `'localStorage'`: reads/writes `localStorage['eds-active-theme']`. Inline pre-hydration script
131
135
  * reads the value before first paint — no SSR flash.
132
136
  * - `'cookie'`: reads/writes a `eds-active-theme` cookie (`path=/; max-age=31536000;
133
- * samesite=lax`). Same no-flash SSR; also readable server-side (e.g. via Next.js `headers()`).
137
+ * samesite=lax`). Same no-flash SSR; pass `getThemeFromCookies()` to `defaultTheme` on the
138
+ * server so it renders the saved theme too.
134
139
  * - `{ get, set }`: custom adapter (different key, encryption, remote storage, etc.). Invalid
135
- * values from `get()` fall back to `defaultTheme`. No pre-hydration script is emitted; for
136
- * zero-flash SSR, inject your own inline script that adds the matching `eds-*` class to
137
- * `<html>` before paint.
140
+ * values from `get()` fall back to `defaultTheme`. In uncontrolled mode, no pre-hydration
141
+ * script is emitted; for zero-flash SSR, inject your own inline script that adds the matching
142
+ * `eds-*` class to `<html>` before paint.
138
143
  *
139
144
  * The two string presets share the key `'eds-active-theme'` — use a custom adapter for per-app
140
145
  * isolation on the same origin.
141
146
  */
142
147
  persistTheme?: ThemePersistence | undefined
148
+ /**
149
+ * Nonce for the inline scripts and styles `EDSProvider` adds, if your Content Security Policy
150
+ * requires one.
151
+ */
152
+ nonce?: string | undefined
143
153
  /** @default 'ltr' */
144
154
  direction?: ComponentProps<typeof DirectionProvider>['direction'] | undefined
145
155
  /** @default 'en' // or for `direction="rtl"`, 'ar' */
@@ -153,7 +163,8 @@ export type { EDSContextValue, ResolvedTheme, Theme, ThemePersistence } from './
153
163
  * Provides global configuration and enhancements for EDS components.
154
164
  *
155
165
  * Note: Only one EDSProvider should be used per app, typically at the root. If you need different
156
- * themes for different parts of the app, use the `eds-dark` / `eds-light` utility classes instead.
166
+ * themes for different parts of the app, use the `Dark`, `Light`, and `LocalTheme` components
167
+ * instead.
157
168
  *
158
169
  * **Next.js App Router:** add `suppressHydrationWarning` to your `<html>` tag in `app/layout.tsx`.
159
170
  * The pre-hydration script applies the theme class to `<html>` before React hydrates (preventing a
@@ -192,6 +203,7 @@ export function EDSProvider({
192
203
  defaultTheme = 'dark',
193
204
  onThemeChange,
194
205
  persistTheme,
206
+ nonce,
195
207
  direction = 'ltr',
196
208
  language = direction === 'rtl' ? 'ar' : 'en',
197
209
  buttonOrLinkConfig,
@@ -280,6 +292,7 @@ export function EDSProvider({
280
292
  }
281
293
 
282
294
  const style = document.createElement('style')
295
+ if (nonce !== undefined) style.nonce = nonce
283
296
  style.textContent = '*:not(.eds-segmented-control *) { transition: none !important; }'
284
297
  document.head.appendChild(style)
285
298
  removeClasses()
@@ -293,30 +306,15 @@ export function EDSProvider({
293
306
  style.remove()
294
307
  removeClasses()
295
308
  }
296
- }, [theme])
309
+ }, [theme, nonce])
297
310
 
298
- // `activeTheme` resolves `'system'` to `'dark'` or `'light'` via `prefers-color-scheme`. Initial
299
- // state skips `matchMedia` to keep SSR and first client render in sync; the effect catches up
300
- // pre-paint.
301
- const systemFallback: ResolvedTheme = defaultTheme === 'system' ? 'dark' : defaultTheme
302
-
303
- const [activeTheme, setActiveTheme] = useState<ResolvedTheme>(() =>
304
- theme === 'system' ? systemFallback : theme,
311
+ // The server can't know the OS preference, so it renders `'dark'`, `:root`'s default
312
+ const systemTheme = useSyncExternalStore<ResolvedTheme>(
313
+ subscribeToSystemTheme,
314
+ getSystemTheme,
315
+ () => 'dark',
305
316
  )
306
317
 
307
- useLayoutEffect(() => {
308
- setActiveTheme(resolveTheme(theme, systemFallback))
309
-
310
- if (theme !== 'system') return
311
-
312
- const handleChange = (event: MediaQueryListEvent) => {
313
- setActiveTheme(event.matches ? 'dark' : 'light')
314
- }
315
-
316
- prefersDarkMediaQuery?.addEventListener('change', handleChange)
317
- return () => prefersDarkMediaQuery?.removeEventListener('change', handleChange)
318
- }, [theme, systemFallback])
319
-
320
318
  /**
321
319
  * Add the `data-eds-active` attribute to the `html` element when the Space key is pressed to
322
320
  * improve Tailwind's `active` state.
@@ -370,15 +368,34 @@ export function EDSProvider({
370
368
  }
371
369
  }, [])
372
370
 
371
+ /**
372
+ * Warn if build tools transpiled `light-dark()` (e.g. Lightning CSS with older browser targets).
373
+ * Its fallback resolves tokens on `:root`, so components inside `Dark` and `Light` would get the
374
+ * page's colors.
375
+ */
376
+ useEffect(() => {
377
+ const lightDarkTest = getComputedStyle(document.documentElement).getPropertyValue(
378
+ '--eds-light-dark-test',
379
+ )
380
+ if (lightDarkTest !== '' && !lightDarkTest.includes('light-dark')) {
381
+ // oxlint-disable-next-line no-console
382
+ console.warn(
383
+ "[EDSProvider] `light-dark()` was transpiled by your build tools, so components inside `Dark`, `Light`, and `LocalTheme` will use the page's colors. Target Chrome 123, Safari 17.5, and Firefox 120 or newer (see the `@edgeandnode/eds-react` README).",
384
+ )
385
+ }
386
+ }, [])
387
+
388
+ const isHydrated = useIsHydrated()
389
+
373
390
  const edsContextValue = useMemo(
374
391
  () => ({
375
392
  theme,
376
- activeTheme,
377
393
  setTheme,
394
+ systemTheme,
378
395
  direction,
379
396
  language,
380
397
  }),
381
- [theme, activeTheme, setTheme, direction, language],
398
+ [theme, setTheme, systemTheme, direction, language],
382
399
  )
383
400
 
384
401
  return (
@@ -387,10 +404,19 @@ export function EDSProvider({
387
404
  <Pane.Provider>
388
405
  <Tooltip.Provider timeout={300}>
389
406
  <ButtonOrLink.Config {...buttonOrLinkConfig}>
390
- {/* Set the theme class on <html> before first paint to prevent a flash */}
391
- <PreHydrationScript>
392
- {buildThemeScript(controlledTheme, defaultTheme, persistTheme)}
393
- </PreHydrationScript>
407
+ {/**
408
+ * Apply the theme before first paint to prevent a flash, as the browser runs this script and
409
+ * applies this style while parsing the server-rendered HTML. The style applies a light or system
410
+ * theme until a theme class is set on `<html>`, in case there's no script (with a custom
411
+ * `persistTheme` adapter) or it can't run (e.g. blocked by a Content Security Policy). Both are
412
+ * removed after hydration.
413
+ */}
414
+ {isHydrated ? null : (
415
+ <>
416
+ {renderThemeScript(controlledTheme, defaultTheme, persistTheme, nonce)}
417
+ {renderThemeStyle(controlledTheme ?? defaultTheme, nonce)}
418
+ </>
419
+ )}
394
420
  <div
395
421
  dir={direction}
396
422
  lang={language}
@@ -406,38 +432,43 @@ export function EDSProvider({
406
432
  )
407
433
  }
408
434
 
409
- function buildThemeScript(
435
+ function renderThemeScript(
410
436
  controlledTheme: Theme | undefined,
411
437
  defaultTheme: Theme,
412
438
  persistTheme: ThemePersistence | undefined,
439
+ nonce: string | undefined,
413
440
  ) {
414
- // Controlled mode and the `undefined` / custom-adapter cases use the known theme — only the
415
- // string presets need a storage lookup.
416
- if (controlledTheme !== undefined) {
417
- return `document.documentElement.classList.add('eds-${controlledTheme}')`
418
- }
419
- if (persistTheme !== 'localStorage' && persistTheme !== 'cookie') {
420
- return `document.documentElement.classList.add('eds-${defaultTheme}')`
441
+ if (controlledTheme !== undefined || persistTheme === undefined) {
442
+ const script = `document.documentElement.classList.add('eds-${controlledTheme ?? defaultTheme}')`
443
+ return <script nonce={nonce}>{script}</script>
421
444
  }
445
+ // Custom adapters need the app's own script (see `persistTheme`), which this would conflict with
446
+ if (persistTheme !== 'localStorage' && persistTheme !== 'cookie') return null
422
447
 
423
- const invalidCheck = VALID_THEMES.map((t) => `t!=='${t}'`).join('&&')
448
+ const invalidCheck = VALID_THEMES.map((theme) => `t!=='${theme}'`).join('&&')
424
449
  const readExpression =
425
450
  persistTheme === 'localStorage'
426
451
  ? `localStorage.getItem('${PERSISTENCE_KEY}')`
427
- : `decodeURIComponent((document.cookie.match(/(?:^|; )${PERSISTENCE_KEY}=([^;]+)/)||[])[1]||'')`
428
- return `(function(){try{var t=${readExpression};if(${invalidCheck})t='${defaultTheme}';document.documentElement.classList.add('eds-'+t)}catch(e){document.documentElement.classList.add('eds-${defaultTheme}')}})()`
452
+ : `(document.cookie.match(${COOKIE_REGEX})||[])[1]`
453
+ const script = `(function(){try{var t=${readExpression};if(${invalidCheck})t='${defaultTheme}';document.documentElement.classList.add('eds-'+t)}catch(e){document.documentElement.classList.add('eds-${defaultTheme}')}})()`
454
+ return <script nonce={nonce}>{script}</script>
429
455
  }
430
456
 
431
- interface PreHydrationScriptProps extends ComponentProps<'script'> {}
457
+ const ROOT_WITHOUT_THEME_CLASS = `:root:not(${Object.values(THEME_CLASSES)
458
+ .map((themeClass) => `.${themeClass}`)
459
+ .join(', ')})`
432
460
 
433
461
  /**
434
- * Renders a `<script>` element that exists only until React hydration completes.
435
- *
436
- * Outputs its tag during SSR and during the client's initial render (ensuring the markup matches),
437
- * and removes itself after hydration to keep the DOM clean. The browser executes the inline script
438
- * as it parses the HTML, before React hydrates and before any styled body content paints.
462
+ * `:root` is dark by default, so other themes need a style until the theme class is set on
463
+ * `<html>`.
439
464
  */
440
- function PreHydrationScript({ children, ...props }: PreHydrationScriptProps) {
441
- if (useIsHydrated()) return null
442
- return <script {...props}>{children}</script>
465
+ const THEME_STYLES: Record<Theme, string | null> = {
466
+ dark: null,
467
+ light: `${ROOT_WITHOUT_THEME_CLASS}{color-scheme:only light;--eds-color-scheme:light}`,
468
+ system: `${ROOT_WITHOUT_THEME_CLASS}{color-scheme:light dark}@media (prefers-color-scheme:light){${ROOT_WITHOUT_THEME_CLASS}{--eds-color-scheme:light}}`,
469
+ }
470
+
471
+ function renderThemeStyle(theme: Theme, nonce: string | undefined) {
472
+ const style = THEME_STYLES[theme]
473
+ return style !== null ? <style nonce={nonce}>{style}</style> : null
443
474
  }
@@ -26,14 +26,15 @@ export declare namespace AddressProps {
26
26
  interface ButtonProps
27
27
  extends
28
28
  CommonProps,
29
- MaybeButtonOrLinkProps.ButtonProps,
29
+ Omit<MaybeButtonOrLinkProps.ButtonProps, 'as'>,
30
30
  MaybeButtonOrLinkProps.DisableableProps {}
31
31
  interface LinkProps
32
32
  extends
33
33
  CommonProps,
34
- MaybeButtonOrLinkProps.LinkProps,
34
+ Omit<MaybeButtonOrLinkProps.LinkProps, 'as'>,
35
35
  MaybeButtonOrLinkProps.DisableableProps {}
36
- interface OtherElementProps extends CommonProps, MaybeButtonOrLinkProps.OtherElementProps {}
36
+ interface OtherElementProps
37
+ extends CommonProps, Omit<MaybeButtonOrLinkProps.OtherElementProps, 'as'> {}
37
38
  }
38
39
 
39
40
  export type AddressProps =
@@ -9,7 +9,8 @@ import avatarPlaceholderRounded from '@edgeandnode/eds-react/assets/avatar-place
9
9
  import { createIdenticon, PLACEHOLDER_ADDRESS } from '@edgeandnode/eds-utils'
10
10
 
11
11
  import { useCSSPropsPolyfill, useEDS } from '../hooks/index.ts'
12
- import { cn, getAssetUrl, getCSSPropsAttributes, isReactNode } from '../utils/index.ts'
12
+ import { cn, getAssetUrl, getCSSPropsAttributes } from '../utils/index.ts'
13
+ import { getTooltipProps } from '../utils/internal/index.ts'
13
14
  import { AvatarMeta } from './Avatar.meta.ts'
14
15
  import { MaybeButtonOrLink, type MaybeButtonOrLinkProps } from './base/MaybeButtonOrLink.tsx'
15
16
  import { Tooltip, type TooltipProps } from './Tooltip.tsx'
@@ -35,10 +36,14 @@ export declare namespace AvatarProps {
35
36
  tooltip?: TooltipProps['content'] | Omit<TooltipProps, 'children'> | undefined
36
37
  }
37
38
  interface CommonProps extends OwnProps, EDSComponentProps<typeof AvatarMeta> {}
38
- interface ButtonProps extends CommonProps, Omit<MaybeButtonOrLinkProps.ButtonProps, 'children'> {}
39
- interface LinkProps extends CommonProps, Omit<MaybeButtonOrLinkProps.LinkProps, 'children'> {}
39
+ interface ButtonProps
40
+ extends CommonProps, Omit<MaybeButtonOrLinkProps.ButtonProps, 'as' | 'children' | 'render'> {}
41
+ interface LinkProps
42
+ extends CommonProps, Omit<MaybeButtonOrLinkProps.LinkProps, 'as' | 'children' | 'render'> {}
40
43
  interface OtherElementProps
41
- extends CommonProps, Omit<MaybeButtonOrLinkProps.OtherElementProps, 'children'> {}
44
+ extends
45
+ CommonProps,
46
+ Omit<MaybeButtonOrLinkProps.OtherElementProps, 'as' | 'children' | 'render'> {}
42
47
  }
43
48
 
44
49
  export type AvatarProps =
@@ -94,7 +99,7 @@ export function Avatar({
94
99
  return (
95
100
  <Tooltip
96
101
  content={category !== 'other' ? alt : undefined}
97
- {...(isReactNode(tooltip) && tooltip !== undefined ? { content: tooltip } : tooltip)}
102
+ {...getTooltipProps(tooltip)}
98
103
  triggerProps={renderProps}
99
104
  >
100
105
  <Element {...elementProps}>
@@ -1,15 +1,15 @@
1
1
  'use client'
2
2
 
3
3
  import {
4
- cloneElement,
4
+ createContext,
5
5
  isValidElement,
6
6
  useCallback,
7
+ useContext,
7
8
  useMemo,
8
9
  useRef,
9
10
  useState,
10
11
  type ComponentProps,
11
12
  type CSSProperties,
12
- type ReactElement,
13
13
  } from 'react'
14
14
  import { mergeProps } from '@base-ui/react'
15
15
  import { useRenderCount, useThrottledEffect } from '@react-hookz/web'
@@ -21,12 +21,20 @@ import { ArrowLeftInteractiveIcon, DotsThreeIcon } from '@edgeandnode/eds-react/
21
21
 
22
22
  import { useCSSPropsPolyfill, useCSSState, useEDS } from '../hooks/index.ts'
23
23
  import { cn, getCSSPropsAttributes, splitProps } from '../utils/index.ts'
24
+ import { resolveLazy } from '../utils/internal/index.ts'
24
25
  import { renderAddon, type AddonValue } from './base/Addon.tsx'
25
26
  import { MaybeButtonOrLink, type MaybeButtonOrLinkProps } from './base/MaybeButtonOrLink.tsx'
26
27
  import { BreadcrumbsItemMeta, BreadcrumbsMeta } from './Breadcrumbs.meta.ts'
27
28
  import { Button } from './Button.tsx'
28
29
  import { Menu } from './Menu.tsx'
29
30
 
31
+ /**
32
+ * Whether a `Breadcrumbs.Item` is (in) the last item of `Breadcrumbs`, which makes it default to
33
+ * `current`. Provided through context rather than by cloning the item, so that it also works when
34
+ * items are wrapped in another component.
35
+ */
36
+ const BreadcrumbsLastItemContext = createContext(false)
37
+
30
38
  export interface BreadcrumbsProps
31
39
  extends ComponentProps<'nav'>, EDSComponentProps<typeof BreadcrumbsMeta> {}
32
40
 
@@ -47,18 +55,23 @@ export function BreadcrumbsRoot({
47
55
 
48
56
  const ariaLabel = passedAriaLabel !== undefined ? passedAriaLabel : 'Breadcrumbs'
49
57
 
50
- // If the last `Breadcrumbs.Item` child doesn't explicitly set `current`, clone it to default
51
- // `current` to `true`.
52
58
  const processedChildren = useMemo(() => {
53
59
  const flatChildren = flattenChildren(children)
54
- for (let index = flatChildren.length - 1; index >= 0; index--) {
55
- const child = flatChildren[index]
56
- if (!isValidElement(child) || child.type !== BreadcrumbsItem) continue
57
- const element = child as ReactElement<BreadcrumbsItemProps>
58
- if (element.props.current !== undefined) return flatChildren
59
- return flatChildren.with(index, cloneElement(element, { current: true }))
60
- }
61
- return flatChildren
60
+ const lastItemIndex = flatChildren.findLastIndex(
61
+ (child) => isValidElement(child) && resolveLazy(child.type) === BreadcrumbsItem,
62
+ )
63
+ // If items are wrapped in another component, the last element renders the last item
64
+ const lastIndex =
65
+ lastItemIndex !== -1 ? lastItemIndex : flatChildren.findLastIndex(isValidElement)
66
+ return flatChildren.map((child, index) =>
67
+ isValidElement(child) ? (
68
+ <BreadcrumbsLastItemContext.Provider key={child.key} value={index === lastIndex}>
69
+ {child}
70
+ </BreadcrumbsLastItemContext.Provider>
71
+ ) : (
72
+ child
73
+ ),
74
+ )
62
75
  }, [children])
63
76
 
64
77
  const hiddenContainerRef = useRef<HTMLDivElement>(null)
@@ -249,15 +262,17 @@ export declare namespace BreadcrumbsItemProps {
249
262
  addonBefore?: AddonValue | undefined
250
263
  /**
251
264
  * Whether this breadcrumb item represents the current page. Defaults to `true` for the last
252
- * `Breadcrumbs.Item` child of `Breadcrumbs`, and `false` for any other item. Set explicitly to
253
- * override the default.
265
+ * `Breadcrumbs.Item` child of `Breadcrumbs` (or the item rendered by its last child, if items
266
+ * are wrapped in another component), and `false` for any other item. Set explicitly to override
267
+ * the default.
254
268
  */
255
269
  current?: boolean | undefined
256
270
  }
257
271
  interface CommonProps extends OwnProps, EDSComponentProps<typeof BreadcrumbsItemMeta> {}
258
- interface ButtonProps extends CommonProps, MaybeButtonOrLinkProps.ButtonProps {}
259
- interface LinkProps extends CommonProps, MaybeButtonOrLinkProps.LinkProps {}
260
- interface OtherElementProps extends CommonProps, MaybeButtonOrLinkProps.OtherElementProps {}
272
+ interface ButtonProps extends CommonProps, Omit<MaybeButtonOrLinkProps.ButtonProps, 'as'> {}
273
+ interface LinkProps extends CommonProps, Omit<MaybeButtonOrLinkProps.LinkProps, 'as'> {}
274
+ interface OtherElementProps
275
+ extends CommonProps, Omit<MaybeButtonOrLinkProps.OtherElementProps, 'as'> {}
261
276
  }
262
277
 
263
278
  export type BreadcrumbsItemProps =
@@ -267,11 +282,13 @@ export type BreadcrumbsItemProps =
267
282
 
268
283
  export function BreadcrumbsItem({
269
284
  addonBefore,
270
- current = false,
285
+ current: passedCurrent,
271
286
  hideLabel,
272
287
  children,
273
288
  ...props
274
289
  }: BreadcrumbsItemProps) {
290
+ const isLastItem = useContext(BreadcrumbsLastItemContext)
291
+ const current = passedCurrent ?? isLastItem
275
292
  const { rootProps, nestedProps } = splitProps(props)
276
293
 
277
294
  const [stateRef, state] = useCSSState({
@@ -7,7 +7,8 @@ import type { EDSComponentProps } from '@edgeandnode/eds-css'
7
7
  import { LoadingIcon } from '@edgeandnode/eds-react/icons'
8
8
 
9
9
  import { useCSSPropsPolyfill, useCSSState, useEDS } from '../hooks/index.ts'
10
- import { cn, getCSSPropsAttributes, isReactNode } from '../utils/index.ts'
10
+ import { cn, getCSSPropsAttributes } from '../utils/index.ts'
11
+ import { getTooltipProps } from '../utils/internal/index.ts'
11
12
  import { renderAddon, type AddonValue } from './base/Addon.tsx'
12
13
  import { ButtonOrLink, type ButtonOrLinkProps } from './base/ButtonOrLink.tsx'
13
14
  import { ButtonMeta } from './Button.meta.ts'
@@ -132,7 +133,7 @@ export function Button({
132
133
  render={(renderProps, { Element, elementProps }) => (
133
134
  <Tooltip
134
135
  closeOnClick={typeof props.checked === 'boolean' ? false : undefined} // If this is a toggle button, keep the tooltip open on click by default
135
- {...(isReactNode(tooltip) && tooltip !== undefined ? { content: tooltip } : tooltip)}
136
+ {...getTooltipProps(tooltip)}
136
137
  triggerProps={renderProps}
137
138
  >
138
139
  <Element {...elementProps}>
@@ -167,6 +168,10 @@ export function Button({
167
168
  u:*:transition-opacity
168
169
  u:*:@state-disabled/button:opacity-disabled
169
170
  u:*:@state-disabled/button:grayscale
171
+ u:*:@prop-variant-inverse/button:scheme-only-dark
172
+ u:*:@prop-variant-inverse/button:dark:scheme-only-light
173
+ u:*:@prop-variant-primary/button:scheme-only-dark
174
+ u:*:@prop-variant-danger/button:scheme-only-dark
170
175
  u:@prop-size-xsmall/button:addon-small
171
176
  u:@prop-size-small/button:addon-small
172
177
  u:@prop-size-medium/button:addon-medium
@@ -1,7 +1,8 @@
1
1
  'use client'
2
2
 
3
- import { useId, type ReactNode } from 'react'
3
+ import { useId, type ElementType, type ReactNode, type Ref } from 'react'
4
4
  import { mergeProps } from '@base-ui/react'
5
+ import type { DistributedOmit } from 'type-fest'
5
6
 
6
7
  import type { EDSComponentProps } from '@edgeandnode/eds-css'
7
8
  import { ArrowUpRightInteractiveIcon } from '@edgeandnode/eds-react/icons'
@@ -32,20 +33,24 @@ export declare namespace CardProps {
32
33
  */
33
34
  autoIcon?: boolean | undefined
34
35
  }
36
+ interface CommonProps extends EDSComponentProps<typeof CardMeta> {
37
+ /** Attached to the card's outer element, even when the card is a button or link. */
38
+ ref?: Ref<HTMLElement> | undefined
39
+ }
35
40
  interface ButtonProps
36
41
  extends
37
- EDSComponentProps<typeof CardMeta>,
42
+ CommonProps,
38
43
  ClickableProps,
39
- MaybeButtonOrLinkProps.ButtonProps,
44
+ Omit<MaybeButtonOrLinkProps.ButtonProps, 'ref' | 'render'>,
40
45
  MaybeButtonOrLinkProps.DisableableProps {}
41
46
  interface LinkProps
42
47
  extends
43
- EDSComponentProps<typeof CardMeta>,
48
+ CommonProps,
44
49
  ClickableProps,
45
- MaybeButtonOrLinkProps.LinkProps,
50
+ Omit<MaybeButtonOrLinkProps.LinkProps, 'ref' | 'render'>,
46
51
  MaybeButtonOrLinkProps.DisableableProps {}
47
52
  interface OtherElementProps
48
- extends EDSComponentProps<typeof CardMeta>, MaybeButtonOrLinkProps.OtherElementProps {}
53
+ extends CommonProps, Omit<MaybeButtonOrLinkProps.OtherElementProps, 'ref' | 'render'> {}
49
54
  }
50
55
 
51
56
  export type CardProps = CardProps.ButtonProps | CardProps.LinkProps | CardProps.OtherElementProps
@@ -57,7 +62,7 @@ export function Card({ ref: passedRef, as, variant, disabled, children, ...props
57
62
  interactiveContent = false,
58
63
  autoIcon = true,
59
64
  ...remainingProps
60
- } = props as CardProps.ButtonProps | CardProps.LinkProps
65
+ } = props as DistributedOmit<CardProps.ButtonProps | CardProps.LinkProps, 'ref'>
61
66
  const { rootProps, nestedProps } = splitProps(remainingProps)
62
67
 
63
68
  const [statePassedRef, state] = useCSSState(
@@ -75,7 +80,8 @@ export function Card({ ref: passedRef, as, variant, disabled, children, ...props
75
80
  { ref: statePassedRef },
76
81
  )
77
82
 
78
- const Element = as ?? 'div'
83
+ // The union of every HTML tag name is too complex a type for JSX
84
+ const Element = (as ?? 'div') as ElementType
79
85
  const uniqueId = useId()
80
86
 
81
87
  return (
@@ -11,11 +11,13 @@ import { ClusterMeta } from './Cluster.meta.ts'
11
11
 
12
12
  export declare namespace ClusterProps {
13
13
  interface ButtonProps
14
- extends EDSComponentProps<typeof ClusterMeta>, MaybeButtonOrLinkProps.ButtonProps {}
14
+ extends EDSComponentProps<typeof ClusterMeta>, Omit<MaybeButtonOrLinkProps.ButtonProps, 'as'> {}
15
15
  interface LinkProps
16
- extends EDSComponentProps<typeof ClusterMeta>, MaybeButtonOrLinkProps.LinkProps {}
16
+ extends EDSComponentProps<typeof ClusterMeta>, Omit<MaybeButtonOrLinkProps.LinkProps, 'as'> {}
17
17
  interface OtherElementProps
18
- extends EDSComponentProps<typeof ClusterMeta>, MaybeButtonOrLinkProps.OtherElementProps {}
18
+ extends
19
+ EDSComponentProps<typeof ClusterMeta>,
20
+ Omit<MaybeButtonOrLinkProps.OtherElementProps, 'as'> {}
19
21
  }
20
22
 
21
23
  export type ClusterProps =