@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.
- package/README.md +32 -7
- package/dist/EDSContext.d.ts +2 -2
- package/dist/EDSContext.d.ts.map +1 -1
- package/dist/EDSContext.js +1 -1
- package/dist/EDSContext.js.map +1 -1
- package/dist/EDSProvider.d.ts +13 -6
- package/dist/EDSProvider.d.ts.map +1 -1
- package/dist/EDSProvider.js +48 -63
- package/dist/EDSProvider.js.map +1 -1
- package/dist/components/Avatar.d.ts +3 -3
- package/dist/components/Avatar.d.ts.map +1 -1
- package/dist/components/Avatar.js.map +1 -1
- package/dist/components/Button.d.ts.map +1 -1
- package/dist/components/Button.js +1 -1
- package/dist/components/Button.js.map +1 -1
- package/dist/components/Card.d.ts +8 -4
- package/dist/components/Card.d.ts.map +1 -1
- package/dist/components/Card.js +2 -1
- package/dist/components/Card.js.map +1 -1
- package/dist/components/Keyboard.js +1 -1
- package/dist/components/LocalTheme.d.ts +21 -8
- package/dist/components/LocalTheme.d.ts.map +1 -1
- package/dist/components/LocalTheme.js +10 -4
- package/dist/components/LocalTheme.js.map +1 -1
- package/dist/components/Menu.parts.d.ts +43 -9
- package/dist/components/Menu.parts.d.ts.map +1 -1
- package/dist/components/Menu.parts.js +118 -114
- package/dist/components/Menu.parts.js.map +1 -1
- package/dist/components/Modal.parts.d.ts +1 -1
- package/dist/components/Modal.parts.d.ts.map +1 -1
- package/dist/components/Modal.parts.js +2 -2
- package/dist/components/Modal.parts.js.map +1 -1
- package/dist/components/Pane.parts.d.ts +23 -22
- package/dist/components/Pane.parts.d.ts.map +1 -1
- package/dist/components/Pane.parts.js +3 -3
- package/dist/components/Pane.parts.js.map +1 -1
- package/dist/components/Prose.d.ts +1 -1
- package/dist/components/Prose.d.ts.map +1 -1
- package/dist/components/Prose.js +3 -3
- package/dist/components/Prose.js.map +1 -1
- package/dist/components/Prose.meta.d.ts +5 -0
- package/dist/components/Prose.meta.d.ts.map +1 -1
- package/dist/components/Prose.meta.js +10 -0
- package/dist/components/Prose.meta.js.map +1 -1
- package/dist/components/Select.parts.d.ts +8 -3
- package/dist/components/Select.parts.d.ts.map +1 -1
- package/dist/components/Select.parts.js +26 -17
- package/dist/components/Select.parts.js.map +1 -1
- package/dist/components/ThemeSwitcher.d.ts +5 -5
- package/dist/components/ThemeSwitcher.d.ts.map +1 -1
- package/dist/components/ThemeSwitcher.js +5 -4
- package/dist/components/ThemeSwitcher.js.map +1 -1
- package/dist/components/base/MaybeButtonOrLink.d.ts +7 -5
- package/dist/components/base/MaybeButtonOrLink.d.ts.map +1 -1
- package/dist/components/base/MaybeButtonOrLink.js +7 -2
- package/dist/components/base/MaybeButtonOrLink.js.map +1 -1
- package/dist/components/base/Render.d.ts +25 -6
- package/dist/components/base/Render.d.ts.map +1 -1
- package/dist/components/base/Render.js +4 -3
- package/dist/components/base/Render.js.map +1 -1
- package/dist/components/base/index.d.ts +1 -1
- package/dist/components/base/index.d.ts.map +1 -1
- package/dist/components/base/index.js.map +1 -1
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/index.js.map +1 -1
- package/dist/hooks/useEDS.d.ts +16 -8
- package/dist/hooks/useEDS.d.ts.map +1 -1
- package/dist/hooks/useEDS.js +16 -8
- package/dist/hooks/useEDS.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/theme-persistence.d.ts +18 -0
- package/dist/theme-persistence.d.ts.map +1 -0
- package/dist/theme-persistence.js +25 -0
- package/dist/theme-persistence.js.map +1 -0
- package/package.json +20 -20
- package/skill/SKILL.md +91 -57
- package/skill/references/tokens.md +1 -1
- package/src/EDSContext.ts +2 -2
- package/src/EDSProvider.tsx +80 -74
- package/src/components/Avatar.tsx +5 -3
- package/src/components/Button.tsx +4 -0
- package/src/components/Card.tsx +14 -8
- package/src/components/Keyboard.tsx +1 -1
- package/src/components/LocalTheme.tsx +22 -14
- package/src/components/Menu.parts.tsx +365 -244
- package/src/components/Modal.parts.tsx +8 -1
- package/src/components/Pane.parts.tsx +31 -26
- package/src/components/Prose.meta.ts +10 -0
- package/src/components/Prose.tsx +4 -3
- package/src/components/Select.parts.tsx +37 -29
- package/src/components/ThemeSwitcher.tsx +11 -13
- package/src/components/base/MaybeButtonOrLink.tsx +11 -6
- package/src/components/base/Render.tsx +28 -4
- package/src/components/base/index.ts +2 -0
- package/src/components/index.ts +8 -1
- package/src/hooks/useEDS.ts +16 -8
- package/src/index.ts +2 -1
- 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 {
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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<
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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<
|
|
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
|
})
|
package/src/components/Prose.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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={
|
|
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({
|
|
676
|
-
|
|
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
|
|
22
|
-
*
|
|
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
|
|
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,
|
|
39
|
+
const { theme, setTheme, systemTheme } = useEDS()
|
|
40
|
+
const pageTheme = theme === 'system' ? systemTheme : theme
|
|
43
41
|
|
|
44
|
-
const tooltip = typeof passedTooltip === 'function' ? 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 ${
|
|
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={
|
|
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' :
|
|
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 {
|
|
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
|
-
|
|
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?:
|
|
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
|
|
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:
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
41
|
-
* element or
|
|
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,
|
package/src/components/index.ts
CHANGED
|
@@ -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 {
|
|
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,
|
package/src/hooks/useEDS.ts
CHANGED
|
@@ -12,21 +12,29 @@ export function useEDS() {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
return {
|
|
15
|
-
/**
|
|
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
|
|
20
|
-
|
|
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 `
|
|
23
|
-
*
|
|
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
|
|
28
|
-
* to
|
|
29
|
-
|
|
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
|
@@ -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
|
+
}
|