@edgeandnode/eds-react 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +50 -8
  2. package/dist/EDSContext.d.ts +4 -1
  3. package/dist/EDSContext.d.ts.map +1 -1
  4. package/dist/EDSContext.js +7 -0
  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 +60 -68
  9. package/dist/EDSProvider.js.map +1 -1
  10. package/dist/components/Address.d.ts +3 -3
  11. package/dist/components/Address.d.ts.map +1 -1
  12. package/dist/components/Address.js.map +1 -1
  13. package/dist/components/Avatar.d.ts +3 -3
  14. package/dist/components/Avatar.d.ts.map +1 -1
  15. package/dist/components/Avatar.js +3 -2
  16. package/dist/components/Avatar.js.map +1 -1
  17. package/dist/components/Breadcrumbs.parts.d.ts +7 -6
  18. package/dist/components/Breadcrumbs.parts.d.ts.map +1 -1
  19. package/dist/components/Breadcrumbs.parts.js +15 -14
  20. package/dist/components/Breadcrumbs.parts.js.map +1 -1
  21. package/dist/components/Button.d.ts.map +1 -1
  22. package/dist/components/Button.js +3 -2
  23. package/dist/components/Button.js.map +1 -1
  24. package/dist/components/Card.d.ts +8 -4
  25. package/dist/components/Card.d.ts.map +1 -1
  26. package/dist/components/Card.js +2 -1
  27. package/dist/components/Card.js.map +1 -1
  28. package/dist/components/Cluster.d.ts +3 -3
  29. package/dist/components/Cluster.d.ts.map +1 -1
  30. package/dist/components/Cluster.js.map +1 -1
  31. package/dist/components/CodeBlock.parts.d.ts.map +1 -1
  32. package/dist/components/CodeBlock.parts.js +16 -20
  33. package/dist/components/CodeBlock.parts.js.map +1 -1
  34. package/dist/components/CopyButton.d.ts.map +1 -1
  35. package/dist/components/CopyButton.js +3 -2
  36. package/dist/components/CopyButton.js.map +1 -1
  37. package/dist/components/Icon.d.ts.map +1 -1
  38. package/dist/components/Icon.js +3 -3
  39. package/dist/components/Icon.js.map +1 -1
  40. package/dist/components/Keyboard.js +1 -1
  41. package/dist/components/LocalTheme.d.ts +29 -0
  42. package/dist/components/LocalTheme.d.ts.map +1 -0
  43. package/dist/components/LocalTheme.js +28 -0
  44. package/dist/components/LocalTheme.js.map +1 -0
  45. package/dist/components/Menu.parts.d.ts +43 -9
  46. package/dist/components/Menu.parts.d.ts.map +1 -1
  47. package/dist/components/Menu.parts.js +121 -115
  48. package/dist/components/Menu.parts.js.map +1 -1
  49. package/dist/components/Modal.parts.d.ts +1 -1
  50. package/dist/components/Modal.parts.d.ts.map +1 -1
  51. package/dist/components/Modal.parts.js +22 -11
  52. package/dist/components/Modal.parts.js.map +1 -1
  53. package/dist/components/Pane.parts.d.ts +23 -22
  54. package/dist/components/Pane.parts.d.ts.map +1 -1
  55. package/dist/components/Pane.parts.js +3 -3
  56. package/dist/components/Pane.parts.js.map +1 -1
  57. package/dist/components/Prose.d.ts +1 -1
  58. package/dist/components/Prose.d.ts.map +1 -1
  59. package/dist/components/Prose.js +3 -3
  60. package/dist/components/Prose.js.map +1 -1
  61. package/dist/components/Prose.meta.d.ts +5 -0
  62. package/dist/components/Prose.meta.d.ts.map +1 -1
  63. package/dist/components/Prose.meta.js +10 -0
  64. package/dist/components/Prose.meta.js.map +1 -1
  65. package/dist/components/SegmentedControl.parts.d.ts.map +1 -1
  66. package/dist/components/SegmentedControl.parts.js +3 -2
  67. package/dist/components/SegmentedControl.parts.js.map +1 -1
  68. package/dist/components/Select.parts.d.ts +12 -7
  69. package/dist/components/Select.parts.d.ts.map +1 -1
  70. package/dist/components/Select.parts.js +28 -18
  71. package/dist/components/Select.parts.js.map +1 -1
  72. package/dist/components/TabSet.parts.d.ts.map +1 -1
  73. package/dist/components/TabSet.parts.js +3 -2
  74. package/dist/components/TabSet.parts.js.map +1 -1
  75. package/dist/components/Table.parts.d.ts.map +1 -1
  76. package/dist/components/Table.parts.js +3 -4
  77. package/dist/components/Table.parts.js.map +1 -1
  78. package/dist/components/ThemeSwitcher.d.ts +5 -5
  79. package/dist/components/ThemeSwitcher.d.ts.map +1 -1
  80. package/dist/components/ThemeSwitcher.js +6 -5
  81. package/dist/components/ThemeSwitcher.js.map +1 -1
  82. package/dist/components/Tooltip.parts.js +2 -2
  83. package/dist/components/Tooltip.parts.js.map +1 -1
  84. package/dist/components/base/Addon.d.ts.map +1 -1
  85. package/dist/components/base/Addon.js +2 -4
  86. package/dist/components/base/Addon.js.map +1 -1
  87. package/dist/components/base/ButtonOrLink.parts.d.ts.map +1 -1
  88. package/dist/components/base/ButtonOrLink.parts.js +2 -1
  89. package/dist/components/base/ButtonOrLink.parts.js.map +1 -1
  90. package/dist/components/base/MaybeButtonOrLink.d.ts +7 -5
  91. package/dist/components/base/MaybeButtonOrLink.d.ts.map +1 -1
  92. package/dist/components/base/MaybeButtonOrLink.js +7 -2
  93. package/dist/components/base/MaybeButtonOrLink.js.map +1 -1
  94. package/dist/components/base/Render.d.ts +25 -6
  95. package/dist/components/base/Render.d.ts.map +1 -1
  96. package/dist/components/base/Render.js +4 -3
  97. package/dist/components/base/Render.js.map +1 -1
  98. package/dist/components/base/index.d.ts +1 -1
  99. package/dist/components/base/index.d.ts.map +1 -1
  100. package/dist/components/base/index.js.map +1 -1
  101. package/dist/components/index.d.ts +1 -0
  102. package/dist/components/index.d.ts.map +1 -1
  103. package/dist/components/index.js +1 -0
  104. package/dist/components/index.js.map +1 -1
  105. package/dist/hooks/useCSSProp.js +1 -1
  106. package/dist/hooks/useCSSProp.js.map +1 -1
  107. package/dist/hooks/useEDS.d.ts +24 -1
  108. package/dist/hooks/useEDS.d.ts.map +1 -1
  109. package/dist/hooks/useEDS.js +26 -2
  110. package/dist/hooks/useEDS.js.map +1 -1
  111. package/dist/hooks/useStyleObserver.d.ts.map +1 -1
  112. package/dist/hooks/useStyleObserver.js +2 -1
  113. package/dist/hooks/useStyleObserver.js.map +1 -1
  114. package/dist/icons/CalendarDynamicIcon.d.ts.map +1 -1
  115. package/dist/icons/CalendarDynamicIcon.js +3 -1
  116. package/dist/icons/CalendarDynamicIcon.js.map +1 -1
  117. package/dist/index.d.ts +2 -1
  118. package/dist/index.d.ts.map +1 -1
  119. package/dist/index.js +1 -0
  120. package/dist/index.js.map +1 -1
  121. package/dist/theme-persistence.d.ts +18 -0
  122. package/dist/theme-persistence.d.ts.map +1 -0
  123. package/dist/theme-persistence.js +25 -0
  124. package/dist/theme-persistence.js.map +1 -0
  125. package/dist/utils/getReactNodeKey.d.ts.map +1 -1
  126. package/dist/utils/getReactNodeKey.js +31 -13
  127. package/dist/utils/getReactNodeKey.js.map +1 -1
  128. package/dist/utils/index.d.ts +1 -1
  129. package/dist/utils/index.d.ts.map +1 -1
  130. package/dist/utils/index.js +1 -1
  131. package/dist/utils/index.js.map +1 -1
  132. package/dist/utils/internal/getTooltipProps.d.ts +9 -0
  133. package/dist/utils/internal/getTooltipProps.d.ts.map +1 -0
  134. package/dist/utils/internal/getTooltipProps.js +18 -0
  135. package/dist/utils/internal/getTooltipProps.js.map +1 -0
  136. package/dist/utils/internal/index.d.ts +4 -0
  137. package/dist/utils/internal/index.d.ts.map +1 -0
  138. package/dist/utils/internal/index.js +4 -0
  139. package/dist/utils/internal/index.js.map +1 -0
  140. package/dist/utils/internal/resolveLazy.d.ts +29 -0
  141. package/dist/utils/internal/resolveLazy.d.ts.map +1 -0
  142. package/dist/utils/internal/resolveLazy.js +40 -0
  143. package/dist/utils/internal/resolveLazy.js.map +1 -0
  144. package/dist/utils/internal/styleScheduler.d.ts.map +1 -0
  145. package/dist/utils/internal/styleScheduler.js.map +1 -0
  146. package/dist/utils/isComponentType.d.ts +9 -0
  147. package/dist/utils/isComponentType.d.ts.map +1 -0
  148. package/dist/utils/isComponentType.js +22 -0
  149. package/dist/utils/isComponentType.js.map +1 -0
  150. package/dist/utils/isReactNode.d.ts +7 -0
  151. package/dist/utils/isReactNode.d.ts.map +1 -1
  152. package/dist/utils/isReactNode.js +11 -0
  153. package/dist/utils/isReactNode.js.map +1 -1
  154. package/dist/utils/reactNodeToString.d.ts +3 -0
  155. package/dist/utils/reactNodeToString.d.ts.map +1 -1
  156. package/dist/utils/reactNodeToString.js +6 -0
  157. package/dist/utils/reactNodeToString.js.map +1 -1
  158. package/dist/utils/styleObserver.js +1 -1
  159. package/dist/utils/trimReactNode.d.ts +3 -0
  160. package/dist/utils/trimReactNode.d.ts.map +1 -1
  161. package/dist/utils/trimReactNode.js +6 -0
  162. package/dist/utils/trimReactNode.js.map +1 -1
  163. package/package.json +24 -23
  164. package/scripts/update-skill.cjs +18 -5
  165. package/skill/SKILL.md +95 -57
  166. package/skill/references/tokens.md +2 -8
  167. package/src/EDSContext.ts +10 -1
  168. package/src/EDSProvider.tsx +111 -80
  169. package/src/components/Address.tsx +4 -3
  170. package/src/components/Avatar.tsx +10 -5
  171. package/src/components/Breadcrumbs.parts.tsx +35 -18
  172. package/src/components/Button.tsx +7 -2
  173. package/src/components/Card.tsx +14 -8
  174. package/src/components/Cluster.tsx +5 -3
  175. package/src/components/CodeBlock.parts.tsx +20 -24
  176. package/src/components/CopyButton.tsx +3 -2
  177. package/src/components/Icon.tsx +3 -9
  178. package/src/components/Keyboard.tsx +1 -1
  179. package/src/components/LocalTheme.tsx +47 -0
  180. package/src/components/Menu.parts.tsx +368 -245
  181. package/src/components/Modal.parts.tsx +28 -11
  182. package/src/components/Pane.parts.tsx +31 -26
  183. package/src/components/Prose.meta.ts +10 -0
  184. package/src/components/Prose.tsx +4 -3
  185. package/src/components/SegmentedControl.parts.tsx +3 -8
  186. package/src/components/Select.parts.tsx +43 -34
  187. package/src/components/TabSet.parts.tsx +3 -5
  188. package/src/components/Table.parts.tsx +3 -3
  189. package/src/components/ThemeSwitcher.tsx +13 -15
  190. package/src/components/Tooltip.parts.tsx +2 -2
  191. package/src/components/base/Addon.tsx +3 -4
  192. package/src/components/base/ButtonOrLink.parts.tsx +2 -1
  193. package/src/components/base/MaybeButtonOrLink.tsx +11 -6
  194. package/src/components/base/Render.tsx +28 -4
  195. package/src/components/base/index.ts +2 -0
  196. package/src/components/index.ts +8 -0
  197. package/src/hooks/useCSSProp.ts +1 -1
  198. package/src/hooks/useEDS.ts +26 -2
  199. package/src/hooks/useStyleObserver.ts +2 -1
  200. package/src/icons/CalendarDynamicIcon.tsx +1 -0
  201. package/src/index.ts +2 -1
  202. package/src/theme-persistence.ts +31 -0
  203. package/src/utils/getReactNodeKey.ts +30 -14
  204. package/src/utils/index.ts +1 -1
  205. package/src/utils/internal/getTooltipProps.ts +25 -0
  206. package/src/utils/internal/index.ts +3 -0
  207. package/src/utils/internal/resolveLazy.ts +44 -0
  208. package/src/utils/isComponentType.ts +27 -0
  209. package/src/utils/isReactNode.ts +11 -0
  210. package/src/utils/reactNodeToString.ts +7 -0
  211. package/src/utils/styleObserver.ts +1 -1
  212. package/src/utils/trimReactNode.tsx +7 -0
  213. package/dist/utils/styleScheduler.d.ts.map +0 -1
  214. package/dist/utils/styleScheduler.js.map +0 -1
  215. package/src/__snapshots__/compiled.css +0 -22499
  216. package/styles/tailwind.storybook.css +0 -19
  217. /package/dist/utils/{styleScheduler.d.ts → internal/styleScheduler.d.ts} +0 -0
  218. /package/dist/utils/{styleScheduler.js → internal/styleScheduler.js} +0 -0
  219. /package/src/utils/{styleScheduler.ts → internal/styleScheduler.ts} +0 -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,
