@remix-run/ui 0.0.0 → 0.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 (273) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -2
  3. package/dist/animation/animate-layout-mixin.d.ts +12 -0
  4. package/dist/animation/animate-layout-mixin.js +214 -0
  5. package/dist/animation/animate-layout-mixin.js.map +1 -0
  6. package/dist/animation/animate-mixins.d.ts +29 -0
  7. package/dist/animation/animate-mixins.js +235 -0
  8. package/dist/animation/animate-mixins.js.map +1 -0
  9. package/dist/animation/index.d.ts +6 -0
  10. package/dist/animation/index.js +5 -0
  11. package/dist/animation/index.js.map +1 -0
  12. package/dist/animation/spring.d.ts +75 -0
  13. package/dist/animation/spring.js +183 -0
  14. package/dist/animation/spring.js.map +1 -0
  15. package/dist/animation/tween.d.ts +70 -0
  16. package/dist/animation/tween.js +93 -0
  17. package/dist/animation/tween.js.map +1 -0
  18. package/dist/components/accordion/accordion.d.ts +92 -0
  19. package/dist/components/accordion/accordion.js +337 -0
  20. package/dist/components/accordion/accordion.js.map +1 -0
  21. package/dist/components/anchor/anchor.d.ts +13 -0
  22. package/dist/components/anchor/anchor.js +558 -0
  23. package/dist/components/anchor/anchor.js.map +1 -0
  24. package/dist/components/breadcrumbs/breadcrumbs.d.ts +11 -0
  25. package/dist/components/breadcrumbs/breadcrumbs.js +78 -0
  26. package/dist/components/breadcrumbs/breadcrumbs.js.map +1 -0
  27. package/dist/components/button/button.d.ts +23 -0
  28. package/dist/components/button/button.js +147 -0
  29. package/dist/components/button/button.js.map +1 -0
  30. package/dist/components/combobox/combobox.d.ts +101 -0
  31. package/dist/components/combobox/combobox.js +708 -0
  32. package/dist/components/combobox/combobox.js.map +1 -0
  33. package/dist/components/glyph/glyph.d.ts +14 -0
  34. package/dist/components/glyph/glyph.js +65 -0
  35. package/dist/components/glyph/glyph.js.map +1 -0
  36. package/dist/components/listbox/listbox.d.ts +67 -0
  37. package/dist/components/listbox/listbox.js +340 -0
  38. package/dist/components/listbox/listbox.js.map +1 -0
  39. package/dist/components/menu/hover-aim.d.ts +5 -0
  40. package/dist/components/menu/hover-aim.js +308 -0
  41. package/dist/components/menu/hover-aim.js.map +1 -0
  42. package/dist/components/menu/menu.d.ts +164 -0
  43. package/dist/components/menu/menu.js +1106 -0
  44. package/dist/components/menu/menu.js.map +1 -0
  45. package/dist/components/popover/popover.d.ts +35 -0
  46. package/dist/components/popover/popover.js +138 -0
  47. package/dist/components/popover/popover.js.map +1 -0
  48. package/dist/components/select/select.d.ts +67 -0
  49. package/dist/components/select/select.js +397 -0
  50. package/dist/components/select/select.js.map +1 -0
  51. package/dist/components/separator/separator.d.ts +2 -0
  52. package/dist/components/separator/separator.js +15 -0
  53. package/dist/components/separator/separator.js.map +1 -0
  54. package/dist/components/tabs/tabs.d.ts +78 -0
  55. package/dist/components/tabs/tabs.js +341 -0
  56. package/dist/components/tabs/tabs.js.map +1 -0
  57. package/dist/index.d.ts +28 -0
  58. package/dist/index.js +21 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/interactions/keydown/keydown.d.ts +1 -0
  61. package/dist/interactions/keydown/keydown.js +8 -0
  62. package/dist/interactions/keydown/keydown.js.map +1 -0
  63. package/dist/interactions/outside-click/outside-click-mixin.d.ts +2 -0
  64. package/dist/interactions/outside-click/outside-click-mixin.js +29 -0
  65. package/dist/interactions/outside-click/outside-click-mixin.js.map +1 -0
  66. package/dist/interactions/typeahead/typeahead-mixin.d.ts +8 -0
  67. package/dist/interactions/typeahead/typeahead-mixin.js +59 -0
  68. package/dist/interactions/typeahead/typeahead-mixin.js.map +1 -0
  69. package/dist/jsx-runtime.d.ts +2 -0
  70. package/dist/jsx-runtime.js +3 -0
  71. package/dist/jsx-runtime.js.map +1 -0
  72. package/dist/runtime/client-entries.d.ts +91 -0
  73. package/dist/runtime/client-entries.js +40 -0
  74. package/dist/runtime/client-entries.js.map +1 -0
  75. package/dist/runtime/component.d.ts +223 -0
  76. package/dist/runtime/component.js +152 -0
  77. package/dist/runtime/component.js.map +1 -0
  78. package/dist/runtime/create-element.d.ts +10 -0
  79. package/dist/runtime/create-element.js +29 -0
  80. package/dist/runtime/create-element.js.map +1 -0
  81. package/dist/runtime/diff-dom.d.ts +2 -0
  82. package/dist/runtime/diff-dom.js +364 -0
  83. package/dist/runtime/diff-dom.js.map +1 -0
  84. package/dist/runtime/diff-props.d.ts +9 -0
  85. package/dist/runtime/diff-props.js +195 -0
  86. package/dist/runtime/diff-props.js.map +1 -0
  87. package/dist/runtime/document-state.d.ts +11 -0
  88. package/dist/runtime/document-state.js +106 -0
  89. package/dist/runtime/document-state.js.map +1 -0
  90. package/dist/runtime/dom.d.ts +3189 -0
  91. package/dist/runtime/dom.js +2 -0
  92. package/dist/runtime/dom.js.map +1 -0
  93. package/dist/runtime/error-event.d.ts +20 -0
  94. package/dist/runtime/error-event.js +19 -0
  95. package/dist/runtime/error-event.js.map +1 -0
  96. package/dist/runtime/event-listeners.d.ts +50 -0
  97. package/dist/runtime/event-listeners.js +31 -0
  98. package/dist/runtime/event-listeners.js.map +1 -0
  99. package/dist/runtime/frame.d.ts +119 -0
  100. package/dist/runtime/frame.js +915 -0
  101. package/dist/runtime/frame.js.map +1 -0
  102. package/dist/runtime/invariant.d.ts +23 -0
  103. package/dist/runtime/invariant.js +33 -0
  104. package/dist/runtime/invariant.js.map +1 -0
  105. package/dist/runtime/jsx.d.ts +320 -0
  106. package/dist/runtime/jsx.js +31 -0
  107. package/dist/runtime/jsx.js.map +1 -0
  108. package/dist/runtime/mixins/attrs-mixin.d.ts +9 -0
  109. package/dist/runtime/mixins/attrs-mixin.js +23 -0
  110. package/dist/runtime/mixins/attrs-mixin.js.map +1 -0
  111. package/dist/runtime/mixins/link-mixin.d.ts +17 -0
  112. package/dist/runtime/mixins/link-mixin.js +101 -0
  113. package/dist/runtime/mixins/link-mixin.js.map +1 -0
  114. package/dist/runtime/mixins/mixin.d.ts +145 -0
  115. package/dist/runtime/mixins/mixin.js +570 -0
  116. package/dist/runtime/mixins/mixin.js.map +1 -0
  117. package/dist/runtime/mixins/on-mixin.d.ts +16 -0
  118. package/dist/runtime/mixins/on-mixin.js +49 -0
  119. package/dist/runtime/mixins/on-mixin.js.map +1 -0
  120. package/dist/runtime/mixins/ref-mixin.d.ts +9 -0
  121. package/dist/runtime/mixins/ref-mixin.js +21 -0
  122. package/dist/runtime/mixins/ref-mixin.js.map +1 -0
  123. package/dist/runtime/navigation.d.ts +22 -0
  124. package/dist/runtime/navigation.js +102 -0
  125. package/dist/runtime/navigation.js.map +1 -0
  126. package/dist/runtime/reconcile.d.ts +11 -0
  127. package/dist/runtime/reconcile.js +1240 -0
  128. package/dist/runtime/reconcile.js.map +1 -0
  129. package/dist/runtime/render.d.ts +54 -0
  130. package/dist/runtime/render.js +50 -0
  131. package/dist/runtime/render.js.map +1 -0
  132. package/dist/runtime/run.d.ts +45 -0
  133. package/dist/runtime/run.js +68 -0
  134. package/dist/runtime/run.js.map +1 -0
  135. package/dist/runtime/scheduler.d.ts +30 -0
  136. package/dist/runtime/scheduler.js +185 -0
  137. package/dist/runtime/scheduler.js.map +1 -0
  138. package/dist/runtime/svg-attributes.d.ts +5 -0
  139. package/dist/runtime/svg-attributes.js +124 -0
  140. package/dist/runtime/svg-attributes.js.map +1 -0
  141. package/dist/runtime/to-vnode.d.ts +3 -0
  142. package/dist/runtime/to-vnode.js +43 -0
  143. package/dist/runtime/to-vnode.js.map +1 -0
  144. package/dist/runtime/typed-event-target.d.ts +50 -0
  145. package/dist/runtime/typed-event-target.js +6 -0
  146. package/dist/runtime/typed-event-target.js.map +1 -0
  147. package/dist/runtime/vdom.d.ts +55 -0
  148. package/dist/runtime/vdom.js +198 -0
  149. package/dist/runtime/vdom.js.map +1 -0
  150. package/dist/runtime/vnode.d.ts +79 -0
  151. package/dist/runtime/vnode.js +38 -0
  152. package/dist/runtime/vnode.js.map +1 -0
  153. package/dist/server/stream.d.ts +57 -0
  154. package/dist/server/stream.js +1007 -0
  155. package/dist/server/stream.js.map +1 -0
  156. package/dist/style/css-mixin.d.ts +8 -0
  157. package/dist/style/css-mixin.js +54 -0
  158. package/dist/style/css-mixin.js.map +1 -0
  159. package/dist/style/index.d.ts +5 -0
  160. package/dist/style/index.js +4 -0
  161. package/dist/style/index.js.map +1 -0
  162. package/dist/style/style.d.ts +24 -0
  163. package/dist/style/style.js +269 -0
  164. package/dist/style/style.js.map +1 -0
  165. package/dist/style/stylesheet.d.ts +11 -0
  166. package/dist/style/stylesheet.js +174 -0
  167. package/dist/style/stylesheet.js.map +1 -0
  168. package/dist/test.d.ts +2 -0
  169. package/dist/test.js +2 -0
  170. package/dist/test.js.map +1 -0
  171. package/dist/theme/contract.d.ts +259 -0
  172. package/dist/theme/contract.js +131 -0
  173. package/dist/theme/contract.js.map +1 -0
  174. package/dist/theme/glyph-contract.d.ts +13 -0
  175. package/dist/theme/glyph-contract.js +34 -0
  176. package/dist/theme/glyph-contract.js.map +1 -0
  177. package/dist/theme/presets/rmx-01/glyphs.d.ts +2 -0
  178. package/dist/theme/presets/rmx-01/glyphs.js +252 -0
  179. package/dist/theme/presets/rmx-01/glyphs.js.map +1 -0
  180. package/dist/theme/presets/rmx-01/index.d.ts +2 -0
  181. package/dist/theme/presets/rmx-01/index.js +119 -0
  182. package/dist/theme/presets/rmx-01/index.js.map +1 -0
  183. package/dist/theme/runtime.d.ts +2 -0
  184. package/dist/theme/runtime.js +81 -0
  185. package/dist/theme/runtime.js.map +1 -0
  186. package/dist/theme/theme.d.ts +6 -0
  187. package/dist/theme/theme.js +5 -0
  188. package/dist/theme/theme.js.map +1 -0
  189. package/dist/utils/flash-attribute.d.ts +1 -0
  190. package/dist/utils/flash-attribute.js +11 -0
  191. package/dist/utils/flash-attribute.js.map +1 -0
  192. package/dist/utils/scroll-lock.d.ts +3 -0
  193. package/dist/utils/scroll-lock.js +69 -0
  194. package/dist/utils/scroll-lock.js.map +1 -0
  195. package/dist/utils/wait-for-css-transition.d.ts +1 -0
  196. package/dist/utils/wait-for-css-transition.js +58 -0
  197. package/dist/utils/wait-for-css-transition.js.map +1 -0
  198. package/dist/utils/wait.d.ts +1 -0
  199. package/dist/utils/wait.js +6 -0
  200. package/dist/utils/wait.js.map +1 -0
  201. package/package.json +125 -5
  202. package/src/animation/animate-layout-mixin.ts +266 -0
  203. package/src/animation/animate-mixins.ts +292 -0
  204. package/src/animation/index.ts +6 -0
  205. package/src/animation/spring.ts +299 -0
  206. package/src/animation/tween.ts +134 -0
  207. package/src/components/accordion/accordion.tsx +565 -0
  208. package/src/components/anchor/anchor.ts +904 -0
  209. package/src/components/breadcrumbs/breadcrumbs.tsx +124 -0
  210. package/src/components/button/README.md +44 -0
  211. package/src/components/button/button.tsx +188 -0
  212. package/src/components/combobox/README.md +145 -0
  213. package/src/components/combobox/combobox.tsx +1014 -0
  214. package/src/components/glyph/glyph.tsx +110 -0
  215. package/src/components/listbox/listbox.ts +447 -0
  216. package/src/components/menu/hover-aim.ts +428 -0
  217. package/src/components/menu/menu.tsx +1547 -0
  218. package/src/components/popover/README.md +122 -0
  219. package/src/components/popover/popover.ts +201 -0
  220. package/src/components/select/select.tsx +585 -0
  221. package/src/components/separator/separator.ts +19 -0
  222. package/src/components/tabs/README.md +105 -0
  223. package/src/components/tabs/tabs.tsx +499 -0
  224. package/src/index.ts +72 -0
  225. package/src/interactions/keydown/keydown.ts +14 -0
  226. package/src/interactions/outside-click/outside-click-mixin.ts +55 -0
  227. package/src/interactions/typeahead/typeahead-mixin.ts +89 -0
  228. package/src/jsx-runtime.ts +2 -0
  229. package/src/runtime/client-entries.ts +137 -0
  230. package/src/runtime/component.ts +402 -0
  231. package/src/runtime/create-element.ts +38 -0
  232. package/src/runtime/diff-dom.ts +404 -0
  233. package/src/runtime/diff-props.ts +209 -0
  234. package/src/runtime/document-state.ts +130 -0
  235. package/src/runtime/dom.ts +3971 -0
  236. package/src/runtime/error-event.ts +26 -0
  237. package/src/runtime/event-listeners.ts +171 -0
  238. package/src/runtime/frame.ts +1211 -0
  239. package/src/runtime/invariant.ts +31 -0
  240. package/src/runtime/jsx.ts +398 -0
  241. package/src/runtime/mixins/attrs-mixin.ts +35 -0
  242. package/src/runtime/mixins/link-mixin.ts +131 -0
  243. package/src/runtime/mixins/mixin.ts +908 -0
  244. package/src/runtime/mixins/on-mixin.ts +89 -0
  245. package/src/runtime/mixins/ref-mixin.ts +32 -0
  246. package/src/runtime/navigation.ts +136 -0
  247. package/src/runtime/reconcile.ts +1722 -0
  248. package/src/runtime/render.ts +101 -0
  249. package/src/runtime/run.ts +100 -0
  250. package/src/runtime/scheduler.ts +262 -0
  251. package/src/runtime/svg-attributes.ts +133 -0
  252. package/src/runtime/to-vnode.ts +50 -0
  253. package/src/runtime/typed-event-target.ts +67 -0
  254. package/src/runtime/vdom.ts +295 -0
  255. package/src/runtime/vnode.ts +137 -0
  256. package/src/server/stream.ts +1342 -0
  257. package/src/style/css-mixin.ts +82 -0
  258. package/src/style/index.ts +10 -0
  259. package/src/style/style.ts +312 -0
  260. package/src/style/stylesheet.ts +209 -0
  261. package/src/test/setup.ts +7 -0
  262. package/src/test/utils.ts +45 -0
  263. package/src/test.ts +2 -0
  264. package/src/theme/contract.ts +183 -0
  265. package/src/theme/glyph-contract.ts +57 -0
  266. package/src/theme/presets/rmx-01/glyphs.tsx +308 -0
  267. package/src/theme/presets/rmx-01/index.ts +121 -0
  268. package/src/theme/runtime.ts +116 -0
  269. package/src/theme/theme.ts +15 -0
  270. package/src/utils/flash-attribute.ts +11 -0
  271. package/src/utils/scroll-lock.ts +97 -0
  272. package/src/utils/wait-for-css-transition.ts +66 -0
  273. package/src/utils/wait.ts +5 -0
