@edgeandnode/eds-react 1.0.1 → 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 +20 -3
  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 +8 -7
  117. package/scripts/update-skill.cjs +18 -5
  118. package/skill/SKILL.md +13 -9
  119. package/skill/references/tokens.md +2 -8
  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 -22499
  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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgeandnode/eds-react",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "React components for the Edge Design System",
5
5
  "author": "Edge & Node and contributors",
6
6
  "license": "MIT",
@@ -35,8 +35,9 @@
35
35
  "src",
36
36
  "!src/**/*.test.*",
37
37
  "!src/**/*.stories.*",
38
+ "!src/**/__snapshots__",
39
+ "!styles/tailwind.storybook.css",
38
40
  "skill",
39
- "!skill/.benchmark",
40
41
  "scripts/update-skill.cjs"
41
42
  ],
42
43
  "peerDependencies": {
@@ -48,14 +49,14 @@
48
49
  "dependencies": {
49
50
  "@base-ui/react": "^1.8.0",
50
51
  "@base-ui/utils": "^0.4.0",
51
- "@edgeandnode/eds-css": "1.0.1",
52
- "@edgeandnode/eds-icons": "1.0.0",
53
- "@edgeandnode/eds-utils": "1.0.0",
52
+ "@edgeandnode/eds-css": "1.0.2",
53
+ "@edgeandnode/eds-icons": "1.0.2",
54
+ "@edgeandnode/eds-utils": "1.0.2",
54
55
  "@react-aria/utils": "^3.34.1",
55
56
  "@react-hookz/web": "^26.1.0",
56
57
  "@ver0/deep-equal": "^2.0.1",
57
58
  "escape-string-regexp": "^5.0.0",
58
- "motion": "^13.4.0",
59
+ "motion": "^13.4.5",
59
60
  "react-aria": "^3.52.1",
60
61
  "react-keyed-flatten-children": "^5.1.3",
61
62
  "react-markdown": "^10.1.0",
@@ -77,7 +78,7 @@
77
78
  "@tailwindcss/cli": "^4.3.3",
78
79
  "@tailwindcss/postcss": "^4.3.3",
79
80
  "@tailwindcss/vite": "^4.3.3",
80
- "@types/node": "^26.6.2",
81
+ "@types/node": "^26.6.3",
81
82
  "@types/react": "^19.3.0",
82
83
  "@types/react-dom": "^19.3.0",
83
84
  "@types/react-is": "^19.3.0",
@@ -14,6 +14,7 @@ const {
14
14
  mkdirSync,
15
15
  copyFileSync,
16
16
  unlinkSync,
17
+ rmSync,
17
18
  statSync,
18
19
  } = require('fs')
19
20
  const { join, dirname, relative } = require('path')
@@ -87,14 +88,11 @@ function compareSemver(a, b) {
87
88
  return 0
88
89
  }
89
90
 
90
- /**
91
- * Recursively copy a directory, overwriting existing files. Only copies files that are included in
92
- * the published package (skips dotdirs).
93
- */
91
+ /** Recursively copy a directory, overwriting existing files. */
94
92
  function copyDir(src, dest) {
95
93
  mkdirSync(dest, { recursive: true })
96
94
  for (const entry of readdirSync(src, { withFileTypes: true })) {
97
- // Skip dotdirs (e.g. .benchmark) — they're not shipped
95
+ // Skip dotfiles and dotdirs (e.g. `.DS_Store` when the package is linked from a workspace)
98
96
  if (entry.name.startsWith('.')) continue
99
97
 
100
98
  const srcPath = join(src, entry.name)
@@ -145,6 +143,21 @@ for (const targetDir of foundDirs) {
145
143
  // If no version field, this may be a custom skill named "eds" — skip silently
146
144
  if (!installedVersion) continue
147
145
 
146
+ /**
147
+ * The skill benchmark used to live in `skill/.benchmark`, so `npx skills add` (which copies the
148
+ * whole skill folder from GitHub) installed it too. It now lives outside the skill folder, so
149
+ * remove any stale copy. This runs even when the skill is up to date, so that a removal that
150
+ * failed (e.g. because a file was locked) is retried on the next install instead of failing it.
151
+ */
152
+ const benchmarkDir = join(targetDir, '.benchmark')
153
+ try {
154
+ rmSync(benchmarkDir, { recursive: true, force: true })
155
+ } catch (error) {
156
+ console.warn(
157
+ `Couldn't remove the stale EDS skill benchmark at ${relative(initCwd, benchmarkDir)}: ${error.message}`,
158
+ )
159
+ }
160
+
148
161
  // If installed version is >= package version, skip (prevents downgrades in monorepos)
149
162
  if (compareSemver(installedVersion, packageVersion) >= 0) {
150
163
  console.log(`EDS skill is up to date (v${installedVersion}) at ${relative(initCwd, targetDir)}`)
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: eds
3
- version: 1.0.1
3
+ version: 1.0.2
4
4
  description: The Edge Design System (EDS) is for building user interfaces for Edge & Node products. Use this skill when designing, implementing, reviewing, or polishing Edge & Node product UIs, when using `@edgeandnode/eds-react`, or when EDS is mentioned. Covers component selection, design patterns, tokens, and best practices – not just APIs. APIs are fully typed in the npm package.
5
5
  allowed-tools:
6
6
  - mcp__storybook__get-changed-stories
@@ -28,7 +28,7 @@ EDS is the design system for Edge & Node products. This skill teaches you how to
28
28
  "mcpServers": {
29
29
  "storybook": {
30
30
  "type": "http",
31
- "url": "https://f87aca8--6ab2abb8934bb76c7e8e086c.chromatic.com/mcp"
31
+ "url": "https://e4b81a8--6ab2abb8934bb76c7e8e086c.chromatic.com/mcp"
32
32
  }
33
33
  }
34
34
  }
@@ -43,7 +43,7 @@ Add this to the project's `.mcp.json` file (create it if it doesn't exist). For
43
43
  ```json
44
44
  {
45
45
  "dependencies": {
46
- "@edgeandnode/eds-react": "^1.0.1",
46
+ "@edgeandnode/eds-react": "^1.0.2",
47
47
  "react": "^19.0.0",
48
48
  "react-dom": "^19.0.0"
49
49
  },
@@ -67,6 +67,8 @@ Add this to the project's `.mcp.json` file (create it if it doesn't exist). For
67
67
 
68
68
  **Important:** `@edgeandnode/eds-css`, which `@edgeandnode/eds-react` depends on, disables Tailwind's default content scanning as an optimization, so you must explicitly [register your source paths with `@source` directives](https://tailwindcss.com/docs/detecting-classes-in-source-files#explicitly-registering-sources). Make sure to include all paths where Tailwind classes are used.
69
69
 
70
+ **Browser targets:** EDS's color tokens rely on native `light-dark()`, so the app must target Chrome 123, Safari 17.5, and Firefox 120 or newer. Otherwise, build tools transpile it (e.g. Lightning CSS, which Vite and Next.js use) and components inside `<Dark>`/`<Light>` get the page's colors (`EDSProvider` warns in the console; with Vite, only in production builds). In Next.js, add `"browserslist": ["chrome >= 123", "edge >= 123", "firefox >= 120", "safari >= 17.5", "ios_saf >= 17.5"]` to `package.json`; in Vite, set `build.target: ['chrome123', 'edge123', 'firefox120', 'safari17.5', 'ios17.5']` (it also covers CSS, and unlike `build.cssTarget`, a Vite-based Storybook picks it up).
71
+
70
72
  ### App Root
71
73
 
72
74
  ```tsx
@@ -80,7 +82,7 @@ function App() {
80
82
  }
81
83
  ```
82
84
 
83
- **Only one `EDSProvider` per app** — rendering more than one will throw an error. To apply different themes to parts of the UI, use the `eds-dark` / `eds-light` / `eds-system` classes instead (see "Theme Classes" in [tokens.md](references/tokens.md)).
85
+ **Only one `EDSProvider` per app** — rendering more than one will throw an error. To render part of the UI in a different theme, wrap it in `<Dark>` or `<Light>` instead (e.g. `<Dark as="section">`).
84
86
 
85
87
  ### Theming
86
88
 
@@ -89,12 +91,12 @@ function App() {
89
91
  To read or change the theme from anywhere in the app, use the `useEDS()` hook:
90
92
 
91
93
  ```tsx
92
- import { useEDS } from '@edgeandnode/eds-react'
94
+ import { useEDS } from '@edgeandnode/eds-react/hooks'
93
95
 
94
96
  const { theme, activeTheme, setTheme } = useEDS()
95
97
  ```
96
98
 
97
- `theme` is `'dark' | 'light' | 'system'`. `activeTheme` resolves `'system'` to `'dark'` or `'light'` based on the user's OS preference. `setTheme(theme)` changes it (use `<ThemeSwitcher>` for the UI).
99
+ `theme` is `'dark' | 'light' | 'system'`. `activeTheme` resolves `'system'` to `'dark'` or `'light'` based on the user's OS preference. `setTheme(theme)` changes it (use `<ThemeSwitcher>` for the UI). `localTheme` is the theme of the nearest `<Dark>` or `<Light>` ancestor, or `undefined` if there's none (including inside a `Modal`, which always uses the page's theme) — use `localTheme ?? activeTheme` when rendering something that depends on the theme in JavaScript. `localThemeClass` is that ancestor's class (or `undefined` if there's none) — add it to content you render outside of that ancestor in the DOM (e.g. with `createPortal`) so it keeps its theme.
98
100
 
99
101
  ### Router Integration (Next.js)
100
102
 
@@ -212,7 +214,7 @@ import { EDSProvider } from '@edgeandnode/eds-react'
212
214
  </Button>
213
215
  ```
214
216
 
215
- 15. **Pass icons as components, not elements, in addon props.** Use `addonBefore={PlayIcon}` not `addonBefore={<PlayIcon alt="" />}`. The element form (`<PlayIcon alt="..." />`) is only needed when passing a non-empty `alt` or a `variant`, or when the component consuming the addon is a React Server Component (RSCs can't receive functions as props — use `addonBefore={<PlayIcon alt="" />}`). Addons have a default size tied to the parent component — don't set `size` on them.
217
+ 15. **Pass icons as components, not elements, in addon props.** Use `addonBefore={PlayIcon}` not `addonBefore={<PlayIcon alt="" />}`. The element form (`<PlayIcon alt="..." />`) is only needed when passing a non-empty `alt` or a `variant`, or when passing it from a server component (server components can't pass functions to client components — use `addonBefore={<PlayIcon alt="" />}`). Addons have a default size tied to the parent component — don't set `size` on them.
216
218
 
217
219
  16. **EDS icons don't shrink.** Icons from `@edgeandnode/eds-react/icons` have `shrink-0` built in. Don't add it manually — it's redundant. However, if you wrap an icon in another element (e.g. a `div`), that wrapper may still need `shrink-0` if it's a child of a `flex` container.
218
220
 
@@ -523,6 +525,7 @@ Memorize this list before any UI work to avoid reimplementing existing component
523
525
  | `CodeInline` | Inline code for variables, function names, etc. |
524
526
  | `CopyButton` | Copy to clipboard. Extends `Button`. |
525
527
  | `CurrencyInput` | Crypto amounts. Use with `useCurrencyInput` hook. |
528
+ | `Dark` / `Light` | Renders part of the page in the dark or light theme. |
526
529
  | `DescriptionList` | Key-value pairs, metadata. Underused. |
527
530
  | `Divider` | Visual separation. Horizontal or vertical. |
528
531
  | `Icon` | Wraps icons. `src` and `alt` required. |
@@ -664,7 +667,7 @@ Some components expand to fill available width by default: `Card`, `CodeBlock`,
664
667
 
665
668
  - **Address:** Handles truncation, ENS/friendly names (via `children`), copy (`'auto'` by default — shows on hover/focus). **Note:** `Address` renders its own `Avatar` using `createIdenticon(address)` — don't add a separate `Avatar` next to it or you'll get duplicate avatars.
666
669
  - **Button & ButtonGroup:** Set `size` on the group, not nested buttons. Can mix `tertiary` and `secondary`. `orientation="vertical"` for modals/panes. `fullWidth` makes buttons equal width; `className="w-full"` makes them grow proportionally. Danger takes over primary's visual role. **Sizing:** Product UIs should default to `size="small"` for most buttons; use medium (default) only for important actions. `size="large"` is for marketing pages only.
667
- - **Breadcrumbs:** Structure: `Product Name > Section > Current Page`. **Quirk:** `current` defaults to `true` when no `href` — always pass `current` explicitly.
670
+ - **Breadcrumbs:** Structure: `Product Name > Section > Current Page`. The last item defaults to `current`, so only set `current` to override that.
668
671
  - **Chip.Group:** Requires `aria-label`.
669
672
  - **DescriptionList:** Long values truncate with gradient fade, scroll on mobile.
670
673
  - **Input:** For number inputs, use the `useNumberInput` hook (or `useCurrencyInput` for currency amounts) — it handles parsing, formatting, and min/max/required validation, and returns `props` to spread on `<Input>` (or `<CurrencyInput>`).
@@ -689,6 +692,7 @@ Some components carry no built-in visual styles — only behavior, semantics, or
689
692
  - **`Checkbox.Area` / `Radio.Area` / `Switch.Area`** — clickable label wrappers for card-style checkable patterns; style them as `Card`s, rows, tiles, whatever the design calls for. See the "Checkable Cards" section in [patterns.md](references/patterns.md).
690
693
  - **`Radio.Group`** — semantic wrapper around a group of `<Radio>`s. Apply your own layout (`flex`, `gap`, etc.).
691
694
  - **`Pane`** — responsive sidebar container. Provides positioning behavior but no visual styling.
695
+ - **`Dark` / `Light`** — render their element (`as`, `div` by default) in the dark or light theme. They only set the color scheme and default text color, so style them as the section needs (e.g. `bg-default p-8`). `dark:`/`light:` variants on the element itself still follow the outer theme (style queries only look at ancestors), so use them on its descendants.
692
696
  - **`ButtonOrLink`** (from `@edgeandnode/eds-react/base`) — semantic `<button>`/`<a>` wrapper with no visual styling. The other base components (`Transition`, `ExperimentalTransition`, `Presence`, `Render`, `Portal`) have transition or structural styles you shouldn't override.
693
697
 
694
698
  ### Base components
@@ -724,7 +728,7 @@ import { formatBigInt, parseBigInt, numberToBigInt, bigIntToNumber } from '@edge
724
728
  ```
725
729
 
726
730
  - `cn` — concatenate class names, filtering out falsy values (like `clsx`)
727
- - `useEDS` — read and change the current theme (`{ theme, activeTheme, setTheme, dirProps }`); see "Theming"
731
+ - `useEDS` — read and change the current theme (`{ theme, setTheme, activeTheme, localTheme, localThemeClass, dirProps }`); see "Theming"
728
732
  - `useControlled` — manage controlled/uncontrolled state
729
733
  - `useNumberInput` — numeric input validation and formatting (use with `Input`)
730
734
  - `useCurrencyInput` — currency input validation (use with `CurrencyInput`)
@@ -166,15 +166,9 @@ Standard Tailwind spacing works (4px grid). EDS does **not** override spacing to
166
166
 
167
167
  When in doubt, add more space. Dense UIs look cheap.
168
168
 
169
- ## Theme Classes
169
+ ## Theme Sections
170
170
 
171
- | Class | Effect |
172
- | ------------ | ---------------------------------------------------------- |
173
- | `eds-dark` | Forces dark color scheme for this element and descendants |
174
- | `eds-light` | Forces light color scheme for this element and descendants |
175
- | `eds-system` | Follows the OS color scheme preference |
176
-
177
- These can be applied to any element to create nested theme regions. The `EDSProvider` `theme` prop sets the root theme; these classes override it locally.
171
+ The `EDSProvider` `theme` prop sets the root theme. To render part of the UI in another theme, wrap it in `<Dark>` or `<Light>` (e.g. `<Dark as="section">`). They can be nested, and components inside them can read the section's theme with `useEDS().localTheme`. Menus, selects, and tooltips use the theme of the section they're in, while modals use the page's theme.
178
172
 
179
173
  ## Breakpoints
180
174
 
package/src/EDSContext.ts CHANGED
@@ -6,6 +6,12 @@ import type { DirectionProvider } from '@base-ui/react/direction-provider'
6
6
  export type Theme = 'dark' | 'light' | 'system'
7
7
  export type ResolvedTheme = Exclude<Theme, 'system'>
8
8
 
9
+ export const THEME_CLASSES: Record<Theme, string> = {
10
+ dark: 'eds-dark',
11
+ light: 'eds-light',
12
+ system: 'eds-system',
13
+ }
14
+
9
15
  export type ThemePersistence =
10
16
  | 'localStorage'
11
17
  | 'cookie'
@@ -23,3 +29,6 @@ export interface EDSContextValue {
23
29
  }
24
30
 
25
31
  export const EDSContext = createContext<EDSContextValue | null>(null)
32
+
33
+ /** The theme of the nearest `Dark` or `Light` ancestor, if any. */
34
+ export const LocalThemeContext = createContext<ResolvedTheme | undefined>(undefined)
@@ -1,13 +1,27 @@
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
+ useState,
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'
13
27
  import { cn } from './utils/index.ts'
@@ -15,12 +29,6 @@ import { cn } from './utils/index.ts'
15
29
  const PERSISTENCE_KEY = 'eds-active-theme'
16
30
  const COOKIE_REGEX = new RegExp(`(?:^|; )${PERSISTENCE_KEY}=([^;]+)`)
17
31
 
18
- const THEME_CLASSES: Record<Theme, string> = {
19
- dark: 'eds-dark',
20
- light: 'eds-light',
21
- system: 'eds-system',
22
- }
23
-
24
32
  const VALID_THEMES: readonly Theme[] = ['dark', 'light', 'system']
25
33
 
26
34
  function isValidTheme(value: unknown): value is Theme {
@@ -153,7 +161,7 @@ export type { EDSContextValue, ResolvedTheme, Theme, ThemePersistence } from './
153
161
  * Provides global configuration and enhancements for EDS components.
154
162
  *
155
163
  * 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.
164
+ * themes for different parts of the app, use the `Dark` and `Light` components instead.
157
165
  *
158
166
  * **Next.js App Router:** add `suppressHydrationWarning` to your `<html>` tag in `app/layout.tsx`.
159
167
  * The pre-hydration script applies the theme class to `<html>` before React hydrates (preventing a
@@ -370,6 +378,23 @@ export function EDSProvider({
370
378
  }
371
379
  }, [])
372
380
 
381
+ /**
382
+ * Warn if build tools transpiled `light-dark()` (e.g. Lightning CSS with older browser targets).
383
+ * Its fallback resolves tokens on `:root`, so components inside `Dark` and `Light` would get the
384
+ * page's colors.
385
+ */
386
+ useEffect(() => {
387
+ const lightDarkTest = getComputedStyle(document.documentElement).getPropertyValue(
388
+ '--eds-light-dark-test',
389
+ )
390
+ if (lightDarkTest !== '' && !lightDarkTest.includes('light-dark')) {
391
+ // oxlint-disable-next-line no-console
392
+ console.warn(
393
+ "[EDSProvider] `light-dark()` was transpiled by your build tools, so components inside `Dark` and `Light` will use the page's colors. Target Chrome 123, Safari 17.5, and Firefox 120 or newer (see the `@edgeandnode/eds-react` README).",
394
+ )
395
+ }
396
+ }, [])
397
+
373
398
  const edsContextValue = useMemo(
374
399
  () => ({
375
400
  theme,
@@ -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'
@@ -94,7 +95,7 @@ export function Avatar({
94
95
  return (
95
96
  <Tooltip
96
97
  content={category !== 'other' ? alt : undefined}
97
- {...(isReactNode(tooltip) && tooltip !== undefined ? { content: tooltip } : tooltip)}
98
+ {...getTooltipProps(tooltip)}
98
99
  triggerProps={renderProps}
99
100
  >
100
101
  <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,8 +262,9 @@ 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
  }
@@ -267,11 +281,13 @@ export type BreadcrumbsItemProps =
267
281
 
268
282
  export function BreadcrumbsItem({
269
283
  addonBefore,
270
- current = false,
284
+ current: passedCurrent,
271
285
  hideLabel,
272
286
  children,
273
287
  ...props
274
288
  }: BreadcrumbsItemProps) {
289
+ const isLastItem = useContext(BreadcrumbsLastItemContext)
290
+ const current = passedCurrent ?? isLastItem
275
291
  const { rootProps, nestedProps } = splitProps(props)
276
292
 
277
293
  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}>
@@ -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
+ }