feat(DropdownMenu): new component (#37)

This commit is contained in:
Benjamin Canac
2024-03-29 13:42:02 +01:00
committed by GitHub
parent 2fbf47e1fc
commit 44033508a7
25 changed files with 735 additions and 215 deletions

View File

@@ -19,7 +19,7 @@ export interface AccordionItem {
disabled?: boolean
}
export interface AccordionProps<T extends AccordionItem> extends Omit<AccordionRootProps, 'asChild' | 'dir' | 'orientation'> {
export interface AccordionProps<T> extends Omit<AccordionRootProps, 'asChild' | 'dir' | 'orientation'> {
items?: T[]
class?: any
ui?: Partial<typeof accordion.slots>
@@ -29,13 +29,12 @@ export interface AccordionEmits extends AccordionRootEmits {}
type SlotProps<T> = (props: { item: T, index: number }) => any
export type AccordionSlots<T extends AccordionItem> = {
export type AccordionSlots<T> = {
leading: SlotProps<T>
default: SlotProps<T>
trailing: SlotProps<T>
content: SlotProps<T>
} & {
[key in T['slot'] as string]?: SlotProps<T>
[key: string]: SlotProps<T>
}
</script>

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import { tv } from 'tailwind-variants'
import type { DropdownMenuRootProps, DropdownMenuRootEmits, DropdownMenuContentProps, DropdownMenuArrowProps } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/dropdownMenu'
import type { LinkProps } from '#ui/components/Link.vue'
import type { AvatarProps } from '#ui/components/Avatar.vue'
import type { IconProps } from '#ui/components/Icon.vue'
import type { KbdProps } from '#ui/components/Kbd.vue'
import type { DropdownMenuContentSlots } from '#ui/components/DropdownMenuContent.vue'
const appConfig = _appConfig as AppConfig & { ui: { dropdownMenu: Partial<typeof theme> } }
const dropdownMenu = tv({ extend: tv(theme), ...(appConfig.ui?.dropdownMenu || {}) })
export type DropdownMenuUI = typeof dropdownMenu
export interface DropdownMenuItem extends Omit<LinkProps, 'type'> {
label?: string
icon?: IconProps['name']
avatar?: AvatarProps
disabled?: boolean
content?: Omit<DropdownMenuContentProps, 'as' | 'asChild' | 'forceMount'>
shortcuts?: string[] | KbdProps[]
type?: 'label' | 'item'
slot?: string
open?: boolean
defaultOpen?: boolean
select? (e: Event): void
children?: DropdownMenuItem[] | DropdownMenuItem[][]
}
export interface DropdownMenuProps<T> extends Omit<DropdownMenuRootProps, 'dir'> {
items?: T[] | T[][]
disabled?: boolean
content?: Omit<DropdownMenuContentProps, 'as' | 'asChild' | 'forceMount'>
arrow?: boolean | Omit<DropdownMenuArrowProps, 'as' | 'asChild'>
portal?: boolean
class?: any
ui?: Partial<typeof dropdownMenu.slots>
}
export interface DropdownMenuEmits extends DropdownMenuRootEmits {}
export interface DropdownMenuSlots<T> extends DropdownMenuContentSlots<T> {
default (): any
}
</script>
<script setup lang="ts" generic="T extends DropdownMenuItem">
import { computed, toRef } from 'vue'
import { defu } from 'defu'
import { DropdownMenuRoot, DropdownMenuTrigger, DropdownMenuArrow, useForwardPropsEmits } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
import { UDropdownMenuContent } from '#components'
import { omit } from '#ui/utils'
const props = withDefaults(defineProps<DropdownMenuProps<T>>(), { portal: true })
const emits = defineEmits<DropdownMenuEmits>()
const slots = defineSlots<DropdownMenuSlots<T>>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'defaultOpen', 'open', 'modal'), emits)
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8 }) as DropdownMenuContentProps)
const arrowProps = toRef(() => props.arrow as DropdownMenuArrowProps)
const proxySlots = omit(slots, ['default'])
const ui = computed(() => tv({ extend: dropdownMenu, slots: props.ui })())
</script>
<template>
<DropdownMenuRoot v-bind="rootProps">
<DropdownMenuTrigger v-if="$slots.default" as-child :disabled="disabled">
<slot />
</DropdownMenuTrigger>
<UDropdownMenuContent :class="ui.content({ class: props.class })" :ui="ui" v-bind="contentProps" :items="items" :portal="portal">
<template v-for="(_, name) in proxySlots" #[name]="slotData: any">
<slot :name="name" v-bind="slotData" />
</template>
<DropdownMenuArrow v-if="!!arrow" v-bind="arrowProps" :class="ui.arrow()" />
</UDropdownMenuContent>
</DropdownMenuRoot>
</template>
<style>
@keyframes dropdown-menu-open {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes dropdown-menu-closed {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
</style>

View File

@@ -0,0 +1,120 @@
<script lang="ts">
import type { DropdownMenuContentProps as RadixDropdownMenuContentProps, DropdownMenuContentEmits as RadixDropdownMenuContentEmits } from 'radix-vue'
import type { DropdownMenuItem } from '#ui/components/DropdownMenu.vue'
export interface DropdownMenuContentProps<T> extends Omit<RadixDropdownMenuContentProps, 'as' | 'asChild' | 'forceMount'> {
items?: T[] | T[][]
portal?: boolean
sub?: boolean
class?: any
ui: any
}
export interface DropdownMenuContentEmits extends RadixDropdownMenuContentEmits {}
type SlotProps<T> = (props: { item: T, active?: boolean }) => any
export interface DropdownMenuContentSlots<T> {
default(): any
leading: SlotProps<T>
label: SlotProps<T>
trailing: SlotProps<T>
}
</script>
<script setup lang="ts" generic="T extends DropdownMenuItem">
import { computed } from 'vue'
import { DropdownMenu } from 'radix-vue/namespaced'
import { useForwardPropsEmits } from 'radix-vue'
import { reactiveOmit, createReusableTemplate } from '@vueuse/core'
import { ULink } from '#components'
import { omit } from '#ui/utils'
import { useAppConfig } from '#imports'
const props = defineProps<DropdownMenuContentProps<T>>()
const emits = defineEmits<DropdownMenuContentEmits>()
const slots = defineSlots<DropdownMenuContentSlots<T>>()
const appConfig = useAppConfig()
const contentProps = useForwardPropsEmits(reactiveOmit(props, 'sub', 'items', 'portal', 'class', 'ui'), emits)
const proxySlots = omit(slots, ['default'])
const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate()
const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0]) ? props.items : [props.items]) as T[][] : [])
</script>
<template>
<DefineItemTemplate v-slot="{ item, active }">
<slot name="leading" :item="item" :active="active">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ active })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ active })" />
</slot>
<span v-if="item.label || $slots.default" :class="ui.itemLabel()">
<slot name="label" :item="item" :active="active">
{{ item.label }}
</slot>
</span>
<span v-if="$slots.trailing || item.children?.length || item.shortcuts?.length" :class="ui.itemTrailing()">
<slot name="trailing" :item="item" :active="active">
<UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.itemTrailingIcon()" />
<span v-else-if="item.shortcuts?.length" :class="ui.itemTrailingShortcuts()">
<UKbd v-for="(shortcut, shortcutIndex) in item.shortcuts" :key="shortcutIndex" size="sm" v-bind="typeof shortcut === 'string' ? { value: shortcut } : shortcut" />
</span>
</slot>
</span>
</DefineItemTemplate>
<DropdownMenu.Portal :disabled="!portal">
<component :is="sub ? DropdownMenu.SubContent : DropdownMenu.Content" :class="props.class" v-bind="contentProps">
<DropdownMenu.Group v-for="(group, index) in groups" :key="`group-${index}`" :class="ui.group()">
<template v-for="(item, itemIndex) in group" :key="`group-${index}-${itemIndex}`">
<DropdownMenu.Label v-if="item.type === 'label'" :class="ui.label()">
<ReuseItemTemplate :item="item" />
</DropdownMenu.Label>
<DropdownMenu.Sub v-else-if="item?.children?.length">
<DropdownMenu.SubTrigger
as="button"
type="button"
:disabled="item.disabled"
:open="item.open"
:default-open="item.defaultOpen"
:text-value="item.label"
:class="ui.item()"
>
<ReuseItemTemplate :item="item" />
</DropdownMenu.SubTrigger>
<UDropdownMenuContent
sub
:class="props.class"
:ui="ui"
:portal="portal"
:items="item.children"
side="right"
align="start"
:align-offset="-4"
:side-offset="3"
v-bind="item.content"
>
<template v-for="(_, name) in proxySlots" #[name]="slotData: any">
<slot :name="name" v-bind="slotData" />
</template>
</UDropdownMenuContent>
</DropdownMenu.Sub>
<DropdownMenu.Item v-else as-child :disabled="item.disabled" :text-value="item.label" @select="item.select">
<ULink v-slot="{ active, ...slotProps }" v-bind="omit((item as DropdownMenuItem), ['label', 'icon', 'avatar', 'shortcuts', 'slot', 'open', 'defaultOpen', 'select', 'children', 'type'])" custom>
<ULinkBase v-bind="slotProps" :class="ui.item({ active })">
<ReuseItemTemplate :item="item" :active="active" />
</ULinkBase>
</ULink>
</DropdownMenu.Item>
</template>
</DropdownMenu.Group>
<slot />
</component>
</DropdownMenu.Portal>
</template>

