@edgeandnode/eds-react 1.0.2 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +32 -7
  2. package/dist/EDSContext.d.ts +2 -2
  3. package/dist/EDSContext.d.ts.map +1 -1
  4. package/dist/EDSContext.js +1 -1
  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 +48 -63
  9. package/dist/EDSProvider.js.map +1 -1
  10. package/dist/components/Avatar.d.ts +3 -3
  11. package/dist/components/Avatar.d.ts.map +1 -1
  12. package/dist/components/Avatar.js.map +1 -1
  13. package/dist/components/Button.d.ts.map +1 -1
  14. package/dist/components/Button.js +1 -1
  15. package/dist/components/Button.js.map +1 -1
  16. package/dist/components/Card.d.ts +8 -4
  17. package/dist/components/Card.d.ts.map +1 -1
  18. package/dist/components/Card.js +2 -1
  19. package/dist/components/Card.js.map +1 -1
  20. package/dist/components/Keyboard.js +1 -1
  21. package/dist/components/LocalTheme.d.ts +21 -8
  22. package/dist/components/LocalTheme.d.ts.map +1 -1
  23. package/dist/components/LocalTheme.js +10 -4
  24. package/dist/components/LocalTheme.js.map +1 -1
  25. package/dist/components/Menu.parts.d.ts +43 -9
  26. package/dist/components/Menu.parts.d.ts.map +1 -1
  27. package/dist/components/Menu.parts.js +118 -114
  28. package/dist/components/Menu.parts.js.map +1 -1
  29. package/dist/components/Modal.parts.d.ts +1 -1
  30. package/dist/components/Modal.parts.d.ts.map +1 -1
  31. package/dist/components/Modal.parts.js +2 -2
  32. package/dist/components/Modal.parts.js.map +1 -1
  33. package/dist/components/Pane.parts.d.ts +23 -22
  34. package/dist/components/Pane.parts.d.ts.map +1 -1
  35. package/dist/components/Pane.parts.js +3 -3
  36. package/dist/components/Pane.parts.js.map +1 -1
  37. package/dist/components/Prose.d.ts +1 -1
  38. package/dist/components/Prose.d.ts.map +1 -1
  39. package/dist/components/Prose.js +3 -3
  40. package/dist/components/Prose.js.map +1 -1
  41. package/dist/components/Prose.meta.d.ts +5 -0
  42. package/dist/components/Prose.meta.d.ts.map +1 -1
  43. package/dist/components/Prose.meta.js +10 -0
  44. package/dist/components/Prose.meta.js.map +1 -1
  45. package/dist/components/Select.parts.d.ts +8 -3
  46. package/dist/components/Select.parts.d.ts.map +1 -1
  47. package/dist/components/Select.parts.js +26 -17
  48. package/dist/components/Select.parts.js.map +1 -1
  49. package/dist/components/ThemeSwitcher.d.ts +5 -5
  50. package/dist/components/ThemeSwitcher.d.ts.map +1 -1
  51. package/dist/components/ThemeSwitcher.js +5 -4
  52. package/dist/components/ThemeSwitcher.js.map +1 -1
  53. package/dist/components/base/MaybeButtonOrLink.d.ts +7 -5
  54. package/dist/components/base/MaybeButtonOrLink.d.ts.map +1 -1
  55. package/dist/components/base/MaybeButtonOrLink.js +7 -2
  56. package/dist/components/base/MaybeButtonOrLink.js.map +1 -1
  57. package/dist/components/base/Render.d.ts +25 -6
  58. package/dist/components/base/Render.d.ts.map +1 -1
  59. package/dist/components/base/Render.js +4 -3
  60. package/dist/components/base/Render.js.map +1 -1
  61. package/dist/components/base/index.d.ts +1 -1
  62. package/dist/components/base/index.d.ts.map +1 -1
  63. package/dist/components/base/index.js.map +1 -1
  64. package/dist/components/index.d.ts +1 -1
  65. package/dist/components/index.d.ts.map +1 -1
  66. package/dist/components/index.js +1 -1
  67. package/dist/components/index.js.map +1 -1
  68. package/dist/hooks/useEDS.d.ts +16 -8
  69. package/dist/hooks/useEDS.d.ts.map +1 -1
  70. package/dist/hooks/useEDS.js +16 -8
  71. package/dist/hooks/useEDS.js.map +1 -1
  72. package/dist/index.d.ts +2 -1
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +1 -0
  75. package/dist/index.js.map +1 -1
  76. package/dist/theme-persistence.d.ts +18 -0
  77. package/dist/theme-persistence.d.ts.map +1 -0
  78. package/dist/theme-persistence.js +25 -0
  79. package/dist/theme-persistence.js.map +1 -0
  80. package/package.json +20 -20
  81. package/skill/SKILL.md +91 -57
  82. package/skill/references/tokens.md +1 -1
  83. package/src/EDSContext.ts +2 -2
  84. package/src/EDSProvider.tsx +80 -74
  85. package/src/components/Avatar.tsx +5 -3
  86. package/src/components/Button.tsx +4 -0
  87. package/src/components/Card.tsx +14 -8
  88. package/src/components/Keyboard.tsx +1 -1
  89. package/src/components/LocalTheme.tsx +22 -14
  90. package/src/components/Menu.parts.tsx +365 -244
  91. package/src/components/Modal.parts.tsx +8 -1
  92. package/src/components/Pane.parts.tsx +31 -26
  93. package/src/components/Prose.meta.ts +10 -0
  94. package/src/components/Prose.tsx +4 -3
  95. package/src/components/Select.parts.tsx +37 -29
  96. package/src/components/ThemeSwitcher.tsx +11 -13
  97. package/src/components/base/MaybeButtonOrLink.tsx +11 -6
  98. package/src/components/base/Render.tsx +28 -4
  99. package/src/components/base/index.ts +2 -0
  100. package/src/components/index.ts +8 -1
  101. package/src/hooks/useEDS.ts +16 -8
  102. package/src/index.ts +2 -1
  103. package/src/theme-persistence.ts +31 -0