@@ -166,7 +199,7 @@ export function MenuRoot<T extends OptionValue>({
166
199
  children,
167
200
  ...props
168
201
  }: MenuProps<T>) {
169
- const { dirProps } = useEDS()
202
+ const { dirProps, localThemeClass } = useEDS()
170
203
 
171
204
  const isSubmenu = useContext(MenuGroupContext) !== null
172
205
  const isContextMenu = triggerMode === 'right-click' && !isSubmenu
@@ -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,76 +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
- }
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
310
325
  >
311
- <Menu.Positioner
312
- ref={passedRef}
313
- anchor={anchor}
314
- side={
315
- cssProps.side === 'start'
316
- ? 'inline-start'
317
- : cssProps.side === 'end'
318
- ? 'inline-end'
319
- : 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
320
357
  }
321
- sideOffset={twToPx(cssProps.gap)}
322
- align={cssProps.align}
323
- alignOffset={twToPx(cssProps.alignOffset)}
324
- collisionPadding={8}
325
- collisionAvoidance={{
326
- side: 'flip',
327
- align: 'flip',
328
- fallbackAxisSide:
329
- cssProps.side === 'start' || cssProps.side === 'end' ? 'end' : 'none',
330
- }}
331
- data-has-trigger={hasTrigger || undefined}
332
- data-match-trigger-width={cssProps.matchTriggerWidth || undefined}
333
- data-match-trigger-height={cssProps.matchTriggerHeight || undefined}
334
- {...mergeProps(
335
- {
336
- 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(`
337
387
  eds-menu root-flex
338
388
  [--available-height-but-not-too-small:max(var(--available-height),--spacing(16))]
339
389
  [anchor-name:--eds-menu]
@@ -352,83 +402,153 @@ export function MenuRoot<T extends OptionValue>({
352
402
  u:i:data-[match-trigger-width=at-most]:max-w-[min(var(--anchor-width),var(--available-width))]
353
403
  u:i:data-[match-trigger-width=true]:w-(--anchor-width)
354
404
  `, cssPropsClassName),
355
- },
356
- cssPropsAttributes,
357
- cssPropsPolyfillAttributes,
358
- dirProps,
359
- props,
360
- )}
361
- render={(renderProps, state) => (
362
- <>
363
- <div {...renderProps} style={hasTrigger ? renderProps.style : undefined} />
364
- {/**
365
- * The `data-anchor-hidden:hidden` class above hides the menu if it's open when the trigger gets
366
- * hidden, but this actually closes it to restore pointer interactions outside the menu (though the
367
- * class is still necessary to prevent a flash of incorrect menu position). We're doing it in an
368
- * effect to prevent a "Cannot update a component while rendering a different component" error.
369
- */}
370
- {hasTrigger && state.open && state.anchorHidden && actionsRef.current ? (
371
- <LayoutEffect effect={actionsRef.current.close} />
372
- ) : null}
373
- </>
374
- )}
375
- >
376
- <Menu.Popup
377
- ref={popupRef}
378
- data-initial-open={initialOpen.current || undefined}
379
- 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="
380
430
  flex grow origin-(--transform-origin) flex-col overflow-clip rounded-inherit bg-muted outline-0 transition
