@edgeandnode/eds-react 1.0.2 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +32 -7
  2. package/dist/EDSContext.d.ts +2 -2
  3. package/dist/EDSContext.d.ts.map +1 -1
  4. package/dist/EDSContext.js +1 -1
  5. package/dist/EDSContext.js.map +1 -1
  6. package/dist/EDSProvider.d.ts +13 -6
  7. package/dist/EDSProvider.d.ts.map +1 -1
  8. package/dist/EDSProvider.js +48 -63
  9. package/dist/EDSProvider.js.map +1 -1
  10. package/dist/components/Avatar.d.ts +3 -3
  11. package/dist/components/Avatar.d.ts.map +1 -1
  12. package/dist/components/Avatar.js.map +1 -1
  13. package/dist/components/Button.d.ts.map +1 -1
  14. package/dist/components/Button.js +1 -1
  15. package/dist/components/Button.js.map +1 -1
  16. package/dist/components/Card.d.ts +8 -4
  17. package/dist/components/Card.d.ts.map +1 -1
  18. package/dist/components/Card.js +2 -1
  19. package/dist/components/Card.js.map +1 -1
  20. package/dist/components/Keyboard.js +1 -1
  21. package/dist/components/LocalTheme.d.ts +21 -8
  22. package/dist/components/LocalTheme.d.ts.map +1 -1
  23. package/dist/components/LocalTheme.js +10 -4
  24. package/dist/components/LocalTheme.js.map +1 -1
  25. package/dist/components/Menu.parts.d.ts +43 -9
  26. package/dist/components/Menu.parts.d.ts.map +1 -1
  27. package/dist/components/Menu.parts.js +118 -114
  28. package/dist/components/Menu.parts.js.map +1 -1
  29. package/dist/components/Modal.parts.d.ts +1 -1
  30. package/dist/components/Modal.parts.d.ts.map +1 -1
  31. package/dist/components/Modal.parts.js +2 -2
  32. package/dist/components/Modal.parts.js.map +1 -1
  33. package/dist/components/Pane.parts.d.ts +23 -22
  34. package/dist/components/Pane.parts.d.ts.map +1 -1
  35. package/dist/components/Pane.parts.js +3 -3
  36. package/dist/components/Pane.parts.js.map +1 -1
  37. package/dist/components/Prose.d.ts +1 -1
  38. package/dist/components/Prose.d.ts.map +1 -1
  39. package/dist/components/Prose.js +3 -3
  40. package/dist/components/Prose.js.map +1 -1
  41. package/dist/components/Prose.meta.d.ts +5 -0
  42. package/dist/components/Prose.meta.d.ts.map +1 -1
  43. package/dist/components/Prose.meta.js +10 -0
  44. package/dist/components/Prose.meta.js.map +1 -1
  45. package/dist/components/Select.parts.d.ts +8 -3
  46. package/dist/components/Select.parts.d.ts.map +1 -1
  47. package/dist/components/Select.parts.js +26 -17
  48. package/dist/components/Select.parts.js.map +1 -1
  49. package/dist/components/ThemeSwitcher.d.ts +5 -5
  50. package/dist/components/ThemeSwitcher.d.ts.map +1 -1
  51. package/dist/components/ThemeSwitcher.js +5 -4
  52. package/dist/components/ThemeSwitcher.js.map +1 -1
  53. package/dist/components/base/MaybeButtonOrLink.d.ts +7 -5
  54. package/dist/components/base/MaybeButtonOrLink.d.ts.map +1 -1
  55. package/dist/components/base/MaybeButtonOrLink.js +7 -2
  56. package/dist/components/base/MaybeButtonOrLink.js.map +1 -1
  57. package/dist/components/base/Render.d.ts +25 -6
  58. package/dist/components/base/Render.d.ts.map +1 -1
  59. package/dist/components/base/Render.js +4 -3
  60. package/dist/components/base/Render.js.map +1 -1
  61. package/dist/components/base/index.d.ts +1 -1
  62. package/dist/components/base/index.d.ts.map +1 -1
  63. package/dist/components/base/index.js.map +1 -1
  64. package/dist/components/index.d.ts +1 -1
  65. package/dist/components/index.d.ts.map +1 -1
  66. package/dist/components/index.js +1 -1
  67. package/dist/components/index.js.map +1 -1
  68. package/dist/hooks/useEDS.d.ts +16 -8
  69. package/dist/hooks/useEDS.d.ts.map +1 -1
  70. package/dist/hooks/useEDS.js +16 -8
  71. package/dist/hooks/useEDS.js.map +1 -1
  72. package/dist/index.d.ts +2 -1
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +1 -0
  75. package/dist/index.js.map +1 -1
  76. package/dist/theme-persistence.d.ts +18 -0
  77. package/dist/theme-persistence.d.ts.map +1 -0
  78. package/dist/theme-persistence.js +25 -0
  79. package/dist/theme-persistence.js.map +1 -0
  80. package/package.json +20 -20
  81. package/skill/SKILL.md +91 -57
  82. package/skill/references/tokens.md +1 -1
  83. package/src/EDSContext.ts +2 -2
  84. package/src/EDSProvider.tsx +80 -74
  85. package/src/components/Avatar.tsx +5 -3
  86. package/src/components/Button.tsx +4 -0
  87. package/src/components/Card.tsx +14 -8
  88. package/src/components/Keyboard.tsx +1 -1
  89. package/src/components/LocalTheme.tsx +22 -14
  90. package/src/components/Menu.parts.tsx +365 -244
  91. package/src/components/Modal.parts.tsx +8 -1
  92. package/src/components/Pane.parts.tsx +31 -26
  93. package/src/components/Prose.meta.ts +10 -0
  94. package/src/components/Prose.tsx +4 -3
  95. package/src/components/Select.parts.tsx +37 -29
  96. package/src/components/ThemeSwitcher.tsx +11 -13
  97. package/src/components/base/MaybeButtonOrLink.tsx +11 -6
  98. package/src/components/base/Render.tsx +28 -4
  99. package/src/components/base/index.ts +2 -0
  100. package/src/components/index.ts +8 -1
  101. package/src/hooks/useEDS.ts +16 -8
  102. package/src/index.ts +2 -1
  103. package/src/theme-persistence.ts +31 -0