@@ -6,7 +6,7 @@ import {
6
6
  useLayoutEffect,
7
7
  useMemo,
8
8
  useRef,
9
- useState,
9
+ useSyncExternalStore,
10
10
  type ComponentProps,
11
11
  } from 'react'
12
12
  import { mergeProps } from '@base-ui/react'
@@ -24,17 +24,15 @@ import {
24
24
  } from './EDSContext.ts'
25
25
  import { useControlled } from './hooks/useControlled.ts'
26
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'
27
34
  import { cn } from './utils/index.ts'
28
35
 
29
- const PERSISTENCE_KEY = 'eds-active-theme'
30
- const COOKIE_REGEX = new RegExp(`(?:^|; )${PERSISTENCE_KEY}=([^;]+)`)
31
-
32
- const VALID_THEMES: readonly Theme[] = ['dark', 'light', 'system']
33
-
34
- function isValidTheme(value: unknown): value is Theme {
35
- return typeof value === 'string' && (VALID_THEMES as readonly string[]).includes(value)
36
- }
37
-
38
36
  interface PersistenceAdapter {
39
37
  get: () => Theme | null
40
38
  set: (value: Theme) => void
@@ -67,17 +65,14 @@ function resolvePersistence(persistTheme: ThemePersistence | undefined): Persist
67
65
  return {
68
66
  get: () => {
69
67
  try {
70
- const match = document.cookie.match(COOKIE_REGEX)
71
- if (match === null) return null
72
- const value = decodeURIComponent(match[1] as string)
73
- return isValidTheme(value) ? value : null
68
+ return parseThemeCookie(document.cookie) ?? null
74
69
  } catch {
75
70
  return null
76
71
  }
77
72
  },
78
73
  set: (value) => {
79
74
  try {
80
- document.cookie = `${PERSISTENCE_KEY}=${encodeURIComponent(value)}; path=/; max-age=31536000; samesite=lax`
75
+ document.cookie = `${PERSISTENCE_KEY}=${value}; path=/; max-age=31536000; samesite=lax`
81
76
  } catch {
82
77
  // ignore restrictive environments
83
78
  }
@@ -107,12 +102,13 @@ function resolvePersistence(persistTheme: ThemePersistence | undefined): Persist
107
102
  const prefersDarkMediaQuery =
108
103
  typeof window !== 'undefined' ? window.matchMedia('(prefers-color-scheme: dark)') : null
109
104
 
110
- function resolveTheme(theme: Theme, systemFallback: ResolvedTheme = 'dark'): ResolvedTheme {
111
- if (theme === 'system') {
112
- if (prefersDarkMediaQuery === null) return systemFallback
113
- return prefersDarkMediaQuery.matches ? 'dark' : 'light'
114
- }
115
- 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'
116
112
  }
117
113
 
118
114
  export interface EDSProviderProps extends Omit<ComponentProps<'div'>, 'dir'> {
@@ -138,16 +134,22 @@ export interface EDSProviderProps extends Omit<ComponentProps<'div'>, 'dir'> {
138
134
  * - `'localStorage'`: reads/writes `localStorage['eds-active-theme']`. Inline pre-hydration script
139
135
  * reads the value before first paint — no SSR flash.
140
136
  * - `'cookie'`: reads/writes a `eds-active-theme` cookie (`path=/; max-age=31536000;
141
- * 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.
142
139
  * - `{ get, set }`: custom adapter (different key, encryption, remote storage, etc.). Invalid
143
- * values from `get()` fall back to `defaultTheme`. No pre-hydration script is emitted; for
144
- * zero-flash SSR, inject your own inline script that adds the matching `eds-*` class to
145
- * `<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.
146
143
  *
147
144
  * The two string presets share the key `'eds-active-theme'` — use a custom adapter for per-app
148
145
  * isolation on the same origin.
149
146
  */
150
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
151
153
  /** @default 'ltr' */
152
154
  direction?: ComponentProps<typeof DirectionProvider>['direction'] | undefined
153
155
  /** @default 'en' // or for `direction="rtl"`, 'ar' */
@@ -161,7 +163,8 @@ export type { EDSContextValue, ResolvedTheme, Theme, ThemePersistence } from './
161
163
  * Provides global configuration and enhancements for EDS components.
162
164
  *
163
165
  * Note: Only one EDSProvider should be used per app, typically at the root. If you need different
164
- * themes for different parts of the app, use the `Dark` and `Light` components instead.
166
+ * themes for different parts of the app, use the `Dark`, `Light`, and `LocalTheme` components
167
+ * instead.
165
168
  *
166
169
  * **Next.js App Router:** add `suppressHydrationWarning` to your `<html>` tag in `app/layout.tsx`.
167
170
  * The pre-hydration script applies the theme class to `<html>` before React hydrates (preventing a
@@ -200,6 +203,7 @@ export function EDSProvider({
200
203
  defaultTheme = 'dark',
201
204
  onThemeChange,
202
205
  persistTheme,
206
+ nonce,
203
207
  direction = 'ltr',
204
208
  language = direction === 'rtl' ? 'ar' : 'en',
205
209
  buttonOrLinkConfig,
@@ -288,6 +292,7 @@ export function EDSProvider({
288
292
  }
289
293
 
290
294
  const style = document.createElement('style')
295
+ if (nonce !== undefined) style.nonce = nonce
291
296
  style.textContent = '*:not(.eds-segmented-control *) { transition: none !important; }'
292
297
  document.head.appendChild(style)
293
298
  removeClasses()
@@ -301,30 +306,15 @@ export function EDSProvider({
301
306
  style.remove()
302
307
  removeClasses()
303
308
  }
304
- }, [theme])
305
-
306
- // `activeTheme` resolves `'system'` to `'dark'` or `'light'` via `prefers-color-scheme`. Initial
307
- // state skips `matchMedia` to keep SSR and first client render in sync; the effect catches up
308
- // pre-paint.
309
- const systemFallback: ResolvedTheme = defaultTheme === 'system' ? 'dark' : defaultTheme
309
+ }, [theme, nonce])
310
310
 
311
- const [activeTheme, setActiveTheme] = useState<ResolvedTheme>(() =>
312
- 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',
313
316
  )
314
317
 
315
- useLayoutEffect(() => {
316
- setActiveTheme(resolveTheme(theme, systemFallback))
317
-
318
- if (theme !== 'system') return
319
-
320
- const handleChange = (event: MediaQueryListEvent) => {
321
- setActiveTheme(event.matches ? 'dark' : 'light')
322
- }
323
-
324
- prefersDarkMediaQuery?.addEventListener('change', handleChange)
325
- return () => prefersDarkMediaQuery?.removeEventListener('change', handleChange)
326
- }, [theme, systemFallback])
327
-
328
318
  /**
329
319
  * Add the `data-eds-active` attribute to the `html` element when the Space key is pressed to
330
320
  * improve Tailwind's `active` state.
@@ -390,20 +380,22 @@ export function EDSProvider({
390
380
  if (lightDarkTest !== '' && !lightDarkTest.includes('light-dark')) {
391
381
  // oxlint-disable-next-line no-console
392
382
  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).",
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).",
394
384
  )
395
385
  }
396
386
  }, [])
397
387
 
388
+ const isHydrated = useIsHydrated()
389
+
398
390
  const edsContextValue = useMemo(
399
391
  () => ({
400
392
  theme,
401
- activeTheme,
402
393
  setTheme,
394
+ systemTheme,
403
395
  direction,
404
396
  language,
405
397
  }),
406
- [theme, activeTheme, setTheme, direction, language],
398
+ [theme, setTheme, systemTheme, direction, language],
407
399
  )
408
400
 
409
401
  return (
@@ -412,10 +404,19 @@ export function EDSProvider({
412
404
  <Pane.Provider>
413
405
  <Tooltip.Provider timeout={300}>
414
406
  <ButtonOrLink.Config {...buttonOrLinkConfig}>
415
- {/* Set the theme class on <html> before first paint to prevent a flash */}
416
- <PreHydrationScript>
417
- {buildThemeScript(controlledTheme, defaultTheme, persistTheme)}
418
- </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
+ )}
419
420
  <div
420
421
  dir={direction}
421
422
  lang={language}
@@ -431,38 +432,43 @@ export function EDSProvider({
431
432
  )
432
433
  }
433
434
 
434
- function buildThemeScript(
435
+ function renderThemeScript(
435
436
  controlledTheme: Theme | undefined,
436
437
  defaultTheme: Theme,
437
438
  persistTheme: ThemePersistence | undefined,
439
+ nonce: string | undefined,
438
440
  ) {
439
- // Controlled mode and the `undefined` / custom-adapter cases use the known theme — only the
440
- // string presets need a storage lookup.
441
- if (controlledTheme !== undefined) {
442
- return `document.documentElement.classList.add('eds-${controlledTheme}')`
443
- }
444
- if (persistTheme !== 'localStorage' && persistTheme !== 'cookie') {
445
- 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>
446
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
447
447
 
448
- const invalidCheck = VALID_THEMES.map((t) => `t!=='${t}'`).join('&&')
448
+ const invalidCheck = VALID_THEMES.map((theme) => `t!=='${theme}'`).join('&&')
449
449
  const readExpression =
450
450
  persistTheme === 'localStorage'
451
451
  ? `localStorage.getItem('${PERSISTENCE_KEY}')`
452
- : `decodeURIComponent((document.cookie.match(/(?:^|; )${PERSISTENCE_KEY}=([^;]+)/)||[])[1]||'')`
453
- 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>
454
455
  }
455
456
 
456
- interface PreHydrationScriptProps extends ComponentProps<'script'> {}
457
+ const ROOT_WITHOUT_THEME_CLASS = `:root:not(${Object.values(THEME_CLASSES)
458
+ .map((themeClass) => `.${themeClass}`)
459
+ .join(', ')})`
457
460
 
458
461
  /**
459
- * Renders a `<script>` element that exists only until React hydration completes.
460
- *
461
- * Outputs its tag during SSR and during the client's initial render (ensuring the markup matches),
462
- * and removes itself after hydration to keep the DOM clean. The browser executes the inline script
463
- * 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>`.
464
464
  */
465
- function PreHydrationScript({ children, ...props }: PreHydrationScriptProps) {
466
- if (useIsHydrated()) return null
467
- 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
468
474
  }
@@ -36,10 +36,12 @@ export declare namespace AvatarProps {
36
36
  tooltip?: TooltipProps['content'] | Omit<TooltipProps, 'children'> | undefined
37
37
  }
38
38
  interface CommonProps extends OwnProps, EDSComponentProps<typeof AvatarMeta> {}
39
- interface ButtonProps extends CommonProps, Omit<MaybeButtonOrLinkProps.ButtonProps, 'children'> {}
40
- interface LinkProps extends CommonProps, Omit<MaybeButtonOrLinkProps.LinkProps, 'children'> {}
39
+ interface ButtonProps
40
+ extends CommonProps, Omit<MaybeButtonOrLinkProps.ButtonProps, 'children' | 'render'> {}
41
+ interface LinkProps
42
+ extends CommonProps, Omit<MaybeButtonOrLinkProps.LinkProps, 'children' | 'render'> {}
41
43
  interface OtherElementProps
42
- extends CommonProps, Omit<MaybeButtonOrLinkProps.OtherElementProps, 'children'> {}
44
+ extends CommonProps, Omit<MaybeButtonOrLinkProps.OtherElementProps, 'children' | 'render'> {}
43
45
  }
44
46
 
45
47
  export type AvatarProps =
@@ -168,6 +168,10 @@ export function Button({
168
168
  u:*:transition-opacity
169
169
  u:*:@state-disabled/button:opacity-disabled
170
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
171
175
  u:@prop-size-xsmall/button:addon-small
172
176
  u:@prop-size-small/button:addon-small
173
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 (
@@ -54,7 +54,7 @@ export function Keyboard({ ref: passedRef, size, children, ...props }: KeyboardP
54
54
  >
55
55
  <span
56
56
  className={cn(
57
- `inline-flex h-(--height) min-w-(--height) items-center justify-center rounded-4 border border-default
57
+ `inline-flex h-(--height) min-w-(--height) items-center justify-center rounded-4 border border-current/15
58
58
  @prop-size-small/keyboard:gap-0.5
59
59
  @prop-size-small/keyboard:px-0.75
60
60
  @prop-size-small/keyboard:text-10
@@ -1,39 +1,47 @@
1
1
  'use client'
2
2
 
3
- import type { ComponentProps, ElementType } from 'react'
4
3
  import { mergeProps } from '@base-ui/react'
4
+ import type { DistributedOmit } from 'type-fest'
5
5
 
6
6
  import { LocalThemeContext, THEME_CLASSES, type ResolvedTheme } from '../EDSContext.ts'
7
+ import { Render, type AsOrRenderProps, type HTMLElementProps } from './base/Render.tsx'
7
8
 
8
- export interface LocalThemeProps extends ComponentProps<'div'> {
9
- /** @default 'div' */
10
- as?: ElementType | undefined
9
+ export declare namespace LocalThemeProps {
10
+ interface CommonProps extends HTMLElementProps {
11
+ /** The theme to render the content in. */
12
+ theme: ResolvedTheme
13
+ }
11
14
  }
12
15
 
13
- function LocalTheme({
14
- theme,
15
- as: Element = 'div',
16
- ...props
17
- }: LocalThemeProps & { theme: ResolvedTheme }) {
16
+ export type LocalThemeProps = LocalThemeProps.CommonProps & AsOrRenderProps
17
+ export type DarkProps = DistributedOmit<LocalThemeProps, 'theme'>
18
+ export type LightProps = DistributedOmit<LocalThemeProps, 'theme'>
19
+
20
+ /**
21
+ * Renders its content in the given theme, whatever the page's theme is. Unlike switching between
22
+ * `Dark` and `Light`, changing `theme` doesn't remount the content. Components that need to know
23
+ * the theme in JavaScript can read it with `useEDS().activeTheme`.
24
+ */
25
+ export function LocalTheme({ theme, ...props }: LocalThemeProps) {
18
26
  return (
19
27
  <LocalThemeContext.Provider value={theme}>
20
- <Element {...mergeProps({ className: THEME_CLASSES[theme] }, props)} />
28
+ <Render {...mergeProps({ className: THEME_CLASSES[theme] }, props)} />
21
29
  </LocalThemeContext.Provider>
22
30
  )
23
31
  }
24
32
 
25
33
  /**
26
34
  * 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`.
35
+ * the theme in JavaScript can read it with `useEDS().activeTheme`.
28
36
  */
29
- export function Dark(props: LocalThemeProps) {
37
+ export function Dark(props: DarkProps) {
30
38
  return <LocalTheme theme="dark" {...props} />
31
39
  }
32
40
 
33
41
  /**
34
42
  * 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`.
43
+ * know the theme in JavaScript can read it with `useEDS().activeTheme`.
36
44
  */
37
- export function Light(props: LocalThemeProps) {
45
+ export function Light(props: LightProps) {
38
46
  return <LocalTheme theme="light" {...props} />
39
47
  }