381
431
  data-ending-style:scale-95
382
432
  data-ending-style:opacity-0
383
433
  data-starting-style:not-data-initial-open:scale-95
384
434
  data-starting-style:not-data-initial-open:opacity-0
385
435
  "
386
- onKeyDown={handleMenuPopupKeyDown}
387
- >
388
- <MenuPopupContent header={header} footer={footer}>
389
- {/* When `label` or `type` is provided at the root level, automatically wrap `children` in a group */}
390
- {label !== undefined || type !== undefined ? (
391
- type === 'radio' ? (
392
- <MenuGroup
393
- label={label}
394
- type={type}
395
- value={value}
396
- defaultValue={defaultValue}
397
- onValueChange={onValueChange}
398
- >
399
- {children}
400
- </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
+ )
401
456
  ) : (
402
- <MenuGroup label={label} type={type}>
403
- {children}
404
- </MenuGroup>
405
- )
406
- ) : (
407
- children
408
- )}
409
- </MenuPopupContent>
410
- </Menu.Popup>
411
- </Menu.Positioner>
412
- </Menu.Portal>
413
- </MenuRoot>
457
+ children
458
+ )}
459
+ </MenuPopupContent>
460
+ </Menu.Popup>
461
+ </Menu.Positioner>
462
+ </Menu.Portal>
463
+ </MenuRoot>
464
+ </MenuFilter>
414
465
  </span>