View File

@@ -1,20 +1,27 @@
<script setup lang="ts">
import { Primitive } from 'radix-vue'
const props = defineProps<{
as: string
type: string
<script lang="ts">
export interface LinkBaseProps {
as?: string
type?: string
disabled?: boolean
click?: (e: MouseEvent) => void
href?: string
navigate: (e: MouseEvent) => void
navigate?: (e: MouseEvent) => void
route?: object
rel?: string
target?: string
isExternal?: boolean
isActive: boolean
isExactActive: boolean
}>()
isActive?: boolean
isExactActive?: boolean
}
</script>
<script setup lang="ts">
import { Primitive } from 'radix-vue'
const props = withDefaults(defineProps<LinkBaseProps>(), {
as: 'button',
type: 'button'
})
function onClick (e: MouseEvent) {
if (props.disabled) {
@@ -27,7 +34,7 @@ function onClick (e: MouseEvent) {
props.click(e)
}
if (props.href && !props.isExternal) {
if (props.href && props.navigate && !props.isExternal) {
props.navigate(e)
}
}
@@ -40,10 +47,12 @@ function onClick (e: MouseEvent) {
href: disabled ? undefined : href,
'aria-disabled': disabled ? 'true' : undefined,
role: disabled ? 'link' : undefined
} : {
} : as === 'button' ? {
as,
type,
disabled
} : {
as
}"
:rel="rel"
:target="target"

View File

@@ -18,9 +18,10 @@ export interface NavigationMenuLink extends LinkProps {
icon?: IconProps['name']
avatar?: AvatarProps
badge?: string | number | BadgeProps
select? (e: MouseEvent): void
}
export interface NavigationMenuProps<T extends NavigationMenuLink> extends Omit<NavigationMenuRootProps, 'asChild' | 'dir'> {
export interface NavigationMenuProps<T> extends Omit<NavigationMenuRootProps, 'asChild' | 'dir'> {
links?: T[] | T[][]
class?: any
ui?: Partial<typeof navigationMenu.slots>
@@ -30,7 +31,7 @@ export interface NavigationMenuEmits extends NavigationMenuRootEmits {}
type SlotProps<T> = (props: { link: T, active: boolean }) => any
export interface NavigationMenuSlots<T extends NavigationMenuLink> {
export interface NavigationMenuSlots<T> {
leading: SlotProps<T>
default: SlotProps<T>
trailing: SlotProps<T>
@@ -59,30 +60,32 @@ const lists = computed(() => props.links?.length ? (Array.isArray(props.links[0]
<NavigationMenuRoot v-bind="rootProps" :class="ui.root({ class: props.class })">
<NavigationMenuList v-for="(list, index) in lists" :key="`list-${index}`" :class="ui.list()">
<NavigationMenuItem v-for="(link, linkIndex) in list" :key="`list-${index}-${linkIndex}`" :class="ui.item()">
<ULink v-slot="{ active, ...slotProps }" v-bind="omit(link, ['label', 'icon', 'avatar', 'badge'])" custom>
<NavigationMenuLink as-child :active="active">
<ULinkBase v-bind="slotProps" :class="ui.base({ active })">
<ULink v-slot="{ active, ...slotProps }" v-bind="omit(link, ['label', 'icon', 'avatar', 'badge', 'select'])" custom>
<NavigationMenuLink as-child :active="active" @select="link.select">
<ULinkBase v-bind="slotProps" :class="ui.link({ active })">
<slot name="leading" :link="link" :active="active">
<UAvatar v-if="link.avatar" size="2xs" v-bind="link.avatar" :class="ui.avatar({ active })" />
<UIcon v-else-if="link.icon" :name="link.icon" :class="ui.icon({ active })" />
<UAvatar v-if="link.avatar" size="2xs" v-bind="link.avatar" :class="ui.linkLeadingAvatar({ active })" />
<UIcon v-else-if="link.icon" :name="link.icon" :class="ui.linkLeadingIcon({ active })" />
</slot>
<span v-if="link.label || $slots.default" :class="ui.label()">
<span v-if="link.label || $slots.default" :class="ui.linkLabel()">
<slot :link="link" :active="active">
{{ link.label }}
</slot>
</span>
<slot name="trailing" :link="link" :active="active">
<UBadge
v-if="link.badge"
color="gray"
variant="solid"
size="xs"
v-bind="(typeof link.badge === 'string' || typeof link.badge === 'number') ? { label: link.badge } : link.badge"
:class="ui.badge()"
/>
</slot>
<span v-if="$slots.trailing || link.badge" :class="ui.linkTrailing()">
<slot name="trailing" :link="link" :active="active">
<UBadge
v-if="link.badge"
color="gray"
variant="solid"
size="xs"
v-bind="(typeof link.badge === 'string' || typeof link.badge === 'number') ? { label: link.badge } : link.badge"
:class="ui.linkTrailingBadge()"
/>
</slot>
</span>
</ULinkBase>
</NavigationMenuLink>
</ULink>

View File

@@ -38,6 +38,7 @@ import { Popover, HoverCard } from 'radix-vue/namespaced'
import { reactivePick } from '@vueuse/core'
const props = withDefaults(defineProps<PopoverProps>(), {
portal: true,
mode: 'click',
openDelay: 0,
closeDelay: 0

View File

@@ -17,7 +17,7 @@ export interface TabsItem {
content?: string
}
export interface TabsProps<T extends TabsItem> extends Omit<TabsRootProps, 'asChild'> {
export interface TabsProps<T> extends Omit<TabsRootProps, 'asChild'> {
items?: T[]
class?: any
ui?: Partial<typeof tabs.slots>
@@ -27,11 +27,10 @@ export interface TabsEmits extends TabsRootEmits {}
type SlotProps<T> = (props: { item: T, index: number }) => any
export type TabsSlots<T extends TabsItem> = {
export type TabsSlots<T> = {
default: SlotProps<T>
content: SlotProps<T>
} & {
[key in T['slot'] as string]?: SlotProps<T>
[key: string]: SlotProps<T>
}
</script>

View File

@@ -37,7 +37,7 @@ import { TooltipRoot, TooltipTrigger, TooltipPortal, TooltipContent, TooltipArro
import { reactivePick } from '@vueuse/core'
import UKbd from '#ui/components/Kbd.vue'
const props = defineProps<TooltipProps>()
const props = withDefaults(defineProps<TooltipProps>(), { portal: true })
const emits = defineEmits<TooltipEmits>()
defineSlots<TooltipSlots>()

View File

@@ -0,0 +1,36 @@
import { createSharedComposable, useActiveElement } from '@vueuse/core'
import { ref, computed, onMounted } from 'vue'
import type {} from '@vueuse/shared'
export const _useShortcuts = () => {
const macOS = computed(() => import.meta.client && navigator && navigator.userAgent && navigator.userAgent.match(/Macintosh;/))
const metaSymbol = ref(' ')
const activeElement = useActiveElement()
const usingInput = computed(() => {
const tagName = activeElement.value?.tagName
const contentEditable = activeElement.value?.contentEditable
const usingInput = !!(tagName === 'INPUT' || tagName === 'TEXTAREA' || contentEditable === 'true' || contentEditable === 'plaintext-only')
if (usingInput) {
return ((activeElement.value as any)?.name as string) || true
}
return false
})
onMounted(() => {
metaSymbol.value = macOS.value ? '⌘' : 'Ctrl'
})
return {
macOS,
metaSymbol,
activeElement,
usingInput
}
}
export const useShortcuts = createSharedComposable(_useShortcuts)