@edgeandnode/eds-react 1.0.2 → 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 (115) 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/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.map +1 -1
  16. package/dist/components/Breadcrumbs.parts.d.ts +3 -3
  17. package/dist/components/Breadcrumbs.parts.d.ts.map +1 -1
  18. package/dist/components/Breadcrumbs.parts.js.map +1 -1
  19. package/dist/components/Button.d.ts.map +1 -1
  20. package/dist/components/Button.js +1 -1
  21. package/dist/components/Button.js.map +1 -1
  22. package/dist/components/Card.d.ts +8 -4
  23. package/dist/components/Card.d.ts.map +1 -1
  24. package/dist/components/Card.js +2 -1
  25. package/dist/components/Card.js.map +1 -1
  26. package/dist/components/Cluster.d.ts +3 -3
  27. package/dist/components/Cluster.d.ts.map +1 -1
  28. package/dist/components/Cluster.js.map +1 -1
  29. package/dist/components/Keyboard.js +1 -1
  30. package/dist/components/LocalTheme.d.ts +21 -8
  31. package/dist/components/LocalTheme.d.ts.map +1 -1
  32. package/dist/components/LocalTheme.js +10 -4
  33. package/dist/components/LocalTheme.js.map +1 -1
  34. package/dist/components/Menu.parts.d.ts +43 -9
  35. package/dist/components/Menu.parts.d.ts.map +1 -1
  36. package/dist/components/Menu.parts.js +118 -114
  37. package/dist/components/Menu.parts.js.map +1 -1
  38. package/dist/components/Modal.parts.d.ts +1 -1
  39. package/dist/components/Modal.parts.d.ts.map +1 -1
  40. package/dist/components/Modal.parts.js +2 -2
  41. package/dist/components/Modal.parts.js.map +1 -1
  42. package/dist/components/Pane.parts.d.ts +23 -22
  43. package/dist/components/Pane.parts.d.ts.map +1 -1
  44. package/dist/components/Pane.parts.js +3 -3
  45. package/dist/components/Pane.parts.js.map +1 -1
  46. package/dist/components/Prose.d.ts +1 -1
  47. package/dist/components/Prose.d.ts.map +1 -1
  48. package/dist/components/Prose.js +3 -3
  49. package/dist/components/Prose.js.map +1 -1
  50. package/dist/components/Prose.meta.d.ts +5 -0
  51. package/dist/components/Prose.meta.d.ts.map +1 -1
  52. package/dist/components/Prose.meta.js +10 -0
  53. package/dist/components/Prose.meta.js.map +1 -1
  54. package/dist/components/Select.parts.d.ts +8 -3
  55. package/dist/components/Select.parts.d.ts.map +1 -1
  56. package/dist/components/Select.parts.js +26 -17
  57. package/dist/components/Select.parts.js.map +1 -1
  58. package/dist/components/ThemeSwitcher.d.ts +5 -5
  59. package/dist/components/ThemeSwitcher.d.ts.map +1 -1
  60. package/dist/components/ThemeSwitcher.js +5 -4
  61. package/dist/components/ThemeSwitcher.js.map +1 -1
  62. package/dist/components/base/MaybeButtonOrLink.d.ts +7 -5
  63. package/dist/components/base/MaybeButtonOrLink.d.ts.map +1 -1
  64. package/dist/components/base/MaybeButtonOrLink.js +7 -2
  65. package/dist/components/base/MaybeButtonOrLink.js.map +1 -1
  66. package/dist/components/base/Render.d.ts +25 -6
  67. package/dist/components/base/Render.d.ts.map +1 -1
  68. package/dist/components/base/Render.js +4 -3
  69. package/dist/components/base/Render.js.map +1 -1
  70. package/dist/components/base/index.d.ts +1 -1
  71. package/dist/components/base/index.d.ts.map +1 -1
  72. package/dist/components/base/index.js.map +1 -1
  73. package/dist/components/index.d.ts +1 -1
  74. package/dist/components/index.d.ts.map +1 -1
  75. package/dist/components/index.js +1 -1
  76. package/dist/components/index.js.map +1 -1
  77. package/dist/hooks/useEDS.d.ts +16 -8
  78. package/dist/hooks/useEDS.d.ts.map +1 -1
  79. package/dist/hooks/useEDS.js +16 -8
  80. package/dist/hooks/useEDS.js.map +1 -1
  81. package/dist/index.d.ts +2 -1
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +1 -0
  84. package/dist/index.js.map +1 -1
  85. package/dist/theme-persistence.d.ts +18 -0
  86. package/dist/theme-persistence.d.ts.map +1 -0
  87. package/dist/theme-persistence.js +25 -0
  88. package/dist/theme-persistence.js.map +1 -0
  89. package/package.json +20 -20
  90. package/skill/SKILL.md +90 -56
  91. package/skill/references/tokens.md +1 -1
  92. package/src/EDSContext.ts +2 -2
  93. package/src/EDSProvider.tsx +80 -74
  94. package/src/components/Address.tsx +4 -3
  95. package/src/components/Avatar.tsx +7 -3
  96. package/src/components/Breadcrumbs.parts.tsx +4 -3
  97. package/src/components/Button.tsx +4 -0
  98. package/src/components/Card.tsx +14 -8
  99. package/src/components/Cluster.tsx +5 -3
  100. package/src/components/Keyboard.tsx +1 -1
  101. package/src/components/LocalTheme.tsx +22 -14
  102. package/src/components/Menu.parts.tsx +365 -244
  103. package/src/components/Modal.parts.tsx +8 -1
  104. package/src/components/Pane.parts.tsx +31 -26
  105. package/src/components/Prose.meta.ts +10 -0
  106. package/src/components/Prose.tsx +4 -3
  107. package/src/components/Select.parts.tsx +37 -29
  108. package/src/components/ThemeSwitcher.tsx +11 -13
  109. package/src/components/base/MaybeButtonOrLink.tsx +11 -6
  110. package/src/components/base/Render.tsx +28 -4
  111. package/src/components/base/index.ts +2 -0
  112. package/src/components/index.ts +8 -1
  113. package/src/hooks/useEDS.ts +16 -8
  114. package/src/index.ts +2 -1
  115. package/src/theme-persistence.ts +31 -0
