@remix-run/ui 0.1.1 → 0.1.2

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 (195) hide show
  1. package/README.md +24 -3
  2. package/dist/animation/animate-layout-mixin.js +66 -66
  3. package/dist/animation/animate-layout-mixin.js.map +1 -1
  4. package/dist/components/accordion/accordion.d.ts +3 -3
  5. package/dist/components/accordion/accordion.js +0 -2
  6. package/dist/components/accordion/accordion.js.map +1 -1
  7. package/dist/components/accordion/demos/card.demo.d.ts +6 -0
  8. package/dist/components/accordion/demos/card.demo.js +58 -0
  9. package/dist/components/accordion/demos/card.demo.js.map +1 -0
  10. package/dist/components/accordion/demos/multiple.demo.d.ts +6 -0
  11. package/dist/components/accordion/demos/multiple.demo.js +19 -0
  12. package/dist/components/accordion/demos/multiple.demo.js.map +1 -0
  13. package/dist/components/accordion/demos/overview.demo.d.ts +6 -0
  14. package/dist/components/accordion/demos/overview.demo.js +19 -0
  15. package/dist/components/accordion/demos/overview.demo.js.map +1 -0
  16. package/dist/components/breadcrumbs/breadcrumbs.js +0 -2
  17. package/dist/components/breadcrumbs/breadcrumbs.js.map +1 -1
  18. package/dist/components/breadcrumbs/demos/basic.demo.d.ts +5 -0
  19. package/dist/components/breadcrumbs/demos/basic.demo.js +14 -0
  20. package/dist/components/breadcrumbs/demos/basic.demo.js.map +1 -0
  21. package/dist/components/breadcrumbs/demos/separator.demo.d.ts +5 -0
  22. package/dist/components/breadcrumbs/demos/separator.demo.js +14 -0
  23. package/dist/components/breadcrumbs/demos/separator.demo.js.map +1 -0
  24. package/dist/components/button/button.d.ts +82 -0
  25. package/dist/components/button/button.js +60 -2
  26. package/dist/components/button/button.js.map +1 -1
  27. package/dist/components/button/demos/aliases.demo.d.ts +6 -0
  28. package/dist/components/button/demos/aliases.demo.js +18 -0
  29. package/dist/components/button/demos/aliases.demo.js.map +1 -0
  30. package/dist/components/button/demos/basic.demo.d.ts +6 -0
  31. package/dist/components/button/demos/basic.demo.js +19 -0
  32. package/dist/components/button/demos/basic.demo.js.map +1 -0
  33. package/dist/components/button/demos/states.demo.d.ts +6 -0
  34. package/dist/components/button/demos/states.demo.js +29 -0
  35. package/dist/components/button/demos/states.demo.js.map +1 -0
  36. package/dist/components/combobox/combobox.d.ts +3 -3
  37. package/dist/components/combobox/combobox.js +0 -2
  38. package/dist/components/combobox/combobox.js.map +1 -1
  39. package/dist/components/combobox/demos/overview.demo.d.ts +6 -0
  40. package/dist/components/combobox/demos/overview.demo.js +153 -0
  41. package/dist/components/combobox/demos/overview.demo.js.map +1 -0
  42. package/dist/components/listbox/demos/overview.demo.d.ts +6 -0
  43. package/dist/components/listbox/demos/overview.demo.js +64 -0
  44. package/dist/components/listbox/demos/overview.demo.js.map +1 -0
  45. package/dist/components/listbox/listbox.d.ts +1 -6
  46. package/dist/components/listbox/listbox.js +21 -33
  47. package/dist/components/listbox/listbox.js.map +1 -1
  48. package/dist/components/menu/demos/bubbling.demo.d.ts +5 -0
  49. package/dist/components/menu/demos/bubbling.demo.js +15 -0
  50. package/dist/components/menu/demos/bubbling.demo.js.map +1 -0
  51. package/dist/components/menu/demos/overview.demo.d.ts +6 -0
  52. package/dist/components/menu/demos/overview.demo.js +32 -0
  53. package/dist/components/menu/demos/overview.demo.js.map +1 -0
  54. package/dist/components/menu/menu.d.ts +5 -14
  55. package/dist/components/menu/menu.js +23 -38
  56. package/dist/components/menu/menu.js.map +1 -1
  57. package/dist/components/popover/demos/overview.demo.d.ts +6 -0
  58. package/dist/components/popover/demos/overview.demo.js +64 -0
  59. package/dist/components/popover/demos/overview.demo.js.map +1 -0
  60. package/dist/components/select/demos/deconstructed.demo.d.ts +6 -0
  61. package/dist/components/select/demos/deconstructed.demo.js +53 -0
  62. package/dist/components/select/demos/deconstructed.demo.js.map +1 -0
  63. package/dist/components/select/demos/overview.demo.d.ts +5 -0
  64. package/dist/components/select/demos/overview.demo.js +27 -0
  65. package/dist/components/select/demos/overview.demo.js.map +1 -0
  66. package/dist/components/select/select.d.ts +3 -3
  67. package/dist/components/select/select.js +17 -26
  68. package/dist/components/select/select.js.map +1 -1
  69. package/dist/components/tabs/tabs.d.ts +3 -3
  70. package/dist/components/tabs/tabs.js +0 -2
  71. package/dist/components/tabs/tabs.js.map +1 -1
  72. package/dist/index.d.ts +1 -1
  73. package/dist/index.js.map +1 -1
  74. package/dist/runtime/component.d.ts +8 -5
  75. package/dist/runtime/component.js +84 -80
  76. package/dist/runtime/component.js.map +1 -1
  77. package/dist/runtime/core/attributes.d.ts +12 -0
  78. package/dist/runtime/core/attributes.js +85 -0
  79. package/dist/runtime/core/attributes.js.map +1 -0
  80. package/dist/runtime/core/children.d.ts +7 -0
  81. package/dist/runtime/core/children.js +30 -0
  82. package/dist/runtime/core/children.js.map +1 -0
  83. package/dist/runtime/core/props.d.ts +9 -0
  84. package/dist/runtime/core/props.js +169 -0
  85. package/dist/runtime/core/props.js.map +1 -0
  86. package/dist/runtime/core/vnode.d.ts +3 -0
  87. package/dist/runtime/core/vnode.js +39 -0
  88. package/dist/runtime/core/vnode.js.map +1 -0
  89. package/dist/runtime/create-element.js +2 -1
  90. package/dist/runtime/create-element.js.map +1 -1
  91. package/dist/runtime/diff-dom.js +47 -14
  92. package/dist/runtime/diff-dom.js.map +1 -1
  93. package/dist/runtime/diff-props.d.ts +1 -2
  94. package/dist/runtime/diff-props.js +2 -181
  95. package/dist/runtime/diff-props.js.map +1 -1
  96. package/dist/runtime/dom.d.ts +4 -0
  97. package/dist/runtime/frame.d.ts +23 -2
  98. package/dist/runtime/frame.js +41 -47
  99. package/dist/runtime/frame.js.map +1 -1
  100. package/dist/runtime/jsx.js +2 -28
  101. package/dist/runtime/jsx.js.map +1 -1
  102. package/dist/runtime/mixins/mixin.js +9 -4
  103. package/dist/runtime/mixins/mixin.js.map +1 -1
  104. package/dist/runtime/mixins/on-mixin.d.ts +15 -4
  105. package/dist/runtime/mixins/on-mixin.js +9 -2
  106. package/dist/runtime/mixins/on-mixin.js.map +1 -1
  107. package/dist/runtime/navigation.js +9 -1
  108. package/dist/runtime/navigation.js.map +1 -1
  109. package/dist/runtime/reconcile.js +433 -192
  110. package/dist/runtime/reconcile.js.map +1 -1
  111. package/dist/runtime/run.d.ts +17 -2
  112. package/dist/runtime/run.js.map +1 -1
  113. package/dist/runtime/stream-protocol.d.ts +8 -0
  114. package/dist/runtime/stream-protocol.js +20 -0
  115. package/dist/runtime/stream-protocol.js.map +1 -0
  116. package/dist/runtime/to-vnode.js +5 -9
  117. package/dist/runtime/to-vnode.js.map +1 -1
  118. package/dist/runtime/vnode.d.ts +5 -4
  119. package/dist/runtime/vnode.js +2 -3
  120. package/dist/runtime/vnode.js.map +1 -1
  121. package/dist/server/stream.js +76 -106
  122. package/dist/server/stream.js.map +1 -1
  123. package/dist/style/layers.d.ts +2 -0
  124. package/dist/style/layers.js +3 -0
  125. package/dist/style/layers.js.map +1 -0
  126. package/dist/style/stylesheet.d.ts +3 -1
  127. package/dist/style/stylesheet.js +69 -8
  128. package/dist/style/stylesheet.js.map +1 -1
  129. package/dist/theme/glyph-contract.d.ts +1 -1
  130. package/dist/theme/runtime.js +4 -1
  131. package/dist/theme/runtime.js.map +1 -1
  132. package/package.json +3 -3
  133. package/src/animation/README.md +314 -0
  134. package/src/animation/animate-layout-mixin.ts +79 -71
  135. package/src/components/accordion/README.md +166 -0
  136. package/src/components/accordion/accordion.tsx +8 -10
  137. package/src/components/accordion/demos/card.demo.tsx +113 -0
  138. package/src/components/accordion/demos/multiple.demo.tsx +56 -0
  139. package/src/components/accordion/demos/overview.demo.tsx +56 -0
  140. package/src/components/anchor/README.md +153 -0
  141. package/src/components/breadcrumbs/README.md +55 -0
  142. package/src/components/breadcrumbs/breadcrumbs.tsx +0 -2
  143. package/src/components/breadcrumbs/demos/basic.demo.tsx +17 -0
  144. package/src/components/breadcrumbs/demos/separator.demo.tsx +18 -0
  145. package/src/components/button/README.md +4 -4
  146. package/src/components/button/button.tsx +88 -2
  147. package/src/components/button/demos/aliases.demo.tsx +27 -0
  148. package/src/components/button/demos/basic.demo.tsx +30 -0
  149. package/src/components/button/demos/states.demo.tsx +52 -0
  150. package/src/components/combobox/combobox.tsx +8 -10
  151. package/src/components/combobox/demos/overview.demo.tsx +199 -0
  152. package/src/components/glyph/README.md +72 -0
  153. package/src/components/listbox/README.md +115 -0
  154. package/src/components/listbox/demos/overview.demo.tsx +101 -0
  155. package/src/components/listbox/listbox.ts +23 -32
  156. package/src/components/menu/README.md +96 -0
  157. package/src/components/menu/demos/bubbling.demo.tsx +34 -0
  158. package/src/components/menu/demos/overview.demo.tsx +76 -0
  159. package/src/components/menu/menu.tsx +35 -41
  160. package/src/components/popover/README.md +3 -3
  161. package/src/components/popover/demos/overview.demo.tsx +143 -0
  162. package/src/components/select/README.md +107 -0
  163. package/src/components/select/demos/deconstructed.demo.tsx +95 -0
  164. package/src/components/select/demos/overview.demo.tsx +51 -0
  165. package/src/components/select/select.tsx +28 -28
  166. package/src/components/tabs/tabs.tsx +10 -5
  167. package/src/index.ts +1 -1
  168. package/src/runtime/component.ts +94 -82
  169. package/src/runtime/core/attributes.ts +97 -0
  170. package/src/runtime/core/children.ts +42 -0
  171. package/src/runtime/core/props.ts +185 -0
  172. package/src/runtime/core/vnode.ts +48 -0
  173. package/src/runtime/create-element.ts +2 -1
  174. package/src/runtime/diff-dom.ts +59 -12
  175. package/src/runtime/diff-props.ts +2 -187
  176. package/src/runtime/dom.ts +4 -0
  177. package/src/runtime/frame.ts +71 -55
  178. package/src/runtime/jsx.ts +2 -29
  179. package/src/runtime/mixins/mixin.ts +9 -4
  180. package/src/runtime/mixins/on-mixin.ts +20 -12
  181. package/src/runtime/navigation.ts +10 -1
  182. package/src/runtime/reconcile.ts +540 -209
  183. package/src/runtime/run.ts +17 -1
  184. package/src/runtime/stream-protocol.ts +27 -0
  185. package/src/runtime/to-vnode.ts +5 -8
  186. package/src/runtime/vnode.ts +6 -9
  187. package/src/server/README.md +88 -0
  188. package/src/server/stream.ts +99 -116
  189. package/src/style/layers.ts +2 -0
  190. package/src/style/stylesheet.ts +75 -10
  191. package/src/test/README.md +107 -0
  192. package/src/test/utils.ts +3 -1
  193. package/src/theme/README.md +103 -0
  194. package/src/theme/runtime.ts +4 -1
  195. package/src/utils/scroll-lock/README.md +33 -0