@@ -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
- <MenuRoot
268
- key={`${hasTrigger}-${triggerMode}`}
269
- actionsRef={actionsRef}
270
- open={open}
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
- <MenuTriggerContext.Provider value={true}>
279
- {renderedTriggerElement ?? (
280
- <MenuInlineFallback label={label} portalRef={portalRef} rootProps={props} />
281
- )}
282
- </MenuTriggerContext.Provider>
283
- {/* Ensure changes to CSS props are detected even when the menu is not mounted */}
284
- <Portal>
285
- <span
286
- ref={cssPropsPolyfillRef}
287
- {...mergeProps(
288
- { className: cn('eds-menu invisible! block!', cssPropsClassName) },
289
- cssPropsAttributes,
290
- cssPropsPolyfillAttributes,
291
- dirProps,
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
- <Menu.Positioner
314
- ref={passedRef}
315
- anchor={anchor}
316
- side={
317
- cssProps.side === 'start'
318
- ? 'inline-start'
319
- : cssProps.side === 'end'
320
- ? 'inline-end'
321
- : cssProps.side
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
- sideOffset={twToPx(cssProps.gap)}
324
- align={cssProps.align}
325
- alignOffset={twToPx(cssProps.alignOffset)}
326
- collisionPadding={8}
327
- collisionAvoidance={{
328
- side: 'flip',
329
- align: 'flip',
330
- fallbackAxisSide:
331
- cssProps.side === 'start' || cssProps.side === 'end' ? 'end' : 'none',
332
- }}
333
- data-has-trigger={hasTrigger || undefined}
334
- data-match-trigger-width={cssProps.matchTriggerWidth || undefined}
335
- data-match-trigger-height={cssProps.matchTriggerHeight || undefined}
336
- {...mergeProps(
337
- {
338
- className: cn(`
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
- cssPropsAttributes,
359
- cssPropsPolyfillAttributes,
360
- dirProps,
361
- props,
362
- )}
363
- render={(renderProps, state) => (
364
- <>
365
- <div {...renderProps} style={hasTrigger ? renderProps.style : undefined} />
366
- {/**
367
- * The `data-anchor-hidden:hidden` class above hides the menu if it's open when the trigger gets
368
- * hidden, but this actually closes it to restore pointer interactions outside the menu (though the
369
- * class is still necessary to prevent a flash of incorrect menu position). We're doing it in an
370
- * effect to prevent a "Cannot update a component while rendering a different component" error.
371
- */}
372
- {hasTrigger && state.open && state.anchorHidden && actionsRef.current ? (
373
- <LayoutEffect effect={actionsRef.current.close} />
374
- ) : null}
375
- </>
376
- )}
377
- >
378
- <Menu.Popup
379
- ref={popupRef}
380
- data-initial-open={initialOpen.current || undefined}
381
- className="
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
- onKeyDown={handleMenuPopupKeyDown}
389
- >
390
- <MenuPopupContent header={header} footer={footer}>
391
- {/* When `label` or `type` is provided at the root level, automatically wrap `children` in a group */}
392
- {label !== undefined || type !== undefined ? (
393
- type === 'radio' ? (
394
- <MenuGroup
395
- label={label}
396
- type={type}
397
- value={value}
398
- defaultValue={defaultValue}
399
- onValueChange={onValueChange}
400
- >
401
- {children}
402
- </MenuGroup>
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
- <MenuGroup label={label} type={type}>
405
- {children}
406
- </MenuGroup>
407
- )
408
- ) : (
409
- children
410
- )}
411
- </MenuPopupContent>
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
- * Popup chrome: minimum-width spacer, optional header, scrollable items area, optional footer,
423
- * border overlay.
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
- {header}
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
- <MenuGroupContext.Provider value="root">
451
- {children}
452
- <div className="mt-[calc(-2*(--spacing(2))-1px)] not-preceded-by-peer/menu-group:hidden" />
453
- </MenuGroupContext.Provider>
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
- { className: cn('peer/menu-group u:-m-2 u:mb-2 u:border-b u:border-muted u:p-2') },
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 <Menu.SubmenuTrigger nativeButton disabled={disabled} render={renderItem(baseProps)} />
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 {...baseProps} closeOnClick href={href} target={target} render={renderItem}>
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 <Menu.Item {...baseProps} nativeButton disabled={disabled} render={renderItem} />
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<ComponentProps<'input'>, 'children'> {
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
- export function MenuSearch({
1094
- ref: passedRef,
1095
- placeholder: passedPlaceholder,
1096
- value: controlledValue,
1097
- defaultValue,
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
- <MagnifyingGlassIcon alt="" className="absolute inset-y-0 inset-s-3.5 my-auto size-3.5" />
1118
- <input
1119
- ref={inputPassedRef}
1120
- type="search"
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
- <Button
1134
- variant="naked"
1135
- size="xsmall"
1136
- tabIndex={-1}
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
- </Button>
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` (typically shown when search filters
1157
- * yield no results, or when a `Menu` has no items at all). Defaults to `"No results"`.
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 {...mergeProps({ className: cn('p-2 u:text-16 u:text-muted') }, props)}>{children}</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'