@remix-run/ui 0.2.0 → 0.3.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 (232) hide show
  1. package/dist/animation/demos/animation.demo.d.ts +5 -0
  2. package/dist/animation/demos/animation.demo.js +92 -0
  3. package/dist/animation/demos/animation.demo.js.map +1 -0
  4. package/dist/animation/demos/aspect-ratio.d.ts +2 -0
  5. package/dist/animation/demos/aspect-ratio.js +69 -0
  6. package/dist/animation/demos/aspect-ratio.js.map +1 -0
  7. package/dist/animation/demos/bouncy-switch.d.ts +2 -0
  8. package/dist/animation/demos/bouncy-switch.js +36 -0
  9. package/dist/animation/demos/bouncy-switch.js.map +1 -0
  10. package/dist/animation/demos/color-interpolation.d.ts +1 -0
  11. package/dist/animation/demos/color-interpolation.js +53 -0
  12. package/dist/animation/demos/color-interpolation.js.map +1 -0
  13. package/dist/animation/demos/cube.d.ts +2 -0
  14. package/dist/animation/demos/cube.js +86 -0
  15. package/dist/animation/demos/cube.js.map +1 -0
  16. package/dist/animation/demos/default-animate.d.ts +2 -0
  17. package/dist/animation/demos/default-animate.js +89 -0
  18. package/dist/animation/demos/default-animate.js.map +1 -0
  19. package/dist/animation/demos/drag-release.d.ts +24 -0
  20. package/dist/animation/demos/drag-release.js +96 -0
  21. package/dist/animation/demos/drag-release.js.map +1 -0
  22. package/dist/animation/demos/enter.d.ts +1 -0
  23. package/dist/animation/demos/enter.js +19 -0
  24. package/dist/animation/demos/enter.js.map +1 -0
  25. package/dist/animation/demos/exit.d.ts +2 -0
  26. package/dist/animation/demos/exit.js +58 -0
  27. package/dist/animation/demos/exit.js.map +1 -0
  28. package/dist/animation/demos/flip-toggle.d.ts +2 -0
  29. package/dist/animation/demos/flip-toggle.js +37 -0
  30. package/dist/animation/demos/flip-toggle.js.map +1 -0
  31. package/dist/animation/demos/hold-to-confirm.d.ts +13 -0
  32. package/dist/animation/demos/hold-to-confirm.js +211 -0
  33. package/dist/animation/demos/hold-to-confirm.js.map +1 -0
  34. package/dist/animation/demos/html-content.d.ts +2 -0
  35. package/dist/animation/demos/html-content.js +30 -0
  36. package/dist/animation/demos/html-content.js.map +1 -0
  37. package/dist/animation/demos/interruptible-keyframes.d.ts +1 -0
  38. package/dist/animation/demos/interruptible-keyframes.js +48 -0
  39. package/dist/animation/demos/interruptible-keyframes.js.map +1 -0
  40. package/dist/animation/demos/keyframes.d.ts +1 -0
  41. package/dist/animation/demos/keyframes.js +24 -0
  42. package/dist/animation/demos/keyframes.js.map +1 -0
  43. package/dist/animation/demos/material-ripple.d.ts +2 -0
  44. package/dist/animation/demos/material-ripple.js +127 -0
  45. package/dist/animation/demos/material-ripple.js.map +1 -0
  46. package/dist/animation/demos/mixin-presence-list.d.ts +2 -0
  47. package/dist/animation/demos/mixin-presence-list.js +88 -0
  48. package/dist/animation/demos/mixin-presence-list.js.map +1 -0
  49. package/dist/animation/demos/mixin-reclaim.d.ts +2 -0
  50. package/dist/animation/demos/mixin-reclaim.js +90 -0
  51. package/dist/animation/demos/mixin-reclaim.js.map +1 -0
  52. package/dist/animation/demos/multi-state-badge.d.ts +2 -0
  53. package/dist/animation/demos/multi-state-badge.js +231 -0
  54. package/dist/animation/demos/multi-state-badge.js.map +1 -0
  55. package/dist/animation/demos/press.d.ts +2 -0
  56. package/dist/animation/demos/press.js +63 -0
  57. package/dist/animation/demos/press.js.map +1 -0
  58. package/dist/animation/demos/reordering.d.ts +2 -0
  59. package/dist/animation/demos/reordering.js +50 -0
  60. package/dist/animation/demos/reordering.js.map +1 -0
  61. package/dist/animation/demos/rolling-square.d.ts +2 -0
  62. package/dist/animation/demos/rolling-square.js +42 -0
  63. package/dist/animation/demos/rolling-square.js.map +1 -0
  64. package/dist/animation/demos/rotate.d.ts +1 -0
  65. package/dist/animation/demos/rotate.js +18 -0
  66. package/dist/animation/demos/rotate.js.map +1 -0
  67. package/dist/animation/demos/shared-layout.d.ts +2 -0
  68. package/dist/animation/demos/shared-layout.js +114 -0
  69. package/dist/animation/demos/shared-layout.js.map +1 -0
  70. package/dist/animation/demos/spring.demo.d.ts +6 -0
  71. package/dist/animation/demos/spring.demo.js +304 -0
  72. package/dist/animation/demos/spring.demo.js.map +1 -0
  73. package/dist/animation/demos/transition-options.d.ts +1 -0
  74. package/dist/animation/demos/transition-options.js +21 -0
  75. package/dist/animation/demos/transition-options.js.map +1 -0
  76. package/dist/animation/spring.d.ts +22 -0
  77. package/dist/animation/spring.js +22 -0
  78. package/dist/animation/spring.js.map +1 -1
  79. package/dist/components/accordion/demos/card.demo.d.ts +1 -0
  80. package/dist/components/accordion/demos/card.demo.js +1 -0
  81. package/dist/components/accordion/demos/card.demo.js.map +1 -1
  82. package/dist/components/accordion/demos/multiple.demo.d.ts +1 -0
  83. package/dist/components/accordion/demos/multiple.demo.js +1 -0
  84. package/dist/components/accordion/demos/multiple.demo.js.map +1 -1
  85. package/dist/components/accordion/demos/overview.demo.d.ts +1 -0
  86. package/dist/components/accordion/demos/overview.demo.js +1 -0
  87. package/dist/components/accordion/demos/overview.demo.js.map +1 -1
  88. package/dist/components/anchor/anchor.d.ts +8 -1
  89. package/dist/components/anchor/anchor.js +30 -11
  90. package/dist/components/anchor/anchor.js.map +1 -1
  91. package/dist/components/breadcrumbs/{demos/basic.demo.d.ts → basic.demo.d.ts} +1 -0
  92. package/dist/components/breadcrumbs/{demos/basic.demo.js → basic.demo.js} +1 -0
  93. package/dist/components/breadcrumbs/basic.demo.js.map +1 -0
  94. package/dist/components/breadcrumbs/{demos/separator.demo.d.ts → separator.demo.d.ts} +1 -0
  95. package/dist/components/breadcrumbs/{demos/separator.demo.js → separator.demo.js} +1 -0
  96. package/dist/components/breadcrumbs/separator.demo.js.map +1 -0
  97. package/dist/components/button/demos/aliases.demo.d.ts +1 -0
  98. package/dist/components/button/demos/aliases.demo.js +1 -0
  99. package/dist/components/button/demos/aliases.demo.js.map +1 -1
  100. package/dist/components/button/demos/basic.demo.d.ts +1 -0
  101. package/dist/components/button/demos/basic.demo.js +1 -0
  102. package/dist/components/button/demos/basic.demo.js.map +1 -1
  103. package/dist/components/button/demos/states.demo.d.ts +1 -0
  104. package/dist/components/button/demos/states.demo.js +1 -0
  105. package/dist/components/button/demos/states.demo.js.map +1 -1
  106. package/dist/components/combobox/{demos/overview.demo.d.ts → overview.demo.d.ts} +1 -0
  107. package/dist/components/combobox/{demos/overview.demo.js → overview.demo.js} +1 -0
  108. package/dist/components/combobox/overview.demo.js.map +1 -0
  109. package/dist/components/listbox/{demos/overview.demo.d.ts → overview.demo.d.ts} +1 -0
  110. package/dist/components/listbox/{demos/overview.demo.js → overview.demo.js} +1 -0
  111. package/dist/components/listbox/overview.demo.js.map +1 -0
  112. package/dist/components/menu/demos/bubbling.demo.d.ts +1 -0
  113. package/dist/components/menu/demos/bubbling.demo.js +1 -0
  114. package/dist/components/menu/demos/bubbling.demo.js.map +1 -1
  115. package/dist/components/menu/demos/context-trigger.demo.d.ts +7 -0
  116. package/dist/components/menu/demos/context-trigger.demo.js +87 -0
  117. package/dist/components/menu/demos/context-trigger.demo.js.map +1 -0
  118. package/dist/components/menu/demos/overview.demo.d.ts +1 -0
  119. package/dist/components/menu/demos/overview.demo.js +1 -0
  120. package/dist/components/menu/demos/overview.demo.js.map +1 -1
  121. package/dist/components/menu/menu.d.ts +3 -0
  122. package/dist/components/menu/menu.js +55 -0
  123. package/dist/components/menu/menu.js.map +1 -1
  124. package/dist/components/popover/{demos/overview.demo.d.ts → overview.demo.d.ts} +1 -0
  125. package/dist/components/popover/{demos/overview.demo.js → overview.demo.js} +1 -0
  126. package/dist/components/popover/overview.demo.js.map +1 -0
  127. package/dist/components/popover/popover.d.ts +2 -2
  128. package/dist/components/popover/popover.js +11 -4
  129. package/dist/components/popover/popover.js.map +1 -1
  130. package/dist/components/select/{demos/deconstructed.demo.d.ts → deconstructed.demo.d.ts} +1 -0
  131. package/dist/components/select/{demos/deconstructed.demo.js → deconstructed.demo.js} +1 -0
  132. package/dist/components/select/deconstructed.demo.js.map +1 -0
  133. package/dist/components/select/{demos/overview.demo.d.ts → overview.demo.d.ts} +1 -0
  134. package/dist/components/select/{demos/overview.demo.js → overview.demo.js} +1 -0
  135. package/dist/components/select/overview.demo.js.map +1 -0
  136. package/dist/components/select/select.js +1 -1
  137. package/dist/components/select/select.js.map +1 -1
  138. package/dist/runtime/client-entries.d.ts +1 -2
  139. package/dist/runtime/client-entries.js.map +1 -1
  140. package/dist/runtime/component.d.ts +2 -2
  141. package/dist/runtime/component.js +1 -1
  142. package/dist/runtime/component.js.map +1 -1
  143. package/dist/runtime/demos/basic.demo.d.ts +6 -0
  144. package/dist/runtime/demos/basic.demo.js +16 -0
  145. package/dist/runtime/demos/basic.demo.js.map +1 -0
  146. package/dist/runtime/demos/controlled-uncontrolled-values.demo.d.ts +6 -0
  147. package/dist/runtime/demos/controlled-uncontrolled-values.demo.js +77 -0
  148. package/dist/runtime/demos/controlled-uncontrolled-values.demo.js.map +1 -0
  149. package/dist/runtime/demos/keyed-list.demo.d.ts +6 -0
  150. package/dist/runtime/demos/keyed-list.demo.js +62 -0
  151. package/dist/runtime/demos/keyed-list.demo.js.map +1 -0
  152. package/dist/runtime/demos/readme.demo.d.ts +5 -0
  153. package/dist/runtime/demos/readme.demo.js +396 -0
  154. package/dist/runtime/demos/readme.demo.js.map +1 -0
  155. package/dist/runtime/diff-dom.js +194 -21
  156. package/dist/runtime/diff-dom.js.map +1 -1
  157. package/dist/runtime/frame.d.ts +7 -0
  158. package/dist/runtime/frame.js +176 -41
  159. package/dist/runtime/frame.js.map +1 -1
  160. package/dist/runtime/jsx.d.ts +2 -3
  161. package/dist/runtime/jsx.js.map +1 -1
  162. package/dist/server/stream.js +3 -2
  163. package/dist/server/stream.js.map +1 -1
  164. package/dist/style/style.js +27 -10
  165. package/dist/style/style.js.map +1 -1
  166. package/package.json +3 -3
  167. package/src/animation/demos/animation.demo.tsx +194 -0
  168. package/src/animation/demos/aspect-ratio.tsx +120 -0
  169. package/src/animation/demos/bouncy-switch.tsx +47 -0
  170. package/src/animation/demos/color-interpolation.tsx +73 -0
  171. package/src/animation/demos/cube.tsx +116 -0
  172. package/src/animation/demos/default-animate.tsx +129 -0
  173. package/src/animation/demos/drag-release.ts +118 -0
  174. package/src/animation/demos/enter.tsx +22 -0
  175. package/src/animation/demos/exit.tsx +79 -0
  176. package/src/animation/demos/flip-toggle.tsx +46 -0
  177. package/src/animation/demos/hold-to-confirm.tsx +317 -0
  178. package/src/animation/demos/html-content.tsx +41 -0
  179. package/src/animation/demos/interruptible-keyframes.tsx +62 -0
  180. package/src/animation/demos/keyframes.tsx +29 -0
  181. package/src/animation/demos/material-ripple.tsx +158 -0
  182. package/src/animation/demos/mixin-presence-list.tsx +121 -0
  183. package/src/animation/demos/mixin-reclaim.tsx +125 -0
  184. package/src/animation/demos/multi-state-badge.tsx +426 -0
  185. package/src/animation/demos/press.tsx +67 -0
  186. package/src/animation/demos/reordering.tsx +65 -0
  187. package/src/animation/demos/rolling-square.tsx +57 -0
  188. package/src/animation/demos/rotate.tsx +21 -0
  189. package/src/animation/demos/shared-layout.tsx +211 -0
  190. package/src/animation/demos/spring.demo.tsx +385 -0
  191. package/src/animation/demos/transition-options.tsx +24 -0
  192. package/src/animation/spring.ts +22 -0
  193. package/src/components/accordion/demos/card.demo.tsx +1 -0
  194. package/src/components/accordion/demos/multiple.demo.tsx +1 -0
  195. package/src/components/accordion/demos/overview.demo.tsx +1 -0
  196. package/src/components/anchor/README.md +11 -3
  197. package/src/components/anchor/anchor.ts +53 -12
  198. package/src/components/breadcrumbs/{demos/basic.demo.tsx → basic.demo.tsx} +1 -0
  199. package/src/components/breadcrumbs/{demos/separator.demo.tsx → separator.demo.tsx} +1 -0
  200. package/src/components/button/demos/aliases.demo.tsx +1 -0
  201. package/src/components/button/demos/basic.demo.tsx +1 -0
  202. package/src/components/button/demos/states.demo.tsx +1 -0
  203. package/src/components/combobox/{demos/overview.demo.tsx → overview.demo.tsx} +1 -0
  204. package/src/components/listbox/{demos/overview.demo.tsx → overview.demo.tsx} +1 -0
  205. package/src/components/menu/README.md +26 -2
  206. package/src/components/menu/demos/bubbling.demo.tsx +1 -0
  207. package/src/components/menu/demos/context-trigger.demo.tsx +133 -0
  208. package/src/components/menu/demos/overview.demo.tsx +1 -0
  209. package/src/components/menu/menu.tsx +73 -1
  210. package/src/components/popover/{demos/overview.demo.tsx → overview.demo.tsx} +1 -0
  211. package/src/components/popover/popover.ts +18 -9
  212. package/src/components/select/{demos/deconstructed.demo.tsx → deconstructed.demo.tsx} +1 -0
  213. package/src/components/select/{demos/overview.demo.tsx → overview.demo.tsx} +1 -0
  214. package/src/components/select/select.tsx +1 -1
  215. package/src/runtime/client-entries.ts +1 -5
  216. package/src/runtime/component.ts +3 -3
  217. package/src/runtime/demos/basic.demo.tsx +21 -0
  218. package/src/runtime/demos/controlled-uncontrolled-values.demo.tsx +177 -0
  219. package/src/runtime/demos/keyed-list.demo.tsx +94 -0
  220. package/src/runtime/demos/readme.demo.tsx +702 -0
  221. package/src/runtime/diff-dom.ts +270 -20
  222. package/src/runtime/frame.ts +218 -41
  223. package/src/runtime/jsx.ts +6 -10
  224. package/src/server/stream.ts +7 -2
  225. package/src/style/style.ts +29 -10
  226. package/dist/components/breadcrumbs/demos/basic.demo.js.map +0 -1
  227. package/dist/components/breadcrumbs/demos/separator.demo.js.map +0 -1
  228. package/dist/components/combobox/demos/overview.demo.js.map +0 -1
  229. package/dist/components/listbox/demos/overview.demo.js.map +0 -1
  230. package/dist/components/popover/demos/overview.demo.js.map +0 -1
  231. package/dist/components/select/demos/deconstructed.demo.js.map +0 -1
  232. package/dist/components/select/demos/overview.demo.js.map +0 -1