@@ -0,0 +1,101 @@
1
+ import { css, on, type Handle } from '@remix-run/ui'
2
+ import { Glyph } from '@remix-run/ui/glyph'
3
+ import * as listbox from '@remix-run/ui/listbox'
4
+ import type { ListboxValue } from '@remix-run/ui/listbox'
5
+ import { theme } from '@remix-run/ui/theme'
6
+
7
+ /**
8
+ * @name Listbox Overview
9
+ * @description A keyboard-navigable listbox with selection, highlighting, and an optional flash-selection animation.
10
+ */
11
+ export default function Example(handle: Handle) {
12
+ let value: ListboxValue = options[0]!.value
13
+ let activeValue: ListboxValue = options[0]!.value
14
+ let flashSelection = false
15
+
16
+ return () => {
17
+ return (
18
+ <div mix={stackCss}>
19
+ <listbox.Context
20
+ value={value}
21
+ activeValue={activeValue}
22
+ flashSelection={flashSelection}
23
+ onSelect={(nextValue) => {
24
+ value = nextValue
25
+ handle.update()
26
+ }}
27
+ onHighlight={(nextActiveValue) => {
28
+ activeValue = nextActiveValue
29
+ handle.update()
30
+ }}
31
+ >
32
+ <div tabIndex={0} mix={[listbox.listStyle, listbox.list(), containerCss]}>
33
+ {options.map((option) => (
34
+ <div key={option.value} mix={[listbox.optionStyle, listbox.option(option)]}>
35
+ <Glyph mix={listbox.glyphStyle} name="check" />
36
+ <span mix={listbox.labelStyle}>{option.label}</span>
37
+ </div>
38
+ ))}
39
+ </div>
40
+ </listbox.Context>
41
+ <div mix={controlsCss}>
42
+ <label mix={checkboxLabelCss}>
43
+ <input
44
+ type="checkbox"
45
+ defaultChecked={flashSelection}
46
+ mix={on('change', (event) => {
47
+ flashSelection = event.currentTarget.checked
48
+ handle.update()
49
+ })}
50
+ />{' '}
51
+ Flash selection
52
+ </label>
53
+ <p mix={valueCss}>{`value=${value ?? 'null'}`}</p>
54
+ </div>
55
+ </div>
56
+ )
57
+ }
58
+ }
59
+
60
+ const options = [
61
+ { label: 'Apple', value: 'apple' },
62
+ { label: 'Banana', value: 'banana' },
63
+ { label: 'Cherry', value: 'cherry' },
64
+ { label: 'Date', value: 'date' },
65
+ { label: 'Elderberry', value: 'elderberry' },
66
+ { label: 'Fig', value: 'fig' },
67
+ { label: 'Grape', value: 'grape' },
68
+ ] as const
69
+
70
+ const containerCss = css({
71
+ borderColor: theme.colors.border.subtle,
72
+ padding: theme.space.xs,
73
+ borderRadius: theme.radius.lg,
74
+ borderStyle: 'solid',
75
+ })
76
+
77
+ const stackCss = css({
78
+ display: 'flex',
79
+ flexDirection: 'column',
80
+ gap: theme.space.md,
81
+ width: '100%',
82
+ })
83
+
84
+ const controlsCss = css({
85
+ display: 'flex',
86
+ flexDirection: 'column',
87
+ gap: theme.space.xs,
88
+ })
89
+
90
+ const checkboxLabelCss = css({
91
+ fontSize: theme.fontSize.sm,
92
+ lineHeight: theme.lineHeight.normal,
93
+ color: theme.colors.text.secondary,
94
+ })
95
+
96
+ const valueCss = css({
97
+ margin: 0,
98
+ fontFamily: theme.fontFamily.mono,
99
+ fontSize: theme.fontSize.xs,
100
+ color: theme.colors.text.secondary,
101
+ })
@@ -17,17 +17,8 @@ import { flashAttribute } from '../../utils/flash-attribute.ts'
17
17
 
