From d964c172fc3490f5e56b33f841d4884342d12ca4 Mon Sep 17 00:00:00 2001 From: lukicenturi Date: Tue, 20 Feb 2024 23:28:03 +0700 Subject: [PATCH] feat(Menu): implement menu component --- example/cypress/e2e/overlays/menu.cy.ts | 64 ++++ example/src/App.vue | 1 + example/src/router/index.ts | 6 + example/src/views/MenuView.vue | 155 +++++++++ src/components/index.ts | 6 + src/components/overlays/menu/Menu.spec.ts | 310 ++++++++++++++++++ src/components/overlays/menu/Menu.stories.ts | 106 ++++++ src/components/overlays/menu/Menu.vue | 187 +++++++++++ src/components/overlays/teleport-container.ts | 11 + .../overlays/tooltip/Tooltip.spec.ts | 13 +- .../overlays/tooltip/Tooltip.stories.ts | 8 + src/components/overlays/tooltip/Tooltip.vue | 8 +- src/composables/popper.ts | 8 +- 13 files changed, 863 insertions(+), 20 deletions(-) create mode 100644 example/cypress/e2e/overlays/menu.cy.ts create mode 100644 example/src/views/MenuView.vue create mode 100644 src/components/overlays/menu/Menu.spec.ts create mode 100644 src/components/overlays/menu/Menu.stories.ts create mode 100644 src/components/overlays/menu/Menu.vue diff --git a/example/cypress/e2e/overlays/menu.cy.ts b/example/cypress/e2e/overlays/menu.cy.ts new file mode 100644 index 0000000..823a65f --- /dev/null +++ b/example/cypress/e2e/overlays/menu.cy.ts @@ -0,0 +1,64 @@ +// https://docs.cypress.io/api/introduction/api.html + +describe('menu', () => { + beforeEach(() => { + cy.visit('/menus'); + }); + + it('checks for and trigger menu', () => { + cy.contains('h2[data-cy=menus]', 'Menus'); + + cy.get('div[data-cy=menu-0]').as('defaultMenu'); + + cy.get('@defaultMenu').find('[data-cy=activator]').as('activator'); + cy.get('@activator').trigger('click'); + cy.get('body').find('div[role=menu]'); + cy.get('@activator').trigger('click'); + cy.get('body').find('div[role=menu-content]').should('not.exist'); + + cy.get('@activator').trigger('click'); + cy.get('body').find('div[role=menu-content]').should('exist').as('menuContent'); + cy.get('@menuContent').trigger('click'); + cy.get('body').find('div[role=menu-content]').should('exist'); + cy.get('body').trigger('click'); + cy.get('body').find('div[role=menu-content]').should('not.exist'); + }); + + it('disabled should not trigger menu', () => { + cy.get('div[data-cy=menu-4]').as('disabledMenu'); + + cy.get('@disabledMenu').find('[data-cy=activator]').as('activator'); + cy.get('@activator').trigger('click'); + cy.get('body').find('div[role=menu-content]').should('not.exist'); + }); + + it('menu should be opened on hover', () => { + cy.get('div[data-cy=menu-8]').as('menu'); + + cy.get('@menu').find('[data-cy=activator]').as('activator'); + cy.get('@activator').trigger('mouseover'); + cy.get('body').find('div[role=menu-content]').should('exist'); + cy.get('@activator').trigger('mouseleave'); + cy.get('body').find('div[role=menu-content]').should('not.exist'); + + cy.get('@activator').trigger('click'); + cy.get('body').find('div[role=menu-content]').should('exist'); + + cy.get('@activator').trigger('mouseleave'); + cy.get('body').find('div[role=menu-content]').should('exist'); + + cy.get('@activator').trigger('click'); + cy.get('body').find('div[role=menu-content]').should('not.exist'); + }); + + it('menu should be closed by clicking the menu content', () => { + cy.get('div[data-cy=menu-12]').as('menu'); + + cy.get('@menu').find('[data-cy=activator]').as('activator'); + cy.get('@activator').trigger('click'); + cy.get('body').find('div[role=menu]').as('menuContent'); + cy.get('body').find('div[role=menu-content]'); + cy.get('@menuContent').trigger('click'); + cy.get('body').find('div[role=menu-content]').should('not.exist'); + }); +}); diff --git a/example/src/App.vue b/example/src/App.vue index 18d038b..3120242 100644 --- a/example/src/App.vue +++ b/example/src/App.vue @@ -23,6 +23,7 @@ const navigation = ref([ { to: { name: 'chips' }, title: 'Chips' }, { to: { name: 'alerts' }, title: 'Alerts' }, { to: { name: 'tooltips' }, title: 'Tooltips' }, + { to: { name: 'menus' }, title: 'Menus' }, { to: { name: 'data-tables' }, title: 'Data Tables' }, { to: { name: 'cards' }, title: 'Cards' }, { to: { name: 'tabs' }, title: 'Tabs' }, diff --git a/example/src/router/index.ts b/example/src/router/index.ts index 962c63b..f78c4ec 100644 --- a/example/src/router/index.ts +++ b/example/src/router/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/max-dependencies */ import VueRouter from 'vue-router'; import Vue from 'vue'; import ButtonView from '@/views/ButtonView.vue'; @@ -96,6 +97,11 @@ const router = new VueRouter({ name: 'tooltips', component: () => import('@/views/TooltipView.vue'), }, + { + path: '/menus', + name: 'menus', + component: () => import('@/views/MenuView.vue'), + }, { path: '/data-tables', name: 'data-tables', diff --git a/example/src/views/MenuView.vue b/example/src/views/MenuView.vue new file mode 100644 index 0000000..c123014 --- /dev/null +++ b/example/src/views/MenuView.vue @@ -0,0 +1,155 @@ + + + diff --git a/src/components/index.ts b/src/components/index.ts index bf68a81..b23ebe6 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -60,6 +60,10 @@ import { type Props as BadgeProps, default as RuiBadge, } from '@/components/overlays/badge/Badge.vue'; +import { + type Props as MenuProps, + default as RuiMenu, +} from '@/components/overlays/menu/Menu.vue'; import { default as RuiTooltip, type Props as TooltipProps, @@ -137,6 +141,7 @@ export { RuiRevealableTextField, RuiStepper, RuiTextField, + RuiMenu, RuiTooltip, RuiDataTable, RuiSimpleSelect, @@ -155,6 +160,7 @@ export { ProgressProps, ChipProps, TextFieldProps, + MenuProps, TooltipProps, SimpleSelectProps, DataTableProps, diff --git a/src/components/overlays/menu/Menu.spec.ts b/src/components/overlays/menu/Menu.spec.ts new file mode 100644 index 0000000..67e2b09 --- /dev/null +++ b/src/components/overlays/menu/Menu.spec.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from 'vitest'; +import { mount } from '@vue/test-utils'; +import Vue from 'vue'; +import Button from '@/components/buttons/button/Button.vue'; +import Menu from '@/components/overlays/menu/Menu.vue'; +import { TeleportPlugin } from '@/components/overlays/teleport-container'; + +const text = 'This is menu'; + +function createWrapper(options?: any) { + Vue.use(TeleportPlugin); + return mount(Menu, { + ...options, + scopedSlots: { + activator: ` + Click me! + `, + }, + slots: { + default: `
${text}
`, + }, + stubs: { RuiButton: Button }, + }); +} + +function delay(time: number = 100) { + return new Promise((resolve) => { + setTimeout(resolve, time); + }); +} + +describe('menu', () => { + const text = 'Menu content'; + + it('renders properly', async () => { + const wrapper = createWrapper(); + + await wrapper.find('#trigger').trigger('click'); + await delay(); + + let menu = document.body.querySelector('div[role=menu]') as HTMLDivElement; + + expect(menu).toBeTruthy(); + expect(menu?.classList).toMatch(/_menu_/); + + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeTruthy(); + + // Click the content shouldn't close the menu + menu.click(); + await delay(); + + menu = document.body.querySelector('div[role=menu]') as HTMLDivElement; + + expect(menu).toBeTruthy(); + expect(menu?.classList).toMatch(/_menu_/); + + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeTruthy(); + + // Click outside should close the menu + document.body.click(); + await delay(); + + menu = document.body.querySelector('div[role=menu]') as HTMLDivElement; + + expect(menu).toBeFalsy(); + + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeFalsy(); + wrapper.destroy(); + }); + + it('passes props correctly', async () => { + const wrapper = createWrapper({ + propsData: { + disabled: true, + }, + }); + expect(wrapper.get('#trigger')).toBeTruthy(); + expect(document.body.querySelector('div[role=menu]')).toBeFalsy(); + wrapper.destroy(); + }); + + it('disabled does not trigger menu', async () => { + const wrapper = createWrapper({ + propsData: { + disabled: true, + }, + }); + + await wrapper.find('#trigger').trigger('click'); + await delay(); + + let menu = document.body.querySelector('div[role=menu]'); + + expect(menu).toBeFalsy(); + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeFalsy(); + await wrapper.setProps({ disabled: false }); + + await wrapper.find('#trigger').trigger('click'); + await delay(); + + menu = document.body.querySelector('div[role=menu]'); + + expect(menu).toBeTruthy(); + expect(menu?.classList).toMatch(/_menu_/); + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeTruthy(); + wrapper.destroy(); + }); + + it('menu only appears after `openDelay` timeout', async () => { + const wrapper = createWrapper({ + propsData: { + closeDelay: 50000, + openDelay: 400, + }, + }); + + await wrapper.find('#trigger').trigger('click'); + await delay(); + + const menu = document.body.querySelector('div[role=menu]'); + + expect(menu).toBeTruthy(); + expect(menu?.classList).toMatch(/_menu_/); + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeTruthy(); + + await delay(100); + expect(document.body.innerHTML).not.toMatch(new RegExp(text)); + await delay(500); + expect(document.body.innerHTML).not.toMatch(new RegExp(text)); + + wrapper.destroy(); + }); + + it('menu disappears after `closeDelay` timeout', async () => { + const wrapper = createWrapper({ + propsData: { + closeDelay: 1000, + }, + }); + + await wrapper.find('#trigger').trigger('click'); + await delay(); + + let menu = document.body.querySelector('div[role=menu]'); + + expect(menu).toBeTruthy(); + expect(menu?.classList).toMatch(/_menu_/); + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeTruthy(); + + await wrapper.find('#trigger').trigger('click'); + + menu = document.body.querySelector('div[role=menu]'); + + expect(menu).toBeTruthy(); + expect(menu?.classList).toMatch(/_menu_/); + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeTruthy(); + + await delay(2100); + + menu = document.body.querySelector('div[role=menu]'); + expect(menu).toBeFalsy(); + + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeFalsy(); + + await wrapper.setProps({ disabled: true }); + + menu = document.body.querySelector('div[role=menu]'); + expect(menu).toBeFalsy(); + wrapper.destroy(); + }); + + describe('menu works with `openOnHover=true`', () => { + it('hover without click', async () => { + const wrapper = createWrapper({ + propsData: { + openOnHover: true, + }, + }); + + await wrapper.find('#trigger').trigger('mouseover'); + await delay(); + + let menu = document.body.querySelector('div[role=menu]'); + + expect(menu).toBeTruthy(); + expect(menu?.classList).toMatch(/_menu_/); + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeTruthy(); + + await wrapper.find('#trigger').trigger('mouseleave'); + + menu = document.body.querySelector('div[role=menu]'); + + expect(menu).toBeTruthy(); + expect(menu?.classList).toMatch(/_menu_/); + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeTruthy(); + + await delay(2100); + + menu = document.body.querySelector('div[role=menu]'); + expect(menu).toBeFalsy(); + + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeFalsy(); + }); + + it('hover with click', async () => { + const wrapper = createWrapper({ + propsData: { + openOnHover: true, + }, + }); + + await wrapper.find('#trigger').trigger('mouseover'); + await delay(); + + let menu = document.body.querySelector('div[role=menu]'); + + expect(menu).toBeTruthy(); + expect(menu?.classList).toMatch(/_menu_/); + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeTruthy(); + + await wrapper.find('#trigger').trigger('click'); + await delay(); + + menu = document.body.querySelector('div[role=menu]'); + expect(menu).toBeTruthy(); + + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeTruthy(); + + await wrapper.find('#trigger').trigger('mouseleave'); + await delay(); + + menu = document.body.querySelector('div[role=menu]'); + expect(menu).toBeTruthy(); + + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeTruthy(); + + await wrapper.find('#trigger').trigger('click'); + await delay(); + + menu = document.body.querySelector('div[role=menu]'); + expect(menu).toBeFalsy(); + + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeFalsy(); + }); + }); + + it('menu works with `closeOnContentClick=true`', async () => { + const wrapper = createWrapper({ + propsData: { + closeOnContentClick: true, + }, + }); + + await wrapper.find('#trigger').trigger('click'); + await delay(); + + let menu = document.body.querySelector('div[role=menu]') as HTMLDivElement; + + expect(menu).toBeTruthy(); + expect(menu?.classList).toMatch(/_menu_/); + + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeTruthy(); + + // Click the content should close the menu + menu.click(); + await delay(); + + menu = document.body.querySelector('div[role=menu]') as HTMLDivElement; + + expect(menu).toBeFalsy(); + + expect( + document.body.querySelector('div[data-popper-placement=bottom]'), + ).toBeFalsy(); + }); +}); diff --git a/src/components/overlays/menu/Menu.stories.ts b/src/components/overlays/menu/Menu.stories.ts new file mode 100644 index 0000000..e0993a2 --- /dev/null +++ b/src/components/overlays/menu/Menu.stories.ts @@ -0,0 +1,106 @@ +import { DEFAULT_POPPER_OPTIONS } from '@/composables/popper'; +import Button from '@/components/buttons/button/Button.vue'; +import Menu, { type Props } from './Menu.vue'; +import type { Meta, StoryFn, StoryObj } from '@storybook/vue'; + +const render: StoryFn = args => ({ + components: { Button, Menu }, + setup() { + return { args }; + }, + template: ` +
+ + +
+ This is menu +
+
+
`, +}); + +const meta: Meta = { + args: { + closeDelay: 0, + closeOnContentClick: false, + disabled: false, + menuClass: 'max-w-[20rem]', + openDelay: 0, + openOnHover: false, + popper: { + ...DEFAULT_POPPER_OPTIONS, + }, + }, + argTypes: { + closeDelay: { + control: 'number', + }, + closeOnContentClick: { control: 'boolean' }, + disabled: { control: 'boolean' }, + menuClass: { control: 'text' }, + openDelay: { control: 'number' }, + openOnHover: { control: 'boolean' }, + popper: { control: 'object' }, + }, + component: Menu, + parameters: { + docs: { + controls: { exclude: ['default'] }, + }, + }, + render, + tags: ['autodocs'], + title: 'Components/Overlays/Menu', +}; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const OpenOnHover: Story = { + args: { + openOnHover: true, + }, +}; + +export const CloseOnContentClick: Story = { + args: { + closeOnContentClick: true, + }, +}; + +export const Top: Story = { + args: { + popper: { + placement: 'top', + }, + }, +}; + +export const Right: Story = { + args: { + popper: { + placement: 'right', + }, + }, +}; + +export const Left: Story = { + args: { + popper: { + placement: 'left', + }, + }, +}; + +export const MenuDisabled: Story = { + args: { + disabled: true, + }, +}; + +export default meta; diff --git a/src/components/overlays/menu/Menu.vue b/src/components/overlays/menu/Menu.vue new file mode 100644 index 0000000..431286d --- /dev/null +++ b/src/components/overlays/menu/Menu.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/src/components/overlays/teleport-container.ts b/src/components/overlays/teleport-container.ts index 63028e5..ca98513 100644 --- a/src/components/overlays/teleport-container.ts +++ b/src/components/overlays/teleport-container.ts @@ -93,3 +93,14 @@ export function createTeleport(tag: string = 'DIV', id = generateId('rui-telepor }, }); } + +export const TeleportPlugin = { + install() { + const teleport = createTeleport(); + Object.defineProperty(Vue.prototype, '$teleport', { + get() { + return teleport; + }, + }); + }, +}; diff --git a/src/components/overlays/tooltip/Tooltip.spec.ts b/src/components/overlays/tooltip/Tooltip.spec.ts index d271d9d..0088e14 100644 --- a/src/components/overlays/tooltip/Tooltip.spec.ts +++ b/src/components/overlays/tooltip/Tooltip.spec.ts @@ -3,18 +3,7 @@ import { mount } from '@vue/test-utils'; import Vue from 'vue'; import Button from '@/components/buttons/button/Button.vue'; import Tooltip from '@/components/overlays/tooltip/Tooltip.vue'; -import { createTeleport } from '@/components/overlays/teleport-container'; - -const TeleportPlugin = { - install() { - const teleport = createTeleport(); - Object.defineProperty(Vue.prototype, '$teleport', { - get() { - return teleport; - }, - }); - }, -}; +import { TeleportPlugin } from '@/components/overlays/teleport-container'; function createWrapper(options?: any) { Vue.use(TeleportPlugin); diff --git a/src/components/overlays/tooltip/Tooltip.stories.ts b/src/components/overlays/tooltip/Tooltip.stories.ts index 22d12e1..7d98a1a 100644 --- a/src/components/overlays/tooltip/Tooltip.stories.ts +++ b/src/components/overlays/tooltip/Tooltip.stories.ts @@ -40,6 +40,7 @@ const meta: Meta = { text: { control: 'text', }, + tooltipClass: { control: 'text' }, }, component: Tooltip, parameters: { @@ -115,6 +116,13 @@ export const NoArrowLeft: Story = { }, }; +export const WithCustomSizeFromTooltipClass: Story = { + args: { + text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', + tooltipClass: 'max-w-[20rem]', + }, +}; + export const TooltipDisabled: Story = { args: { disabled: true, diff --git a/src/components/overlays/tooltip/Tooltip.vue b/src/components/overlays/tooltip/Tooltip.vue index 24fdb46..b2a6f0e 100644 --- a/src/components/overlays/tooltip/Tooltip.vue +++ b/src/components/overlays/tooltip/Tooltip.vue @@ -35,8 +35,8 @@ const { popper: tooltip, open, popperEnter, - onMouseOver, - onMouseLeave, + onOpen, + onClose, onPopperLeave, updatePopper, } = usePopper(popper, disabled, openDelay, closeDelay); @@ -47,8 +47,8 @@ const { ref="activator" :class="css.wrapper" :data-tooltip-disabled="disabled" - @mouseover="onMouseOver()" - @mouseleave="onMouseLeave()" + @mouseover="onOpen()" + @mouseleave="onClose()" v-on=" // eslint-disable-next-line vue/no-deprecated-dollar-listeners-api $listeners diff --git a/src/composables/popper.ts b/src/composables/popper.ts index 274659b..f5bcf6a 100644 --- a/src/composables/popper.ts +++ b/src/composables/popper.ts @@ -69,7 +69,7 @@ export function usePopper(options: Ref, disabled: Ref = get(instance)?.update(); }; - const onMouseOver = () => { + const onOpen = () => { if (get(disabled)) return; @@ -90,7 +90,7 @@ export function usePopper(options: Ref, disabled: Ref = } }; - const onMouseLeave = () => { + const onClose = () => { if (get(disabled)) return; @@ -193,8 +193,8 @@ export function usePopper(options: Ref, disabled: Ref = return { instance, - onMouseLeave, - onMouseOver, + onClose, + onOpen, onPopperLeave, open, popper,