@@ -0,0 +1,1547 @@
1
+ // @jsxRuntime classic
2
+ // @jsx createElement
3
+ import {
4
+ attrs,
5
+ createElement,
6
+ createMixin,
7
+ css,
8
+ on,
9
+ ref,
10
+ type CSSMixinDescriptor,
11
+ type ElementProps,
12
+ type Handle,
13
+ type Props,
14
+ type RemixNode,
15
+ } from '@remix-run/ui'
16
+ import { type AnchorOptions } from '../anchor/anchor.ts'
17
+ import * as button from '../button/button.tsx'
18
+ import { Glyph } from '../glyph/glyph.tsx'
19
+ import * as popover from '../popover/popover.ts'
20
+ import { theme } from '../../theme/theme.ts'
21
+ import {
22
+ hiddenTypeahead,
23
+ matchNextItemBySearchText,
24
+ type SearchValue,
25
+ } from '../../interactions/typeahead/typeahead-mixin.ts'
26
+ import { waitForCssTransition } from '../../utils/wait-for-css-transition.ts'
27
+ import { wait } from '../../utils/wait.ts'
28
+ import { createHoverAim } from './hover-aim.ts'
29
+
30
+ const MENU_SELECT_EVENT = 'rmx:menu-select' as const
31
+ const MENU_FLASH_DURATION_MS = 60
32
+ const SUBMENU_OPEN_DELAY_MS = 300
33
+ const SUBMENU_ANCHOR_RELATIVE_TO = '[role="menu"] > [role^="menuitem"]'
34
+
35
+ type MenuSelectHandler = (event: MenuSelectEvent, signal: AbortSignal) => void | Promise<void>
36
+
37
+ const menuButtonCss: CSSMixinDescriptor = css({
38
+ display: 'grid',
39
+ gridTemplateColumns: 'minmax(0, 1fr) auto',
40
+ alignItems: 'center',
41
+ borderRadius: theme.radius.md,
42
+ paddingInlineEnd: theme.space.sm,
43
+ textAlign: 'left',
44
+ '&[aria-expanded="true"], &[aria-expanded="true"]:hover, &[aria-expanded="true"]:focus-visible': {
45
+ backgroundColor: theme.surface.lvl3,
46
+ color: theme.colors.text.primary,
47
+ },
48
+ })
49
+
50
+ const menuPopoverCss: CSSMixinDescriptor = css({
51
+ '&[data-menu-submenu="true"][data-anchor-placement^="right"]': {
52
+ marginLeft: `calc(${theme.space.xs} * -1)`,
53
+ },
54
+ '&[data-menu-submenu="true"][data-anchor-placement^="left"]': {
55
+ marginLeft: theme.space.xs,
56
+ },
57
+ '&[data-close-animation="none"]:not(:popover-open)': {
58
+ transition: 'none',
59
+ transitionBehavior: 'normal',
60
+ },
61
+ })
62
+
63
+ const menuListCss: CSSMixinDescriptor = css({
64
+ display: 'flex',
65
+ flexDirection: 'column',
66
+ flex: '1 1 auto',
67
+ minHeight: 0,
68
+ paddingBlock: theme.space.xs,
69
+ paddingInline: theme.space.none,
70
+ overflow: 'auto',
71
+ overscrollBehavior: 'contain',
72
+ outline: 'none',
73
+ userSelect: 'none',
74
+ WebkitUserSelect: 'none',
75
+ '--rmx-ui-item-inset': `calc(${theme.space.sm} + ${theme.space.xs})`,
76
+ '--rmx-ui-item-indicator-gap': 'var(--rmx-menu-item-slot-gap)',
77
+ '--rmx-ui-item-indicator-width': 'var(--rmx-menu-item-slot-width)',
78
+ '--rmx-menu-item-slot-gap': theme.space.none,
79
+ '--rmx-menu-item-slot-width': theme.space.none,
80
+ '&:has(> [role="menuitemcheckbox"])': {
81
+ '--rmx-menu-item-slot-gap': theme.space.xs,
82
+ '--rmx-menu-item-slot-width': theme.fontSize.md,
83
+ },
84
+ '&:has(> [role="menuitemradio"])': {
85
+ '--rmx-menu-item-slot-gap': theme.space.xs,
86
+ '--rmx-menu-item-slot-width': theme.fontSize.md,
87
+ },
88
+ })
89
+
90
+ const menuItemCss: CSSMixinDescriptor = css({
91
+ display: 'flex',
92
+ alignItems: 'center',
93
+ width: '100%',
94
+ minWidth: 0,
95
+ minHeight: theme.control.height.md,
96
+ boxSizing: 'border-box',
97
+ position: 'relative',
98
+ isolation: 'isolate',
99
+ paddingInline: `calc(${theme.space.sm} + ${theme.space.xs})`,
100
+ color: theme.colors.text.primary,
101
+ fontFamily: theme.fontFamily.sans,
102
+ fontSize: theme.fontSize.sm,
103
+ fontWeight: theme.fontWeight.normal,
104
+ lineHeight: theme.lineHeight.normal,
105
+ textAlign: 'left',
106
+ userSelect: 'none',
107
+ WebkitUserSelect: 'none',
108
+ '--rmx-menu-item-indicator-opacity': '0',
109
+ '&::before': {
110
+ content: '""',
111
+ position: 'absolute',
112
+ insetBlock: 0,
113
+ insetInline: theme.space.xs,
114
+ borderRadius: theme.radius.md,
115
+ backgroundColor: 'transparent',
116
+ pointerEvents: 'none',
117
+ zIndex: -1,
118
+ },
119
+ '&:focus': {
120
+ outline: 'none',
121
+ },
122
+ '&[data-highlighted="true"]': {
123
+ color: theme.colors.action.primary.foreground,
124
+ },
125
+ '&[data-highlighted="true"]::before': {
126
+ backgroundColor: theme.colors.action.primary.background,
127
+ },
128
+ '&[aria-haspopup="menu"][aria-expanded="true"]:not(:focus)': {
129
+ color: theme.colors.text.primary,
130
+ },
131
+ '&[aria-haspopup="menu"][aria-expanded="true"]:not(:focus)::before': {
132
+ backgroundColor: theme.surface.lvl2,
133
+ },
134
+ '&[data-submenu-state="selecting"], &[data-submenu-state="dismissing"]': {
135
+ color: theme.colors.text.primary,
136
+ },
137
+ '&[data-submenu-state="selecting"]::before, &[data-submenu-state="dismissing"]::before': {
138
+ backgroundColor: theme.surface.lvl2,
139
+ },
140
+ '&[data-menu-flash="true"]': {
141
+ color: theme.colors.text.primary,
142
+ },
143
+ '&[data-menu-flash="true"]::before': {
144
+ backgroundColor: 'transparent',
145
+ },
146
+ '&[aria-disabled="true"]': {
147
+ opacity: 0.5,
148
+ },
149
+ '&[aria-checked="true"], &[aria-selected="true"]': {
150
+ '--rmx-menu-item-indicator-opacity': '1',
151
+ },
152
+ })
153
+
154
+ const menuItemSlotCss: CSSMixinDescriptor = css({
155
+ display: 'inline-flex',
156
+ alignItems: 'center',
157
+ justifyContent: 'center',
158
+ flex: '0 0 var(--rmx-menu-item-slot-width)',
159
+ width: 'var(--rmx-menu-item-slot-width)',
160
+ minWidth: 0,
161
+ marginInlineEnd: 'var(--rmx-menu-item-slot-gap)',
162
+ overflow: 'hidden',
163
+ })
164
+
165
+ const menuItemLabelCss: CSSMixinDescriptor = css({
166
+ display: 'inline-flex',
167
+ alignItems: 'center',
168
+ flex: '1 1 auto',
169
+ minWidth: 0,
170
+ })
171
+
172
+ const menuItemGlyphCss: CSSMixinDescriptor = css({
173
+ display: 'inline-flex',
174
+ alignItems: 'center',
175
+ justifyContent: 'center',
176
+ width: theme.fontSize.md,
177
+ height: theme.fontSize.md,
178
+ color: 'currentColor',
179
+ flexShrink: 0,
180
+ '& > svg': {
181
+ display: 'block',
182
+ width: '100%',
183
+ height: '100%',
184
+ },
185
+ opacity: 'var(--rmx-menu-item-indicator-opacity)',
186
+ })
187
+
188
+ const menuTriggerGlyphCss: CSSMixinDescriptor = css({
189
+ display: 'inline-flex',
190
+ alignItems: 'center',
191
+ justifyContent: 'center',
192
+ width: theme.fontSize.md,
193
+ height: theme.fontSize.md,
194
+ color: 'currentColor',
195
+ flexShrink: 0,
196
+ marginInlineStart: 'auto',
197
+ '& > svg': {
198
+ display: 'block',
199
+ width: '100%',
200
+ height: '100%',
201
+ },
202
+ })
203
+
204
+ export const buttonStyle = menuButtonCss
205
+ export const popoverStyle = menuPopoverCss
206
+ export const listStyle = menuListCss
207
+ export const itemStyle = menuItemCss
208
+ export const itemSlotStyle = menuItemSlotCss
209
+ export const itemLabelStyle = menuItemLabelCss
210
+ export const itemGlyphStyle = menuItemGlyphCss
211
+ export const triggerGlyphStyle = menuTriggerGlyphCss
212
+
213
+ declare global {
214
+ interface HTMLElementEventMap {
215
+ [MENU_SELECT_EVENT]: MenuSelectEvent
216
+ }
217
+ }
218
+
219
+ type MenuItemType = 'item' | 'checkbox' | 'radio'
220
+ type CloseAnimation = 'fade' | 'none'
221
+ 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
+ }
235
+
236
+ export interface MenuSelectItem {
237
+ checked?: boolean
238
+ id: string
239
+ label: string
240
+ name: string
241
+ type: MenuItemType
242
+ value: string | null
243
+ }
244
+
245
+ export class MenuSelectEvent extends Event {
246
+ readonly item: MenuSelectItem
247
+
248
+ constructor(item: MenuSelectItem) {
249
+ super(MENU_SELECT_EVENT, { bubbles: true })
250
+ this.item = item
251
+ }
252
+ }
253
+
254
+ export interface MenuProviderProps {
255
+ children?: RemixNode
256
+ label?: string
257
+ }
258
+
259
+ export interface MenuTriggerOptions extends AnchorOptions {}
260
+
261
+ export interface MenuItemOptions {
262
+ checked?: boolean
263
+ disabled?: boolean
264
+ label?: string
265
+ name: string
266
+ searchValue?: SearchValue
267
+ type?: Exclude<MenuItemType, 'item'>
268
+ value?: string
269
+ }
270
+
271
+ export interface SubmenuTriggerOptions {
272
+ disabled?: boolean
273
+ label?: string
274
+ searchValue?: SearchValue
275
+ value?: string
276
+ }
277
+
278
+ type OpenMenuOptions = {
279
+ focus?: boolean
280
+ strategy?: OpenStrategy
281
+ }
282
+
283
+ type CloseBranchOptions = {
284
+ focusTrigger?: boolean
285
+ }
286
+
287
+ type CloseAllOptions = {
288
+ focusRoot?: boolean
289
+ }
290
+
291
+ type CloseSyncOptions = {
292
+ animation?: CloseAnimation
293
+ }
294
+
295
+ type HighlightOptions = {
296
+ focus?: boolean
297
+ }
298
+
299
+ type FlashState = {
300
+ checked?: boolean
301
+ id: string
302
+ } | null
303
+
304
+ interface RegisteredMenuItem {
305
+ checked?: boolean
306
+ disabled?: boolean
307
+ id: string
308
+ searchValue?: SearchValue
309
+ submenu?: MenuContextValue
310
+ type: MenuItemType
311
+ value?: string
312
+ name?: string
313
+ readonly hidden: boolean
314
+ readonly label: string
315
+ readonly node: HTMLElement
316
+ }
317
+
318
+ interface MenuContextValue {
319
+ readonly activeId: string | undefined
320
+ readonly closeAnimation: CloseAnimation
321
+ readonly flashingChecked: boolean | undefined
322
+ readonly flashingId: string | undefined
323
+ readonly isOpen: boolean
324
+ readonly isRoot: boolean
325
+ readonly label: string | undefined
326
+ readonly listId: string
327
+ readonly parent: MenuContextValue | undefined
328
+ readonly root: MenuContextValue
329
+ readonly state: State
330
+ readonly surfaceNode: HTMLElement | undefined
331
+ readonly triggerId: string | undefined
332
+ activateActive: () => Promise<void>
333
+ activateItem: (id: string) => Promise<void>
334
+ allowsPointer: (event: PointerEvent) => boolean
335
+ closeAll: (options?: CloseAllOptions) => Promise<void>
336
+ closeBranch: (options?: CloseBranchOptions) => Promise<void>
337
+ closeSync: (updates: Promise<AbortSignal>[], options?: CloseSyncOptions) => void
338
+ consumePointerLeaveClearSuppression: () => boolean
339
+ finishDismissalSync: (updates: Promise<AbortSignal>[]) => void
340
+ getOpenChild: () => MenuContextValue | undefined
341
+ hasOpenChild: () => boolean
342
+ highlight: (id: string | null, options?: HighlightOptions) => void
343
+ highlightSearchMatch: (text: string) => void
344
+ navigate: (strategy: NavigationStrategy) => void
345
+ openActiveSubmenu: () => Promise<void>
346
+ open: (options?: OpenMenuOptions) => Promise<void>
347
+ registerChild: (menu: MenuContextValue) => void
348
+ registerItem: (item: RegisteredMenuItem) => void
349
+ registerList: (node: HTMLElement) => void
350
+ registerSurface: (node: HTMLElement) => void
351
+ registerTrigger: (node: HTMLElement, id: string) => void
352
+ suppressNextPointerLeaveClear: () => void
353
+ startHoverAim: (
354
+ source: HTMLElement | null,
355
+ target: HTMLElement | null,
356
+ event: PointerEvent,
357
+ ) => boolean
358
+ unregisterList: (node: HTMLElement) => void
359
+ unregisterSurface: (node: HTMLElement) => void
360
+ unregisterTrigger: (node: HTMLElement) => void
361
+ }
362
+
363
+ function isPrintableKey(event: KeyboardEvent) {
364
+ return event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey
365
+ }
366
+
367
+ function normalizeText(text: string | null | undefined) {
368
+ return (text ?? '').replace(/\s+/g, ' ').trim()
369
+ }
370
+
371
+ function getItemLabel(node: HTMLElement | undefined, label?: string) {
372
+ return normalizeText(label ?? node?.textContent)
373
+ }
374
+
375
+ function getItemRole(type: MenuItemType) {
376
+ switch (type) {
377
+ case 'checkbox':
378
+ return 'menuitemcheckbox'
379
+ case 'radio':
380
+ return 'menuitemradio'
381
+ default:
382
+ return 'menuitem'
383
+ }
384
+ }
385
+
386
+ function eventBelongsToCurrentMenu(event: Event) {
387
+ if (!(event.target instanceof Element) || !(event.currentTarget instanceof Element)) {
388
+ return false
389
+ }
390
+
391
+ return event.target.closest('[role="menu"]') === event.currentTarget
392
+ }
393
+
394
+ function shouldClearHighlightOnPointerLeave(event: PointerEvent) {
395
+ if (!(event.currentTarget instanceof Element)) {
396
+ return false
397
+ }
398
+
399
+ if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) {
400
+ return false
401
+ }
402
+
403
+ if (!(event.relatedTarget instanceof Element)) {
404
+ return true
405
+ }
406
+
407
+ let currentMenu = event.currentTarget.closest('[role="menu"]')
408
+ let nextItem = event.relatedTarget.closest('[role^="menuitem"]')
409
+
410
+ return !currentMenu || !nextItem || nextItem.closest('[role="menu"]') !== currentMenu
411
+ }
412
+
413
+ function focusNode(node: HTMLElement | undefined) {
414
+ if (!node || !node.isConnected || document.activeElement === node) {
415
+ return
416
+ }
417
+
418
+ node.focus()
419
+
420
+ if ('scrollIntoView' in node) {
421
+ node.scrollIntoView({
422
+ block: 'nearest',
423
+ inline: 'nearest',
424
+ })
425
+ }
426
+ }
427
+
428
+ function MenuProvider(handle: Handle<MenuProviderProps, MenuContextValue>) {
429
+ let parent = handle.context.get(MenuProvider)
430
+
431
+ let activeId: string | null = null
432
+ let childMenus: MenuContextValue[] = []
433
+ let closeAnimation: CloseAnimation = 'none'
434
+ let flashState: FlashState = null
435
+ let items: RegisteredMenuItem[] = []
436
+ let listRef: HTMLElement | undefined
437
+ let open = false
438
+ let state = State.Idle
439
+ let suppressNextPointerLeaveClear = false
440
+ let surfaceRef: HTMLElement | undefined
441
+ let triggerId: string | undefined
442
+ let triggerRef: HTMLElement | undefined
443
+
444
+ let hoverAim = createHoverAim()
445
+
446
+ function getItem(id: string | null | undefined) {
447
+ return items.find((item) => item.id === id)
448
+ }
449
+
450
+ function isVisibleItem(item: RegisteredMenuItem | undefined): item is RegisteredMenuItem {
451
+ return !!item?.node?.isConnected && !item.hidden
452
+ }
453
+
454
+ function isInteractableItem(item: RegisteredMenuItem | undefined): item is RegisteredMenuItem {
455
+ return isVisibleItem(item) && !item.disabled
456
+ }
457
+
458
+ function getInteractableItems() {
459
+ return items.filter(isInteractableItem)
460
+ }
461
+
462
+ function getOpenChild() {
463
+ return childMenus.find((menu) => menu.isOpen)
464
+ }
465
+
466
+ function closeChildrenSync(
467
+ updates: Promise<AbortSignal>[],
468
+ options: CloseSyncOptions = { animation: 'none' },
469
+ ) {
470
+ for (let child of childMenus) {
471
+ child.closeSync(updates, options)
472
+ }
473
+ }
474
+
475
+ function focusOpenStrategy(strategy: OpenStrategy, nextActiveId: string | null) {
476
+ let activeItem = getItem(nextActiveId)
477
+
478
+ if (strategy === 'list' || strategy === 'none' || !isInteractableItem(activeItem)) {
479
+ focusNode(listRef)
480
+ return
481
+ }
482
+
483
+ focusNode(activeItem.node)
484
+ }
485
+
486
+ async function openMenu({
487
+ strategy = 'list',
488
+ focus = strategy !== 'none',
489
+ }: OpenMenuOptions = {}) {
490
+ if (state !== State.Idle) {
491
+ return
492
+ }
493
+
494
+ let updates: Promise<AbortSignal>[] = []
495
+ closeChildrenSync(updates, { animation: 'none' })
496
+
497
+ let nextActiveId: string | null = null
498
+ let interactableItems = getInteractableItems()
499
+
500
+ if (strategy === 'first') {
501
+ nextActiveId = interactableItems[0]?.id ?? null
502
+ } else if (strategy === 'last') {
503
+ nextActiveId = interactableItems[interactableItems.length - 1]?.id ?? null
504
+ }
505
+
506
+ let shouldUpdate = !open || activeId !== nextActiveId
507
+ open = true
508
+ activeId = nextActiveId
509
+
510
+ if (shouldUpdate) {
511
+ updates.push(handle.update())
512
+ }
513
+
514
+ if (updates.length > 0) {
515
+ let signals = await Promise.all(updates)
516
+ if (signals.some((signal) => signal.aborted)) {
517
+ return
518
+ }
519
+ }
520
+
521
+ if (focus) {
522
+ focusOpenStrategy(strategy, nextActiveId)
523
+ }
524
+ }
525
+
526
+ function closeSync(
527
+ updates: Promise<AbortSignal>[],
528
+ { animation = 'none' }: CloseSyncOptions = {},
529
+ ) {
530
+ let wasOpen = open
531
+ closeAnimation = animation
532
+ closeChildrenSync(updates, { animation })
533
+
534
+ if (!open && (animation === 'fade' || activeId === null)) {
535
+ return
536
+ }
537
+
538
+ open = false
539
+ if (animation === 'none') {
540
+ activeId = null
541
+ state = State.Idle
542
+ } else if (wasOpen && state === State.Idle) {
543
+ state = State.Dismissing
544
+ }
545
+ updates.push(handle.update())
546
+ }
547
+
548
+ function finishDismissalSync(updates: Promise<AbortSignal>[]) {
549
+ for (let child of childMenus) {
550
+ child.finishDismissalSync(updates)
551
+ }
552
+
553
+ if (open) {
554
+ return
555
+ }
556
+
557
+ let shouldUpdate = false
558
+
559
+ if (activeId !== null) {
560
+ activeId = null
561
+ shouldUpdate = true
562
+ }
563
+
564
+ if (state !== State.Idle) {
565
+ state = State.Idle
566
+ shouldUpdate = true
567
+ }
568
+
569
+ if (closeAnimation !== 'none') {
570
+ closeAnimation = 'none'
571
+ shouldUpdate = true
572
+ }
573
+
574
+ if (shouldUpdate) {
575
+ updates.push(handle.update())
576
+ }
577
+ }
578
+
579
+ async function closeBranch({ focusTrigger = false }: CloseBranchOptions = {}) {
580
+ let updates: Promise<AbortSignal>[] = []
581
+ closeSync(updates, { animation: 'none' })
582
+
583
+ if (updates.length > 0) {
584
+ let signals = await Promise.all(updates)
585
+ if (signals.some((signal) => signal.aborted)) {
586
+ return
587
+ }
588
+ }
589
+
590
+ if (focusTrigger) {
591
+ parent?.suppressNextPointerLeaveClear()
592
+ focusNode(triggerRef)
593
+ }
594
+ }
595
+
596
+ async function closeAll({ focusRoot = true }: CloseAllOptions = {}) {
597
+ if (parent) {
598
+ await context.root.closeAll({ focusRoot })
599
+ return
600
+ }
601
+
602
+ let shouldWaitForTransition = open && !!surfaceRef?.isConnected
603
+ let updates: Promise<AbortSignal>[] = []
604
+ closeSync(updates, { animation: 'fade' })
605
+
606
+ if (updates.length > 0) {
607
+ let signals = await Promise.all(updates)
608
+ if (signals.some((signal) => signal.aborted)) {
609
+ return
610
+ }
611
+ }
612
+
613
+ if (shouldWaitForTransition && surfaceRef?.isConnected) {
614
+ await waitForCssTransition(surfaceRef, handle.signal)
615
+ }
616
+
617
+ updates = []
618
+ finishDismissalSync(updates)
619
+
620
+ if (updates.length > 0) {
621
+ let signals = await Promise.all(updates)
622
+ if (signals.some((signal) => signal.aborted)) {
623
+ return
624
+ }
625
+ }
626
+
627
+ if (focusRoot) {
628
+ focusNode(triggerRef)
629
+ }
630
+ }
631
+
632
+ function highlight(id: string | null, { focus = false }: HighlightOptions = {}) {
633
+ if (state !== State.Idle) {
634
+ return
635
+ }
636
+
637
+ let nextItem = id ? getItem(id) : undefined
638
+
639
+ if (id && !isInteractableItem(nextItem)) {
640
+ return
641
+ }
642
+
643
+ let updates: Promise<AbortSignal>[] = []
644
+ let openChild = getOpenChild()
645
+
646
+ if (openChild && nextItem?.submenu !== openChild) {
647
+ openChild.closeSync(updates, { animation: 'none' })
648
+ }
649
+
650
+ if (activeId !== id) {
651
+ activeId = id
652
+ updates.push(handle.update())
653
+ }
654
+
655
+ if (!focus) {
656
+ return
657
+ }
658
+
659
+ let focusTarget = nextItem?.node
660
+
661
+ if (updates.length === 0) {
662
+ focusNode(focusTarget)
663
+ return
664
+ }
665
+
666
+ void Promise.all(updates).then((signals) => {
667
+ if (signals.some((signal) => signal.aborted)) {
668
+ return
669
+ }
670
+
671
+ focusNode(focusTarget)
672
+ })
673
+ }
674
+
675
+ function navigate(strategy: NavigationStrategy) {
676
+ if (state !== State.Idle) {
677
+ return
678
+ }
679
+
680
+ let interactableItems = getInteractableItems()
681
+ let activeIndex = interactableItems.findIndex((item) => item.id === activeId)
682
+ let nextItem: RegisteredMenuItem | undefined
683
+
684
+ switch (strategy) {
685
+ case NavigationStrategy.Next:
686
+ nextItem =
687
+ activeIndex === -1
688
+ ? interactableItems[0]
689
+ : (interactableItems[activeIndex + 1] ?? interactableItems[activeIndex])
690
+ break
691
+ case NavigationStrategy.Previous:
692
+ nextItem =
693
+ activeIndex === -1
694
+ ? interactableItems[interactableItems.length - 1]
695
+ : interactableItems[activeIndex - 1]
696
+ break
697
+ case NavigationStrategy.First:
698
+ nextItem = interactableItems[0]
699
+ break
700
+ case NavigationStrategy.Last:
701
+ nextItem = interactableItems[interactableItems.length - 1]
702
+ break
703
+ }
704
+
705
+ if (nextItem) {
706
+ highlight(nextItem.id, { focus: true })
707
+ }
708
+ }
709
+
710
+ function highlightSearchMatch(text: string) {
711
+ if (state !== State.Idle) {
712
+ return
713
+ }
714
+
715
+ let interactableItems = getInteractableItems()
716
+ let activeIndex = interactableItems.findIndex((item) => item.id === activeId)
717
+ let nextItem = matchNextItemBySearchText(text, interactableItems, {
718
+ fromIndex: activeIndex,
719
+ getSearchValues(item) {
720
+ return item.searchValue ?? item.label
721
+ },
722
+ })
723
+
724
+ if (nextItem) {
725
+ highlight(nextItem.id, { focus: true })
726
+ }
727
+ }
728
+
729
+ async function activateItem(id: string) {
730
+ if (state !== State.Idle) {
731
+ return
732
+ }
733
+
734
+ let item = getItem(id)
735
+ if (!isInteractableItem(item)) {
736
+ return
737
+ }
738
+
739
+ if (item.submenu) {
740
+ await item.submenu.open({ strategy: 'first' })
741
+ return
742
+ }
743
+
744
+ let committedChecked =
745
+ item.type === 'checkbox' ? !item.checked : item.type === 'radio' ? true : undefined
746
+
747
+ state = State.Selecting
748
+ activeId = item.id
749
+ flashState = item.type === 'item' ? { id: item.id } : { checked: committedChecked, id: item.id }
750
+ let signal = await handle.update()
751
+ if (signal.aborted) {
752
+ return
753
+ }
754
+
755
+ item.node.dispatchEvent(
756
+ new MenuSelectEvent({
757
+ checked: committedChecked,
758
+ id: item.id,
759
+ label: item.label,
760
+ name: item.name!,
761
+ type: item.type,
762
+ value: item.value ?? null,
763
+ }),
764
+ )
765
+
766
+ await wait(MENU_FLASH_DURATION_MS)
767
+ if (handle.signal.aborted) {
768
+ return
769
+ }
770
+
771
+ flashState = null
772
+ signal = await handle.update()
773
+ if (signal.aborted) {
774
+ return
775
+ }
776
+
777
+ await closeAll()
778
+
779
+ state = State.Idle
780
+ signal = await handle.update()
781
+ if (signal.aborted) {
782
+ return
783
+ }
784
+ }
785
+
786
+ async function activateActive() {
787
+ if (!activeId) {
788
+ return
789
+ }
790
+
791
+ await activateItem(activeId)
792
+ }
793
+
794
+ async function openActiveSubmenu() {
795
+ if (state !== State.Idle) {
796
+ return
797
+ }
798
+
799
+ if (!activeId) {
800
+ return
801
+ }
802
+
803
+ let item = getItem(activeId)
804
+ if (!isInteractableItem(item) || !item.submenu) {
805
+ return
806
+ }
807
+
808
+ await item.submenu.open({ strategy: 'first' })
809
+ }
810
+
811
+ let context: MenuContextValue = {
812
+ get activeId() {
813
+ return activeId ?? undefined
814
+ },
815
+
816
+ get closeAnimation() {
817
+ return closeAnimation
818
+ },
819
+
820
+ get flashingChecked() {
821
+ return flashState?.checked
822
+ },
823
+
824
+ get flashingId() {
825
+ return flashState?.id
826
+ },
827
+
828
+ get isOpen() {
829
+ return open
830
+ },
831
+
832
+ get isRoot() {
833
+ return !parent
834
+ },
835
+
836
+ get label() {
837
+ return handle.props.label
838
+ },
839
+
840
+ get listId() {
841
+ return `${handle.id}-list`
842
+ },
843
+
844
+ get parent() {
845
+ return parent
846
+ },
847
+
848
+ get root() {
849
+ return parent?.root ?? context
850
+ },
851
+
852
+ get state() {
853
+ return state
854
+ },
855
+
856
+ get surfaceNode() {
857
+ return surfaceRef
858
+ },
859
+
860
+ get triggerId() {
861
+ return triggerId
862
+ },
863
+
864
+ activateActive,
865
+
866
+ activateItem,
867
+
868
+ allowsPointer(event) {
869
+ return hoverAim.accepts(event)
870
+ },
871
+
872
+ closeAll,
873
+
874
+ closeBranch,
875
+
876
+ closeSync,
877
+
878
+ consumePointerLeaveClearSuppression() {
879
+ if (!suppressNextPointerLeaveClear) {
880
+ return false
881
+ }
882
+
883
+ suppressNextPointerLeaveClear = false
884
+ return true
885
+ },
886
+
887
+ finishDismissalSync,
888
+
889
+ getOpenChild,
890
+
891
+ hasOpenChild() {
892
+ return !!getOpenChild()
893
+ },
894
+
895
+ highlight,
896
+
897
+ highlightSearchMatch,
898
+
899
+ navigate,
900
+
901
+ openActiveSubmenu,
902
+
903
+ open: openMenu,
904
+
905
+ registerChild(menu) {
906
+ childMenus.push(menu)
907
+ },
908
+
909
+ registerItem(item) {
910
+ items.push(item)
911
+ },
912
+
913
+ registerList(node) {
914
+ listRef = node
915
+ },
916
+
917
+ registerSurface(node) {
918
+ surfaceRef = node
919
+ },
920
+
921
+ registerTrigger(node, id) {
922
+ triggerRef = node
923
+ triggerId = id
924
+ },
925
+
926
+ suppressNextPointerLeaveClear() {
927
+ suppressNextPointerLeaveClear = true
928
+ },
929
+
930
+ startHoverAim(source, target, event) {
931
+ if (!source || !target) {
932
+ return false
933
+ }
934
+
935
+ return hoverAim.start(source, target, event)
936
+ },
937
+
938
+ unregisterList(node) {
939
+ if (listRef === node) {
940
+ listRef = undefined
941
+ }
942
+ },
943
+
944
+ unregisterSurface(node) {
945
+ if (surfaceRef === node) {
946
+ surfaceRef = undefined
947
+ }
948
+ },
949
+
950
+ unregisterTrigger(node) {
951
+ if (triggerRef === node) {
952
+ triggerRef = undefined
953
+ triggerId = undefined
954
+ }
955
+ },
956
+ }
957
+
958
+ handle.context.set(context)
959
+
960
+ return () => {
961
+ childMenus = []
962
+ items = []
963
+ parent?.registerChild(context)
964
+
965
+ return <popover.Context>{handle.props.children}</popover.Context>
966
+ }
967
+ }
968
+
969
+ const triggerMixin = createMixin<HTMLElement, [options?: MenuTriggerOptions], ElementProps>(
970
+ (handle) => {
971
+ let context = handle.context.get(MenuProvider)
972
+
973
+ return (options = {}) => [
974
+ attrs({
975
+ id: handle.id,
976
+ 'aria-controls': context.listId,
977
+ 'aria-expanded': context.isOpen ? 'true' : 'false',
978
+ 'aria-haspopup': 'menu',
979
+ }),
980
+ ref((node: HTMLElement, signal) => {
981
+ context.registerTrigger(node, handle.id)
982
+ signal.addEventListener('abort', () => {
983
+ context.unregisterTrigger(node)
984
+ })
985
+ }),
986
+ popover.anchor({
987
+ placement: 'bottom-start',
988
+ ...options,
989
+ }),
990
+ on('click', () => {
991
+ if (context.isOpen) {
992
+ void context.closeAll()
993
+ } else {
994
+ void context.open({ strategy: 'list' })
995
+ }
996
+ }),
997
+ on('keydown', (event) => {
998
+ switch (event.key) {
999
+ case 'ArrowDown':
1000
+ event.preventDefault()
1001
+ event.stopPropagation()
1002
+ void context.open({ strategy: 'first' })
1003
+ break
1004
+ case 'ArrowUp':
1005
+ event.preventDefault()
1006
+ event.stopPropagation()
1007
+ void context.open({ strategy: 'last' })
1008
+ break
1009
+ case 'Enter':
1010
+ case ' ':
1011
+ event.preventDefault()
1012
+ event.stopPropagation()
1013
+ void context.open({ strategy: 'list' })
1014
+ }
1015
+ }),
1016
+ ]
1017
+ },
1018
+ )
1019
+
1020
+ const popoverMixin = createMixin<HTMLElement, [], ElementProps>((handle) => {
1021
+ let context = handle.context.get(MenuProvider)
1022
+
1023
+ return () => [
1024
+ attrs({
1025
+ 'data-close-animation': context.closeAnimation === 'none' ? 'none' : undefined,
1026
+ 'data-menu-submenu': context.isRoot ? undefined : 'true',
1027
+ }),
1028
+ ref((node: HTMLElement, signal) => {
1029
+ context.registerSurface(node)
1030
+ signal.addEventListener('abort', () => {
1031
+ context.unregisterSurface(node)
1032
+ })
1033
+ }),
1034
+ popover.surface({
1035
+ open: context.isOpen,
1036
+ onHide(request?: popover.PopoverHideRequest) {
1037
+ if (context.isRoot || request?.reason === 'escape-key') {
1038
+ void context.closeAll()
1039
+ return
1040
+ }
1041
+
1042
+ if (
1043
+ request?.reason === 'outside-click' &&
1044
+ request.target instanceof Node &&
1045
+ context.root.surfaceNode?.contains(request.target)
1046
+ ) {
1047
+ void context.closeBranch()
1048
+ }
1049
+ },
1050
+ closeOnAnchorClick: false,
1051
+ restoreFocusOnHide: false,
1052
+ stopOutsideClickPropagation: false,
1053
+ }),
1054
+ ]
1055
+ })
1056
+
1057
+ const listMixin = createMixin<HTMLElement, [], ElementProps>((handle) => {
1058
+ let context = handle.context.get(MenuProvider)
1059
+
1060
+ return () => [
1061
+ attrs({
1062
+ id: context.listId,
1063
+ role: 'menu',
1064
+ tabIndex: -1,
1065
+ 'aria-label': context.label,
1066
+ 'aria-labelledby': context.label ? undefined : context.triggerId,
1067
+ }),
1068
+ ref((node: HTMLElement, signal) => {
1069
+ context.registerList(node)
1070
+ signal.addEventListener('abort', () => {
1071
+ context.unregisterList(node)
1072
+ })
1073
+ }),
1074
+ on('pointerleave', (event) => {
1075
+ if (
1076
+ event.relatedTarget instanceof Node &&
1077
+ event.currentTarget.contains(event.relatedTarget)
1078
+ ) {
1079
+ return
1080
+ }
1081
+
1082
+ if (!context.hasOpenChild()) {
1083
+ if (context.consumePointerLeaveClearSuppression()) {
1084
+ return
1085
+ }
1086
+
1087
+ context.highlight(null)
1088
+ }
1089
+ }),
1090
+ on('keydown', (event) => {
1091
+ if (!eventBelongsToCurrentMenu(event)) {
1092
+ return
1093
+ }
1094
+
1095
+ let isTypeaheadKey = isPrintableKey(event) || event.key === 'Backspace'
1096
+ if (isTypeaheadKey) {
1097
+ event.stopPropagation()
1098
+ return
1099
+ }
1100
+
1101
+ switch (event.key) {
1102
+ case 'ArrowDown':
1103
+ event.preventDefault()
1104
+ event.stopPropagation()
1105
+ context.navigate(NavigationStrategy.Next)
1106
+ return
1107
+ case 'ArrowUp':
1108
+ event.preventDefault()
1109
+ event.stopPropagation()
1110
+ context.navigate(NavigationStrategy.Previous)
1111
+ return
1112
+ case 'Home':
1113
+ event.preventDefault()
1114
+ event.stopPropagation()
1115
+ context.navigate(NavigationStrategy.First)
1116
+ return
1117
+ case 'End':
1118
+ event.preventDefault()
1119
+ event.stopPropagation()
1120
+ context.navigate(NavigationStrategy.Last)
1121
+ return
1122
+ case 'Enter':
1123
+ case ' ':
1124
+ event.preventDefault()
1125
+ event.stopPropagation()
1126
+ void context.activateActive()
1127
+ return
1128
+ case 'ArrowRight':
1129
+ event.preventDefault()
1130
+ event.stopPropagation()
1131
+ void context.openActiveSubmenu()
1132
+ return
1133
+ case 'ArrowLeft':
1134
+ if (!context.parent) {
1135
+ return
1136
+ }
1137
+
1138
+ event.preventDefault()
1139
+ event.stopPropagation()
1140
+ void context.closeBranch({ focusTrigger: true })
1141
+ return
1142
+ case 'Escape':
1143
+ event.preventDefault()
1144
+ event.stopPropagation()
1145
+ void context.closeAll()
1146
+ return
1147
+ case 'Tab':
1148
+ event.stopPropagation()
1149
+ void context.closeAll({ focusRoot: false })
1150
+ }
1151
+ }),
1152
+ hiddenTypeahead((text) => {
1153
+ context.highlightSearchMatch(text)
1154
+ }),
1155
+ ]
1156
+ })
1157
+
1158
+ const itemMixin = createMixin<HTMLElement, [options: MenuItemOptions], ElementProps>((handle) => {
1159
+ let itemRef: HTMLElement | undefined
1160
+
1161
+ handle.queueTask((node) => {
1162
+ itemRef = node
1163
+ })
1164
+
1165
+ return (options) => {
1166
+ let context = handle.context.get(MenuProvider)
1167
+ let type: MenuItemType = options.type ?? 'item'
1168
+ let isFlashing = context.flashingId === handle.id
1169
+
1170
+ context.registerItem({
1171
+ checked: options.checked,
1172
+ disabled: options.disabled,
1173
+ id: handle.id,
1174
+ name: options.name,
1175
+ searchValue: options.searchValue,
1176
+ type,
1177
+ value: options.value,
1178
+ get hidden() {
1179
+ return itemRef?.hidden === true
1180
+ },
1181
+ get label() {
1182
+ return getItemLabel(itemRef, options.label)
1183
+ },
1184
+ get node() {
1185
+ return itemRef as HTMLElement
1186
+ },
1187
+ })
1188
+
1189
+ return [
1190
+ attrs({
1191
+ id: handle.id,
1192
+ role: getItemRole(type),
1193
+ tabIndex: -1,
1194
+ 'aria-checked':
1195
+ type === 'item'
1196
+ ? undefined
1197
+ : isFlashing
1198
+ ? context.flashingChecked
1199
+ ? 'true'
1200
+ : 'false'
1201
+ : options.checked
1202
+ ? 'true'
1203
+ : 'false',
1204
+ 'aria-disabled': options.disabled ? 'true' : undefined,
1205
+ 'data-menu-flash': isFlashing ? 'true' : undefined,
1206
+ 'data-highlighted': context.activeId === handle.id ? 'true' : undefined,
1207
+ }),
1208
+ !options.disabled && [
1209
+ on('click', () => {
1210
+ void context.activateItem(handle.id)
1211
+ }),
1212
+ on('pointermove', (event) => {
1213
+ if (!context.allowsPointer(event)) {
1214
+ return
1215
+ }
1216
+
1217
+ context.highlight(handle.id, { focus: true })
1218
+ }),
1219
+ on('pointerleave', (event) => {
1220
+ if (context.activeId !== handle.id) {
1221
+ return
1222
+ }
1223
+
1224
+ if (!shouldClearHighlightOnPointerLeave(event)) {
1225
+ return
1226
+ }
1227
+
1228
+ context.highlight(null)
1229
+ }),
1230
+ on('focus', () => {
1231
+ context.highlight(handle.id)
1232
+ }),
1233
+ ],
1234
+ ]
1235
+ }
1236
+ })
1237
+
1238
+ const submenuTriggerMixin = createMixin<
1239
+ HTMLElement,
1240
+ [options: SubmenuTriggerOptions],
1241
+ ElementProps
1242
+ >((handle) => {
1243
+ let itemRef: HTMLElement | undefined
1244
+ let openTimeoutId = 0
1245
+ let aborted = false
1246
+
1247
+ let childMenu = handle.context.get(MenuProvider)
1248
+ let parentMenu = childMenu.parent
1249
+ let signal = handle.signal
1250
+
1251
+ handle.queueTask((node) => {
1252
+ itemRef = node
1253
+ })
1254
+
1255
+ function clearScheduledOpen() {
1256
+ clearTimeout(openTimeoutId)
1257
+ openTimeoutId = 0
1258
+ }
1259
+
1260
+ signal.addEventListener('abort', () => {
1261
+ aborted = true
1262
+ clearScheduledOpen()
1263
+ })
1264
+
1265
+ return (options) => {
1266
+ if (!parentMenu) {
1267
+ return []
1268
+ }
1269
+
1270
+ let parent = parentMenu
1271
+
1272
+ parent.registerItem({
1273
+ disabled: options.disabled,
1274
+ id: handle.id,
1275
+ searchValue: options.searchValue,
1276
+ submenu: childMenu,
1277
+ type: 'item',
1278
+ value: options.value,
1279
+ get hidden() {
1280
+ return itemRef?.hidden === true
1281
+ },
1282
+ get label() {
1283
+ return getItemLabel(itemRef, options.label)
1284
+ },
1285
+ get node() {
1286
+ return itemRef as HTMLElement
1287
+ },
1288
+ })
1289
+
1290
+ function scheduleOpen() {
1291
+ clearScheduledOpen()
1292
+
1293
+ if (options.disabled || childMenu.isOpen) {
1294
+ return
1295
+ }
1296
+
1297
+ openTimeoutId = window.setTimeout(() => {
1298
+ if (aborted || parent.activeId !== handle.id) {
1299
+ return
1300
+ }
1301
+
1302
+ void childMenu.open({ focus: false, strategy: 'none' })
1303
+ }, SUBMENU_OPEN_DELAY_MS)
1304
+ }
1305
+
1306
+ return [
1307
+ attrs({
1308
+ id: handle.id,
1309
+ role: 'menuitem',
1310
+ tabIndex: -1,
1311
+ 'aria-controls': childMenu.listId,
1312
+ 'aria-disabled': options.disabled ? 'true' : undefined,
1313
+ 'aria-expanded': childMenu.isOpen ? 'true' : 'false',
1314
+ 'aria-haspopup': 'menu',
1315
+ 'data-submenu-state': childMenu.state === State.Idle ? undefined : childMenu.state,
1316
+ 'data-highlighted': parent.activeId === handle.id ? 'true' : undefined,
1317
+ }),
1318
+ ref((node: HTMLElement, signal) => {
1319
+ childMenu.registerTrigger(node, handle.id)
1320
+ signal.addEventListener('abort', () => {
1321
+ childMenu.unregisterTrigger(node)
1322
+ })
1323
+ }),
1324
+ popover.anchor({
1325
+ placement: 'right-start',
1326
+ relativeTo: SUBMENU_ANCHOR_RELATIVE_TO,
1327
+ }),
1328
+ !options.disabled && [
1329
+ on('click', () => {
1330
+ parent.highlight(handle.id, { focus: true })
1331
+ void childMenu.open({ focus: false, strategy: 'none' })
1332
+ }),
1333
+ on('pointermove', (event) => {
1334
+ if (!parent.allowsPointer(event)) {
1335
+ return
1336
+ }
1337
+
1338
+ let shouldScheduleOpen = document.activeElement === itemRef && openTimeoutId === 0
1339
+ parent.highlight(handle.id, { focus: true })
1340
+
1341
+ if (shouldScheduleOpen) {
1342
+ scheduleOpen()
1343
+ }
1344
+ }),
1345
+ on('pointerleave', (event) => {
1346
+ clearScheduledOpen()
1347
+
1348
+ let childSurface = childMenu.surfaceNode
1349
+ if (
1350
+ childMenu.isOpen &&
1351
+ parent.startHoverAim(itemRef ?? null, childSurface ?? null, event)
1352
+ ) {
1353
+ return
1354
+ }
1355
+
1356
+ if (parent.activeId !== handle.id) {
1357
+ return
1358
+ }
1359
+
1360
+ if (!shouldClearHighlightOnPointerLeave(event)) {
1361
+ return
1362
+ }
1363
+
1364
+ parent.highlight(null)
1365
+ }),
1366
+ on('focus', (event) => {
1367
+ parent.highlight(handle.id)
1368
+
1369
+ if (
1370
+ event.relatedTarget instanceof Element &&
1371
+ event.relatedTarget.closest('[role="menu"]')?.id === childMenu.listId
1372
+ ) {
1373
+ return
1374
+ }
1375
+
1376
+ scheduleOpen()
1377
+ }),
1378
+ on('blur', () => {
1379
+ clearScheduledOpen()
1380
+ }),
1381
+ ],
1382
+ ]
1383
+ }
1384
+ })
1385
+
1386
+ export interface MenuListProps extends Props<'div'> {}
1387
+
1388
+ type MenuListChildProps = Omit<
1389
+ JSX.LibraryManagedAttributes<typeof MenuList, MenuListProps>,
1390
+ 'children'
1391
+ >
1392
+
1393
+ export const Context = MenuProvider
1394
+ export const item = itemMixin
1395
+ export const list = listMixin
1396
+ export { popoverMixin as popover }
1397
+ export const submenuTrigger = submenuTriggerMixin
1398
+ export const trigger = triggerMixin
1399
+
1400
+ const menu = {
1401
+ Context,
1402
+ item,
1403
+ list,
1404
+ popover: popoverMixin,
1405
+ submenuTrigger,
1406
+ trigger,
1407
+ } as const
1408
+
1409
+ export function onMenuSelect(handler: MenuSelectHandler, captureBoolean?: boolean) {
1410
+ return on<HTMLElement, typeof MENU_SELECT_EVENT>(MENU_SELECT_EVENT, handler, captureBoolean)
1411
+ }
1412
+
1413
+ export interface MenuProps extends Omit<Props<'button'>, 'children'> {
1414
+ children?: RemixNode
1415
+ label: RemixNode
1416
+ menuLabel?: string
1417
+ }
1418
+
1419
+ export interface MenuItemProps
1420
+ extends Omit<Props<'div'>, 'children' | 'name' | 'type' | 'value'>,
1421
+ MenuItemOptions {
1422
+ children?: RemixNode
1423
+ }
1424
+
1425
+ export interface SubmenuProps
1426
+ extends Omit<Props<'div'>, 'children' | 'name' | 'type' | 'value'>,
1427
+ Omit<SubmenuTriggerOptions, 'label'> {
1428
+ children?: RemixNode
1429
+ label: RemixNode
1430
+ listProps?: MenuListChildProps
1431
+ menuLabel?: string
1432
+ }
1433
+
1434
+ export function Menu(handle: Handle<MenuProps>) {
1435
+ let buttonRef: HTMLButtonElement | undefined
1436
+
1437
+ return () => {
1438
+ let { children, label, menuLabel, mix, type, ...buttonProps } = handle.props
1439
+
1440
+ return (
1441
+ <menu.Context label={menuLabel}>
1442
+ <button
1443
+ {...buttonProps}
1444
+ type={type ?? 'button'}
1445
+ mix={[
1446
+ button.baseStyle,
1447
+ button.ghostStyle,
1448
+ buttonStyle,
1449
+ menu.trigger(),
1450
+ ref((node: HTMLButtonElement, signal) => {
1451
+ buttonRef = node
1452
+ signal.addEventListener('abort', () => {
1453
+ if (buttonRef === node) {
1454
+ buttonRef = undefined
1455
+ }
1456
+ })
1457
+ }),
1458
+ mix,
1459
+ ]}
1460
+ >
1461
+ <span mix={button.labelStyle}>{label}</span>
1462
+ <Glyph mix={button.iconStyle} name="chevronDown" />
1463
+ </button>
1464
+ <MenuList
1465
+ mix={onMenuSelect((event) => {
1466
+ if (!buttonRef) {
1467
+ return
1468
+ }
1469
+
1470
+ event.stopPropagation()
1471
+ buttonRef.dispatchEvent(new MenuSelectEvent(event.item))
1472
+ })}
1473
+ >
1474
+ {children}
1475
+ </MenuList>
1476
+ </menu.Context>
1477
+ )
1478
+ }
1479
+ }
1480
+
1481
+ export function MenuList(handle: Handle<MenuListProps>) {
1482
+ return () => {
1483
+ let { children, mix, ...divProps } = handle.props
1484
+
1485
+ return (
1486
+ <div mix={[popover.surfaceStyle, popoverStyle, menu.popover()]}>
1487
+ <div {...divProps} mix={[listStyle, menu.list(), mix]}>
1488
+ {children}
1489
+ </div>
1490
+ </div>
1491
+ )
1492
+ }
1493
+ }
1494
+
1495
+ export function MenuItem(handle: Handle<MenuItemProps>) {
1496
+ return () => {
1497
+ let { checked, children, disabled, label, mix, name, searchValue, type, value, ...divProps } =
1498
+ handle.props
1499
+
1500
+ return (
1501
+ <div
1502
+ {...divProps}
1503
+ mix={[
1504
+ itemStyle,
1505
+ menu.item({ checked, disabled, label, name, searchValue, type, value }),
1506
+ mix,
1507
+ ]}
1508
+ >
1509
+ <span mix={itemSlotStyle}>
1510
+ <Glyph mix={itemGlyphStyle} name="check" />
1511
+ </span>
1512
+ <span mix={itemLabelStyle}>{children ?? label}</span>
1513
+ </div>
1514
+ )
1515
+ }
1516
+ }
1517
+
1518
+ export function Submenu(handle: Handle<SubmenuProps>) {
1519
+ return () => {
1520
+ let { children, disabled, label, listProps, menuLabel, mix, searchValue, value, ...divProps } =
1521
+ handle.props
1522
+
1523
+ return (
1524
+ <menu.Context label={menuLabel}>
1525
+ <div
1526
+ {...divProps}
1527
+ mix={[
1528
+ itemStyle,
1529
+ menu.submenuTrigger({
1530
+ disabled,
1531
+ searchValue,
1532
+ value,
1533
+ }),
1534
+ mix,
1535
+ ]}
1536
+ >
1537
+ <span mix={itemSlotStyle}>
1538
+ <Glyph mix={itemGlyphStyle} name="check" />
1539
+ </span>
1540
+ <span mix={itemLabelStyle}>{label}</span>
1541
+ <Glyph mix={triggerGlyphStyle} name="chevronRight" />
1542
+ </div>
1543
+ <MenuList {...listProps}>{children}</MenuList>
1544
+ </menu.Context>
1545
+ )
1546
+ }
1547
+ }