415
466
  )
416
467
  }
417
468
  MenuRoot.displayName = 'Menu'
418
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
+
419
507
  /**
420
- * Popup chrome: minimum-width spacer, optional header, scrollable items area, optional footer,
421
- * 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.
422
534
  */
423
535
  function MenuPopupContent({
424
536
  header,
425
537
  footer,
538
+ empty,
539
+ loading = false,
426
540
  children,
427
541
  }: {
428
542
  header?: ReactNode | undefined
429
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
430
547
  children?: ReactNode | undefined
431
548
  }) {
549
+ const searchable = useContext(MenuSearchableContext)
550
+ const [emptyShowsLoading, setEmptyShowsLoading] = useState(false)
551
+
432
552
  return (
433
553
  <>
434
554
  {/**
@@ -440,15 +560,25 @@ function MenuPopupContent({
440
560
  <div className="w-58" />
441
561
  {header ? (
442
562
  <div className="nested/menu-header u:shrink-0 u:border-b u:border-muted u:p-4">
443
- {header}
563
+ <MenuSearchLoadingContext.Provider value={loading && !emptyShowsLoading}>
564
+ {header}
565
+ </MenuSearchLoadingContext.Provider>
444
566
  </div>
445
567
  ) : null}
446
568
  <div className="grow scroll-p-8 scrollbar-thin overflow-x-clip overflow-y-auto gradient-mask-y">
447
569
  <div className="overflow-clip p-2">
448
- <MenuGroupContext.Provider value="root">
449
- {children}
450
- <div className="mt-[calc(-2*(--spacing(2))-1px)] not-preceded-by-peer/menu-group:hidden" />
451
- </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>
452
582
  </div>
453
583
  </div>
454
584
  {footer ? (
@@ -535,8 +665,8 @@ function MenuInlineFallback({
535
665
  {...mergeProps(
536
666
  {
537
667
  className: cn(`
538
- pointer-events-none absolute inset-[anchor(inside)] size-auto! [position-anchor:--eds-menu]
539
- not-open:hidden
668
+ pointer-events-none! absolute! inset-[anchor(inside)]! z-auto! m-0! size-auto! [position-anchor:--eds-menu]!
669
+ not-open:hidden!
540
670
  u:rounded-12
541
671
  `),
542
672
  },
@@ -622,9 +752,44 @@ function renderMenuTriggerElement({
622
752
  }
623
753
  }
624
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
+
625
789
  /**
626
790
  * Focus the `[data-checked]` item on open so Base UI's composite syncs its active index to it (Base
627
- * 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.
628
793
  */
629
794
  function useHighlightCheckedOnOpen(
630
795
  popupRef: RefObject<HTMLDivElement | null>,
@@ -646,19 +811,13 @@ function useHighlightCheckedOnOpen(
646
811
  ) {
647
812
  return
648
813
  }
649
- const searchInput = popup.querySelector<HTMLInputElement>('input[type="search"]')
650
- const searchWasFocused = searchInput !== null && document.activeElement === searchInput
651
814
  checkedItem.focus({ preventScroll: false })
652
- if (searchWasFocused) searchInput.focus({ preventScroll: true })
653
815
  })
654
816
  return () => cancelAnimationFrame(frameId)
655
817
  }, [popupRef, open, enabled])