@@ -68,6 +68,29 @@ Use `menuLabel` when the menu surface needs a different accessible label from th
68
68
  </Menu>
69
69
  ```
70
70
 
71
+ Use `menu.contextTrigger()` with `menu.Context` and `MenuList` when a menu should open at the right-click location of an element.
72
+
73
+ ```tsx
74
+ import * as menu from 'remix/ui/menu'
75
+ import { MenuItem, MenuList } from 'remix/ui/menu'
76
+
77
+ export function FileContextMenu(handle: Handle) {
78
+ return () => (
79
+ <menu.Context label="File actions">
80
+ <div mix={menu.contextTrigger()} tabIndex={0}>
81
+ File.txt
82
+ </div>
83
+ <MenuList>
84
+ <MenuItem name="rename">Rename</MenuItem>
85
+ <MenuItem name="delete">Delete</MenuItem>
86
+ </MenuList>
87
+ </menu.Context>
88
+ )
89
+ }
90
+ ```
91
+
92
+ Attach `onMenuSelect(...)` to `MenuList` or a shared ancestor when using lower-level context menu composition.
93
+
71
94
  ## `menu.*`
72
95
 
73
96
  - `Menu`: composed trigger, popover, and list component for the common menu case.
@@ -77,13 +100,14 @@ Use `menuLabel` when the menu surface needs a different accessible label from th
77
100
  - `onMenuSelect(...)`: event mixin for the bubbling `MenuSelectEvent`.
78
101
  - `MenuSelectEvent`: bubbling event class whose `item` describes the selected item.
79
102
  - `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.
