@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
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
useMemo,
|
|
10
10
|
useRef,
|
|
11
11
|
useState,
|
|
12
|
-
type ChangeEvent,
|
|
13
12
|
type ComponentProps,
|
|
14
13
|
type KeyboardEvent,
|
|
15
14
|
type PointerEvent,
|
|
@@ -41,13 +40,7 @@ import {
|
|
|
41
40
|
useCSSState,
|
|
42
41
|
useEDS,
|
|
43
42
|
} from '../hooks/index.ts'
|
|
44
|
-
import {
|
|
45
|
-
cn,
|
|
46
|
-
getCSSPropsAttributes,
|
|
47
|
-
setInputValue,
|
|
48
|
-
splitProps,
|
|
49
|
-
type OptionValue,
|
|
50
|
-
} from '../utils/index.ts'
|
|
43
|
+
import { cn, getCSSPropsAttributes, splitProps, type OptionValue } from '../utils/index.ts'
|
|
51
44
|
import { renderAddon, type AddonValue } from './base/Addon.tsx'
|
|
52
45
|
import { ButtonOrLink, type ButtonOrLinkProps } from './base/ButtonOrLink.tsx'
|
|
53
46
|
import { Portal } from './base/Portal.tsx'
|
|
@@ -62,6 +55,8 @@ import { Checkbox } from './Checkbox.tsx'
|
|
|
62
55
|
import { MenuMeta, type MenuItemMeta } from './Menu.meta.ts'
|
|
63
56
|
|
|
64
57
|
const MenuTriggerContext = createContext<boolean>(false)
|
|
58
|
+
const MenuSearchableContext = createContext<boolean>(false)
|
|
59
|
+
const MenuSearchLoadingContext = createContext<boolean>(false)
|
|
65
60
|
|
|
66
61
|
type MenuItemType = 'plain' | 'checkbox' | 'radio'
|
|
67
62
|
|
|
@@ -131,6 +126,39 @@ export declare namespace MenuProps {
|
|
|
131
126
|
* @default false
|
|
132
127
|
*/
|
|
133
128
|
highlightCheckedOnOpen?: boolean | undefined
|
|
129
|
+
/**
|
|
130
|
+
* Adds `Menu.Search` to the header and filters items by their text (or `label`) as the user
|
|
131
|
+
* types. A custom `header` must include `Menu.Search`, which handles the keyboard navigation.
|
|
132
|
+
* Pass a function to customize matching, or `'manual'` to filter the items yourself (e.g.
|
|
133
|
+
* results from an API). Defaults to `true` if `searchValue` or `onSearchValueChange` is set.
|
|
134
|
+
*/
|
|
135
|
+
searchable?:
|
|
136
|
+
| boolean
|
|
137
|
+
| ((item: { label: string; searchValue: string }) => boolean)
|
|
138
|
+
| 'manual'
|
|
139
|
+
| undefined
|
|
140
|
+
/** The search query, to control it along with `onSearchValueChange`. */
|
|
141
|
+
searchValue?: string | undefined
|
|
142
|
+
/**
|
|
143
|
+
* Called when the search query changes, including when it's reset after the menu closes (then
|
|
144
|
+
* `eventDetails.reason` is `'popup-close'`).
|
|
145
|
+
*/
|
|
146
|
+
onSearchValueChange?:
|
|
147
|
+
| ((searchValue: string, eventDetails: Menu.FilterProvider.ChangeEventDetails) => void)
|
|
148
|
+
| undefined
|
|
149
|
+
/**
|
|
150
|
+
* Shown in a searchable menu when no item matches. A string is rendered in a `Menu.Empty`, and
|
|
151
|
+
* `null` renders nothing.
|
|
152
|
+
*
|
|
153
|
+
* @default <Menu.Empty />
|
|
154
|
+
*/
|
|
155
|
+
empty?: ReactNode | undefined
|
|
156
|
+
/**
|
|
157
|
+
* Marks the items as loading (e.g. search results from an API). In a searchable menu, a
|
|
158
|
+
* `LoadingIcon` also shows in the search input, or in place of the empty state until a search
|
|
159
|
+
* finishes.
|
|
160
|
+
*/
|
|
161
|
+
loading?: boolean | undefined
|
|
134
162
|
}
|
|
135
163
|
interface CommonProps extends HTMLProps, OwnProps, EDSComponentProps<typeof MenuMeta> {}
|
|
136
164
|
interface NonRadioProps extends CommonProps, GroupProps.NonRadioProps {}
|
|
@@ -155,9 +183,14 @@ export function MenuRoot<T extends OptionValue>({
|
|
|
155
183
|
open: controlledOpen,
|
|
156
184
|
defaultOpen,
|
|
157
185
|
onOpenChange,
|
|
158
|
-
header,
|
|
186
|
+
header: passedHeader,
|
|
159
187
|
footer,
|
|
160
188
|
highlightCheckedOnOpen,
|
|
189
|
+
searchable: passedSearchable,
|
|
190
|
+
searchValue,
|
|
191
|
+
onSearchValueChange,
|
|
192
|
+
empty,
|
|
193
|
+
loading,
|
|
161
194
|
label,
|
|
162
195
|
type,
|
|
163
196
|
value,
|
|
@@ -247,6 +280,12 @@ export function MenuRoot<T extends OptionValue>({
|
|
|
247
280
|
|
|
248
281
|
useHighlightCheckedOnOpen(popupRef, open, highlightCheckedOnOpen)
|
|
249
282
|
|
|
283
|
+
const searchable =
|
|
284
|
+
passedSearchable ?? (searchValue !== undefined || onSearchValueChange !== undefined)
|
|
285
|
+
const [onSearchValueChangeAfterClose, reportPendingSearchReset] =
|
|
286
|
+
useSearchResetAfterClose(onSearchValueChange)
|
|
287
|
+
const header = passedHeader !== undefined ? passedHeader : searchable ? <MenuSearch /> : undefined
|
|
288
|
+
|
|
250
289
|
return (
|
|
251
290
|
/**
|
|
252
291
|
* This wrapper element ensures a `Menu` always renders a single element, unlike Base UI's which
|
|
@@ -264,78 +303,87 @@ export function MenuRoot<T extends OptionValue>({
|
|
|
264
303
|
i:safari:not-data-is-submenu:size-max`,
|
|
265
304
|
)}
|
|
266
305
|
>
|
|
267
|
-
<
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
onOpenChange={(newOpen) => {
|
|
272
|
-
// A menu with no trigger can still be opened/closed by the consumer, but it should not send events
|
|
273
|
-
if (hasTrigger) setOpen(newOpen)
|
|
274
|
-
}}
|
|
275
|
-
{...(!isSubmenu && !isContextMenu && { modal: hasTrigger })}
|
|
276
|
-
closeParentOnEsc
|
|
306
|
+
<MenuFilter
|
|
307
|
+
searchable={searchable}
|
|
308
|
+
searchValue={searchValue}
|
|
309
|
+
onSearchValueChange={onSearchValueChangeAfterClose}
|
|
277
310
|
>
|
|
278
|
-
<
|
|
279
|
-
{
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
props,
|
|
293
|
-
)}
|
|
294
|
-
hidden={undefined}
|
|
295
|
-
/>
|
|
296
|
-
</Portal>
|
|
297
|
-
<Menu.Portal
|
|
298
|
-
container={
|
|
299
|
-
!hasTrigger
|
|
300
|
-
? portalRef
|
|
301
|
-
: /**
|
|
302
|
-
* Specifying `document.body` because while it is the default for top-level menus, a submenu
|
|
303
|
-
* defaults to using the same portal as its parent, which is not desirable when the parent is
|
|
304
|
-
* rendered inline (it can cause the submenu to appear behind other elements).
|
|
305
|
-
*/
|
|
306
|
-
typeof document !== 'undefined'
|
|
307
|
-
? document.body
|
|
308
|
-
: undefined
|
|
309
|
-
}
|
|
310
|
-
// Inline menus are portalled in place, so they already inherit the local theme
|
|
311
|
-
className={hasTrigger ? localThemeClass : undefined}
|
|
311
|
+
<MenuRoot
|
|
312
|
+
key={`${hasTrigger}-${triggerMode}`}
|
|
313
|
+
actionsRef={actionsRef}
|
|
314
|
+
open={open}
|
|
315
|
+
onOpenChange={(newOpen) => {
|
|
316
|
+
if (newOpen) reportPendingSearchReset()
|
|
317
|
+
// A menu with no trigger can still be opened/closed by the consumer, but it should not send events
|
|
318
|
+
if (hasTrigger) setOpen(newOpen)
|
|
319
|
+
}}
|
|
320
|
+
onOpenChangeComplete={(newOpen) => {
|
|
321
|
+
if (!newOpen) reportPendingSearchReset()
|
|
322
|
+
}}
|
|
323
|
+
{...(!isSubmenu && !isContextMenu && { modal: hasTrigger })}
|
|
324
|
+
closeParentOnEsc
|
|
312
325
|
>
|
|
313
|
-
<
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
326
|
+
<MenuTriggerContext.Provider value={true}>
|
|
327
|
+
{renderedTriggerElement ?? (
|
|
328
|
+
<MenuInlineFallback label={label} portalRef={portalRef} rootProps={props} />
|
|
329
|
+
)}
|
|
330
|
+
</MenuTriggerContext.Provider>
|
|
331
|
+
{/* Ensure changes to CSS props are detected even when the menu is not mounted */}
|
|
332
|
+
<Portal>
|
|
333
|
+
<span
|
|
334
|
+
ref={cssPropsPolyfillRef}
|
|
335
|
+
{...mergeProps(
|
|
336
|
+
{ className: cn('eds-menu invisible! block!', cssPropsClassName) },
|
|
337
|
+
cssPropsAttributes,
|
|
338
|
+
cssPropsPolyfillAttributes,
|
|
339
|
+
dirProps,
|
|
340
|
+
props,
|
|
341
|
+
)}
|
|
342
|
+
hidden={undefined}
|
|
343
|
+
/>
|
|
344
|
+
</Portal>
|
|
345
|
+
<Menu.Portal
|
|
346
|
+
container={
|
|
347
|
+
!hasTrigger
|
|
348
|
+
? portalRef
|
|
349
|
+
: /**
|
|
350
|
+
* Specifying `document.body` because while it is the default for top-level menus, a submenu
|
|
351
|
+
* defaults to using the same portal as its parent, which is not desirable when the parent is
|
|
352
|
+
* rendered inline (it can cause the submenu to appear behind other elements).
|
|
353
|
+
*/
|
|
354
|
+
typeof document !== 'undefined'
|
|
355
|
+
? document.body
|
|
356
|
+
: undefined
|
|
322
357
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
{
|
|
338
|
-
|
|
358
|
+
// Inline menus are portalled in place, so they already inherit the local theme
|
|
359
|
+
className={hasTrigger ? localThemeClass : undefined}
|
|
360
|
+
>
|
|
361
|
+
<Menu.Positioner
|
|
362
|
+
ref={passedRef}
|
|
363
|
+
anchor={anchor}
|
|
364
|
+
side={
|
|
365
|
+
cssProps.side === 'start'
|
|
366
|
+
? 'inline-start'
|
|
367
|
+
: cssProps.side === 'end'
|
|
368
|
+
? 'inline-end'
|
|
369
|
+
: cssProps.side
|
|
370
|
+
}
|
|
371
|
+
sideOffset={twToPx(cssProps.gap)}
|
|
372
|
+
align={cssProps.align}
|
|
373
|
+
alignOffset={twToPx(cssProps.alignOffset)}
|
|
374
|
+
collisionPadding={8}
|
|
375
|
+
collisionAvoidance={{
|
|
376
|
+
side: 'flip',
|
|
377
|
+
align: 'flip',
|
|
378
|
+
fallbackAxisSide:
|
|
379
|
+
cssProps.side === 'start' || cssProps.side === 'end' ? 'end' : 'none',
|
|
380
|
+
}}
|
|
381
|
+
data-has-trigger={hasTrigger || undefined}
|
|
382
|
+
data-match-trigger-width={cssProps.matchTriggerWidth || undefined}
|
|
383
|
+
data-match-trigger-height={cssProps.matchTriggerHeight || undefined}
|
|
384
|
+
{...mergeProps(
|
|
385
|
+
{
|
|
386
|
+
className: cn(`
|
|
339
387
|
eds-menu root-flex
|
|
340
388
|
[--available-height-but-not-too-small:max(var(--available-height),--spacing(16))]
|
|
341
389
|
[anchor-name:--eds-menu]
|
|
@@ -354,83 +402,153 @@ export function MenuRoot<T extends OptionValue>({
|
|
|
354
402
|
u:i:data-[match-trigger-width=at-most]:max-w-[min(var(--anchor-width),var(--available-width))]
|
|
355
403
|
u:i:data-[match-trigger-width=true]:w-(--anchor-width)
|
|
356
404
|
`, cssPropsClassName),
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
405
|
+
},
|
|
406
|
+
cssPropsAttributes,
|
|
407
|
+
cssPropsPolyfillAttributes,
|
|
408
|
+
dirProps,
|
|
409
|
+
props,
|
|
410
|
+
)}
|
|
411
|
+
render={(renderProps, state) => (
|
|
412
|
+
<>
|
|
413
|
+
<div {...renderProps} style={hasTrigger ? renderProps.style : undefined} />
|
|
414
|
+
{/**
|
|
415
|
+
* The `data-anchor-hidden:hidden` class above hides the menu if it's open when the trigger gets
|
|
416
|
+
* hidden, but this actually closes it to restore pointer interactions outside the menu (though the
|
|
417
|
+
* class is still necessary to prevent a flash of incorrect menu position). We're doing it in an
|
|
418
|
+
* effect to prevent a "Cannot update a component while rendering a different component" error.
|
|
419
|
+
*/}
|
|
420
|
+
{hasTrigger && state.open && state.anchorHidden && actionsRef.current ? (
|
|
421
|
+
<LayoutEffect effect={actionsRef.current.close} />
|
|
422
|
+
) : null}
|
|
423
|
+
</>
|
|
424
|
+
)}
|
|
425
|
+
>
|
|
426
|
+
<Menu.Popup
|
|
427
|
+
ref={popupRef}
|
|
428
|
+
data-initial-open={initialOpen.current || undefined}
|
|
429
|
+
className="
|
|
382
430
|
flex grow origin-(--transform-origin) flex-col overflow-clip rounded-inherit bg-muted outline-0 transition
|
|
383
431
|
data-ending-style:scale-95
|
|
384
432
|
data-ending-style:opacity-0
|
|
385
433
|
data-starting-style:not-data-initial-open:scale-95
|
|
386
434
|
data-starting-style:not-data-initial-open:opacity-0
|
|
387
435
|
"
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
436
|
+
onKeyDown={handleMenuPopupKeyDown}
|
|
437
|
+
>
|
|
438
|
+
<MenuPopupContent header={header} footer={footer} empty={empty} loading={loading}>
|
|
439
|
+
{/* When `label` or `type` is provided at the root level, automatically wrap `children` in a group */}
|
|
440
|
+
{label !== undefined || type !== undefined ? (
|
|
441
|
+
type === 'radio' ? (
|
|
442
|
+
<MenuGroup
|
|
443
|
+
label={label}
|
|
444
|
+
type={type}
|
|
445
|
+
value={value}
|
|
446
|
+
defaultValue={defaultValue}
|
|
447
|
+
onValueChange={onValueChange}
|
|
448
|
+
>
|
|
449
|
+
{children}
|
|
450
|
+
</MenuGroup>
|
|
451
|
+
) : (
|
|
452
|
+
<MenuGroup label={label} type={type}>
|
|
453
|
+
{children}
|
|
454
|
+
</MenuGroup>
|
|
455
|
+
)
|
|
403
456
|
) : (
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
</Menu.Popup>
|
|
413
|
-
</Menu.Positioner>
|
|
414
|
-
</Menu.Portal>
|
|
415
|
-
</MenuRoot>
|
|
457
|
+
children
|
|
458
|
+
)}
|
|
459
|
+
</MenuPopupContent>
|
|
460
|
+
</Menu.Popup>
|
|
461
|
+
</Menu.Positioner>
|
|
462
|
+
</Menu.Portal>
|
|
463
|
+
</MenuRoot>
|
|
464
|
+
</MenuFilter>
|
|
416
465
|
</span>
|
|
417
466
|
)
|
|
418
467
|
}
|
|
419
468
|
MenuRoot.displayName = 'Menu'
|
|
420
469
|
|
|
470
|
+
/** Makes the menu filterable when it's `searchable` (see `MenuProps.OwnProps['searchable']`). */
|
|
471
|
+
function MenuFilter({
|
|
472
|
+
searchable,
|
|
473
|
+
searchValue,
|
|
474
|
+
onSearchValueChange,
|
|
475
|
+
children,
|
|
476
|
+
}: Pick<MenuProps.OwnProps, 'searchable' | 'searchValue' | 'onSearchValueChange'> & {
|
|
477
|
+
children: ReactNode
|
|
478
|
+
}) {
|
|
479
|
+
const filter = useMemo(
|
|
480
|
+
() =>
|
|
481
|
+
searchable === 'manual'
|
|
482
|
+
? null
|
|
483
|
+
: typeof searchable === 'function'
|
|
484
|
+
? (label: string, query: string) => searchable({ label, searchValue: query })
|
|
485
|
+
: undefined,
|
|
486
|
+
[searchable],
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
return (
|
|
490
|
+
<MenuSearchableContext.Provider value={Boolean(searchable)}>
|
|
491
|
+
{searchable ? (
|
|
492
|
+
<Menu.FilterProvider
|
|
493
|
+
filter={filter}
|
|
494
|
+
autoHighlight
|
|
495
|
+
value={searchValue}
|
|
496
|
+
onValueChange={onSearchValueChange}
|
|
497
|
+
>
|
|
498
|
+
{children}
|
|
499
|
+
</Menu.FilterProvider>
|
|
500
|
+
) : (
|
|
501
|
+
children
|
|
502
|
+
)}
|
|
503
|
+
</MenuSearchableContext.Provider>
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
|
|
421
507
|
/**
|
|
422
|
-
*
|
|
423
|
-
*
|
|
508
|
+
* Holds back the search reset that Base UI reports as the menu starts closing until it has finished
|
|
509
|
+
* closing (or reopens), so items filtered outside of Base UI (e.g. with `searchable="manual"`)
|
|
510
|
+
* don't change while it animates out. Base UI keeps filtering its own items with the previous query
|
|
511
|
+
* until then too.
|
|
512
|
+
*/
|
|
513
|
+
function useSearchResetAfterClose(onSearchValueChange: MenuProps.OwnProps['onSearchValueChange']) {
|
|
514
|
+
const pendingResetRef = useRef<(() => void) | null>(null)
|
|
515
|
+
const onSearchValueChangeAfterClose: MenuProps.OwnProps['onSearchValueChange'] =
|
|
516
|
+
onSearchValueChange &&
|
|
517
|
+
((searchValue, eventDetails) => {
|
|
518
|
+
if (eventDetails.reason === 'popup-close') {
|
|
519
|
+
pendingResetRef.current = () => onSearchValueChange(searchValue, eventDetails)
|
|
520
|
+
} else {
|
|
521
|
+
onSearchValueChange(searchValue, eventDetails)
|
|
522
|
+
}
|
|
523
|
+
})
|
|
524
|
+
const reportPendingReset = () => {
|
|
525
|
+
pendingResetRef.current?.()
|
|
526
|
+
pendingResetRef.current = null
|
|
527
|
+
}
|
|
528
|
+
return [onSearchValueChangeAfterClose, reportPendingReset] as const
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Popup chrome: minimum-width spacer, optional header, scrollable items area (with a searchable
|
|
533
|
+
* menu's empty state), optional footer, border overlay.
|
|
424
534
|
*/
|
|
425
535
|
function MenuPopupContent({
|
|
426
536
|
header,
|
|
427
537
|
footer,
|
|
538
|
+
empty,
|
|
539
|
+
loading = false,
|
|
428
540
|
children,
|
|
429
541
|
}: {
|
|
430
542
|
header?: ReactNode | undefined
|
|
431
543
|
footer?: ReactNode | undefined
|
|
544
|
+
/** Only for searchable menus, where Base UI shows it when no item matches. */
|
|
545
|
+
empty?: ReactNode | undefined
|
|
546
|
+
loading?: boolean | undefined
|
|
432
547
|
children?: ReactNode | undefined
|
|
433
548
|
}) {
|
|
549
|
+
const searchable = useContext(MenuSearchableContext)
|
|
550
|
+
const [emptyShowsLoading, setEmptyShowsLoading] = useState(false)
|
|
551
|
+
|
|
434
552
|
return (
|
|
435
553
|
<>
|
|
436
554
|
{/**
|
|
@@ -442,15 +560,25 @@ function MenuPopupContent({
|
|
|
442
560
|
<div className="w-58" />
|
|
443
561
|
{header ? (
|
|
444
562
|
<div className="nested/menu-header u:shrink-0 u:border-b u:border-muted u:p-4">
|
|
445
|
-
{
|
|
563
|
+
<MenuSearchLoadingContext.Provider value={loading && !emptyShowsLoading}>
|
|
564
|
+
{header}
|
|
565
|
+
</MenuSearchLoadingContext.Provider>
|
|
446
566
|
</div>
|
|
447
567
|
) : null}
|
|
448
568
|
<div className="grow scroll-p-8 scrollbar-thin overflow-x-clip overflow-y-auto gradient-mask-y">
|
|
449
569
|
<div className="overflow-clip p-2">
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
570
|
+
{searchable ? (
|
|
571
|
+
<Menu.Empty>
|
|
572
|
+
<MenuEmptyContent
|
|
573
|
+
empty={empty}
|
|
574
|
+
loading={loading}
|
|
575
|
+
onLoadingShownChange={setEmptyShowsLoading}
|
|
576
|
+
/>
|
|
577
|
+
</Menu.Empty>
|
|
578
|
+
) : null}
|
|
579
|
+
<Menu.List aria-busy={loading || undefined} className="outline-0">
|
|
580
|
+
<MenuGroupContext.Provider value="root">{children}</MenuGroupContext.Provider>
|
|
581
|
+
</Menu.List>
|
|
454
582
|
</div>
|
|
455
583
|
</div>
|
|
456
584
|
{footer ? (
|
|
@@ -624,9 +752,44 @@ function renderMenuTriggerElement({
|
|
|
624
752
|
}
|
|
625
753
|
}
|
|
626
754
|
|
|
755
|
+
/**
|
|
756
|
+
* The content of a searchable menu's `Menu.Empty`, which Base UI only renders while no item
|
|
757
|
+
* matches. Until a search finishes, `empty` would be premature, so `loading` shows a `LoadingIcon`
|
|
758
|
+
* instead (rather than in the search input).
|
|
759
|
+
*/
|
|
760
|
+
function MenuEmptyContent({
|
|
761
|
+
empty,
|
|
762
|
+
loading,
|
|
763
|
+
onLoadingShownChange,
|
|
764
|
+
}: {
|
|
765
|
+
empty: ReactNode
|
|
766
|
+
loading: boolean
|
|
767
|
+
onLoadingShownChange: (loadingShown: boolean) => void
|
|
768
|
+
}) {
|
|
769
|
+
const [searchFinished, setSearchFinished] = useState(!loading)
|
|
770
|
+
if (!loading && !searchFinished) setSearchFinished(true)
|
|
771
|
+
|
|
772
|
+
useLayoutEffect(() => {
|
|
773
|
+
onLoadingShownChange(!searchFinished)
|
|
774
|
+
return () => onLoadingShownChange(false)
|
|
775
|
+
}, [searchFinished, onLoadingShownChange])
|
|
776
|
+
|
|
777
|
+
if (!searchFinished) {
|
|
778
|
+
return (
|
|
779
|
+
<MenuEmpty>
|
|
780
|
+
<span className="flex h-lh items-center justify-center">
|
|
781
|
+
<LoadingIcon />
|
|
782
|
+
</span>
|
|
783
|
+
</MenuEmpty>
|
|
784
|
+
)
|
|
785
|
+
}
|
|
786
|
+
return empty === undefined || typeof empty === 'string' ? <MenuEmpty>{empty}</MenuEmpty> : empty
|
|
787
|
+
}
|
|
788
|
+
|
|
627
789
|
/**
|
|
628
790
|
* Focus the `[data-checked]` item on open so Base UI's composite syncs its active index to it (Base
|
|
629
|
-
* UI has no `initialActiveIndex` API).
|
|
791
|
+
* UI has no `initialActiveIndex` API). In a searchable menu, typing then moves focus back to the
|
|
792
|
+
* search input.
|
|
630
793
|
*/
|
|
631
794
|
function useHighlightCheckedOnOpen(
|
|
632
795
|
popupRef: RefObject<HTMLDivElement | null>,
|
|
@@ -648,19 +811,13 @@ function useHighlightCheckedOnOpen(
|
|
|
648
811
|
) {
|
|
649
812
|
return
|
|
650
813
|
}
|
|
651
|
-
const searchInput = popup.querySelector<HTMLInputElement>('input[type="search"]')
|
|
652
|
-
const searchWasFocused = searchInput !== null && document.activeElement === searchInput
|
|
653
814
|
checkedItem.focus({ preventScroll: false })
|
|
654
|
-
if (searchWasFocused) searchInput.focus({ preventScroll: true })
|
|
655
815
|
})
|
|
656
816
|
return () => cancelAnimationFrame(frameId)
|
|
657
817
|
}, [popupRef, open, enabled])
|
|
658
818
|
}
|
|
659
819
|
|
|
660
|
-
/**
|
|
661
|
-
* Keydown handler for `Menu.Popup`. Handles Shift + Tab escaping the popup and inline-search-input
|
|
662
|
-
* / composite-item coordination.
|
|
663
|
-
*/
|
|
820
|
+
/** Keydown handler for `Menu.Popup`. Handles Shift + Tab escaping the popup. */
|
|
664
821
|
function handleMenuPopupKeyDown(event: BaseUIEvent<KeyboardEvent<HTMLDivElement>>) {
|
|
665
822
|
/**
|
|
666
823
|
* Ensure Shift + Tab doesn't close the menu if there is a previous focusable element that is not
|
|
@@ -692,59 +849,6 @@ function handleMenuPopupKeyDown(event: BaseUIEvent<KeyboardEvent<HTMLDivElement>
|
|
|
692
849
|
(!activeElementIsMenuItem && previousElementIsMenuItem)
|
|
693
850
|
) {
|
|
694
851
|
event.preventBaseUIHandler()
|
|
695
|
-
return
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
// If there is a search input in the menu...
|
|
699
|
-
const searchInput = event.currentTarget.querySelector('input[type="search"]')
|
|
700
|
-
if (searchInput instanceof HTMLInputElement) {
|
|
701
|
-
// If the user typed some text, ensure it goes in the input instead of Base UI focusing the item that matches
|
|
702
|
-
if (
|
|
703
|
-
(event.key.length === 1 && event.key !== ' ') ||
|
|
704
|
-
event.key === 'Backspace' ||
|
|
705
|
-
event.key === 'ArrowLeft' ||
|
|
706
|
-
event.key === 'ArrowRight'
|
|
707
|
-
) {
|
|
708
|
-
event.preventBaseUIHandler()
|
|
709
|
-
searchInput.focus()
|
|
710
|
-
} else {
|
|
711
|
-
const searchInputIsFocused = document.activeElement === searchInput
|
|
712
|
-
const menuItems = [...event.currentTarget.querySelectorAll<HTMLElement>('.eds-menu-item')]
|
|
713
|
-
const highlightedMenuItem = menuItems.find((item) => item.matches('[data-highlighted]'))
|
|
714
|
-
/**
|
|
715
|
-
* If the user pressed the down arrow while the search input is focused and there is a single
|
|
716
|
-
* menu item in the results, move the focus to it because it may already be highlighted, which
|
|
717
|
-
* would prevent Base UI from focusing it.
|
|
718
|
-
*/
|
|
719
|
-
if (event.key === 'ArrowDown' && searchInputIsFocused && menuItems.length === 1) {
|
|
720
|
-
event.preventBaseUIHandler()
|
|
721
|
-
menuItems[0]!.focus()
|
|
722
|
-
}
|
|
723
|
-
// Inversely, if the user pressed the up arrow and the focus is on the only menu item, move it back to the search input
|
|
724
|
-
else if (
|
|
725
|
-
event.key === 'ArrowUp' &&
|
|
726
|
-
menuItems.length === 1 &&
|
|
727
|
-
document.activeElement === menuItems[0]
|
|
728
|
-
) {
|
|
729
|
-
event.preventDefault()
|
|
730
|
-
searchInput.focus()
|
|
731
|
-
}
|
|
732
|
-
// If the user pressed Enter while the search input is focused and a menu item is highlighted, select it
|
|
733
|
-
else if (event.key === 'Enter' && searchInputIsFocused && highlightedMenuItem) {
|
|
734
|
-
highlightedMenuItem.focus()
|
|
735
|
-
window.setTimeout(() => {
|
|
736
|
-
highlightedMenuItem.dispatchEvent(
|
|
737
|
-
new KeyboardEvent('keydown', {
|
|
738
|
-
key: 'Enter',
|
|
739
|
-
code: 'Enter',
|
|
740
|
-
keyCode: 13,
|
|
741
|
-
which: 13,
|
|
742
|
-
bubbles: true,
|
|
743
|
-
cancelable: true,
|
|
744
|
-
}),
|
|
745
|
-
)
|
|
746
|
-
}, 0)
|
|
747
|
-
}
|
|
748
852
|
}
|
|
749
853
|
}
|
|
750
854
|
}
|
|
@@ -796,7 +900,11 @@ export function MenuGroup<T extends OptionValue>({
|
|
|
796
900
|
<MenuGroupContext.Provider value={menuGroupContextValue}>
|
|
797
901
|
<Menu.Group
|
|
798
902
|
{...mergeProps(
|
|
799
|
-
{
|
|
903
|
+
{
|
|
904
|
+
className: cn(
|
|
905
|
+
'u:-m-2 u:border-muted u:p-2 u:has-[~:not([hidden]):not(:empty)]:mb-2 u:has-[~:not([hidden]):not(:empty)]:border-b',
|
|
906
|
+
),
|
|
907
|
+
},
|
|
800
908
|
props,
|
|
801
909
|
)}
|
|
802
910
|
render={
|
|
@@ -827,6 +935,11 @@ export declare namespace MenuItemProps {
|
|
|
827
935
|
'defaultChecked' | 'defaultValue' | 'onChange' | 'type' | 'value'
|
|
828
936
|
> {}
|
|
829
937
|
interface OwnProps {
|
|
938
|
+
/**
|
|
939
|
+
* Text that search and typeahead match against. Defaults to the item's rendered text, addons
|
|
940
|
+
* included.
|
|
941
|
+
*/
|
|
942
|
+
label?: string | undefined
|
|
830
943
|
addonBefore?: AddonValue | undefined
|
|
831
944
|
addonAfter?: AddonValue | undefined
|
|
832
945
|
disabled?: boolean | undefined
|
|
@@ -875,6 +988,7 @@ export type MenuItemProps<T extends OptionValue = OptionValue> =
|
|
|
875
988
|
export function MenuItem<T extends OptionValue>({
|
|
876
989
|
ref: passedRef,
|
|
877
990
|
type: passedType,
|
|
991
|
+
label,
|
|
878
992
|
addonBefore: passedAddonBefore,
|
|
879
993
|
addonAfter: passedAddonAfter,
|
|
880
994
|
variant,
|
|
@@ -1023,7 +1137,14 @@ export function MenuItem<T extends OptionValue>({
|
|
|
1023
1137
|
}
|
|
1024
1138
|
|
|
1025
1139
|
if (type === 'submenu-trigger') {
|
|
1026
|
-
return
|
|
1140
|
+
return (
|
|
1141
|
+
<Menu.SubmenuTrigger
|
|
1142
|
+
nativeButton
|
|
1143
|
+
label={label}
|
|
1144
|
+
disabled={disabled}
|
|
1145
|
+
render={renderItem(baseProps)}
|
|
1146
|
+
/>
|
|
1147
|
+
)
|
|
1027
1148
|
}
|
|
1028
1149
|
|
|
1029
1150
|
if (type === 'checkbox') {
|
|
@@ -1031,6 +1152,7 @@ export function MenuItem<T extends OptionValue>({
|
|
|
1031
1152
|
<CheckboxItem
|
|
1032
1153
|
{...baseProps}
|
|
1033
1154
|
nativeButton
|
|
1155
|
+
label={label}
|
|
1034
1156
|
checked={checked}
|
|
1035
1157
|
defaultChecked={defaultChecked}
|
|
1036
1158
|
onCheckedChange={onCheckedChange}
|
|
@@ -1046,6 +1168,7 @@ export function MenuItem<T extends OptionValue>({
|
|
|
1046
1168
|
{...baseProps}
|
|
1047
1169
|
nativeButton
|
|
1048
1170
|
closeOnClick
|
|
1171
|
+
label={label}
|
|
1049
1172
|
value={passedValue !== undefined ? passedValue : autoValue}
|
|
1050
1173
|
disabled={disabled}
|
|
1051
1174
|
render={renderItem}
|
|
@@ -1055,13 +1178,22 @@ export function MenuItem<T extends OptionValue>({
|
|
|
1055
1178
|
|
|
1056
1179
|
if (href !== undefined) {
|
|
1057
1180
|
return (
|
|
1058
|
-
<Menu.LinkItem
|
|
1181
|
+
<Menu.LinkItem
|
|
1182
|
+
{...baseProps}
|
|
1183
|
+
closeOnClick
|
|
1184
|
+
label={label}
|
|
1185
|
+
href={href}
|
|
1186
|
+
target={target}
|
|
1187
|
+
render={renderItem}
|
|
1188
|
+
>
|
|
1059
1189
|
{children}
|
|
1060
1190
|
</Menu.LinkItem>
|
|
1061
1191
|
)
|
|
1062
1192
|
}
|
|
1063
1193
|
|
|
1064
|
-
return
|
|
1194
|
+
return (
|
|
1195
|
+
<Menu.Item {...baseProps} nativeButton label={label} disabled={disabled} render={renderItem} />
|
|
1196
|
+
)
|
|
1065
1197
|
}
|
|
1066
1198
|
MenuItem.displayName = 'Menu.Item'
|
|
1067
1199
|
|
|
@@ -1082,29 +1214,21 @@ function MenuItemAddon({ children, ...props }: ComponentProps<'span'>) {
|
|
|
1082
1214
|
)
|
|
1083
1215
|
}
|
|
1084
1216
|
|
|
1085
|
-
export interface MenuSearchProps extends Omit<
|
|
1217
|
+
export interface MenuSearchProps extends Omit<
|
|
1218
|
+
ComponentProps<'input'>,
|
|
1219
|
+
'children' | 'value' | 'defaultValue'
|
|
1220
|
+
> {
|
|
1086
1221
|
/** @default 'Search' */
|
|
1087
1222
|
placeholder?: string | undefined
|
|
1088
|
-
value?: string | undefined
|
|
1089
|
-
defaultValue?: string | undefined
|
|
1090
|
-
onValueChange?: ((value: string) => void) | undefined
|
|
1091
1223
|
}
|
|
1092
1224
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
onValueChange,
|
|
1099
|
-
...props
|
|
1100
|
-
}: MenuSearchProps) {
|
|
1101
|
-
const inputRef = useRef<HTMLInputElement>(null)
|
|
1102
|
-
const inputPassedRef = useMergedRefs(inputRef, passedRef)
|
|
1103
|
-
|
|
1104
|
-
const placeholder = passedPlaceholder ?? 'Search'
|
|
1105
|
-
|
|
1106
|
-
const [value, setValue] = useControlled(controlledValue, defaultValue ?? '', onValueChange)
|
|
1225
|
+
/** Search input for the `header` of a `searchable` menu, which filters its items. */
|
|
1226
|
+
export function MenuSearch({ placeholder = 'Search', ...props }: MenuSearchProps) {
|
|
1227
|
+
if (!useContext(MenuSearchableContext)) {
|
|
1228
|
+
throw new Error('[Menu] `Menu.Search` must be used in a `searchable` menu')
|
|
1229
|
+
}
|
|
1107
1230
|
|
|
1231
|
+
const loading = useContext(MenuSearchLoadingContext)
|
|
1108
1232
|
const { rootProps, nestedProps } = splitProps(props)
|
|
1109
1233
|
|
|
1110
1234
|
return (
|
|
@@ -1114,36 +1238,27 @@ export function MenuSearch({
|
|
|
1114
1238
|
rootProps,
|
|
1115
1239
|
)}
|
|
1116
1240
|
>
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1241
|
+
{loading ? (
|
|
1242
|
+
<LoadingIcon className="absolute inset-y-0 inset-s-3.5 my-auto size-3.5" />
|
|
1243
|
+
) : (
|
|
1244
|
+
<MagnifyingGlassIcon alt="" className="absolute inset-y-0 inset-s-3.5 my-auto size-3.5" />
|
|
1245
|
+
)}
|
|
1246
|
+
<Menu.Input
|
|
1121
1247
|
placeholder={placeholder}
|
|
1122
1248
|
aria-label={placeholder}
|
|
1123
|
-
value={value}
|
|
1124
1249
|
{...mergeProps(
|
|
1125
|
-
{
|
|
1126
|
-
className: cn('peer -mb-px h-10 w-0 grow ps-8.5 outline-0'),
|
|
1127
|
-
onChange: (event: ChangeEvent<HTMLInputElement>) => setValue(event.target.value),
|
|
1128
|
-
},
|
|
1250
|
+
{ className: cn('peer -mb-px h-10 w-0 grow ps-8.5 outline-0') },
|
|
1129
1251
|
nestedProps,
|
|
1130
1252
|
)}
|
|
1131
1253
|
/>
|
|
1132
1254
|
<div className="me-1.5 flex size-7 shrink-0 items-center justify-center peer-placeholder-shown:hidden">
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
onClick={() => {
|
|
1138
|
-
const input = inputRef.current
|
|
1139
|
-
if (!input) return
|
|
1140
|
-
setInputValue(input, '')
|
|
1141
|
-
input.focus()
|
|
1142
|
-
}}
|
|
1143
|
-
className="h-full"
|
|
1255
|
+
{/* Exposed to assistive tech, unlike Base UI's default */}
|
|
1256
|
+
<Menu.Clear
|
|
1257
|
+
aria-hidden={false}
|
|
1258
|
+
render={<Button variant="naked" size="xsmall" className="h-full" />}
|
|
1144
1259
|
>
|
|
1145
1260
|
<XIcon alt="Clear" />
|
|
1146
|
-
</
|
|
1261
|
+
</Menu.Clear>
|
|
1147
1262
|
</div>
|
|
1148
1263
|
</div>
|
|
1149
1264
|
)
|
|
@@ -1153,12 +1268,18 @@ MenuSearch.displayName = 'Menu.Search'
|
|
|
1153
1268
|
export interface MenuEmptyProps extends ComponentProps<'div'> {}
|
|
1154
1269
|
|
|
1155
1270
|
/**
|
|
1156
|
-
* Renders the standard empty-state treatment for a `Menu
|
|
1157
|
-
*
|
|
1271
|
+
* Renders the standard empty-state treatment for a `Menu`, e.g. when it has no items. Searchable
|
|
1272
|
+
* menus render it when no item matches (see `empty`). Its text wraps instead of widening the menu.
|
|
1273
|
+
* Defaults to `"No results"`.
|
|
1158
1274
|
*/
|
|
1159
1275
|
export function MenuEmpty({ children = 'No results', ...props }: MenuEmptyProps) {
|
|
1276
|
+
// `contain-inline-size` keeps the text from widening a menu that sizes to fit its content
|
|
1160
1277
|
return (
|
|
1161
|
-
<div
|
|
1278
|
+
<div
|
|
1279
|
+
{...mergeProps({ className: cn('p-2 contain-inline-size u:text-16 u:text-muted') }, props)}
|
|
1280
|
+
>
|
|
1281
|
+
{children}
|
|
1282
|
+
</div>
|
|
1162
1283
|
)
|
|
1163
1284
|
}
|
|
1164
1285
|
MenuEmpty.displayName = 'Menu.Empty'
|