656
818
  }
657
819
 
658
- /**
659
- * Keydown handler for `Menu.Popup`. Handles Shift + Tab escaping the popup and inline-search-input
660
- * / composite-item coordination.
661
- */
820
+ /** Keydown handler for `Menu.Popup`. Handles Shift + Tab escaping the popup. */
662
821
  function handleMenuPopupKeyDown(event: BaseUIEvent<KeyboardEvent<HTMLDivElement>>) {
663
822
  /**
664
823
  * Ensure Shift + Tab doesn't close the menu if there is a previous focusable element that is not
@@ -690,59 +849,6 @@ function handleMenuPopupKeyDown(event: BaseUIEvent<KeyboardEvent<HTMLDivElement>
690
849
  (!activeElementIsMenuItem && previousElementIsMenuItem)
691
850
  ) {
692
851
  event.preventBaseUIHandler()
693
- return
694
- }
695
- }
696
- // If there is a search input in the menu...
697
- const searchInput = event.currentTarget.querySelector('input[type="search"]')
698
- if (searchInput instanceof HTMLInputElement) {
699
- // If the user typed some text, ensure it goes in the input instead of Base UI focusing the item that matches
700
- if (
701
- (event.key.length === 1 && event.key !== ' ') ||
702
- event.key === 'Backspace' ||
703
- event.key === 'ArrowLeft' ||
704
- event.key === 'ArrowRight'
705
- ) {
706
- event.preventBaseUIHandler()
707
- searchInput.focus()
708
- } else {
709
- const searchInputIsFocused = document.activeElement === searchInput
710
- const menuItems = [...event.currentTarget.querySelectorAll<HTMLElement>('.eds-menu-item')]
711
- const highlightedMenuItem = menuItems.find((item) => item.matches('[data-highlighted]'))
712
- /**
713
- * If the user pressed the down arrow while the search input is focused and there is a single
714
- * menu item in the results, move the focus to it because it may already be highlighted, which
715
- * would prevent Base UI from focusing it.
716
- */
717
- if (event.key === 'ArrowDown' && searchInputIsFocused && menuItems.length === 1) {
718
- event.preventBaseUIHandler()
719
- menuItems[0]!.focus()
720
- }
721
- // Inversely, if the user pressed the up arrow and the focus is on the only menu item, move it back to the search input
722
- else if (
723
- event.key === 'ArrowUp' &&
724
- menuItems.length === 1 &&
725
- document.activeElement === menuItems[0]
726
- ) {
727
- event.preventDefault()
728
- searchInput.focus()
729
- }
730
- // If the user pressed Enter while the search input is focused and a menu item is highlighted, select it
731
- else if (event.key === 'Enter' && searchInputIsFocused && highlightedMenuItem) {
732
- highlightedMenuItem.focus()
733
- window.setTimeout(() => {
734
- highlightedMenuItem.dispatchEvent(
735
- new KeyboardEvent('keydown', {
736
- key: 'Enter',
737
- code: 'Enter',
738
- keyCode: 13,
739
- which: 13,
740
- bubbles: true,
741
- cancelable: true,
742
- }),
743
- )
744
- }, 0)
745
- }
746
852
  }