@@ -114,10 +114,13 @@ export const ModalRoot = <
114
114
  defaultOpen,
115
115
  onOpenChange,
116
116
  dismissible: passedDismissible,
117
- role,
118
117
  steps: passedSteps = 1 as Steps,
119
118
  currentStep: controlledCurrentStep,
120
119
  onCurrentStepChange,
120
+ role,
121
+ 'aria-label': ariaLabel,
122
+ 'aria-labelledby': ariaLabelledBy,
123
+ 'aria-describedby': ariaDescribedBy,
121
124
  children,
122
125
  ...props
123
126
  }: ModalProps<Steps, Step>) => {
@@ -325,6 +328,10 @@ export const ModalRoot = <
325
328
  ? 'dialog'
326
329
  : 'alertdialog')
327
330
  }
331
+ aria-label={ariaLabel}
332
+ // Only when passed, so Base UI's title and description wiring still applies
333
+ {...(ariaLabelledBy !== undefined && { 'aria-labelledby': ariaLabelledBy })}
334
+ {...(ariaDescribedBy !== undefined && { 'aria-describedby': ariaDescribedBy })}
328
335
  className="
329
336
  fixed inset-0 m-auto h-max
330
337
  max-h-[min(max(100cqb,100vh-100cqb*9999),var(--strict-max-height))]
