From 7bc9bb881d36e825e468e580a07c890503da79fc Mon Sep 17 00:00:00 2001 From: Konstantinos Paparas Date: Tue, 28 Apr 2026 14:55:46 +0200 Subject: [PATCH] feat(date-time-picker): expose v-model:menu-open Lets parent overlays read whether any teleported sub-overlay (the picker menu itself or its calendar month/year sub-menu) is open, so they can bind :persistent during that window instead of forcing it on always. Closes #516. --- apps/example/e2e/datetimepicker.spec.ts | 58 +++++++++++++++++++ apps/example/src/views/DateTimePickerView.vue | 39 ++++++++++++- .../RuiDateTimePicker.spec.ts | 49 ++++++++++++++++ .../RuiDateTimePicker.stories.ts | 31 ++++++++++ .../date-time-picker/RuiDateTimePicker.vue | 7 +++ 5 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 apps/example/e2e/datetimepicker.spec.ts diff --git a/apps/example/e2e/datetimepicker.spec.ts b/apps/example/e2e/datetimepicker.spec.ts new file mode 100644 index 00000000..09ad458c --- /dev/null +++ b/apps/example/e2e/datetimepicker.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from '@playwright/test'; + +test.describe('datetimepicker inside parent menu', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/datetimepickers'); + }); + + test.afterEach(async ({ page }) => { + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); + }); + + test('parent menu stays open while calendar sub-menu is open and click-outside fires', async ({ page }) => { + await page.getByTestId('parent-menu-activator').click(); + const parentContent = page.getByTestId('parent-menu-content'); + await expect(parentContent).toBeVisible(); + + // Open the date-time picker (its own RuiMenu) + await parentContent.getByRole('textbox').click(); + + // Open the calendar's teleported month/year sub-menu by clicking the header title + await page.getByTestId('header-title').click(); + + // Click somewhere outside the calendar sub-menu but on the page body — + // because the picker exposes menu-open and the page binds :persistent to it, + // the parent menu must NOT close. + await page.mouse.click(5, 5); + + await expect(parentContent).toBeVisible(); + }); + + test('parent menu stays open while only the picker menu is open and click-outside fires', async ({ page }) => { + await page.getByTestId('parent-menu-activator').click(); + const parentContent = page.getByTestId('parent-menu-content'); + await expect(parentContent).toBeVisible(); + + // Open the picker but do NOT open the calendar's year/month sub-menu + await parentContent.getByRole('textbox').click(); + + // Click outside — picker exposes menu-open while its own menu is open, + // so parent stays open. + await page.mouse.click(5, 5); + + await expect(parentContent).toBeVisible(); + }); + + test('parent menu closes on outside click when no picker overlay is open', async ({ page }) => { + await page.getByTestId('parent-menu-activator').click(); + const parentContent = page.getByTestId('parent-menu-content'); + await expect(parentContent).toBeVisible(); + + // Click outside without opening the picker / calendar sub-menu + await page.mouse.click(5, 5); + + await expect(parentContent).toBeHidden(); + }); +}); diff --git a/apps/example/src/views/DateTimePickerView.vue b/apps/example/src/views/DateTimePickerView.vue index 199ce732..7b58afc8 100644 --- a/apps/example/src/views/DateTimePickerView.vue +++ b/apps/example/src/views/DateTimePickerView.vue @@ -1,6 +1,6 @@ diff --git a/packages/ui-library/src/components/date-time-picker/RuiDateTimePicker.spec.ts b/packages/ui-library/src/components/date-time-picker/RuiDateTimePicker.spec.ts index 7d30b72c..a6c4740b 100644 --- a/packages/ui-library/src/components/date-time-picker/RuiDateTimePicker.spec.ts +++ b/packages/ui-library/src/components/date-time-picker/RuiDateTimePicker.spec.ts @@ -2377,4 +2377,53 @@ describe('components/date-time-picker/RuiDateTimePicker.vue', () => { expect(wrapper.text()).toContain('This hint should be visible'); }); }); + + describe('menu-open model', () => { + it('emits update:menuOpen when the picker menu opens and closes', async () => { + wrapper = createWrapper({ + props: { + modelValue: new Date(), + }, + }); + + await vi.runOnlyPendingTimersAsync(); + await wrapper.find('[data-id="activator"]').trigger('click'); + await vi.runOnlyPendingTimersAsync(); + + let events = wrapper.emitted('update:menuOpen'); + expect(events?.at(-1)).toEqual([true]); + + await wrapper.find('[data-id="append"]').trigger('click'); + await vi.runOnlyPendingTimersAsync(); + + events = wrapper.emitted('update:menuOpen'); + expect(events?.at(-1)).toEqual([false]); + }); + + it('stays open while the calendar sub-menu opens after the picker is open', async () => { + wrapper = createWrapper({ + props: { + modelValue: new Date(), + }, + }); + + await vi.runOnlyPendingTimersAsync(); + await wrapper.find('[data-id="activator"]').trigger('click'); + await vi.runOnlyPendingTimersAsync(); + + const menu = wrapper.findComponent({ name: 'RuiDateTimePickerMenu' }); + assert(menu.exists()); + + menu.vm.$emit('update:calendarMenuOpen', true); + await nextTick(); + await vi.runOnlyPendingTimersAsync(); + const menuAfter = wrapper.findComponent({ name: 'RuiDateTimePickerMenu' }); + menuAfter.vm.$emit('update:calendarMenuOpen', false); + await nextTick(); + await vi.runOnlyPendingTimersAsync(); + + const events = wrapper.emitted('update:menuOpen'); + expect(events?.every(e => e[0] === true)).toBe(true); + }); + }); }); diff --git a/packages/ui-library/src/components/date-time-picker/RuiDateTimePicker.stories.ts b/packages/ui-library/src/components/date-time-picker/RuiDateTimePicker.stories.ts index 0082a30e..fa74b72f 100644 --- a/packages/ui-library/src/components/date-time-picker/RuiDateTimePicker.stories.ts +++ b/packages/ui-library/src/components/date-time-picker/RuiDateTimePicker.stories.ts @@ -1,5 +1,7 @@ import type { ComponentPropsAndSlots } from '@storybook/vue3-vite'; import { expect, waitFor, within } from 'storybook/test'; +import RuiButton from '@/components/buttons/button/RuiButton.vue'; +import RuiMenu from '@/components/overlays/menu/RuiMenu.vue'; import { TimeAccuracy } from '@/consts/time-accuracy'; import preview from '~/.storybook/preview'; import RuiDateTimePicker from './RuiDateTimePicker.vue'; @@ -174,4 +176,33 @@ export const Required = meta.story({ }, }); +export const InsideParentMenu = meta.story({ + args: { + modelValue: new Date(), + variant: 'outlined', + }, + render: args => ({ + components: { RuiButton, RuiDateTimePicker, RuiMenu }, + setup() { + const open = ref(false); + const pickerMenuOpen = ref(false); + return { args, open, pickerMenuOpen }; + }, + template: `
+ + +
+ +
+
+
`, + }), +}); + export default meta; diff --git a/packages/ui-library/src/components/date-time-picker/RuiDateTimePicker.vue b/packages/ui-library/src/components/date-time-picker/RuiDateTimePicker.vue index b3f5a2f7..92196499 100644 --- a/packages/ui-library/src/components/date-time-picker/RuiDateTimePicker.vue +++ b/packages/ui-library/src/components/date-time-picker/RuiDateTimePicker.vue @@ -50,6 +50,7 @@ defineOptions({ }); const modelValue = defineModel>({ required: true }); +const menuOpen = defineModel('menuOpen', { default: false }); const { disabled = false, @@ -284,6 +285,12 @@ function arrowClicked(event: MouseEvent): void { event.stopPropagation(); } } + +const anyMenuOpen = computed(() => get(isOpen) || get(calendarMenuOpen)); + +watch(anyMenuOpen, (value) => { + set(menuOpen, value); +});