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