@@ -11,7 +11,6 @@ import {
11
11
  useState,
12
12
  useSyncExternalStore,
13
13
  type ComponentProps,
14
- type ElementType,
15
14
  type MouseEvent,
16
15
  type ReactNode,
17
16
  type RefObject,
@@ -25,7 +24,12 @@ import type { EDSComponentProps } from '@edgeandnode/eds-css'
25
24
  import { useCSSPropsPolyfill, useFirstRender, usePrevious, useStyle } from '../hooks/index.ts'
26
25
  import { cn, getCSSPropsAttributes } from '../utils/index.ts'
27
26
  import { Presence } from './base/Presence.tsx'
28
- import { Render, type RenderProp } from './base/Render.tsx'
27
+ import {
28
+ Render,
29
+ type AsOrRenderProps,
30
+ type HTMLElementProps,
31
+ type RenderProp,
32
+ } from './base/Render.tsx'
29
33
  import { PaneContainerMeta, PaneMeta } from './Pane.meta.ts'
30
34
  import { ToggleButton, type ToggleButtonProps } from './ToggleButton.tsx'
31
35
 
@@ -282,30 +286,31 @@ export function PaneContainer({
282
286
  }
283
287
  PaneContainer.displayName = 'Pane.Container'
284
288
 
285
- export interface PaneProps extends ComponentProps<'div'>, EDSComponentProps<typeof PaneMeta> {
286
- /** @default 'div' */
287
- as?: ElementType | undefined
288
- /** Unique name for this pane. */
289
- name: string
290
- /**
291
- * Whether the pane is pseudo-modal in overlay mode. When `true`, the overlay shows a backdrop,
292
- * receives focus when opened, and can be closed by pressing Escape or interacting with elements
293
- * covered by the backdrop.
294
- *
295
- * @default true
296
- */
297
- overlayModal?: boolean | undefined
298
- /**
299
- * Whether the content should stay mounted when the pane is closed.
300
- *
301
- * @default true
302
- */
303
- keepMounted?: boolean | undefined
289
+ export declare namespace PaneProps {
290
+ interface CommonProps extends HTMLElementProps, EDSComponentProps<typeof PaneMeta> {
291
+ /** Unique name for this pane. */
292
+ name: string
293
+ /**
294
+ * Whether the pane is pseudo-modal in overlay mode. When `true`, the overlay shows a backdrop,
295
+ * receives focus when opened, and can be closed by pressing Escape or interacting with elements
296
+ * covered by the backdrop.
297
+ *
298
+ * @default true
299
+ */
300
+ overlayModal?: boolean | undefined
301
+ /**
302
+ * Whether the content should stay mounted when the pane is closed.
303
+ *
304
+ * @default true
305
+ */
306
+ keepMounted?: boolean | undefined
307
+ }
304
308
  }
305
309
 
310
+ export type PaneProps = PaneProps.CommonProps & AsOrRenderProps
311
+
306
312
  export function PaneRoot({
307
313
  ref: passedRef,
308
- as: Element = 'div',
309
314
  name,
310
315
  layout,
311
316
  side,
@@ -316,7 +321,7 @@ export function PaneRoot({
316
321
  }: PaneProps) {
317
322
  const { store, pane } = usePaneInternal(name)
318
323
 
319
- const rootRef = useRef<HTMLDivElement>(null)
324
+ const rootRef = useRef<HTMLElement>(null)
320
325
  const rootPassedRef = useMergedRefs(rootRef, passedRef)
321
326
 
322
327
  // Observe `--tw-duration` to allow customizing the transition duration via CSS
@@ -379,7 +384,7 @@ export function PaneRoot({
379
384
  }
380
385
 
381
386
  return (
382
- <Element
387
+ <Render
383
388
  ref={rootPassedRef}
384
389
  id={pane.id}
385
390
  data-eds-exposed-open={open}
@@ -587,7 +592,7 @@ export function PaneRoot({
587
592
  return <Fragment key={key} />
588
593
  })}
589
594
  </Presence>
590
- </Element>
595
+ </Render>
591
596
  )
592
597
  }
593
598
  PaneRoot.displayName = 'Pane'
@@ -604,7 +609,7 @@ function useOverlayModalFocus({
604
609
  layout,
605
610
  overlayOpen,
606
611
  }: {
607
- rootRef: RefObject<HTMLDivElement | null>
612
+ rootRef: RefObject<HTMLElement | null>
608
613
  store: PaneStore
609
614
  name: string
610
615
  enabled: boolean
@@ -8,5 +8,15 @@ export const ProseMeta = createComponent('Prose', {
8
8
  values: ['xsmall', 'small', 'medium'],
9
9
  defaultValue: 'medium',
10
10
  },
11
+ /**
12
+ * Use the serif font for headings, e.g. for editorial or marketing content.
13
+ *
14
+ * @default false
15
+ */
16
+ serif: {
17
+ type: 'values',
18
+ values: [false, true],
19
+ defaultValue: false,
20
+ },
11
21
  },
12
22
  })
@@ -20,12 +20,12 @@ export interface ProseProps
20
20
  children?: string | undefined
21
21
  }
22
22
 
23
- export function Prose({ ref: passedRef, size, children, ...props }: ProseProps) {
23
+ export function Prose({ ref: passedRef, size, serif, children, ...props }: ProseProps) {
24
24
  useEDS()
25
25
 
26
26
  const [cssPropsPolyfillPassedRef, cssPropsPolyfillAttributes] = useCSSPropsPolyfill(
27
27
  ProseMeta,
28
- { size },
28
+ { size, serif },
29
29
  { ref: passedRef },
30
30
  )
31
31
 
@@ -34,7 +34,7 @@ export function Prose({ ref: passedRef, size, children, ...props }: ProseProps)
34
34
  ref={cssPropsPolyfillPassedRef}
35
35
  {...mergeProps(
36
36
  { className: cn('eds-prose u:text-muted u:text-wrap-style-pretty') },
37
- getCSSPropsAttributes(ProseMeta, { size }),
37
+ getCSSPropsAttributes(ProseMeta, { size, serif }),
38
38
  cssPropsPolyfillAttributes,
39
39
  props,
40
40
  )}
@@ -45,6 +45,7 @@ export function Prose({ ref: passedRef, size, children, ...props }: ProseProps)
45
45
  @prop-size-xsmall/prose:prose-xsmall
46
46
  @prop-size-small/prose:prose-small
47
47
  @prop-size-medium/prose:prose-medium
48
+ @prop-serif-true/prose:prose-serif
48
49
  "
49
50
  >
50
51
  <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
@@ -5,12 +5,14 @@ import {
5
5
  createContext,
6
6
  isValidElement,
7
7
  useContext,
8
+ useState,
8
9
  type ComponentProps,
9
10
  type ElementType,
10
11
  type ReactElement,
11
12
  type ReactNode,
12
13
  } from 'react'
13
14
  import { mergeProps } from '@base-ui/react'
15
+ import { Menu as BaseUIMenu } from '@base-ui/react/menu'
14
16
  import flattenChildren from 'react-keyed-flatten-children'
15
17
 
16
18
  import type { EDSComponentProps } from '@edgeandnode/eds-css'
@@ -23,7 +25,7 @@ import { renderAddon, type AddonValue } from './base/Addon.tsx'
23
25
  import { Field, type FieldProps } from './base/Field.tsx'
24
26
  import { Render, type RenderFnHandlers, type RenderFnProps } from './base/Render.tsx'
25
27
  import type { MenuMeta } from './Menu.meta.ts'
26
- import { Menu } from './Menu.tsx'
28
+ import { Menu, type MenuProps } from './Menu.tsx'
27
29
  import type { SelectMeta, SelectOptionMeta } from './Select.meta.ts'
28
30
 
29
31
  type MenuCSSProps = EDSComponentProps<typeof MenuMeta>
@@ -282,11 +284,15 @@ export declare namespace SelectProps {
282
284
  /**
283
285
  * `true` for the default filter; a function for custom filtering. Auto-enables when
284
286
  * `searchValue` or `onSearchValueChange` is set. When truthy and `header` is unset,
285
- * `Menu.Search` becomes the default header.
287
+ * `Menu.Search` becomes the default header. A custom `header` must include `Menu.Search`.
286
288
  */
287
289
  searchable?: boolean | SelectSearchableFn<T>
288
290
  searchValue?: string
289
- onSearchValueChange?: (value: string) => void
291
+ /**
292
+ * Called when the search query changes, including when it's reset after the menu closes (then
293
+ * `eventDetails.reason` is `'popup-close'`).
294
+ */
295
+ onSearchValueChange?: MenuProps['onSearchValueChange']
290
296
  }
291
297
 
292
298
  interface DeclarativeProps {
@@ -350,7 +356,7 @@ export function SelectRoot<T extends OptionValue = number>({
350
356
  alignOffset,
351
357
  matchTriggerWidth = 'at-least',
352
358
  matchTriggerHeight,
353
- header: passedHeader,
359
+ header,
354
360
  footer,
355
361
  empty,
356
362
  trigger: passedTrigger,
@@ -374,11 +380,12 @@ export function SelectRoot<T extends OptionValue = number>({
374
380
  onValueChange as ((value: T | T[] | undefined) => void) | undefined,
375
381
  )
376
382
 
377
- const [searchValue, setSearchValue] = useControlled(
378
- controlledSearchValue,
379
- '',
380
- onSearchValueChange,
381
- )
383
+ /**
384
+ * Not `useControlled`, which skips a change equal to the previous one even if the parent ignored
385
+ * it (e.g. the reset after the menu closes), so clearing the search would then do nothing.
386
+ */
387
+ const [uncontrolledSearchValue, setUncontrolledSearchValue] = useState('')
388
+ const searchValue = controlledSearchValue ?? uncontrolledSearchValue
382
389
 
383
390
  /**
384
391
  * `searchable` auto-enables when `searchValue` / `onSearchValueChange` is wired (matches how
@@ -424,16 +431,9 @@ export function SelectRoot<T extends OptionValue = number>({
424
431
  }
425
432
  : null
426
433
 
427
- /** Default `header` to a wired `<Menu.Search>` when search is enabled and none was provided. */
428
- const header =
429
- passedHeader !== undefined ? (
430
- passedHeader
431
- ) : searchEnabled ? (
432
- <Menu.Search value={searchValue} onValueChange={setSearchValue} />
433
- ) : undefined
434
-
435
434
  const renderMenuContent = () => {
436
- if (augmentedChildren.length === 0) {
435
+ // A searchable menu shows `empty` itself when no option matches
436
+ if (augmentedChildren.length === 0 && !searchEnabled) {
437
437
  if (empty === undefined) return <Menu.Empty />
438
438
  return typeof empty === 'string' ? <Menu.Empty>{empty}</Menu.Empty> : empty
439
439
  }
@@ -583,14 +583,7 @@ export function SelectRoot<T extends OptionValue = number>({
583
583
  {...radioGroupProps}
584
584
  open={open}
585
585
  defaultOpen={defaultOpen}
586
- onOpenChange={(newOpen) => {
587
- // Reset uncontrolled search on open (not close) — resetting on close would flash
588
- // the unfiltered list mid close-animation. Controlled consumers own their lifecycle.
589
- if (newOpen && searchEnabled && controlledSearchValue === undefined) {
590
- setSearchValue('')
591
- }
592
- onOpenChange?.(newOpen)
593
- }}
586
+ onOpenChange={onOpenChange}
594
587
  side={side}
595
588
  gap={gap}
596
589
  align={align}
@@ -599,6 +592,13 @@ export function SelectRoot<T extends OptionValue = number>({
599
592
  matchTriggerHeight={matchTriggerHeight}
600
593
  header={header}
601
594
  footer={footer}
595
+ searchable={searchEnabled && 'manual'}
596
+ searchValue={searchValue}
597
+ onSearchValueChange={(newSearchValue, eventDetails) => {
598
+ setUncontrolledSearchValue(newSearchValue)
599
+ onSearchValueChange?.(newSearchValue, eventDetails)
600
+ }}
601
+ empty={empty}
602
602
  highlightCheckedOnOpen
603
603
  trigger={menuTrigger}
604
604
  >
@@ -654,6 +654,8 @@ function useSelectOptions<T extends OptionValue>({
654
654
  addonBefore: ReactNode
655
655
  addonAfter: ReactNode
656
656
  }> = []
657
+ const { contains } = BaseUIMenu.useFilter()
658
+ const trimmedSearchValue = searchValue.trim()
657
659
 
658
660
  /**
659
661
  * Per-iteration helper: records the option in `optionsList` (always the full set) and returns the
@@ -668,12 +670,18 @@ function useSelectOptions<T extends OptionValue>({
668
670
  renderNode: () => ReactNode,
669
671
  ): ReactNode[] => {
670
672
  optionsList.push({ value, label, addonBefore, addonAfter })
671
- if (searchEnabled) {
673
+ if (searchEnabled && trimmedSearchValue !== '') {
672
674
  const stringLabel = reactNodeToString(label)
673
675
  const passes =
674
676
  typeof searchable === 'function'
675
- ? searchable({ value, label: stringLabel, addonBefore, addonAfter, searchValue })
676
- : stringLabel.toLowerCase().includes(searchValue.toLowerCase())
677
+ ? searchable({
678
+ value,
679
+ label: stringLabel,
680
+ addonBefore,
681
+ addonAfter,
682
+ searchValue: trimmedSearchValue,
683
+ })
684
+ : contains(stringLabel, trimmedSearchValue)
677
685
  if (!passes) return []
678
686
  }
679
687
  return [renderNode()]
@@ -18,19 +18,16 @@ export interface ThemeSwitcherProps
18
18
  Omit<SegmentedControlProps<'dark' | 'light'>, 'value' | 'defaultValue' | 'onValueChange'>,
19
19
  EDSComponentProps<typeof ThemeSwitcherMeta> {
20
20
  /**
21
- * Defaults to a label that flips with `activeTheme` (e.g. `Switch to light theme` when dark is
22
- * active). Accepts a function that receives `activeTheme` for dynamic content. Pass `null` to
23
- * disable.
21
+ * Defaults to a label that flips with the page's theme (e.g. `Switch to light theme` when it's
22
+ * dark). Accepts a function that receives the page's theme, resolved to `'dark'` or `'light'`,
23
+ * for dynamic content. Pass `null` to disable.
24
24
  */
25
- tooltip?:
26
- | ThemeSwitcherTooltip
27
- | ((activeTheme: ResolvedTheme) => ThemeSwitcherTooltip)
28
- | undefined
25
+ tooltip?: ThemeSwitcherTooltip | ((theme: ResolvedTheme) => ThemeSwitcherTooltip) | undefined
29
26
  }
30
27
 
31
28
  /**
32
29
  * Toggles the EDS theme between light and dark. Right-click for a context menu with a "Use system
33
- * appearance" toggle. Always uncontrolled — reads `activeTheme` from `useEDS()` and drives
30
+ * appearance" toggle. Always uncontrolled — reads the theme from `useEDS()` and drives
34
31
  * `setTheme()`. `theme="system"` (or `defaultTheme="system"`) on `EDSProvider` also opts into the
35
32
  * user's OS preference.
36
33
  */
@@ -39,23 +36,24 @@ export function ThemeSwitcher({
39
36
  tooltip: passedTooltip,
40
37
  ...props
41
38
  }: ThemeSwitcherProps) {
42
- const { theme, activeTheme, setTheme } = useEDS()
39
+ const { theme, setTheme, systemTheme } = useEDS()
40
+ const pageTheme = theme === 'system' ? systemTheme : theme
43
41
 
44
- const tooltip = typeof passedTooltip === 'function' ? passedTooltip(activeTheme) : passedTooltip
42
+ const tooltip = typeof passedTooltip === 'function' ? passedTooltip(pageTheme) : passedTooltip
45
43
 
46
44
  return (
47
45
  <Menu
48
46
  triggerMode="right-click"
49
47
  trigger={
50
48
  <Tooltip
51
- content={`Switch to ${activeTheme === 'dark' ? 'light' : 'dark'} theme`}
49
+ content={`Switch to ${pageTheme === 'dark' ? 'light' : 'dark'} theme`}
52
50
  closeOnClick={false}
53
51
  {...getTooltipProps(tooltip)}
54
52
  triggerProps="forward"
55
53
  >
56
54
  <SegmentedControl<'dark' | 'light'>
57
55
  aria-label={ariaLabel}
58
- value={activeTheme}
56
+ value={pageTheme}
59
57
  onValueChange={setTheme}
60
58
  {...props}
61
59
  >
@@ -72,7 +70,7 @@ export function ThemeSwitcher({
72
70
  <Menu.Item
73
71
  type="checkbox"
74
72
  checked={theme === 'system'}
75
- onCheckedChange={(checked) => setTheme(checked ? 'system' : activeTheme)}
73
+ onCheckedChange={(checked) => setTheme(checked ? 'system' : pageTheme)}
76
74
  >
77
75
  Use system appearance
78
76
  </Menu.Item>
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import type { ComponentProps, ElementType } from 'react'
3
+ import type { ElementType } from 'react'
4
4
 
5
5
  import {
6
6
  ButtonOrLink,
@@ -8,7 +8,7 @@ import {
8
8
  type InternalButtonOrLinkProps,
9
9
  type OmitInternalButtonOrLinkProps,
10
10
  } from './ButtonOrLink.tsx'
11
- import type { RenderFn } from './Render.tsx'
11
+ import type { HTMLElementProps, RenderFn } from './Render.tsx'
12
12
 
13
13
  type OtherElementRenderState = {
14
14
  category: 'other'
@@ -43,9 +43,9 @@ export declare namespace MaybeButtonOrLinkProps {
43
43
  }
44
44
  export type OtherElementProps =
45
45
  OmitInternalButtonOrLinkProps<InternalButtonOrLinkProps.CommonProps> &
46
- ComponentProps<'span'> & {
46
+ HTMLElementProps & {
47
47
  onClick?: InternalButtonOrLinkProps.ButtonProps['onClick'] // Not technically correct, but prevents the event from being `any` when `as` is not specified
48
- as?: ElementType | undefined
48
+ as?: keyof HTMLElementTagNameMap | undefined
49
49
  render?: RenderFn<MaybeButtonOrLinkRenderState> | undefined
50
50
  disabled?: undefined
51
51
  type?: undefined
@@ -67,14 +67,17 @@ type InternalMaybeButtonOrLinkProps = MaybeButtonOrLinkProps &
67
67
 
68
68
  /**
69
69
  * Renders a `ButtonOrLink` if one of `href` or `onClick` is passed along with no `as`. Otherwise,
70
- * renders the element type passed in `as` (defaulting to `span`).
70
+ * renders the tag name passed in `as` (defaulting to `span`), so an element that isn't a button can
71
+ * still have an `onClick` (e.g. a `label`). Like `ButtonOrLink`'s (and unlike `Render`'s), the
72
+ * `render` function wraps that element instead of replacing it: it receives it as `Element`, along
73
+ * with the `elementProps` to spread on it.
71
74
  */
72
75
  export function MaybeButtonOrLink(props: InternalMaybeButtonOrLinkProps) {
73
76
  if ((props.href !== undefined || props.onClick !== undefined) && props.as === undefined) {
74
77
  return <ButtonOrLink {...(props as InternalButtonOrLinkProps)} />
75
78
  } else {
76
79
  const {
77
- as: Element = 'span',
80
+ as: tagName = 'span',
78
81
  render,
79
82
  disabled: _disabled,
80
83
  inline: _inline,
@@ -86,6 +89,8 @@ export function MaybeButtonOrLink(props: InternalMaybeButtonOrLinkProps) {
86
89
  ...otherElementProps
87
90
  } = props
88
91
 
92
+ // The union of every HTML tag name is too complex a type for JSX
93
+ const Element = tagName as ElementType
89
94
  const renderState: OtherElementRenderState = {
90
95
  category: 'other',
91
96
  role: props.role ?? 'none',
@@ -30,22 +30,46 @@ export type RenderProp<State extends RenderFnState = undefined, ElementProps = u
30
30
  | ReactElement<ElementProps>
31
31
  | RenderFn<State, ElementProps>
32
32
 
33
- export interface RenderProps<State extends RenderFnState = undefined> {
34
- render: RenderProp<State>
33
+ /**
34
+ * Props of a generic HTML element, for components whose element can be changed with `as` or
35
+ * `render`.
36
+ */
37
+ export interface HTMLElementProps extends HTMLProps<HTMLElement> {}
38
+
39
+ /**
40
+ * Props to change the element a component renders: a tag name with `as`, or an element or render
41
+ * function with `render`, which is also how to pass props specific to that element (e.g.
42
+ * `render={<a href="/" />}`).
43
+ */
44
+ export type AsOrRenderProps<State extends RenderFnState = undefined> =
45
+ | {
46
+ /** Tag name of the element to render. */
47
+ as?: keyof HTMLElementTagNameMap | undefined
48
+ render?: undefined
49
+ }
50
+ | {
51
+ as?: undefined
52
+ /** Element or render function to render instead of the default element. */
53
+ render?: RenderProp<State> | undefined
54
+ }
55
+
56
+ export type RenderProps<State extends RenderFnState = undefined> = AsOrRenderProps<State> & {
35
57
  state?: State | undefined
36
58
  [key: string]: unknown
37
59
  }
38
60
 
39
61
  /**
40
- * A utility component that provides polymorphic rendering capabilities. It can render as any HTML
41
- * element or React component.
62
+ * A utility component that provides polymorphic rendering capabilities. It renders a `div` by
63
+ * default, the tag name passed in `as`, or the element or render function passed in `render`.
42
64
  */
43
65
  export function Render<State extends RenderFnState>({
66
+ as,
44
67
  render,
45
68
  state,
46
69
  ...props
47
70
  }: RenderProps<State>) {
48
71
  return useRender({
72
+ defaultTagName: as,
49
73
  render,
50
74
  state: state !== undefined ? state : undefined,
51
75
  props,
@@ -25,6 +25,8 @@ export { Portal } from './Portal.tsx'
25
25
  export { Presence, usePresence, type PresenceProps } from './Presence.tsx'
26
26
  export {
27
27
  Render,
28
+ type AsOrRenderProps,
29
+ type HTMLElementProps,
28
30
  type RenderFnProps,
29
31
  type RenderFnState,
30
32
  type RenderFn,
@@ -44,7 +44,14 @@ export { Label, type LabelProps } from './Label.tsx'
44
44
  export { LabelMeta } from './Label.meta.ts'
45
45
  export { Link, type LinkProps } from './Link.tsx'
46
46
  export { LinkMeta } from './Link.meta.ts'
47
- export { Dark, Light, type LocalThemeProps } from './LocalTheme.tsx'
47
+ export {
48
+ Dark,
49
+ Light,
50
+ LocalTheme,
51
+ type DarkProps,
52
+ type LightProps,
53
+ type LocalThemeProps,
54
+ } from './LocalTheme.tsx'
48
55
  export {
49
56
  Menu,
50
57
  type MenuEmptyProps,
@@ -12,21 +12,29 @@ export function useEDS() {
12
12
  }
13
13
 
14
14
  return {
15
- /** The page's theme: `'dark'`, `'light'`, or `'system'` (follows the OS preference) */
15
+ /**
16
+ * The page's theme, as set on `EDSProvider` or with `setTheme`: `'dark'`, `'light'`, or
17
+ * `'system'` (follows `systemTheme`).
18
+ */
16
19
  theme: context.theme,
17
20
  /** Changes the page's theme (use `ThemeSwitcher` for the UI) */
18
21
  setTheme: context.setTheme,
19
- /** The page's theme, with `'system'` resolved to `'dark'` or `'light'` */
20
- activeTheme: context.activeTheme,
22
+ /** The theme the OS prefers (`prefers-color-scheme`), which the `'system'` theme follows. */
23
+ systemTheme: context.systemTheme,
21
24
  /**
22
- * The theme of the nearest `Dark` or `Light` ancestor, or `undefined` if there's none. Use
23
- * `localTheme ?? activeTheme` to get the theme content is rendered in.
25
+ * The theme of the nearest `Dark`, `Light`, or `LocalTheme` ancestor, or `undefined` if there's
26
+ * none.
24
27
  */
25
28
  localTheme,
26
29
  /**
27
- * The class of the nearest `Dark` or `Light` ancestor, or `undefined` if there's none. Add it
28
- * to content rendered outside of that ancestor in the DOM (e.g. with `createPortal`) so it
29
- * keeps its theme.
30
+ * The theme content is rendered in: `localTheme`, or the page's theme with `'system'` resolved
31
+ * to `systemTheme`.
32
+ */
33
+ activeTheme: localTheme ?? (context.theme === 'system' ? context.systemTheme : context.theme),
34
+ /**
35
+ * The class of the nearest `Dark`, `Light`, or `LocalTheme` ancestor, or `undefined` if there's
36
+ * none. Add it to content rendered outside of that ancestor in the DOM (e.g. with
37
+ * `createPortal`) so it keeps its theme.
30
38
  */
31
39
  localThemeClass: localTheme !== undefined ? THEME_CLASSES[localTheme] : undefined,
32
40
  /** The `dir` and `lang` of `EDSProvider`, to spread on content rendered outside of it. */
package/src/index.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './components/index.ts'
2
- export { EDSProvider } from './EDSProvider.tsx'
2
+ export { EDSProvider, type ResolvedTheme, type Theme } from './EDSProvider.tsx'
3
+ export { getThemeFromCookies } from './theme-persistence.ts'
@@ -0,0 +1,31 @@
1
+ // Not a client module, so server components can call `getThemeFromCookies`
2
+
3
+ import type { Theme } from './EDSContext.ts'
4
+
5
+ export const PERSISTENCE_KEY = 'eds-active-theme'
6
+ export const COOKIE_REGEX = new RegExp(`(?:^|;\\s*)${PERSISTENCE_KEY}=([^;]+)`)
7
+
8
+ export const VALID_THEMES: readonly Theme[] = ['dark', 'light', 'system']
9
+
10
+ export function isValidTheme(value: unknown): value is Theme {
11
+ return typeof value === 'string' && (VALID_THEMES as readonly string[]).includes(value)
12
+ }
13
+
14
+ export function parseThemeCookie(cookies: string | null) {
15
+ const value = cookies?.match(COOKIE_REGEX)?.[1]
16
+ return isValidTheme(value) ? value : undefined
17
+ }
18
+
19
+ /**
20
+ * Returns the theme saved by `EDSProvider`'s `persistTheme="cookie"` from the request's headers, or
21
+ * `undefined` if there's none. Pass the result to `defaultTheme`, so the server renders the saved
22
+ * theme too.
23
+ *
24
+ * @example
25
+ * ````tsx
26
+ * <EDSProvider persistTheme="cookie" defaultTheme={getThemeFromCookies(await headers())}>
27
+ * ````
28
+ */
29
+ export function getThemeFromCookies(headers: Pick<Headers, 'get'>) {
30
+ return parseThemeCookie(headers.get('cookie'))
31
+ }