From 44033508a7347a5c75204d359b641a6f2da2cff9 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Fri, 29 Mar 2024 13:42:02 +0100 Subject: [PATCH] feat(DropdownMenu): new component (#37) --- playground/app.vue | 1 + playground/pages/dropdown-menu.vue | 109 ++++++++ playground/pages/navigation-menu.vue | 5 +- src/runtime/components/Accordion.vue | 7 +- src/runtime/components/DropdownMenu.vue | 108 ++++++++ .../components/DropdownMenuContent.vue | 120 +++++++++ src/runtime/components/LinkBase.vue | 33 ++- src/runtime/components/NavigationMenu.vue | 39 +-- src/runtime/components/Popover.vue | 1 + src/runtime/components/Tabs.vue | 7 +- src/runtime/components/Tooltip.vue | 2 +- src/runtime/composables/useShortcuts.ts | 36 +++ src/theme/accordion.ts | 4 +- src/theme/dropdownMenu.ts | 27 ++ src/theme/index.ts | 1 + src/theme/navigationMenu.ts | 35 ++- test/components/Checkbox.spec.ts | 7 +- test/components/DropdownMenu.spec.ts | 30 +++ test/components/NavigationMenu.spec.ts | 2 +- test/components/Popover.spec.ts | 2 +- test/components/Tooltip.spec.ts | 6 +- .../__snapshots__/Accordion.spec.ts.snap | 240 +++++++++--------- .../__snapshots__/DropdownMenu.spec.ts.snap | 70 +++++ .../__snapshots__/Link.spec.ts.snap | 2 +- .../__snapshots__/NavigationMenu.spec.ts.snap | 56 ++-- 25 files changed, 735 insertions(+), 215 deletions(-) create mode 100644 playground/pages/dropdown-menu.vue create mode 100644 src/runtime/components/DropdownMenu.vue create mode 100644 src/runtime/components/DropdownMenuContent.vue create mode 100644 src/runtime/composables/useShortcuts.ts create mode 100644 src/theme/dropdownMenu.ts create mode 100644 test/components/DropdownMenu.spec.ts create mode 100644 test/components/__snapshots__/DropdownMenu.spec.ts.snap diff --git a/playground/app.vue b/playground/app.vue index a8db9412..dcd57c9a 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -16,6 +16,7 @@ const components = [ 'checkbox', 'chip', 'collapsible', + 'dropdown-menu', 'form', 'form-field', 'input', diff --git a/playground/pages/dropdown-menu.vue b/playground/pages/dropdown-menu.vue new file mode 100644 index 00000000..01099afb --- /dev/null +++ b/playground/pages/dropdown-menu.vue @@ -0,0 +1,109 @@ + + + diff --git a/playground/pages/navigation-menu.vue b/playground/pages/navigation-menu.vue index 2215bc00..4dd97f58 100644 --- a/playground/pages/navigation-menu.vue +++ b/playground/pages/navigation-menu.vue @@ -7,7 +7,7 @@ const links = [ src: 'https://avatars.githubusercontent.com/u/739984?v=4' }, badge: 100, - click () { + select () { console.log('Profile clicked') } }, { @@ -29,7 +29,8 @@ const links = [ target: '_blank' }, { label: 'Help', - icon: 'i-heroicons-question-mark-circle' + icon: 'i-heroicons-question-mark-circle', + disabled: true }] ] diff --git a/src/runtime/components/Accordion.vue b/src/runtime/components/Accordion.vue index 824e02a8..e2196991 100644 --- a/src/runtime/components/Accordion.vue +++ b/src/runtime/components/Accordion.vue @@ -19,7 +19,7 @@ export interface AccordionItem { disabled?: boolean } -export interface AccordionProps extends Omit { +export interface AccordionProps extends Omit { items?: T[] class?: any ui?: Partial @@ -29,13 +29,12 @@ export interface AccordionEmits extends AccordionRootEmits {} type SlotProps = (props: { item: T, index: number }) => any -export type AccordionSlots = { +export type AccordionSlots = { leading: SlotProps default: SlotProps trailing: SlotProps content: SlotProps -} & { - [key in T['slot'] as string]?: SlotProps + [key: string]: SlotProps } diff --git a/src/runtime/components/DropdownMenu.vue b/src/runtime/components/DropdownMenu.vue new file mode 100644 index 00000000..383a1173 --- /dev/null +++ b/src/runtime/components/DropdownMenu.vue @@ -0,0 +1,108 @@ + + + + + + + diff --git a/src/runtime/components/DropdownMenuContent.vue b/src/runtime/components/DropdownMenuContent.vue new file mode 100644 index 00000000..20a1e345 --- /dev/null +++ b/src/runtime/components/DropdownMenuContent.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/src/runtime/components/LinkBase.vue b/src/runtime/components/LinkBase.vue index b3ebeb43..26a2a292 100644 --- a/src/runtime/components/LinkBase.vue +++ b/src/runtime/components/LinkBase.vue @@ -1,20 +1,27 @@ - + + diff --git a/src/runtime/components/Tooltip.vue b/src/runtime/components/Tooltip.vue index 4e9d988e..fbbce4cc 100644 --- a/src/runtime/components/Tooltip.vue +++ b/src/runtime/components/Tooltip.vue @@ -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() +const props = withDefaults(defineProps(), { portal: true }) const emits = defineEmits() defineSlots() diff --git a/src/runtime/composables/useShortcuts.ts b/src/runtime/composables/useShortcuts.ts new file mode 100644 index 00000000..cd1f6378 --- /dev/null +++ b/src/runtime/composables/useShortcuts.ts @@ -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) diff --git a/src/theme/accordion.ts b/src/theme/accordion.ts index 31891924..038c18f3 100644 --- a/src/theme/accordion.ts +++ b/src/theme/accordion.ts @@ -5,8 +5,8 @@ export default { header: 'flex', trigger: 'group flex-1 flex items-center gap-1.5 font-medium text-sm hover:underline py-3.5 disabled:cursor-not-allowed disabled:opacity-75 disabled:hover:no-underline focus-visible:ring-2 focus-visible:ring-primary-500 dark:focus-visible:ring-primary-400 focus-visible:outline-0', content: 'text-sm pb-3.5 data-[state=open]:animate-[accordion-down_200ms_ease-in-out] data-[state=closed]:animate-[accordion-up_200ms_ease-in-out] overflow-hidden focus:outline-none', - leadingIcon: 'shrink-0 w-5 h-5', - trailingIcon: 'ms-auto w-5 h-5 group-data-[state=open]:rotate-180 transition-transform duration-200', + leadingIcon: 'shrink-0 size-5', + trailingIcon: 'ms-auto size-5 group-data-[state=open]:rotate-180 transition-transform duration-200', label: 'truncate' } } diff --git a/src/theme/dropdownMenu.ts b/src/theme/dropdownMenu.ts new file mode 100644 index 00000000..01b35034 --- /dev/null +++ b/src/theme/dropdownMenu.ts @@ -0,0 +1,27 @@ +export default { + slots: { + content: 'min-w-48 bg-white dark:bg-gray-900 shadow-lg rounded-md ring ring-gray-200 dark:ring-gray-800 divide-y divide-gray-200 dark:divide-gray-800 will-change-[transform,opacity] data-[state=open]:animate-[dropdown-menu-open_100ms_ease-out] data-[state=closed]:animate-[dropdown-menu-closed_100ms_ease-in]', + arrow: 'fill-gray-200 dark:fill-gray-800', + group: 'p-1', + label: 'w-full flex items-center gap-1.5 p-1.5 text-sm font-medium select-none', + item: 'group relative w-full flex items-center gap-1.5 p-1.5 text-sm select-none outline-none before:absolute before:z-[-1] before:inset-px before:rounded-md disabled:cursor-not-allowed disabled:opacity-75', + itemLeadingIcon: 'shrink-0 size-5', + itemLeadingAvatar: 'shrink-0', + itemTrailing: 'ms-auto inline-flex', + itemTrailingIcon: 'shrink-0 size-5', + itemTrailingShortcuts: 'hidden lg:inline-flex items-center shrink-0 gap-0.5', + itemLabel: 'truncate' + }, + variants: { + active: { + true: { + item: 'text-gray-900 dark:text-white before:bg-gray-100 dark:before:bg-gray-800', + itemLeadingIcon: 'text-gray-700 dark:text-gray-200' + }, + false: { + item: 'text-gray-700 dark:text-gray-200 data-[highlighted]:text-gray-900 dark:data-[highlighted]:text-white data-[state=open]:text-gray-900 dark:data-[state=open]:text-white data-[highlighted]:before:bg-gray-50 dark:data-[highlighted]:before:bg-gray-800/50 data-[state=open]:before:bg-gray-50 dark:data-[state=open]:before:bg-gray-800/50', + itemLeadingIcon: 'text-gray-400 dark:text-gray-500 group-data-[highlighted]:text-gray-700 dark:group-data-[highlighted]:text-gray-200 group-data-[state=open]:text-gray-700 dark:group-data-[state=open]:text-gray-200' + } + } + } +} diff --git a/src/theme/index.ts b/src/theme/index.ts index 44cdc181..4a847f9d 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -7,6 +7,7 @@ export { default as checkbox } from './checkbox' export { default as chip } from './chip' export { default as collapsible } from './collapsible' export { default as container } from './container' +export { default as dropdownMenu } from './dropdownMenu' export { default as form } from './form' export { default as formField } from './formField' export { default as icons } from './icons' diff --git a/src/theme/navigationMenu.ts b/src/theme/navigationMenu.ts index 510a18cb..c12d4710 100644 --- a/src/theme/navigationMenu.ts +++ b/src/theme/navigationMenu.ts @@ -3,11 +3,12 @@ export default { root: 'relative', list: '', item: '', - base: 'group relative w-full flex items-center gap-1.5 font-medium text-sm before:absolute before:rounded-md focus:outline-none focus-visible:outline-none dark:focus-visible:outline-none focus-visible:before:ring-inset focus-visible:before:ring-2 focus-visible:before:ring-primary-500 dark:focus-visible:before:ring-primary-400 disabled:cursor-not-allowed disabled:opacity-75', - icon: 'shrink-0 w-5 h-5 relative', - avatar: 'shrink-0 relative', - label: 'truncate relative', - badge: 'shrink-0 ms-auto relative rounded' + link: 'group relative w-full flex items-center gap-1.5 font-medium text-sm before:absolute before:z-[-1] before:rounded-md focus:outline-none focus-visible:outline-none dark:focus-visible:outline-none focus-visible:before:ring-inset focus-visible:before:ring-2 focus-visible:before:ring-primary-500 dark:focus-visible:before:ring-primary-400 disabled:cursor-not-allowed disabled:opacity-75', + linkLeadingIcon: 'shrink-0 size-5', + linkLeadingAvatar: 'shrink-0', + linkLabel: 'truncate', + linkTrailing: 'ms-auto', + linkTrailingBadge: 'shrink-0 rounded' }, variants: { orientation: { @@ -15,35 +16,41 @@ export default { root: 'w-full flex items-center justify-between', list: 'flex items-center min-w-0', item: 'min-w-0', - base: 'px-2.5 py-3.5 before:inset-x-0 before:inset-y-2 hover:before:bg-gray-50 dark:hover:before:bg-gray-800/50 after:absolute after:bottom-0 after:inset-x-2.5 after:block after:h-[2px] after:mt-2 after:rounded-full' + link: 'px-2.5 py-3.5 before:inset-x-0 before:inset-y-2 hover:before:bg-gray-50 dark:hover:before:bg-gray-800/50 after:absolute after:bottom-0 after:inset-x-2.5 after:block after:h-[2px] after:mt-2 after:rounded-full' }, vertical: { root: 'flex flex-col *:py-1.5 first:*:pt-0 last:*:pb-0 divide-y divide-gray-200 dark:divide-gray-800', - base: 'px-2.5 py-1.5 before:inset-px' + link: 'px-2.5 py-1.5 before:inset-px' } }, active: { true: { - base: 'text-gray-900 dark:text-white', - icon: 'text-gray-700 dark:text-gray-200' + link: 'text-gray-900 dark:text-white', + linkLeadingIcon: 'text-gray-700 dark:text-gray-200' }, false: { - base: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white', - icon: 'text-gray-400 dark:text-gray-500 group-hover:text-gray-700 dark:group-hover:text-gray-200' + link: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white', + linkLeadingIcon: 'text-gray-400 dark:text-gray-500 group-hover:text-gray-700 dark:group-hover:text-gray-200' } } }, compoundVariants: [{ orientation: 'horizontal', active: true, - class: 'after:bg-primary-500 dark:after:bg-primary-400' + class: { + link: 'after:bg-primary-500 dark:after:bg-primary-400' + } }, { orientation: 'vertical', active: true, - class: 'before:bg-gray-100 dark:before:bg-gray-800' + class: { + link: 'before:bg-gray-100 dark:before:bg-gray-800' + } }, { orientation: 'vertical', active: false, - class: 'hover:before:bg-gray-50 dark:hover:before:bg-gray-800/50' + class: { + link: 'hover:before:bg-gray-50 dark:hover:before:bg-gray-800/50' + } }] } diff --git a/test/components/Checkbox.spec.ts b/test/components/Checkbox.spec.ts index 0bcc7333..d097b64c 100644 --- a/test/components/Checkbox.spec.ts +++ b/test/components/Checkbox.spec.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from 'vitest' -import { UCheckbox } from '#components' -import type { TypeOf } from 'zod' +import Checkbox, { type CheckboxProps } from '../../src/runtime/components/Checkbox.vue' import ComponentRender from '../component-render' import { defu } from 'defu' @@ -25,8 +24,8 @@ describe('Checkbox', () => { ['with size lg', { props: { size: 'lg' as const } }], ['with size xl', { props: { size: 'xl' as const } }] // @ts-ignore - ])('renders %s correctly', async (nameOrHtml: string, options: TypeOf) => { - const html = await ComponentRender(nameOrHtml, defu(options, { props: { id: 42 } }), UCheckbox) + ])('renders %s correctly', async (nameOrHtml: string, options: { props?: CheckboxProps }) => { + const html = await ComponentRender(nameOrHtml, defu(options, { props: { id: 42 } }), Checkbox) expect(html).toMatchSnapshot() }) }) diff --git a/test/components/DropdownMenu.spec.ts b/test/components/DropdownMenu.spec.ts new file mode 100644 index 00000000..708b9054 --- /dev/null +++ b/test/components/DropdownMenu.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import DropdownMenu, { type DropdownMenuProps } from '../../src/runtime/components/DropdownMenu.vue' +import ComponentRender from '../component-render' + +const items = [{ + label: 'Profile', + avatar: { + src: 'https://avatars.githubusercontent.com/u/739984?v=4' + } +}, { + label: 'Edit', + icon: 'i-heroicons-pencil-square-20-solid', + shortcuts: ['E'] +}, { + label: 'Duplicate', + icon: 'i-heroicons-document-duplicate-20-solid', + shortcuts: ['D'], + disabled: true +}] + +describe('DropdownMenu', () => { + it.each([ + ['basic case', { props: { open: true, portal: false, items } }], + ['with class', { props: { open: true, portal: false, items, class: 'min-w-96' } }], + ['with ui', { props: { open: true, portal: false, items, ui: { itemLeadingIcon: 'size-4' } } }] + ])('renders %s correctly', async (nameOrHtml: string, options: { props?: DropdownMenuProps, slots?: any }) => { + const html = await ComponentRender(nameOrHtml, options, DropdownMenu) + expect(html).toMatchSnapshot() + }) +}) diff --git a/test/components/NavigationMenu.spec.ts b/test/components/NavigationMenu.spec.ts index feeb6cc3..891d30e3 100644 --- a/test/components/NavigationMenu.spec.ts +++ b/test/components/NavigationMenu.spec.ts @@ -27,7 +27,7 @@ describe('NavigationMenu', () => { ['basic case', { props: { links } }], ['with vertical orientation', { props: { links, orientation: 'vertical' as const } }], ['with class', { props: { links, class: 'w-48' } }], - ['with ui', { props: { links, ui: { icon: 'w-4 h-4' } } }] + ['with ui', { props: { links, ui: { linkLeadingIcon: 'size-4' } } }] ])('renders %s correctly', async (nameOrHtml: string, options: { props?: NavigationMenuProps, slots?: any }) => { const html = await ComponentRender(nameOrHtml, options, NavigationMenu) expect(html).toMatchSnapshot() diff --git a/test/components/Popover.spec.ts b/test/components/Popover.spec.ts index a17975e4..e94048f9 100644 --- a/test/components/Popover.spec.ts +++ b/test/components/Popover.spec.ts @@ -4,7 +4,7 @@ import ComponentRender from '../component-render' describe('Popover', () => { it.each([ - ['basic case', { props: { open: true, arrow: true }, slots: { default: () => 'Click me', content: () => 'Popover content' } }] + ['basic case', { props: { open: true, portal: false, arrow: true }, slots: { default: () => 'Click me', content: () => 'Popover content' } }] ])('renders %s correctly', async (nameOrHtml: string, options: { props?: PopoverProps, slots?: any }) => { const html = await ComponentRender(nameOrHtml, options, Popover) expect(html).toMatchSnapshot() diff --git a/test/components/Tooltip.spec.ts b/test/components/Tooltip.spec.ts index 8b1988e1..293a13f6 100644 --- a/test/components/Tooltip.spec.ts +++ b/test/components/Tooltip.spec.ts @@ -15,9 +15,9 @@ const TooltipWrapper = defineComponent({ describe('Tooltip', () => { it.each([ - ['with text', { props: { text: 'Tooltip', open: true } }], - ['with arrow', { props: { text: 'Tooltip', arrow: true, open: true } }], - ['with shortcuts', { props: { text: 'Tooltip', shortcuts: ['⌘', 'K'], open: true } }] + ['with text', { props: { text: 'Tooltip', open: true, portal: false } }], + ['with arrow', { props: { text: 'Tooltip', arrow: true, open: true, portal: false } }], + ['with shortcuts', { props: { text: 'Tooltip', shortcuts: ['⌘', 'K'], open: true, portal: false } }] ])('renders %s correctly', async (nameOrHtml: string, options: { props?: TooltipProps, slots?: any }) => { const html = await ComponentRender(nameOrHtml, options, TooltipWrapper) expect(html).toMatchSnapshot() diff --git a/test/components/__snapshots__/Accordion.spec.ts.snap b/test/components/__snapshots__/Accordion.spec.ts.snap index 29e36e34..613d3563 100644 --- a/test/components/__snapshots__/Accordion.spec.ts.snap +++ b/test/components/__snapshots__/Accordion.spec.ts.snap @@ -3,17 +3,17 @@ exports[`Accordion > renders basic case correctly 1`] = ` "
-

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.
-

-

-

-

-