103
+ - `menu.Context`, `menu.trigger()`, `menu.contextTrigger()`, `menu.popover()`, `menu.list()`, `menu.item(...)`, and `menu.submenuTrigger(...)`: lower-level composition primitives.
81
104
  - `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.
105
+ - `MenuProps`, `MenuItemProps`, `MenuListProps`, `MenuProviderProps`, `MenuTriggerOptions`, `MenuContextTriggerOptions`, `MenuItemOptions`, and `SubmenuProps`: public TypeScript props and option types.
83
106
 
84
107
  ## Behavior Notes
85
108
 
86
109
  - Click opens the root menu and focuses the list; clicking the trigger again closes it and restores focus.
110
+ - `menu.contextTrigger()` opens the root menu from a `contextmenu` event at the pointer coordinates and supports keyboard opening with the Context Menu key or Shift+F10.
87
111
  - `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
112
  - Keyboard navigation skips disabled items and does not wrap past the first or last enabled item.
89
113
  - `Home` and `End` move to the first and last enabled item. Enter and Space activate the highlighted item.
@@ -4,6 +4,7 @@ import { Menu, MenuItem, onMenuSelect } from '@remix-run/ui/menu'
4
4
  /**
5
5
  * @name Menu Event Bubbling
6
6
  * @description Item-level handlers fire before the root handler, letting individual items intercept events while others bubble up.
7
+ * @layout center
7
8
  */
