Skip to content

Commit 1bb8477

Browse files
feat: Menubar
1 parent 35f5835 commit 1bb8477

24 files changed

+1905
-9
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Enter the component you want most in the components, leave the emojis and follow
7676
| Form | ✖️ |
7777
| [HoverCard](https://vue-primitives.netlify.app/?path=/story/components-hovercard--chromatic) ||
7878
| [Label](https://vue-primitives.netlify.app/?path=/story/components-label--styled) ||
79-
| Menubar | 🚧 |
79+
| [Menubar](https://vue-primitives.netlify.app/?path=/story/components-menubar--styled) | |
8080
| NavigationMenu | 🚧 |
8181
| [Popover](https://vue-primitives.netlify.app/?path=/story/components-popover--styled) ||
8282
| [Progress](https://vue-primitives.netlify.app/?path=/story/components-progress--styled) ||

packages/vue-primitives/src/context-menu/ContextMenuContent.vue

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,20 @@ const popperContext = usePopperContext('MenuContent')
4242
const isPresent = usePresence(popperContext.content, () => props.forceMount || menuContext.open())
4343
4444
const Comp = rootContext.modal ? MenuRootContentModal : MenuRootContentNonModal
45+
46+
const style = {
47+
'--radix-context-menu-content-transform-origin': 'var(--radix-popper-transform-origin)',
48+
'--radix-context-menu-content-available-width': 'var(--radix-popper-available-width)',
49+
'--radix-context-menu-content-available-height': 'var(--radix-popper-available-height)',
50+
'--radix-context-menu-trigger-width': 'var(--radix-popper-anchor-width)',
51+
'--radix-context-menu-trigger-height': 'var(--radix-popper-anchor-height)',
52+
}
4553
</script>
4654

4755
<template>
4856
<Comp
4957
v-if="isPresent"
50-
:style="{
51-
'--radix-context-menu-content-transform-origin': 'var(--radix-popper-transform-origin)',
52-
'--radix-context-menu-content-available-width': 'var(--radix-popper-available-width)',
53-
'--radix-context-menu-content-available-height': 'var(--radix-popper-available-height)',
54-
'--radix-context-menu-trigger-width': 'var(--radix-popper-anchor-width)',
55-
'--radix-context-menu-trigger-height': 'var(--radix-popper-anchor-height)',
56-
}"
58+
:style="style"
5759
side="right"
5860
:side-offset="2"
5961
align="start"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<script setup lang="ts">
2+
import { MenuSubContent } from '../menu/index.ts'
3+
4+
defineOptions({
5+
name: 'ContextMenuSubContent',
6+
})
7+
8+
const style = {
9+
'--radix-context-menu-content-transform-origin': 'var(--radix-popper-transform-origin)',
10+
'--radix-context-menu-content-available-width': 'var(--radix-popper-available-width)',
11+
'--radix-context-menu-content-available-height': 'var(--radix-popper-available-height)',
12+
'--radix-context-menu-trigger-width': 'var(--radix-popper-anchor-width)',
13+
'--radix-context-menu-trigger-height': 'var(--radix-popper-anchor-height)',
14+
}
15+
</script>
16+
17+
<template>
18+
<MenuSubContent :style="style">
19+
<slot />
20+
</MenuSubContent>
21+
</template>

packages/vue-primitives/src/context-menu/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ export { MenuItemIndicator as ContextMenuItemIndicator } from '../menu/index.ts'
99
export { MenuSeparator as ContextMenuSeparator } from '../menu/index.ts'
1010
export { MenuArrow as ContextMenuArrow } from '../menu/index.ts'
1111
export { MenuSubTrigger as ContextMenuSubTrigger } from '../menu/index.ts'
12-
export { MenuSubContent as ContextMenuSubContent } from '../menu/index.ts'
1312
export {
1413
type ContextMenuContentEmits,
1514
type ContextMenuContentProps,
@@ -28,4 +27,5 @@ export {
2827
type ContextMenuSubProps,
2928
} from './ContextMenuSub.ts'
3029
export { default as ContextMenuSub } from './ContextMenuSub.vue'
30+
export { default as ContextMenuSubContent } from './ContextMenuSubContent.vue'
3131
export { default as ContextMenuTrigger } from './ContextMenuTrigger.vue'
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { DismissableLayerEmits } from '../dismissable-layer/DismissableLayer.ts'
2+
import type { PopperContentProps } from '../popper/index.ts'
3+
4+
export interface MenubarContentProps {
5+
align?: PopperContentProps['align']
6+
}
7+
8+
// eslint-disable-next-line ts/consistent-type-definitions
9+
export type MenubarContentEmits = {
10+
keydown: [event: KeyboardEvent]
11+
closeAutoFocus: [event: Event]
12+
focusOutside: DismissableLayerEmits['focusOutside']
13+
interactOutside: DismissableLayerEmits['interactOutside']
14+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<script setup lang="ts">
2+
import type { MenubarContentEmits, MenubarContentProps } from './MenubarContent.ts'
3+
import { MenuContent } from '../menu/index.ts'
4+
import { wrapArray } from '../utils/array.ts'
5+
import { composeEventHandlers } from '../utils/vue.ts'
6+
import { useMenubarMenuContext } from './MenubarMenu.ts'
7+
import { useCollection, useMenubarContext } from './MenubarRoot.ts'
8+
9+
defineOptions({
10+
name: 'MenubarContent',
11+
})
12+
13+
withDefaults(defineProps<MenubarContentProps>(), {
14+
align: 'start',
15+
})
16+
const emit = defineEmits<MenubarContentEmits>()
17+
18+
const context = useMenubarContext('MenubarContent')
19+
const menuContext = useMenubarMenuContext('MenubarContent')
20+
const getItems = useCollection()
21+
let hasInteractedOutsideRef = false
22+
23+
const onCloseAutoFocus = composeEventHandlers((event) => {
24+
emit('closeAutoFocus', event)
25+
}, (event) => {
26+
const menubarOpen = Boolean(context.value.value)
27+
if (!menubarOpen && !hasInteractedOutsideRef) {
28+
menuContext.triggerRef.current?.focus()
29+
}
30+
31+
hasInteractedOutsideRef = false
32+
// Always prevent auto focus because we either focus manually or want user agent focus
33+
event.preventDefault()
34+
})
35+
36+
const onFocusOutside = composeEventHandlers<MenubarContentEmits['focusOutside'][0]>((event) => {
37+
emit('focusOutside', event)
38+
}, (event) => {
39+
const target = event.target as HTMLElement
40+
const isMenubarTrigger = getItems().some(item => item?.contains(target))
41+
if (isMenubarTrigger)
42+
event.preventDefault()
43+
})
44+
45+
const onInteractOutside = composeEventHandlers<MenubarContentEmits['interactOutside'][0]>((event) => {
46+
emit('interactOutside', event)
47+
}, () => {
48+
hasInteractedOutsideRef = true
49+
})
50+
51+
function onEntryFocus(event: Event) {
52+
if (!menuContext.wasKeyboardTriggerOpenRef.current)
53+
event.preventDefault()
54+
}
55+
56+
const onKeydown = composeEventHandlers<KeyboardEvent>(
57+
(event) => {
58+
emit('keydown', event)
59+
},
60+
(event) => {
61+
if (['ArrowRight', 'ArrowLeft'].includes(event.key)) {
62+
const target = event.target as HTMLElement
63+
const targetIsSubTrigger = target.hasAttribute('data-radix-menubar-subtrigger')
64+
const isKeyDownInsideSubMenu
65+
= target.closest('[data-radix-menubar-content]') !== event.currentTarget
66+
67+
const prevMenuKey = context.dir.value === 'rtl' ? 'ArrowRight' : 'ArrowLeft'
68+
const isPrevKey = prevMenuKey === event.key
69+
const isNextKey = !isPrevKey
70+
71+
// Prevent navigation when we're opening a submenu
72+
if (isNextKey && targetIsSubTrigger)
73+
return
74+
// or we're inside a submenu and are moving backwards to close it
75+
if (isKeyDownInsideSubMenu && isPrevKey)
76+
return
77+
78+
let candidateValues: string[] = []
79+
80+
for (const item of getItems()) {
81+
if (item.$$rcid.$menubar.disabled)
82+
continue
83+
candidateValues.push(item.$$rcid.$menubar.value)
84+
}
85+
86+
if (isPrevKey)
87+
candidateValues.reverse()
88+
89+
const currentIndex = candidateValues.indexOf(menuContext.value)
90+
91+
candidateValues = context.loop()
92+
? wrapArray(candidateValues, currentIndex + 1)
93+
: candidateValues.slice(currentIndex + 1)
94+
95+
const [nextValue] = candidateValues
96+
if (nextValue)
97+
context.onMenuOpen(nextValue)
98+
}
99+
},
100+
{ checkForDefaultPrevented: false },
101+
)
102+
103+
const style = {
104+
// re-namespace exposed content custom properties
105+
'--radix-menubar-content-transform-origin': 'var(--radix-popper-transform-origin)',
106+
'--radix-menubar-content-available-width': 'var(--radix-popper-available-width)',
107+
'--radix-menubar-content-available-height': 'var(--radix-popper-available-height)',
108+
'--radix-menubar-trigger-width': 'var(--radix-popper-anchor-width)',
109+
'--radix-menubar-trigger-height': 'var(--radix-popper-anchor-height)',
110+
}
111+
</script>
112+
113+
<template>
114+
<MenuContent
115+
:id="menuContext.contentId"
116+
:aria-labelledby="menuContext.triggerId"
117+
data-radix-menubar-content=""
118+
:align="align"
119+
:style="style"
120+
@close-auto-focus="onCloseAutoFocus"
121+
@focus-outside="onFocusOutside"
122+
@interact-outside="onInteractOutside"
123+
@entry-focus="onEntryFocus"
124+
@keydown="onKeydown"
125+
>
126+
<slot />
127+
</MenuContent>
128+
</template>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { createContext, type MutableRefObject } from '../hooks/index.ts'
2+
3+
export interface MenubarMenuProps {
4+
value?: string
5+
}
6+
7+
export interface MenubarMenuContextValue {
8+
value: string
9+
triggerId: string
10+
triggerRef: MutableRefObject<HTMLButtonElement | undefined>
11+
contentId: string
12+
wasKeyboardTriggerOpenRef: MutableRefObject<boolean>
13+
}
14+
15+
export const [provideMenubarMenuContext, useMenubarMenuContext] = createContext<MenubarMenuContextValue>('MenubarMenu')
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<script setup lang="ts">
2+
import { computed, shallowRef, watch } from 'vue'
3+
import { useId, useRef } from '../hooks/index.ts'
4+
import { provideMenuContext, provideMenuRootContext, useIsUsingKeyboard } from '../menu/index.ts'
5+
import { type Measurable, providePopperContext } from '../popper/index.ts'
6+
import { type MenubarMenuProps, provideMenubarMenuContext } from './MenubarMenu.ts'
7+
import { useMenubarContext } from './MenubarRoot.ts'
8+
9+
defineOptions({
10+
name: 'MenubarMenu',
11+
})
12+
const props = defineProps<MenubarMenuProps>()
13+
14+
// We need to provide an initial deterministic value as `useId` will return
15+
// empty string on the first render and we don't want to match our internal "closed" value.
16+
const value = props.value || useId()
17+
const context = useMenubarContext('MenubarMenu')
18+
const triggerRef = useRef<HTMLButtonElement>()
19+
const wasKeyboardTriggerOpenRef = useRef(false)
20+
const open = computed(() => context.value.value === value)
21+
22+
watch(open, () => {
23+
if (!open.value)
24+
wasKeyboardTriggerOpenRef.current = false
25+
})
26+
27+
provideMenubarMenuContext({
28+
value,
29+
triggerId: useId(),
30+
triggerRef,
31+
contentId: useId(),
32+
wasKeyboardTriggerOpenRef,
33+
})
34+
35+
function onOpenChange(open: boolean) {
36+
// Menu only calls `onOpenChange` when dismissing so we
37+
// want to close our MenuBar based on the same events.
38+
if (!open)
39+
context.onMenuClose()
40+
}
41+
42+
// COMP::MenuRoot
43+
44+
const isUsingKeyboardRef = useIsUsingKeyboard()
45+
46+
provideMenuContext({
47+
open() {
48+
return open.value
49+
},
50+
onOpenChange,
51+
})
52+
53+
provideMenuRootContext({
54+
onClose() {
55+
onOpenChange(false)
56+
},
57+
isUsingKeyboardRef,
58+
dir: context.dir,
59+
modal: false,
60+
})
61+
62+
// COMP::PopperRoot
63+
64+
const anchor = shallowRef<Measurable>()
65+
66+
providePopperContext({
67+
content: shallowRef(),
68+
anchor,
69+
onAnchorChange(newAnchor) {
70+
anchor.value = newAnchor
71+
},
72+
})
73+
</script>
74+
75+
<template>
76+
<slot />
77+
</template>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { Ref } from 'vue'
2+
import type { Direction } from '../direction/index.ts'
3+
import type { RovingFocusGroupRootProps } from '../roving-focus/RovingFocusGroupRoot.ts'
4+
import { createCollection } from '../collection/index.ts'
5+
import { createContext } from '../hooks/index.ts'
6+
7+
export interface MenubarRootProps {
8+
value?: string
9+
defaultValue?: string
10+
loop?: RovingFocusGroupRootProps['loop']
11+
dir?: RovingFocusGroupRootProps['dir']
12+
}
13+
14+
// eslint-disable-next-line ts/consistent-type-definitions
15+
export type MenubarRootEmits = {
16+
'update:value': [value: string]
17+
}
18+
19+
export interface MenubarContextValue {
20+
value: Ref<string | undefined>
21+
dir: Ref<Direction>
22+
loop: () => boolean
23+
onMenuOpen: (value: string) => void
24+
onMenuClose: () => void
25+
onMenuToggle: (value: string) => void
26+
}
27+
28+
export const [provideMenubarContext, useMenubarContext] = createContext<MenubarContextValue>('Menubar')
29+
30+
export interface ItemData {
31+
$menubar: {
32+
value: string
33+
disabled: boolean
34+
}
35+
}
36+
37+
export const [Collection, useCollection] = createCollection<HTMLButtonElement, ItemData>('Menubar')

0 commit comments

Comments
 (0)