747
853
  }
748
854
  }
@@ -794,7 +900,11 @@ export function MenuGroup<T extends OptionValue>({
794
900
  <MenuGroupContext.Provider value={menuGroupContextValue}>
795
901
  <Menu.Group
796
902
  {...mergeProps(
797
- { 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
+ },
798
908
  props,
799
909
  )}
800
910
  render={
@@ -825,6 +935,11 @@ export declare namespace MenuItemProps {
825
935
  'defaultChecked' | 'defaultValue' | 'onChange' | 'type' | 'value'
826
936
  > {}
827
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
828
943
  addonBefore?: AddonValue | undefined
829
944
  addonAfter?: AddonValue | undefined
830
945
  disabled?: boolean | undefined
@@ -873,6 +988,7 @@ export type MenuItemProps<T extends OptionValue = OptionValue> =
873
988
  export function MenuItem<T extends OptionValue>({
874
989
  ref: passedRef,
875
990
  type: passedType,
991
+ label,
876
992
  addonBefore: passedAddonBefore,
877
993
  addonAfter: passedAddonAfter,
878
994
  variant,
@@ -1021,7 +1137,14 @@ export function MenuItem<T extends OptionValue>({
1021
1137
  }
1022
1138
 
1023
1139
  if (type === 'submenu-trigger') {
1024
- 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
+ )
1025
1148
  }
1026
1149
 
1027
1150
  if (type === 'checkbox') {
@@ -1029,6 +1152,7 @@ export function MenuItem<T extends OptionValue>({
1029
1152
  <CheckboxItem
1030
1153
  {...baseProps}
1031
1154
  nativeButton
1155
+ label={label}
1032
1156
  checked={checked}
1033
1157
  defaultChecked={defaultChecked}
1034
1158
  onCheckedChange={onCheckedChange}
@@ -1044,6 +1168,7 @@ export function MenuItem<T extends OptionValue>({
1044
1168
  {...baseProps}
1045
1169
  nativeButton
1046
1170
  closeOnClick
1171
+ label={label}
1047
1172
  value={passedValue !== undefined ? passedValue : autoValue}
1048
1173
  disabled={disabled}
1049
1174
  render={renderItem}
@@ -1053,13 +1178,22 @@ export function MenuItem<T extends OptionValue>({
1053
1178
 
1054
1179
  if (href !== undefined) {
1055
1180
  return (
1056
- <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
+ >
1057
1189
  {children}
1058
1190
  </Menu.LinkItem>
1059
1191
  )
1060
1192
  }
1061
1193
 
1062
- return <Menu.Item {...baseProps} nativeButton disabled={disabled} render={renderItem} />
1194
+ return (
1195
+ <Menu.Item {...baseProps} nativeButton label={label} disabled={disabled} render={renderItem} />
1196
+ )
1063
1197
  }
1064
1198
  MenuItem.displayName = 'Menu.Item'
1065
1199
 
@@ -1080,29 +1214,21 @@ function MenuItemAddon({ children, ...props }: ComponentProps<'span'>) {
1080
1214
  )
1081
1215
  }
1082
1216
 
1083
- export interface MenuSearchProps extends Omit<ComponentProps<'input'>, 'children'> {
1217
+ export interface MenuSearchProps extends Omit<
1218
+ ComponentProps<'input'>,
1219
+ 'children' | 'value' | 'defaultValue'
1220
+ > {
1084
1221
  /** @default 'Search' */
1085
1222
  placeholder?: string | undefined
1086
- value?: string | undefined
1087
- defaultValue?: string | undefined
1088
- onValueChange?: ((value: string) => void) | undefined
1089
1223
  }
1090
1224
 
1091
- export function MenuSearch({
1092
- ref: passedRef,
1093
- placeholder: passedPlaceholder,
1094
- value: controlledValue,
1095
- defaultValue,
1096
- onValueChange,
1097
- ...props
1098
- }: MenuSearchProps) {
1099
- const inputRef = useRef<HTMLInputElement>(null)
1100
- const inputPassedRef = useMergedRefs(inputRef, passedRef)
1101
-
1102
- const placeholder = passedPlaceholder ?? 'Search'
1103
-
1104
- 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
+ }
1105
1230
 
1231
+ const loading = useContext(MenuSearchLoadingContext)
1106
1232
  const { rootProps, nestedProps } = splitProps(props)
1107
1233
 
1108
1234
  return (
@@ -1112,36 +1238,27 @@ export function MenuSearch({
1112
1238
  rootProps,
1113
1239
  )}
1114
1240
  >
1115
- <MagnifyingGlassIcon alt="" className="absolute inset-y-0 inset-s-3.5 my-auto size-3.5" />
1116
- <input
1117
- ref={inputPassedRef}
1118
- 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
1119
1247
  placeholder={placeholder}
1120
1248
  aria-label={placeholder}
1121
- value={value}
1122
1249
  {...mergeProps(
1123
- {
1124
- className: cn('peer -mb-px h-10 w-0 grow ps-8.5 outline-0'),
1125
- onChange: (event: ChangeEvent<HTMLInputElement>) => setValue(event.target.value),
1126
- },
1250
+ { className: cn('peer -mb-px h-10 w-0 grow ps-8.5 outline-0') },
1127
1251
  nestedProps,
1128
1252
  )}
1129
1253
  />
1130
1254
  <div className="me-1.5 flex size-7 shrink-0 items-center justify-center peer-placeholder-shown:hidden">
1131
- <Button
1132
- variant="naked"
1133
- size="xsmall"
1134
- tabIndex={-1}
1135
- onClick={() => {
1136
- const input = inputRef.current
1137
- if (!input) return
1138
- setInputValue(input, '')
1139
- input.focus()
1140
- }}
1141
- 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" />}
1142
1259
  >
1143
1260
  <XIcon alt="Clear" />
1144
- </Button>
1261
+ </Menu.Clear>
1145
1262
  </div>
1146
1263
  </div>
1147
1264
  )
@@ -1151,12 +1268,18 @@ MenuSearch.displayName = 'Menu.Search'
1151
1268
  export interface MenuEmptyProps extends ComponentProps<'div'> {}
1152
1269
 
1153
1270
  /**
1154
- * Renders the standard empty-state treatment for a `Menu` (typically shown when search filters
1155
- * 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"`.
1156
1274
  */
1157
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
1158
1277
  return (
1159
- <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>
1160
1283
  )
1161
1284
  }
1162
1285
  MenuEmpty.displayName = 'Menu.Empty'