18
18
  export type ListboxValue = string | null
19
19
 
20
- enum NavigationStrategy {
21
- Next = 0,
22
- Previous = 1,
23
- First = 2,
24
- Last = 3,
25
- }
26
-
27
- enum State {
28
- Idle = 'idle',
29
- Selecting = 'selecting',
30
- }
20
+ type NavigationStrategy = 'next' | 'previous' | 'first' | 'last'
21
+ type State = 'idle' | 'selecting'
31
22
 
32
23
  interface Values {
33
24
  value: ListboxValue
@@ -160,7 +151,7 @@ export const labelStyle = itemLabelCss
160
151
 
161
152
  function ListboxProvider(handle: Handle<ListboxProviderProps, ListboxContext>) {
162
153
  let options: RegisteredOption[] = []
163
- let state = State.Idle
154
+ let state: State = 'idle'
164
155
 
165
156
  function getOption(value: ListboxValue) {
166
157
  return options.find((option) => option.value === value)
@@ -221,16 +212,16 @@ function ListboxProvider(handle: Handle<ListboxProviderProps, ListboxContext>) {
221
212
  return findSearchMatch(text, fromValue)
222
213
  },
223
214
  navigateFirst() {
224
- context.navigate(NavigationStrategy.First)
215
+ context.navigate('first')
225
216
  },
226
217
  navigateLast() {
227
- context.navigate(NavigationStrategy.Last)
218
+ context.navigate('last')
228
219
  },
229
220
  navigateNext() {
230
- context.navigate(NavigationStrategy.Next)
221
+ context.navigate('next')
231
222
  },
232
223
  navigatePrevious() {
233
- context.navigate(NavigationStrategy.Previous)
224
+ context.navigate('previous')
234
225
  },
235
226
  scrollActiveOptionIntoView() {
236
227
  context.scrollActiveOptionIntoView()
@@ -261,13 +252,13 @@ function ListboxProvider(handle: Handle<ListboxProviderProps, ListboxContext>) {
261
252
  },
262
253
 
263
254
  async select(value) {
264
- if (state === State.Selecting) {
255
+ if (state === 'selecting') {
265
256
  return
266
257
  }
267
- state = State.Selecting
258
+ state = 'selecting'
268
259
  let option = getOption(value)
269
260
  if (!isInteractableOption(option)) {
270
- state = State.Idle
261
+ state = 'idle'
271
262
  return
272
263
  }
273
264
  handle.props.onSelect(value, option)
@@ -279,17 +270,17 @@ function ListboxProvider(handle: Handle<ListboxProviderProps, ListboxContext>) {
279
270
  )
280
271
  }
281
272
  await handle.props.onSelectSettled?.(value, option)
282
- state = State.Idle
273
+ state = 'idle'
283
274
  },
284
275
 
285
276
  highlight(value) {
286
- if (state === State.Selecting) return
277
+ if (state === 'selecting') return
287
278
  let option = getOption(value)
288
279
  handle.props.onHighlight(value, option)
289
280
  },
290
281
 
291
282
  highlightSearchMatch(text) {
292
- if (state === State.Selecting) return
283
+ if (state === 'selecting') return
293
284
 
294
285
  let option = findSearchMatch(text, handle.props.activeValue)
295
286
 
@@ -300,7 +291,7 @@ function ListboxProvider(handle: Handle<ListboxProviderProps, ListboxContext>) {
300
291
  },
301
292
 
302
293
  navigate(strategy: NavigationStrategy) {
303
- if (state === State.Selecting) return
294
+ if (state === 'selecting') return
304
295
 
305
296
  let option: RegisteredOption | undefined
306
297
  let interactableOptions = getInteractableOptions()
@@ -309,19 +300,19 @@ function ListboxProvider(handle: Handle<ListboxProviderProps, ListboxContext>) {
309
300
  )
310
301
 
311
302
  switch (strategy) {
312
- case NavigationStrategy.Next:
303
+ case 'next':
313
304
  option = interactableOptions[activeIndex + 1] ?? interactableOptions[0]
314
305
  break
315
- case NavigationStrategy.Previous:
306
+ case 'previous':
316
307
  option =
317
308
  activeIndex === -1
318
309
  ? interactableOptions[interactableOptions.length - 1]
319
310
  : interactableOptions[activeIndex - 1]
320
311
  break
321
- case NavigationStrategy.First:
312
+ case 'first':
322
313
  option = interactableOptions[0]
323
314
  break
324
- case NavigationStrategy.Last:
315
+ case 'last':
325
316
  option = interactableOptions[interactableOptions.length - 1]
326
317
  break
327
318
  }
@@ -359,15 +350,15 @@ const listMixin = createMixin<HTMLElement>((handle) => (props) => {
359
350
  switch (event.key) {
360
351
  case 'ArrowDown':
361
352
  event.preventDefault()
362
- context.navigate(NavigationStrategy.Next)
353
+ context.navigate('next')
363
354
  break
364
355
  case 'ArrowUp':
365
356
  event.preventDefault()
366
- context.navigate(NavigationStrategy.Previous)
357
+ context.navigate('previous')
367
358
  break
368
359
  case 'Tab':
369
360
  event.preventDefault()
370
- context.navigate(NavigationStrategy.First)
361
+ context.navigate('first')
371
362
  break
372
363
  case 'Enter':
373
364
  case ' ':
@@ -376,11 +367,11 @@ const listMixin = createMixin<HTMLElement>((handle) => (props) => {
376
367
  break
377
368
  case 'Home':
378
369
  event.preventDefault()
379
- context.navigate(NavigationStrategy.First)
370
+ context.navigate('first')
380
371
  break
381
372
  case 'End':
382
373
  event.preventDefault()
383
- context.navigate(NavigationStrategy.Last)
374
+ context.navigate('last')
384
375
  }
385
376
  }),
386
377
  hiddenTypeahead((text) => {
@@ -0,0 +1,96 @@
1
+ # menu
2
+
3
+ `Menu` renders a button-triggered menu with keyboard navigation, checked items, selection events, and nested submenus. Use it for action menus and command groups.
4
+
5
+ ## Usage
6
+
7
+ ```tsx
8
+ import type { Handle } from 'remix/ui'
9
+ import { Menu, MenuItem, Submenu, onMenuSelect } from 'remix/ui/menu'
10
+ import { separatorStyle } from 'remix/ui/separator'
11
+
12
+ export function ViewMenu(handle: Handle) {
13
+ let wordWrap = false
14
+ let density = 'comfortable'
15
+
16
+ return () => (
17
+ <Menu
18
+ label="View"
19
+ mix={onMenuSelect((event) => {
20
+ if (event.item.name === 'wordWrap') {
21
+ wordWrap = event.item.checked ?? false
22
+ } else if (event.item.name === 'density' && event.item.value) {
23
+ density = event.item.value
24
+ }
25
+
26
+ void handle.update()
27
+ })}
28
+ >
29
+ <MenuItem checked={wordWrap} name="wordWrap" type="checkbox">
30
+ Word wrap
31
+ </MenuItem>
32
+ <MenuItem disabled name="minimap">
33
+ Minimap
34
+ </MenuItem>
35
+ <hr mix={separatorStyle} />
36
+ <MenuItem checked={density === 'compact'} name="density" type="radio" value="compact">
37
+ Compact
38
+ </MenuItem>
39
+ <MenuItem checked={density === 'comfortable'} name="density" type="radio" value="comfortable">
40
+ Comfortable
41
+ </MenuItem>
42
+ <Submenu label="Zoom">
43
+ <MenuItem name="zoomIn" value="zoom-in">
44
+ Zoom in
45
+ </MenuItem>
46
+ <MenuItem name="zoomOut" value="zoom-out">
47
+ Zoom out
48
+ </MenuItem>
49
+ </Submenu>
50
+ </Menu>
51
+ )
52
+ }
53
+ ```
54
+
55
+ Use `label` or `searchValue` when the rendered item content is not the text that should be used for event labels or typeahead.
56
+
57
+ ```tsx
58
+ <MenuItem label="Open command palette" name="commandPalette" searchValue="palette">
59
+ Command palette
60
+ </MenuItem>
61
+ ```
62
+
63
+ Use `menuLabel` when the menu surface needs a different accessible label from the visible trigger.
64
+
65
+ ```tsx
66
+ <Menu label="..." menuLabel="Project actions">
67
+ <MenuItem name="rename">Rename project</MenuItem>
68
+ </Menu>
69
+ ```
70
+
71
+ ## `menu.*`
72
+
73
+ - `Menu`: composed trigger, popover, and list component for the common menu case.
74
+ - `MenuItem`: menu item wrapper. Supports regular, checkbox, and radio item roles through `type`, `checked`, `name`, `value`, `label`, `disabled`, and `searchValue`.
75
+ - `Submenu`: nested menu wrapper with its own trigger and child menu surface.
76
+ - `MenuList`: lower-level list wrapper for custom composition.
77
+ - `onMenuSelect(...)`: event mixin for the bubbling `MenuSelectEvent`.
78
+ - `MenuSelectEvent`: bubbling event class whose `item` describes the selected item.
79
+ - `MenuSelectItem`: selected item shape with `checked`, `id`, `label`, `name`, `type`, and `value`.
80
+ - `menu.Context`, `menu.trigger()`, `menu.popover()`, `menu.list()`, `menu.item(...)`, and `menu.submenuTrigger(...)`: lower-level composition primitives.
81
+ - `buttonStyle`, `popoverStyle`, `listStyle`, `itemStyle`, `itemSlotStyle`, `itemLabelStyle`, `itemGlyphStyle`, and `triggerGlyphStyle`: flat style mixins used by the wrappers.
82
+ - `MenuProps`, `MenuItemProps`, `MenuListProps`, `MenuProviderProps`, `MenuTriggerOptions`, `MenuItemOptions`, and `SubmenuProps`: public TypeScript props and option types.
83
+
84
+ ## Behavior Notes
85
+
86
+ - Click opens the root menu and focuses the list; clicking the trigger again closes it and restores focus.
87
+ - `ArrowDown` opens from the trigger at the first enabled item. `ArrowUp` opens at the last enabled item. Enter and Space open the menu with focus on the list.
88
+ - Keyboard navigation skips disabled items and does not wrap past the first or last enabled item.
89
+ - `Home` and `End` move to the first and last enabled item. Enter and Space activate the highlighted item.
90
+ - Printable keys use typeahead. Typeahead matches `searchValue` when provided and otherwise uses the item label.
91
+ - Submenus open with `ArrowRight`, close with `ArrowLeft`, and are anchored to the submenu trigger with `right-start` placement.
92
+ - Pointer movement highlights enabled items. Submenus open after a short focus/pointer delay and use hover aim so they stay open while moving toward the child surface.
93
+ - Selecting a regular item flashes that item. Selecting a checkbox or radio item flashes the committed checked state.
94
+ - Selection dispatches one bubbled `MenuSelectEvent`, closes the full menu tree, and restores focus to the root trigger.
95
+ - The composed `Menu` re-dispatches selection from its trigger so handlers on the `Menu` button and shared ancestors see the event once.
96
+ - `menuLabel`, `Submenu.menuLabel`, and `Submenu.listProps` let composed menus label and customize menu surfaces separately from trigger content.
@@ -0,0 +1,34 @@
1
+ import { on } from '@remix-run/ui'
2
+ import { Menu, MenuItem, onMenuSelect } from '@remix-run/ui/menu'
3
+
4
+ /**
5
+ * @name Menu Event Bubbling
6
+ * @description Item-level handlers fire before the root handler, letting individual items intercept events while others bubble up.
7
+ */
8
+ export default function Example() {
9
+ return () => (
10
+ <Menu
11
+ label="Project"
12
+ menuLabel="Project actions"
13
+ mix={onMenuSelect((event) => {
14
+ console.log('Menu root handler:', event.item)
15
+ })}
16
+ >
17
+ <MenuItem name="open" value="open-project">
18
+ Open project
19
+ </MenuItem>
20
+ <MenuItem
21
+ name="rename"
22
+ value="rename-project"
23
+ mix={onMenuSelect((event) => {
24
+ console.log('Menu item handler:', event.item)
25
+ })}
26
+ >
27
+ Rename project
28
+ </MenuItem>
29
+ <MenuItem name="duplicate" value="duplicate-project">
30
+ Duplicate project
31
+ </MenuItem>
32
+ </Menu>
33
+ )
34
+ }
@@ -0,0 +1,76 @@
1
+ import { type Handle } from '@remix-run/ui'
2
+ import { Menu, MenuItem, onMenuSelect, Submenu } from '@remix-run/ui/menu'
3
+ import { separatorStyle } from '@remix-run/ui/separator'
4
+
5
+ /**
6
+ * @name Menu Overview
7
+ * @description A hierarchical menu with checkboxes, radio groups, submenus, and separators.
8
+ */
9
+ export default function Example(handle: Handle) {
10
+ type Density = 'comfortable' | 'compact'
11
+ let wordWrap = true
12
+ let minimap = false
13
+ let showGutter = true
14
+ let density: Density = 'comfortable'
15
+
16
+ return () => (
17
+ <Menu
18
+ label="View"
19
+ menuLabel="View options"
20
+ mix={onMenuSelect((event) => {
21
+ switch (event.item.name) {
22
+ case 'wordWrap':
23
+ wordWrap = !wordWrap
24
+ break
25
+ case 'minimap':
26
+ minimap = !minimap
27
+ break
28
+ case 'showGutter':
29
+ showGutter = !showGutter
30
+ break
31
+ case 'density':
32
+ density = event.item.value as Density
33
+ break
34
+ }
35
+ handle.update()
36
+ })}
37
+ >
38
+ <MenuItem name="wordWrap" type="checkbox" checked={wordWrap}>
39
+ Word wrap
40
+ </MenuItem>
41
+ <MenuItem name="minimap" type="checkbox" checked={minimap}>
42
+ Minimap
43
+ </MenuItem>
44
+ <MenuItem name="showGutter" type="checkbox" checked={showGutter}>
45
+ Show gutter
46
+ </MenuItem>
47
+
48
+ <hr mix={separatorStyle} />
49
+ <Submenu label="Zoom">
50
+ <MenuItem name="zoomIn" value="zoom-in">
51
+ Zoom In
52
+ </MenuItem>
53
+ <MenuItem name="zoomOut" value="zoom-out">
54
+ Zoom Out
55
+ </MenuItem>
56
+ <MenuItem name="resetZoom" value="reset-zoom">
57
+ Reset Zoom
58
+ </MenuItem>
59
+ </Submenu>
60
+
61
+ <Submenu label="Density">
62
+ <MenuItem
63
+ name="density"
64
+ type="radio"
65
+ value="comfortable"
66
+ checked={density === 'comfortable'}
67
+ >
68
+ Comfortable
69
+ </MenuItem>
70
+ <MenuItem name="density" type="radio" value="compact" checked={density === 'compact'}>
71
+ Compact
72
+ </MenuItem>
73
+ </Submenu>
74
+ </Menu>
75
+ )
76
+ }
@@ -1,5 +1,3 @@
1
- // @jsxRuntime classic
2
- // @jsx createElement
3
1
  import {
4
2
  attrs,
5
3
  createElement,
@@ -8,6 +6,7 @@ import {
8
6
  on,
9
7
  ref,
10
8
  type CSSMixinDescriptor,
9
+ type Dispatched,
11
10
  type ElementProps,
12
11
  type Handle,
13
12
  type Props,
@@ -32,7 +31,10 @@ const MENU_FLASH_DURATION_MS = 60
32
31
  const SUBMENU_OPEN_DELAY_MS = 300
33
32
  const SUBMENU_ANCHOR_RELATIVE_TO = '[role="menu"] > [role^="menuitem"]'
34
33
 
35
- type MenuSelectHandler = (event: MenuSelectEvent, signal: AbortSignal) => void | Promise<void>
34
+ type MenuSelectHandler<target extends HTMLElement> = (
35
+ event: Dispatched<MenuSelectEvent, target>,
36
+ signal: AbortSignal,
37
+ ) => void | Promise<void>
36
38
 
37
39
  const menuButtonCss: CSSMixinDescriptor = css({
38
40
  display: 'grid',
@@ -219,19 +221,8 @@ declare global {
219
221
  type MenuItemType = 'item' | 'checkbox' | 'radio'
220
222
  type CloseAnimation = 'fade' | 'none'
221
223
  type OpenStrategy = 'first' | 'last' | 'list' | 'none'
222
-
223
- enum NavigationStrategy {
224
- Next = 0,
225
- Previous = 1,
226
- First = 2,
227
- Last = 3,
228
- }
229
-
230
- enum State {
231
- Idle = 'idle',
232
- Dismissing = 'dismissing',
233
- Selecting = 'selecting',
234
- }
224
+ type NavigationStrategy = 'next' | 'previous' | 'first' | 'last'
225
+ type State = 'idle' | 'dismissing' | 'selecting'
235
226
 
236
227
  export interface MenuSelectItem {
237
228
  checked?: boolean
@@ -435,7 +426,7 @@ function MenuProvider(handle: Handle<MenuProviderProps, MenuContextValue>) {
435
426
  let items: RegisteredMenuItem[] = []
436
427
  let listRef: HTMLElement | undefined
437
428
  let open = false
438
- let state = State.Idle
429
+ let state: State = 'idle'
439
430
  let suppressNextPointerLeaveClear = false
440
431
  let surfaceRef: HTMLElement | undefined
441
432
  let triggerId: string | undefined
@@ -487,7 +478,7 @@ function MenuProvider(handle: Handle<MenuProviderProps, MenuContextValue>) {
487
478
  strategy = 'list',
488
479
  focus = strategy !== 'none',
489
480
  }: OpenMenuOptions = {}) {
490
- if (state !== State.Idle) {
481
+ if (state !== 'idle') {
491
482
  return
492
483
  }
493
484
 
@@ -538,9 +529,9 @@ function MenuProvider(handle: Handle<MenuProviderProps, MenuContextValue>) {
538
529
  open = false
539
530
  if (animation === 'none') {
540
531
  activeId = null
541
- state = State.Idle
542
- } else if (wasOpen && state === State.Idle) {
543
- state = State.Dismissing
532
+ state = 'idle'
533
+ } else if (wasOpen && state === 'idle') {
534
+ state = 'dismissing'
544
535
  }
545
536
  updates.push(handle.update())
546
537
  }
@@ -561,8 +552,8 @@ function MenuProvider(handle: Handle<MenuProviderProps, MenuContextValue>) {
561
552
  shouldUpdate = true
562
553
  }
563
554
 
564
- if (state !== State.Idle) {
565
- state = State.Idle
555
+ if (state !== 'idle') {
556
+ state = 'idle'
566
557
  shouldUpdate = true
567
558
  }
568
559
 
@@ -630,7 +621,7 @@ function MenuProvider(handle: Handle<MenuProviderProps, MenuContextValue>) {
630
621
  }
631
622
 
632
623
  function highlight(id: string | null, { focus = false }: HighlightOptions = {}) {
633
- if (state !== State.Idle) {
624
+ if (state !== 'idle') {
634
625
  return
635
626
  }
636
627
 
@@ -673,7 +664,7 @@ function MenuProvider(handle: Handle<MenuProviderProps, MenuContextValue>) {
673
664
  }
674
665
 
675
666
  function navigate(strategy: NavigationStrategy) {
676
- if (state !== State.Idle) {
667
+ if (state !== 'idle') {
677
668
  return
678
669
  }
679
670
 
@@ -682,22 +673,22 @@ function MenuProvider(handle: Handle<MenuProviderProps, MenuContextValue>) {
682
673
  let nextItem: RegisteredMenuItem | undefined
683
674
 
684
675
  switch (strategy) {
685
- case NavigationStrategy.Next:
676
+ case 'next':
686
677
  nextItem =
687
678
  activeIndex === -1
688
679
  ? interactableItems[0]
689
680
  : (interactableItems[activeIndex + 1] ?? interactableItems[activeIndex])
690
681
  break
691
- case NavigationStrategy.Previous:
682
+ case 'previous':
692
683
  nextItem =
693
684
  activeIndex === -1
694
685
  ? interactableItems[interactableItems.length - 1]
695
686
  : interactableItems[activeIndex - 1]
696
687
  break
697
- case NavigationStrategy.First:
688
+ case 'first':
698
689
  nextItem = interactableItems[0]
699
690
  break
700
- case NavigationStrategy.Last:
691
+ case 'last':
701
692
  nextItem = interactableItems[interactableItems.length - 1]
702
693
  break
703
694
  }
@@ -708,7 +699,7 @@ function MenuProvider(handle: Handle<MenuProviderProps, MenuContextValue>) {
708
699
  }
709
700
 
710
701
  function highlightSearchMatch(text: string) {
711
- if (state !== State.Idle) {
702
+ if (state !== 'idle') {
712
703
  return
713
704
  }
714
705
 
@@ -727,7 +718,7 @@ function MenuProvider(handle: Handle<MenuProviderProps, MenuContextValue>) {
727
718
  }
728
719
 
729
720
  async function activateItem(id: string) {
730
- if (state !== State.Idle) {
721
+ if (state !== 'idle') {
731
722
  return
732
723
  }
733
724
 
@@ -744,7 +735,7 @@ function MenuProvider(handle: Handle<MenuProviderProps, MenuContextValue>) {
744
735
  let committedChecked =
745
736
  item.type === 'checkbox' ? !item.checked : item.type === 'radio' ? true : undefined
746
737
 
747
- state = State.Selecting
738
+ state = 'selecting'
748
739
  activeId = item.id
749
740
  flashState = item.type === 'item' ? { id: item.id } : { checked: committedChecked, id: item.id }
750
741
  let signal = await handle.update()
@@ -776,7 +767,7 @@ function MenuProvider(handle: Handle<MenuProviderProps, MenuContextValue>) {
776
767
 
777
768
  await closeAll()
778
769
 
779
- state = State.Idle
770
+ state = 'idle'
780
771
  signal = await handle.update()
781
772
  if (signal.aborted) {
782
773
  return
@@ -792,7 +783,7 @@ function MenuProvider(handle: Handle<MenuProviderProps, MenuContextValue>) {
792
783
  }
793
784
 
794
785
  async function openActiveSubmenu() {
795
- if (state !== State.Idle) {
786
+ if (state !== 'idle') {
796
787
  return
797
788
  }
798
789
 
@@ -1102,22 +1093,22 @@ const listMixin = createMixin<HTMLElement, [], ElementProps>((handle) => {
1102
1093
  case 'ArrowDown':
1103
1094
  event.preventDefault()
1104
1095
  event.stopPropagation()
1105
- context.navigate(NavigationStrategy.Next)
1096
+ context.navigate('next')
1106
1097
  return
1107
1098
  case 'ArrowUp':
1108
1099
  event.preventDefault()
1109
1100
  event.stopPropagation()
1110
- context.navigate(NavigationStrategy.Previous)
1101
+ context.navigate('previous')
1111
1102
  return
1112
1103
  case 'Home':
1113
1104
  event.preventDefault()
1114
1105
  event.stopPropagation()
1115
- context.navigate(NavigationStrategy.First)
1106
+ context.navigate('first')
1116
1107
  return
1117
1108
  case 'End':
1118
1109
  event.preventDefault()
1119
1110
  event.stopPropagation()
1120
- context.navigate(NavigationStrategy.Last)
1111
+ context.navigate('last')
1121
1112
  return
1122
1113
  case 'Enter':
1123
1114
  case ' ':
@@ -1312,7 +1303,7 @@ const submenuTriggerMixin = createMixin<
1312
1303
  'aria-disabled': options.disabled ? 'true' : undefined,
1313
1304
  'aria-expanded': childMenu.isOpen ? 'true' : 'false',
1314
1305
  'aria-haspopup': 'menu',
1315
- 'data-submenu-state': childMenu.state === State.Idle ? undefined : childMenu.state,
1306
+ 'data-submenu-state': childMenu.state === 'idle' ? undefined : childMenu.state,
1316
1307
  'data-highlighted': parent.activeId === handle.id ? 'true' : undefined,
1317
1308
  }),
1318
1309
  ref((node: HTMLElement, signal) => {
@@ -1406,8 +1397,11 @@ const menu = {
1406
1397
  trigger,
1407
1398
  } as const
1408
1399
 
1409
- export function onMenuSelect(handler: MenuSelectHandler, captureBoolean?: boolean) {
1410
- return on<HTMLElement, typeof MENU_SELECT_EVENT>(MENU_SELECT_EVENT, handler, captureBoolean)
1400
+ export function onMenuSelect<target extends HTMLElement>(
1401
+ handler: MenuSelectHandler<target>,
1402
+ captureBoolean?: boolean,
1403
+ ) {
1404
+ return on(MENU_SELECT_EVENT, handler, captureBoolean)
1411
1405
  }
1412
1406
 
1413
1407
  export interface MenuProps extends Omit<Props<'button'>, 'children'> {