8
9
  export default function Example() {
9
10
  return () => (
@@ -0,0 +1,133 @@
1
+ import { css, type Handle } from '@remix-run/ui'
2
+ import * as menu from '@remix-run/ui/menu'
3
+ import { MenuItem, MenuList, onMenuSelect, Submenu } from '@remix-run/ui/menu'
4
+ import { separatorStyle } from '@remix-run/ui/separator'
5
+ import { theme } from '@remix-run/ui/theme'
6
+
7
+ type FileAction = 'copyPath' | 'duplicate' | 'move' | 'rename' | 'reveal' | 'trash'
8
+
9
+ const actionLabelByName: Record<FileAction, string> = {
10
+ copyPath: 'Copied path',
11
+ duplicate: 'Duplicated file',
12
+ move: 'Moved file',
13
+ rename: 'Renamed file',
14
+ reveal: 'Revealed in Finder',
15
+ trash: 'Moved to trash',
16
+ }
17
+
18
+ /**
19
+ * @name Context Menu Trigger
20
+ * @description A lower-level menu composition that opens from right-click coordinates while keeping standard menu selection and submenu behavior.
21
+ * @layout center
22
+ */
23
+ export default function Example(handle: Handle) {
24
+ let latestAction = 'Right-click the card.'
25
+
26
+ return () => (
27
+ <menu.Context label="File actions">
28
+ <div mix={layoutCss}>
29
+ <div tabIndex={0} mix={[fileCardCss, menu.contextTrigger()]}>
30
+ <span mix={fileIconCss}>TS</span>
31
+ <span mix={fileTextCss}>
32
+ <strong mix={fileNameCss}>context-menu.tsx</strong>
33
+ <span mix={fileMetaCss}>Right-click or press Shift+F10</span>
34
+ </span>
35
+ </div>
36
+
37
+ <p aria-live="polite" mix={statusCss}>
38
+ {latestAction}
39
+ </p>
40
+ </div>
41
+
42
+ <MenuList
43
+ mix={onMenuSelect((event) => {
44
+ latestAction =
45
+ actionLabelByName[event.item.name as FileAction] ?? `Selected ${event.item.label}`
46
+ void handle.update()
47
+ })}
48
+ >
49
+ <MenuItem name="rename">Rename</MenuItem>
50
+ <MenuItem name="duplicate">Duplicate</MenuItem>
51
+ <MenuItem name="copyPath">Copy path</MenuItem>
52
+ <hr mix={separatorStyle} />
53
+ <Submenu label="Move to">
54
+ <MenuItem name="move" value="drafts">
55
+ Drafts
56
+ </MenuItem>
57
+ <MenuItem name="move" value="archive">
58
+ Archive
59
+ </MenuItem>
60
+ </Submenu>
61
+ <MenuItem name="reveal">Reveal in Finder</MenuItem>
62
+ <hr mix={separatorStyle} />
63
+ <MenuItem name="trash">Move to trash</MenuItem>
64
+ </MenuList>
65
+ </menu.Context>
66
+ )
67
+ }
68
+
69
+ const layoutCss = css({
70
+ display: 'grid',
71
+ justifyItems: 'start',
72
+ gap: theme.space.md,
73
+ })
74
+
75
+ const fileCardCss = css({
76
+ display: 'grid',
77
+ gridTemplateColumns: 'auto minmax(0, 1fr)',
78
+ alignItems: 'center',
79
+ gap: theme.space.md,
80
+ width: 'min(100%, 21rem)',
81
+ padding: theme.space.md,
82
+ border: `1px solid ${theme.colors.border.subtle}`,
83
+ borderRadius: theme.radius.lg,
84
+ backgroundColor: theme.surface.lvl1,
85
+ color: theme.colors.text.primary,
86
+ boxShadow: theme.shadow.xs,
87
+ cursor: 'context-menu',
88
+ userSelect: 'none',
89
+ '&:focus-visible': {
90
+ outline: `2px solid ${theme.colors.focus.ring}`,
91
+ outlineOffset: '2px',
92
+ },
93
+ })
94
+
95
+ const fileIconCss = css({
96
+ display: 'inline-grid',
97
+ placeItems: 'center',
98
+ width: theme.control.height.lg,
99
+ height: theme.control.height.lg,
100
+ borderRadius: theme.radius.md,
101
+ backgroundColor: theme.colors.action.primary.background,
102
+ color: theme.colors.action.primary.foreground,
103
+ fontSize: theme.fontSize.xs,
104
+ fontWeight: theme.fontWeight.bold,
105
+ })
106
+
107
+ const fileTextCss = css({
108
+ display: 'grid',
109
+ gap: theme.space.px,
110
+ minWidth: 0,
111
+ })
112
+
113
+ const fileNameCss = css({
114
+ overflow: 'hidden',
115
+ textOverflow: 'ellipsis',
116
+ whiteSpace: 'nowrap',
117
+ fontSize: theme.fontSize.sm,
118
+ lineHeight: theme.lineHeight.normal,
119
+ })
120
+
121
+ const fileMetaCss = css({
122
+ color: theme.colors.text.secondary,
123
+ fontSize: theme.fontSize.xs,
124
+ lineHeight: theme.lineHeight.normal,
125
+ })
126
+
127
+ const statusCss = css({
128
+ margin: 0,
129
+ minHeight: theme.lineHeight.normal,
130
+ color: theme.colors.text.secondary,
131
+ fontSize: theme.fontSize.sm,
132
+ lineHeight: theme.lineHeight.normal,
133
+ })
@@ -5,6 +5,7 @@ import { separatorStyle } from '@remix-run/ui/separator'
5
5
  /**
6
6
  * @name Menu Overview
7
7
  * @description A hierarchical menu with checkboxes, radio groups, submenus, and separators.
8
+ * @layout center
8
9
  */
9
10
  export default function Example(handle: Handle) {
10
11
  type Density = 'comfortable' | 'compact'
@@ -13,7 +13,7 @@ import {
13
13
  type Props,
14
14
  type RemixNode,
15
15
  } from '@remix-run/ui'
16
- import { type AnchorOptions } from '../anchor/anchor.ts'
16
+ import { type AnchorOptions, type AnchorPoint } from '../anchor/anchor.ts'
17
17
  import * as button from '../button/button.tsx'
18
18
  import { Glyph } from '../glyph/glyph.tsx'
19
19
  import * as popover from '../popover/popover.ts'
@@ -250,6 +250,8 @@ export interface MenuProviderProps {
250
250
 
251
251
  export interface MenuTriggerOptions extends AnchorOptions {}
252
252
 
253
+ export interface MenuContextTriggerOptions extends AnchorOptions {}
254
+
253
255
  export interface MenuItemOptions {
254
256
  checked?: boolean
255
257
  disabled?: boolean
@@ -1008,6 +1010,74 @@ const triggerMixin: MixinFactory<HTMLElement, [options?: MenuTriggerOptions], El
1008
1010
  ]
1009
1011
  })
1010
1012
 
1013
+ const contextTriggerMixin: MixinFactory<
1014
+ HTMLElement,
1015
+ [options?: MenuContextTriggerOptions],
1016
+ ElementProps
1017
+ > = createMixin<HTMLElement, [options?: MenuContextTriggerOptions], ElementProps>((handle) => {
1018
+ let context = handle.context.get(MenuProvider)
1019
+ let popoverContext = handle.context.get(popover.Context)
1020
+ let anchorPoint: AnchorPoint = { x: 0, y: 0, width: 0, height: 0 }
1021
+
1022
+ function setAnchorPoint(
1023
+ { x, y, width = 0, height = 0 }: AnchorPoint,
1024
+ options: MenuContextTriggerOptions,
1025
+ ) {
1026
+ anchorPoint.x = x
1027
+ anchorPoint.y = y
1028
+ anchorPoint.width = width
1029
+ anchorPoint.height = height
1030
+ popoverContext.anchor = {
1031
+ target: anchorPoint,
1032
+ options: {
1033
+ placement: 'bottom-start',
1034
+ ...options,
1035
+ },
1036
+ }
1037
+ }
1038
+
1039
+ return (options = {}) => [
1040
+ attrs({
1041
+ id: handle.id,
1042
+ 'aria-controls': context.listId,
1043
+ 'aria-expanded': context.isOpen ? 'true' : 'false',
1044
+ 'aria-haspopup': 'menu',
1045
+ }),
1046
+ ref((node: HTMLElement, signal) => {
1047
+ context.registerTrigger(node, handle.id)
1048
+ signal.addEventListener('abort', () => {
1049
+ context.unregisterTrigger(node)
1050
+ })
1051
+ }),
1052
+ on('contextmenu', (event) => {
1053
+ event.preventDefault()
1054
+ event.stopPropagation()
1055
+ setAnchorPoint({ x: event.clientX, y: event.clientY }, options)
1056
+ void context.open({ strategy: 'list' })
1057
+ }),
1058
+ on('keydown', (event) => {
1059
+ if (event.key !== 'ContextMenu' && !(event.shiftKey && event.key === 'F10')) {
1060
+ return
1061
+ }
1062
+
1063
+ event.preventDefault()
1064
+ event.stopPropagation()
1065
+
1066
+ let rect = event.currentTarget.getBoundingClientRect()
1067
+ setAnchorPoint(
1068
+ {
1069
+ x: rect.left,
1070
+ y: rect.top,
1071
+ width: rect.width,
1072
+ height: rect.height,
1073
+ },
1074
+ options,
1075
+ )
1076
+ void context.open({ strategy: 'list' })
1077
+ }),
1078
+ ]
1079
+ })
1080
+
1011
1081
  const popoverMixin: MixinFactory<HTMLElement, [], ElementProps> = createMixin<
1012
1082
  HTMLElement,
1013
1083
  [],
@@ -1394,6 +1464,7 @@ type MenuListChildProps = Omit<
1394
1464
  >
1395
1465
 
1396
1466
  export const Context = MenuProvider
1467
+ export const contextTrigger = contextTriggerMixin
1397
1468
  export const item = itemMixin
1398
1469
  export const list = listMixin
1399
1470
  export { popoverMixin as popover }
@@ -1402,6 +1473,7 @@ export const trigger = triggerMixin
1402
1473
 
1403
1474
  const menu = {
1404
1475
  Context,
1476
+ contextTrigger,
1405
1477
  item,
1406
1478
  list,
1407
1479
  popover: popoverMixin,
@@ -8,6 +8,7 @@ import { theme } from '@remix-run/ui/theme'
8
8
  /**
9
9
  * @name Popover Overview
10
10
  * @description A popover panel anchored to a trigger button, containing form controls and a close action.
11
+ * @layout center
11
12
  */
12
13
  export default function Example(handle: Handle) {
13
14
  let open = false
@@ -9,7 +9,11 @@ import {
9
9
  type MixinFactory,
10
10
  type RemixNode,
11
11
  } from '@remix-run/ui'
12
- import { anchor as positionAnchor, type AnchorOptions } from '../anchor/anchor.ts'
12
+ import {
13
+ anchor as positionAnchor,
14
+ type AnchorOptions,
15
+ type AnchorTarget,
16
+ } from '../anchor/anchor.ts'
13
17
  import { onOutsideClick } from '../../interactions/outside-click/outside-click-mixin.ts'
14
18
  import { theme } from '../../theme/theme.ts'
15
19
  import { lockScroll } from '../../utils/scroll-lock.ts'
@@ -26,7 +30,7 @@ export interface PopoverProps {
26
30
  }
27
31
 
28
32
  interface AnchorRef {
29
- node: HTMLElement
33
+ target: AnchorTarget
30
34
  options: AnchorOptions
31
35
  }
32
36
 
@@ -108,11 +112,15 @@ const anchorMixin: MixinFactory<HTMLElement, [options: AnchorOptions], ElementPr
108
112
  let context = handle.context.get(PopoverProvider)
109
113
  return (options) => {
110
114
  handle.queueTask((node) => {
111
- context.anchor = { node, options }
115
+ context.anchor = { target: node, options }
112
116
  })
113
117
  }
114
118
  })
115
119
 
120
+ function anchorContains(anchor: AnchorRef | null, target: Node) {
121
+ return anchor?.target instanceof HTMLElement && anchor.target.contains(target)
122
+ }
123
+
116
124
  const surfaceMixin: MixinFactory<HTMLElement, [options: PopoverSurfaceOptions], ElementProps> =
117
125
  createMixin<HTMLElement, [options: PopoverSurfaceOptions]>((handle) => {
118
126
  let openProp = false
@@ -147,11 +155,12 @@ const surfaceMixin: MixinFactory<HTMLElement, [options: PopoverSurfaceOptions],
147
155
 
148
156
  on('beforetoggle', (event) => {
149
157
  if (event.newState === 'open') {
150
- cleanupAnchor = positionAnchor(
151
- event.currentTarget,
152
- context.anchor!.node,
153
- context.anchor!.options,
154
- )
158
+ let anchor = context.anchor
159
+ if (!anchor) {
160
+ throw new Error('popover.surface() requires a registered anchor before opening')
161
+ }
162
+
163
+ cleanupAnchor = positionAnchor(event.currentTarget, anchor.target, anchor.options)
155
164
  unlockScroll = lockScroll()
156
165
  } else if (event.newState === 'closed') {
157
166
  cleanupAnchor()
@@ -180,7 +189,7 @@ const surfaceMixin: MixinFactory<HTMLElement, [options: PopoverSurfaceOptions],
180
189
  options.onHide({ reason: 'outside-click', target })
181
190
  },
182
191
  (target) =>
183
- options.closeOnAnchorClick === false && !!context.anchor?.node.contains(target),
192
+ options.closeOnAnchorClick === false && anchorContains(context.anchor, target),
184
193
  options.stopOutsideClickPropagation ?? true,
185
194
  ),
186
195
  ]
@@ -9,6 +9,7 @@ import { theme } from '@remix-run/ui/theme'
9
9
  /**
10
10
  * @name Select Deconstructed
11
11
  * @description Build a fully custom select by composing the select, popover, and listbox primitives directly.
12
+ * @layout center
12
13
  */
13
14
  export default function Example(handle: Handle) {
14
15
  let label = 'Local'
@@ -5,6 +5,7 @@ import { theme } from '@remix-run/ui/theme'
5
5
  /**
6
6
  * @name Select Overview
7
7
  * @description A styled select control with a searchable dropdown and accessible label.
8
+ * @layout center
8
9
  */
9
10
  export default function Example() {
10
11
  return () => (
@@ -156,7 +156,7 @@ function SelectProvider(handle: Handle<SelectContextProps, SelectContextValue>):
156
156
  popoverContextRef.hideFocusTarget = triggerRef ?? null
157
157
  popoverContextRef.anchor = triggerRef
158
158
  ? {
159
- node: triggerRef,
159
+ target: triggerRef,
160
160
  options: getPopoverAnchorOptions(),
161
161
  }
162
162
  : null
@@ -47,7 +47,7 @@ export type EntryMetadata = {
47
47
  */
48
48
  export type EntryComponent<props extends SerializableProps = {}, context = NoContext> = ((
49
49
  handle: Handle<props, context>,
50
- ) => RenderFn<props>) &
50
+ ) => RenderFn) &
51
51
  EntryMetadata
52
52
 
53
53
  /**
@@ -86,10 +86,6 @@ export function clientEntry<props extends SerializableProps = {}, context = NoCo
86
86
  entryId: string,
87
87
  component: (handle: Handle<props, context>) => RenderFn,
88
88
  ): EntryComponent<props, context>
89
- export function clientEntry<props extends SerializableProps = {}, context = NoContext>(
90
- entryId: string,
91
- component: (handle: Handle<Record<string, never>, context>) => RenderFn<props>,
92
- ): EntryComponent<props, context>
93
89
 
94
90
  // Implementation
95
91
  export function clientEntry(entryId: string, component: any): any {
@@ -179,9 +179,9 @@ export type ComponentFn<Props = Record<string, never>, ContextValue = NoContext>
179
179
  ) => RenderFn
180
180
 
181
181
  /**
182
- * Render function returned by a component factory.
182
+ * Zero-argument render function returned by a component factory.
183
183
  */
184
- export type RenderFn<Props = ElementProps> = (props: Props) => RemixNode
184
+ export type RenderFn = () => RemixNode
185
185
 
186
186
  export type { RemixNode } from './jsx.ts'
187
187
 
@@ -287,7 +287,7 @@ class ComponentRuntime<C = NoContext> implements ComponentHandle<C> {
287
287
  this.#renderFn = renderFn
288
288
  }
289
289
 
290
- return [renderFn(this.#props), this.#dequeueTasks()]
290
+ return [renderFn(), this.#dequeueTasks()]
291
291
  }
292
292
 
293
293
  remove = (): Array<() => void> => {
@@ -0,0 +1,21 @@
1
+ import { on, type Handle } from '@remix-run/ui'
2
+
3
+ /**
4
+ * @name Basic Counter
5
+ * @description The smallest interactive Remix UI counter from the standalone demos.
6
+ */
7
+ export default function App(handle: Handle) {
8
+ let count = 0
9
+ return () => (
10
+ <button
11
+ mix={[
12
+ on('click', () => {
13
+ count++
14
+ handle.update()
15
+ }),
16
+ ]}
17
+ >
18
+ Ye ol' counter: {count}
19
+ </button>
20
+ )
21
+ }
@@ -0,0 +1,177 @@
1
+ import { on, type Handle } from '@remix-run/ui'
2
+
3
+ /**
4
+ * @name Controlled and Uncontrolled Values
5
+ * @description Compares controlled props with uncontrolled DOM values across rerenders and remounts.
6
+ */
7
+ export default function App(handle: Handle) {
8
+ let controlledText = 'hello'
9
+ let controlledChecked = true
10
+ let controlledChoice = 'alpha'
11
+ let uncontrolledTextSnapshot = 'type to update this'
12
+ let uncontrolledCheckedSnapshot = true
13
+ let renderCount = 0
14
+ let uncontrolledVersion = 0
15
+
16
+ let rerender = () => {
17
+ renderCount++
18
+ handle.update()
19
+ }
20
+
21
+ let resetControlled = () => {
22
+ controlledText = 'hello'
23
+ controlledChecked = true
24
+ controlledChoice = 'alpha'
25
+ rerender()
26
+ }
27
+
28
+ let remountUncontrolled = () => {
29
+ uncontrolledVersion++
30
+ uncontrolledTextSnapshot = 'type to update this'
31
+ uncontrolledCheckedSnapshot = true
32
+ rerender()
33
+ }
34
+
35
+ return () => (
36
+ <main
37
+ style={{
38
+ fontFamily: 'system-ui, sans-serif',
39
+ maxWidth: '860px',
40
+ margin: '24px auto',
41
+ padding: '0 16px',
42
+ lineHeight: 1.45,
43
+ }}
44
+ >
45
+ <h1>Controlled vs Uncontrolled Values</h1>
46
+
47
+ <p>
48
+ Render count: <strong>{renderCount}</strong>
49
+ </p>
50
+
51
+ <div style={{ display: 'flex', gap: '10px', marginBottom: '18px' }}>
52
+ <button mix={[on('click', rerender)]}>Force Re-render</button>
53
+ <button mix={[on('click', resetControlled)]}>Reset Controlled</button>
54
+ <button mix={[on('click', remountUncontrolled)]}>Remount Uncontrolled</button>
55
+ </div>
56
+
57
+ <section
58
+ style={{
59
+ border: '1px solid #d0d7de',
60
+ borderRadius: '8px',
61
+ padding: '12px',
62
+ marginBottom: '12px',
63
+ }}
64
+ >
65
+ <h2>Controlled</h2>
66
+ <p>
67
+ These values come from component state. The text input allows everything except digits,
68
+ and invalid input does not call update.
69
+ </p>
70
+
71
+ <label style={{ display: 'block', marginBottom: '8px' }}>
72
+ Text:
73
+ <input
74
+ style={{ marginLeft: '8px' }}
75
+ value={controlledText}
76
+ mix={[
77
+ on('input', (event) => {
78
+ let nextValue = event.currentTarget.value
79
+ if (/\d/.test(nextValue)) {
80
+ return
81
+ }
82
+ controlledText = nextValue
83
+ rerender()
84
+ }),
85
+ ]}
86
+ />
87
+ </label>
88
+
89
+ <label style={{ display: 'block', marginBottom: '8px' }}>
90
+ <input
91
+ type="checkbox"
92
+ checked={controlledChecked}
93
+ mix={[
94
+ on('change', (event) => {
95
+ controlledChecked = event.currentTarget.checked
96
+ rerender()
97
+ }),
98
+ ]}
99
+ />{' '}
100
+ Checked
101
+ </label>
102
+
103
+ <label style={{ display: 'block', marginBottom: '8px' }}>
104
+ Choice:
105
+ <select
106
+ style={{ marginLeft: '8px' }}
107
+ value={controlledChoice}
108
+ mix={[
109
+ on('change', (event) => {
110
+ controlledChoice = event.currentTarget.value
111
+ rerender()
112
+ }),
113
+ ]}
114
+ >
115
+ <option value="alpha">Alpha</option>
116
+ <option value="beta">Beta</option>
117
+ <option value="gamma">Gamma</option>
118
+ </select>
119
+ </label>
120
+
121
+ <div>
122
+ State snapshot: text=<code>{JSON.stringify(controlledText)}</code>, checked=
123
+ <code>{String(controlledChecked)}</code>, choice=
124
+ <code>{JSON.stringify(controlledChoice)}</code>
125
+ </div>
126
+ </section>
127
+
128
+ <section
129
+ key={`uncontrolled-${uncontrolledVersion}`}
130
+ style={{
131
+ border: '1px solid #d0d7de',
132
+ borderRadius: '8px',
133
+ padding: '12px',
134
+ }}
135
+ >
136
+ <h2>Uncontrolled</h2>
137
+ <p>
138
+ These initialize from <code>defaultValue/defaultChecked</code> once and then keep their
139
+ own DOM state.
140
+ </p>
141
+
142
+ <label style={{ display: 'block', marginBottom: '8px' }}>
143
+ Text:
144
+ <input
145
+ style={{ marginLeft: '8px' }}
146
+ defaultValue="type to update this"
147
+ mix={[
148
+ on('input', (event) => {
149
+ uncontrolledTextSnapshot = event.currentTarget.value
150
+ rerender()
151
+ }),
152
+ ]}
153
+ />
154
+ </label>
155
+
156
+ <label style={{ display: 'block', marginBottom: '8px' }}>
157
+ <input
158
+ type="checkbox"
159
+ defaultChecked={true}
160
+ mix={[
161
+ on('change', (event) => {
162
+ uncontrolledCheckedSnapshot = event.currentTarget.checked
163
+ rerender()
164
+ }),
165
+ ]}
166
+ />{' '}
167
+ Checked
168
+ </label>
169
+
170
+ <div>
171
+ Last DOM snapshot: text=<code>{JSON.stringify(uncontrolledTextSnapshot)}</code>, checked=
172
+ <code>{String(uncontrolledCheckedSnapshot)}</code>
173
+ </div>
174
+ </section>
175
+ </main>
176
+ )
177
+ }