Skip to content

Commit f35a8ab

Browse files
feat: DropdowmMenu
1 parent 1bb8477 commit f35a8ab

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+2525
-76
lines changed

README.md

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -62,36 +62,36 @@ Enter the component you want most in the components, leave the emojis and follow
6262

6363
**Developers can work on unclaimed components**.
6464

65-
| Component | Status |
66-
| --------------------------------------------------------------------------------------------- | ------ |
67-
| [Accordion](https://vue-primitives.netlify.app/?path=/story/components-accordion--single) ||
68-
| [AlertDialog](https://vue-primitives.netlify.app/?path=/story/components-alertdialog--styled) ||
69-
| [AspectRatio](https://vue-primitives.netlify.app/?path=/story/components-aspectratio--styled) ||
70-
| [Avatar](https://vue-primitives.netlify.app/?path=/story/components-avatar--styled) ||
71-
| [Checkbox](https://vue-primitives.netlify.app/?path=/story/components-checkbox--styled) ||
72-
| [Collapsible](https://vue-primitives.netlify.app/?path=/story/components-collapsible--styled) ||
73-
| [Context Menu](https://vue-primitives.netlify.app/?path=/story/components-contextmenu--styled) ||
74-
| [Dialog](https://vue-primitives.netlify.app/?path=/story/components-dialog--styled) ||
75-
| DropdownMenu | 🚧 |
76-
| Form | ✖️ |
77-
| [HoverCard](https://vue-primitives.netlify.app/?path=/story/components-hovercard--chromatic) ||
78-
| [Label](https://vue-primitives.netlify.app/?path=/story/components-label--styled) ||
79-
| [Menubar](https://vue-primitives.netlify.app/?path=/story/components-menubar--styled) ||
80-
| NavigationMenu | 🚧 |
81-
| [Popover](https://vue-primitives.netlify.app/?path=/story/components-popover--styled) ||
82-
| [Progress](https://vue-primitives.netlify.app/?path=/story/components-progress--styled) ||
83-
| [RadioGroup](https://vue-primitives.netlify.app/?path=/story/components-radiogroup--styled) ||
84-
| [ScrollArea](https://vue-primitives.netlify.app/?path=/story/components-scrollarea--basic) ||
85-
| Select | 🚧 |
86-
| [Separator](https://vue-primitives.netlify.app/?path=/story/components-separator--styled) ||
87-
| [Slider](https://vue-primitives.netlify.app/?path=/story/components-slider--styled) ||
88-
| [Switch](https://vue-primitives.netlify.app/?path=/story/components-switch--styled) ||
89-
| [Tabs](https://vue-primitives.netlify.app/?path=/story/components-tabs--styled) ||
90-
| [Toast](https://vue-primitives.netlify.app/?path=/story/components-toast--styled) ||
91-
| [ToggleGroup](https://vue-primitives.netlify.app/?path=/story/components-togglegroup--single) ||
92-
| [Toggle](https://vue-primitives.netlify.app/?path=/story/components-toggle--styled) ||
93-
| [Toolbar](https://vue-primitives.netlify.app/?path=/story/components-toolbar--styled) ||
94-
| [Tooltip](https://vue-primitives.netlify.app/?path=/story/components-tooltip--styled) ||
65+
| Component | Status |
66+
| ----------------------------------------------------------------------------------------------- | ------ |
67+
| [Accordion](https://vue-primitives.netlify.app/?path=/story/components-accordion--single) ||
68+
| [AlertDialog](https://vue-primitives.netlify.app/?path=/story/components-alertdialog--styled) ||
69+
| [AspectRatio](https://vue-primitives.netlify.app/?path=/story/components-aspectratio--styled) ||
70+
| [Avatar](https://vue-primitives.netlify.app/?path=/story/components-avatar--styled) ||
71+
| [Checkbox](https://vue-primitives.netlify.app/?path=/story/components-checkbox--styled) ||
72+
| [Collapsible](https://vue-primitives.netlify.app/?path=/story/components-collapsible--styled) ||
73+
| [Context Menu](https://vue-primitives.netlify.app/?path=/story/components-contextmenu--styled) ||
74+
| [Dialog](https://vue-primitives.netlify.app/?path=/story/components-dialog--styled) ||
75+
| [DropdownMenu](https://vue-primitives.netlify.app/?path=/story/components-dropdownmenu--styled) | |
76+
| Form | ✖️ |
77+
| [HoverCard](https://vue-primitives.netlify.app/?path=/story/components-hovercard--chromatic) ||
78+
| [Label](https://vue-primitives.netlify.app/?path=/story/components-label--styled) ||
79+
| [Menubar](https://vue-primitives.netlify.app/?path=/story/components-menubar--styled) ||
80+
| NavigationMenu | 🚧 |
81+
| [Popover](https://vue-primitives.netlify.app/?path=/story/components-popover--styled) ||
82+
| [Progress](https://vue-primitives.netlify.app/?path=/story/components-progress--styled) ||
83+
| [RadioGroup](https://vue-primitives.netlify.app/?path=/story/components-radiogroup--styled) ||
84+
| [ScrollArea](https://vue-primitives.netlify.app/?path=/story/components-scrollarea--basic) ||
85+
| Select | 🚧 |
86+
| [Separator](https://vue-primitives.netlify.app/?path=/story/components-separator--styled) ||
87+
| [Slider](https://vue-primitives.netlify.app/?path=/story/components-slider--styled) ||
88+
| [Switch](https://vue-primitives.netlify.app/?path=/story/components-switch--styled) ||
89+
| [Tabs](https://vue-primitives.netlify.app/?path=/story/components-tabs--styled) ||
90+
| [Toast](https://vue-primitives.netlify.app/?path=/story/components-toast--styled) ||
91+
| [ToggleGroup](https://vue-primitives.netlify.app/?path=/story/components-togglegroup--single) ||
92+
| [Toggle](https://vue-primitives.netlify.app/?path=/story/components-toggle--styled) ||
93+
| [Toolbar](https://vue-primitives.netlify.app/?path=/story/components-toolbar--styled) ||
94+
| [Tooltip](https://vue-primitives.netlify.app/?path=/story/components-tooltip--styled) ||
9595

9696
## Utilites
9797

packages/vue-primitives/src/dialog/DialogContentNonModal.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ function onCloseAutoFocus(event: Event) {
2727
emit('closeAutoFocus', event)
2828
2929
if (!event.defaultPrevented) {
30-
if (!hasInteractedOutsideRef)
30+
if (!hasInteractedOutsideRef) {
3131
context.triggerRef.current?.focus()
32+
}
3233
// Always prevent auto focus because we either focus manually or want user agent focus
3334
event.preventDefault()
3435
}

packages/vue-primitives/src/dismissable-layer/DismissableLayer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export interface UseDismissableLayerProps {
6161
export interface UseDismissableLayerEmits {
6262
onPointerdownOutside?: (event: PointerdownOutsideEvent) => void
6363
onFocusOutside?: (event: FocusOutsideEvent) => void
64-
onInteractOutside?: (event: PointerdownOutsideEvent | FocusOutsideEvent) => void
64+
onInteractOutside?: (event: DismissableLayerEmits['interactOutside'][0]) => void
6565
onEscapeKeydown?: (event: KeyboardEvent) => void
6666
onDismiss?: () => void
6767
// onFocusCapture?: (event: FocusEvent) => void
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { DismissableLayerEmits } from '../dismissable-layer/index.ts'
2+
import type { MenuContentImplEmits } from '../menu/MenuContentImpl.ts'
3+
4+
// eslint-disable-next-line ts/consistent-type-definitions
5+
export type DropdownMenuContentEmits = {
6+
closeAutoFocus: MenuContentImplEmits['closeAutoFocus']
7+
interactOutside: DismissableLayerEmits['interactOutside']
8+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<script setup lang="ts">
2+
import type { DropdownMenuContentEmits } from './DropdownMenuContent.ts'
3+
import { MenuContent } from '../menu/index.ts'
4+
import { composeEventHandlers } from '../utils/vue.ts'
5+
import { useDropdownMenuContext } from './DropdownMenuRoot.ts'
6+
7+
defineOptions({
8+
name: 'DropdownMenuContent',
9+
})
10+
11+
const emit = defineEmits<DropdownMenuContentEmits>()
12+
13+
const context = useDropdownMenuContext('DropdownMenuContent')
14+
15+
let hasInteractedOutsideRef = false
16+
17+
const onCloseAutoFocus = composeEventHandlers((event) => {
18+
emit('closeAutoFocus', event)
19+
}, (event) => {
20+
if (!hasInteractedOutsideRef) {
21+
context.triggerRef.current?.focus()
22+
}
23+
hasInteractedOutsideRef = false
24+
// Always prevent auto focus because we either focus manually or want user agent focus
25+
event.preventDefault()
26+
})
27+
28+
const onInteractOutside = composeEventHandlers<DropdownMenuContentEmits['interactOutside'][0]>((event) => {
29+
emit('interactOutside', event)
30+
}, (event) => {
31+
const originalEvent = event.detail.originalEvent as PointerEvent
32+
const ctrlLeftClick = originalEvent.button === 0 && originalEvent.ctrlKey === true
33+
const isRightClick = originalEvent.button === 2 || ctrlLeftClick
34+
if (!context.modal || isRightClick)
35+
hasInteractedOutsideRef = true
36+
})
37+
38+
const style = {
39+
'--radix-dropdown-menu-content-transform-origin': 'var(--radix-popper-transform-origin)',
40+
'--radix-dropdown-menu-content-available-width': 'var(--radix-popper-available-width)',
41+
'--radix-dropdown-menu-content-available-height': 'var(--radix-popper-available-height)',
42+
'--radix-dropdown-menu-trigger-width': 'var(--radix-popper-anchor-width)',
43+
'--radix-dropdown-menu-trigger-height': 'var(--radix-popper-anchor-height)',
44+
}
45+
</script>
46+
47+
<template>
48+
<MenuContent
49+
:id="context.contentId"
50+
:aria-labelledby="context.triggerId"
51+
:style="style"
52+
@close-auto-focus="onCloseAutoFocus"
53+
@interact-outside="onInteractOutside"
54+
>
55+
<slot />
56+
</MenuContent>
57+
</template>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { Direction } from '../direction/index.ts'
2+
import { createContext, type MutableRefObject } from '../hooks/index.ts'
3+
4+
export interface DropdownMenuRootProps {
5+
dir?: Direction
6+
open?: boolean
7+
defaultOpen?: boolean
8+
modal?: boolean
9+
}
10+
11+
// eslint-disable-next-line ts/consistent-type-definitions
12+
export type DropdownMenuRootEmits = {
13+
'update:open': [open: boolean]
14+
}
15+
16+
export interface DropdownMenuContextValue {
17+
triggerId: string
18+
triggerRef: MutableRefObject<HTMLButtonElement | undefined>
19+
contentId: string
20+
open: () => boolean
21+
onOpenChange: (open: boolean) => void
22+
onOpenToggle: () => void
23+
modal: boolean
24+
}
25+
26+
export const [provideDropdownMenuContext, useDropdownMenuContext] = createContext<DropdownMenuContextValue>('DropdownMenu')
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<script setup lang="ts">
2+
import { shallowRef } from 'vue'
3+
import { useDirection } from '../direction/index.ts'
4+
import { useControllableState, useId, useRef } from '../hooks/index.ts'
5+
import { provideMenuContext, provideMenuRootContext, useIsUsingKeyboard } from '../menu/index.ts'
6+
import { type Measurable, providePopperContext } from '../popper/index.ts'
7+
import { type DropdownMenuRootEmits, type DropdownMenuRootProps, provideDropdownMenuContext } from './DropdownMenuRoot.ts'
8+
9+
defineOptions({
10+
name: 'DropdownMenuRoot',
11+
})
12+
13+
const props = withDefaults(defineProps<DropdownMenuRootProps>(), {
14+
open: undefined,
15+
defaultOpen: false,
16+
modal: true,
17+
})
18+
const emit = defineEmits<DropdownMenuRootEmits>()
19+
20+
const triggerRef = useRef<HTMLButtonElement>()
21+
22+
const open = useControllableState(props, v => emit('update:open', v), 'open', props.defaultOpen)
23+
24+
provideDropdownMenuContext({
25+
triggerId: useId(),
26+
triggerRef,
27+
contentId: useId(),
28+
open() {
29+
return open.value
30+
},
31+
onOpenChange(value) {
32+
open.value = value
33+
},
34+
onOpenToggle() {
35+
open.value = !open.value
36+
},
37+
modal: props.modal,
38+
})
39+
40+
// COMP::MenuRoot
41+
42+
const isUsingKeyboardRef = useIsUsingKeyboard()
43+
const direction = useDirection(() => props.dir)
44+
45+
provideMenuContext({
46+
open() {
47+
return open.value
48+
},
49+
onOpenChange(value) {
50+
open.value = value
51+
},
52+
})
53+
54+
provideMenuRootContext({
55+
onClose() {
56+
open.value = false
57+
},
58+
isUsingKeyboardRef,
59+
dir: direction,
60+
modal: props.modal,
61+
})
62+
63+
// COMP::PopperRoot
64+
65+
const anchor = shallowRef<Measurable>()
66+
67+
providePopperContext({
68+
content: shallowRef(),
69+
anchor,
70+
onAnchorChange(newAnchor) {
71+
anchor.value = newAnchor
72+
},
73+
})
74+
</script>
75+
76+
<template>
77+
<slot />
78+
</template>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export interface DropdownMenuSubProps {
2+
open?: boolean
3+
defaultOpen?: boolean
4+
}
5+
6+
// eslint-disable-next-line ts/consistent-type-definitions
7+
export type DropdownMenuSubEmits = {
8+
'update:open': [open: boolean]
9+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<script setup lang="ts">
2+
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from './DropdownMenuSub.ts'
3+
import { onWatcherCleanup, shallowRef, watchEffect } from 'vue'
4+
import { useControllableState, useId, useRef } from '../hooks/index.ts'
5+
import { provideMenuContext, provideMenuSubContext, useMenuContext } from '../menu/index.ts'
6+
import { type Measurable, providePopperContext } from '../popper/index.ts'
7+
8+
defineOptions({
9+
name: 'DropdownMenuSub',
10+
})
11+
12+
const props = withDefaults(defineProps<DropdownMenuSubProps>(), {
13+
open: undefined,
14+
defaultOpen: false,
15+
})
16+
const emit = defineEmits<DropdownMenuSubEmits>()
17+
18+
const open = useControllableState(props, v => emit('update:open', v), 'open', props.defaultOpen)
19+
20+
// COMP::MenuSub
21+
22+
const parentMenuContext = useMenuContext('DropdownMenuSub')
23+
const trigger = useRef<HTMLDivElement>()
24+
25+
// Prevent the parent menu from reopening with open submenus.
26+
watchEffect(() => {
27+
if (parentMenuContext.open() === false)
28+
open.value = false
29+
30+
onWatcherCleanup(() => {
31+
open.value = false
32+
})
33+
})
34+
35+
provideMenuContext({
36+
open() {
37+
return open.value
38+
},
39+
onOpenChange(v) {
40+
open.value = v
41+
},
42+
})
43+
44+
provideMenuSubContext({
45+
contentId: useId(),
46+
triggerId: useId(),
47+
trigger,
48+
onTriggerChange(el) {
49+
trigger.current = el
50+
},
51+
})
52+
53+
// COMP::PopperRoot
54+
55+
const anchor = shallowRef<Measurable>()
56+
57+
providePopperContext({
58+
content: shallowRef(),
59+
anchor,
60+
onAnchorChange(newAnchor) {
61+
anchor.value = newAnchor
62+
},
63+
})
64+
</script>
65+
66+
<template>
67+
<slot />
68+
</template>
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: 'DropdownMenuSubContent',
6+
})
7+
8+
const style = {
9+
'--radix-dropdown-menu-content-transform-origin': 'var(--radix-popper-transform-origin)',
10+
'--radix-dropdown-menu-content-available-width': 'var(--radix-popper-available-width)',
11+
'--radix-dropdown-menu-content-available-height': 'var(--radix-popper-available-height)',
12+
'--radix-dropdown-menu-trigger-width': 'var(--radix-popper-anchor-width)',
13+
'--radix-dropdown-menu-trigger-height': 'var(--radix-popper-anchor-height)',
14+
}
15+
</script>
16+
17+
<template>
18+
<MenuSubContent :style="style">
19+
<slot />
20+
</MenuSubContent>
21+
</template>

0 commit comments

Comments
 (0)