diff --git a/apps/example/e2e/overlay-stack.spec.ts b/apps/example/e2e/overlay-stack.spec.ts new file mode 100644 index 00000000..776cf3f5 --- /dev/null +++ b/apps/example/e2e/overlay-stack.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from '@playwright/test'; + +test.describe('overlay stack', () => { + test.beforeEach(async ({ page }) => { + // Arrive from somewhere, so there is an entry underneath to be wrongly popped + await page.goto('/cards'); + await page.getByTestId('link-overlay-stack').click(); + await expect(page).toHaveURL(/\/overlay-stack$/); + }); + + test('should take the dialogs down one at a time before leaving the page', async ({ page }) => { + await page.getByTestId('open-outer').click(); + await expect(page.getByTestId('open-inner')).toBeVisible(); + + await page.getByTestId('open-inner').click(); + await expect(page.getByText('Back takes this one first')).toBeVisible(); + + await page.goBack(); + await expect(page.getByText('Back takes this one first')).toHaveCount(0); + await expect(page.getByTestId('open-inner')).toBeVisible(); + await expect(page).toHaveURL(/\/overlay-stack$/); + + await page.goBack(); + await expect(page.getByTestId('open-inner')).toHaveCount(0); + await expect(page).toHaveURL(/\/overlay-stack$/); + + // Nothing left covering the page, so the gesture is finally let through + await page.goBack(); + await expect(page).toHaveURL(/\/cards$/); + }); + + test('should report whether anything is covering the page', async ({ page }) => { + await expect(page.getByTestId('has-overlay')).toContainText('false'); + + await page.getByTestId('open-outer').click(); + await expect(page.getByTestId('has-overlay')).toContainText('true'); + + await page.goBack(); + await expect(page.getByTestId('has-overlay')).toContainText('false'); + }); + + test('should swallow the gesture for a persistent dialog rather than pass it through', async ({ page }) => { + await page.getByTestId('open-guarded').click(); + await expect(page.getByTestId('close-guarded')).toBeVisible(); + + await page.goBack(); + await expect(page.getByTestId('close-guarded')).toBeVisible(); + await expect(page.getByTestId('refusals')).toContainText('1'); + await expect(page).toHaveURL(/\/overlay-stack$/); + + await page.goBack(); + await expect(page.getByTestId('refusals')).toContainText('2'); + await expect(page).toHaveURL(/\/overlay-stack$/); + + await page.getByTestId('close-guarded').click(); + await expect(page.getByTestId('close-guarded')).toHaveCount(0); + + await page.goBack(); + await expect(page).toHaveURL(/\/cards$/); + }); + + test('should take a bottom sheet down before leaving the page', async ({ page }) => { + await page.getByTestId('open-sheet').click(); + await expect(page.getByTestId('sheet-body')).toBeVisible(); + + await page.goBack(); + await expect(page.getByTestId('sheet-body')).toHaveCount(0); + await expect(page).toHaveURL(/\/overlay-stack$/); + + await page.goBack(); + await expect(page).toHaveURL(/\/cards$/); + }); + + test('should let a forward navigation through while a dialog is open', async ({ page }) => { + await page.getByTestId('open-outer').click(); + + // From inside the dialog, since a modal covers the links behind it + await page.getByTestId('link-from-dialog').click(); + await expect(page).toHaveURL(/\/tables$/); + }); +}); diff --git a/apps/example/src/App.vue b/apps/example/src/App.vue index 8d8592d9..e2d6c2c4 100644 --- a/apps/example/src/App.vue +++ b/apps/example/src/App.vue @@ -32,6 +32,7 @@ const navigation = ref([ { to: '/simple-selects', title: 'Simple Selects' }, { to: '/data-tables', title: 'Data Tables' }, { to: '/tables', title: 'Tables' }, + { to: '/overlay-stack', title: 'Overlay stack' }, { to: '/dividers', title: 'Dividers' }, { to: '/cards', title: 'Cards' }, { to: '/tabs', title: 'Tabs' }, diff --git a/apps/example/src/components/AppSideNav.vue b/apps/example/src/components/AppSideNav.vue index 61826f82..83ff03e5 100644 --- a/apps/example/src/components/AppSideNav.vue +++ b/apps/example/src/components/AppSideNav.vue @@ -34,6 +34,7 @@ const route = useRoute(); class="relative" > +import OverlayStackView from '@/views/OverlayStackView.vue'; + + + diff --git a/apps/example/src/route-map.d.ts b/apps/example/src/route-map.d.ts index 6100a1bd..d9681b49 100644 --- a/apps/example/src/route-map.d.ts +++ b/apps/example/src/route-map.d.ts @@ -374,6 +374,13 @@ declare module 'vue-router/auto-routes' { Record, | never >, + '/overlay-stack': RouteRecordInfo< + '/overlay-stack', + '/overlay-stack', + Record, + Record, + | never + >, '/progress': RouteRecordInfo< '/progress', '/progress', @@ -877,6 +884,14 @@ declare module 'vue-router/auto-routes' { pathParamNames: | never } + 'src/pages/overlay-stack.vue': { + routes: + | '/overlay-stack' + views: + | never + pathParamNames: + | never + } 'src/pages/progress.vue': { routes: | '/progress' diff --git a/apps/example/src/router/index.ts b/apps/example/src/router/index.ts index 0d216849..96c2afe9 100644 --- a/apps/example/src/router/index.ts +++ b/apps/example/src/router/index.ts @@ -1,3 +1,4 @@ +import { useOverlayStack } from '@rotki/ui-library'; import { createRouter, createWebHistory } from 'vue-router'; import { routes } from 'vue-router/auto-routes'; @@ -17,3 +18,33 @@ export const router = createRouter({ }, routes, }); + +/** + * Turns a back gesture into "close the topmost overlay" whenever one is up. + * + * A dialog is not a history entry, so without this a back press pops the entry + * underneath it and leaves the page it was sitting on. Telling a pop from a push + * apart is the consumer's business rather than the library's, and vue-router + * already knows: its own history listener is handed the direction, and it runs + * before the guards because it is what starts the navigation. A raw `popstate` + * listener cannot be relied on here, since the restoration of an aborted pop is + * itself a pop and would read as a second gesture. + */ +const { dismissTop } = useOverlayStack(); + +let direction: string | undefined; + +router.options.history.listen((_to, _from, info) => { + direction = info.direction; +}); + +router.beforeEach(() => { + const isBack = direction === 'back'; + direction = undefined; + + if (!isBack) + return true; + + // Aborting the pop makes vue-router restore the entry it moved from + return !dismissTop(); +}); diff --git a/apps/example/src/views/OverlayStackView.vue b/apps/example/src/views/OverlayStackView.vue new file mode 100644 index 00000000..f4e985a0 --- /dev/null +++ b/apps/example/src/views/OverlayStackView.vue @@ -0,0 +1,146 @@ + + + diff --git a/packages/ui-library/src/components/overlays/dialog/RuiDialog.overlay-stack.spec.ts b/packages/ui-library/src/components/overlays/dialog/RuiDialog.overlay-stack.spec.ts new file mode 100644 index 00000000..6c5bed40 --- /dev/null +++ b/packages/ui-library/src/components/overlays/dialog/RuiDialog.overlay-stack.spec.ts @@ -0,0 +1,218 @@ +import { mount } from '@vue/test-utils'; +import { get } from '@vueuse/shared'; +import { afterEach, describe, expect, it } from 'vitest'; +import { nextTick } from 'vue'; +import RuiBottomSheet from '@/components/overlays/bottom-sheet/RuiBottomSheet.vue'; +import RuiDialog from '@/components/overlays/dialog/RuiDialog.vue'; +import { resetOverlayStack, useOverlayStack } from '@/composables/overlay-stack'; +import { cleanupElements } from '~/tests/helpers/dom-helpers'; + +describe('components/overlays/dialog/RuiDialog.vue overlay stack', () => { + afterEach(() => { + resetOverlayStack(); + cleanupElements('*', document.body); + }); + + it('should put an open dialog in the stack and take it out again', async () => { + const wrapper = mount(RuiDialog, { + props: { modelValue: false }, + slots: { default: '

content

' }, + }); + const { hasOverlay } = useOverlayStack(); + + expect(get(hasOverlay)).toBe(false); + + await wrapper.setProps({ modelValue: true }); + expect(get(hasOverlay)).toBe(true); + + await wrapper.setProps({ modelValue: false }); + expect(get(hasOverlay)).toBe(false); + + wrapper.unmount(); + }); + + it('should close on dismissal and say so', async () => { + const wrapper = mount(RuiDialog, { + props: { modelValue: true }, + slots: { default: '

content

' }, + }); + await nextTick(); + + const { dismissTop } = useOverlayStack(); + expect(dismissTop()).toBe(true); + await nextTick(); + + expect(wrapper.emitted('dismiss')).toHaveLength(1); + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false]); + + wrapper.unmount(); + }); + + it('should refuse while persistent, and still swallow the gesture', async () => { + const wrapper = mount(RuiDialog, { + props: { modelValue: true, persistent: true }, + slots: { default: '

content

' }, + }); + await nextTick(); + + const { dismissTop } = useOverlayStack(); + expect(dismissTop()).toBe(true); + await nextTick(); + + expect(wrapper.emitted('dismiss')).toHaveLength(1); + expect(wrapper.emitted('update:modelValue')).toBeUndefined(); + + wrapper.unmount(); + }); + + it('should stay in the stack when it turns persistent partway through', async () => { + const wrapper = mount(RuiDialog, { + props: { modelValue: true, persistent: false }, + slots: { default: '

content

' }, + }); + await nextTick(); + + await wrapper.setProps({ persistent: true }); + + const { dismissTop, hasOverlay } = useOverlayStack(); + expect(get(hasOverlay)).toBe(true); + expect(dismissTop()).toBe(true); + await nextTick(); + + expect(wrapper.emitted('update:modelValue')).toBeUndefined(); + + wrapper.unmount(); + }); + + it('should reach a bottom sheet through the dialog it wraps', async () => { + const wrapper = mount(RuiBottomSheet, { + props: { modelValue: true }, + slots: { default: '

content

' }, + }); + await nextTick(); + + const { dismissTop, hasOverlay } = useOverlayStack(); + expect(get(hasOverlay)).toBe(true); + + expect(dismissTop()).toBe(true); + await nextTick(); + + expect(wrapper.findComponent(RuiDialog).emitted('dismiss')).toHaveLength(1); + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false]); + expect(get(hasOverlay)).toBe(false); + + wrapper.unmount(); + }); + + it('should let go of the stack the moment it closes, not when it finishes leaving', async () => { + const wrapper = mount(RuiDialog, { + props: { modelValue: true }, + slots: { default: '

content

' }, + }); + await nextTick(); + + const { dismissTop, hasOverlay } = useOverlayStack(); + await wrapper.setProps({ modelValue: false }); + + /** + * A closing dialog is on its way out rather than covering the page, so it holds the + * stack no longer than `modelValue` says. How that overlaps its leave transition is + * not observable here: happy-dom runs no CSS, so Vue resolves the leave at once. + */ + expect(get(hasOverlay)).toBe(false); + expect(dismissTop()).toBe(false); + + wrapper.unmount(); + }); + + it('should keep escape and dismissal apart, so neither fires the other', async () => { + const wrapper = mount(RuiDialog, { + props: { modelValue: true }, + slots: { default: '

content

' }, + }); + await nextTick(); + + const { dismissTop } = useOverlayStack(); + dismissTop(); + await nextTick(); + + expect(wrapper.emitted('dismiss')).toHaveLength(1); + expect(wrapper.emitted('click:esc')).toBeUndefined(); + expect(wrapper.emitted('click:outside')).toBeUndefined(); + + wrapper.unmount(); + }); + + it('should keep handing gestures to a refusing dialog rather than the one underneath', async () => { + const lower = mount(RuiDialog, { props: { modelValue: true }, slots: { default: '

lower

' } }); + await nextTick(); + const upper = mount(RuiDialog, { + props: { modelValue: true, persistent: true }, + slots: { default: '

upper

' }, + }); + await nextTick(); + + const { dismissTop } = useOverlayStack(); + + expect(dismissTop()).toBe(true); + await nextTick(); + expect(upper.emitted('dismiss')).toHaveLength(1); + + // A persistent dialog never closes, so it never leaves the stack to be passed + expect(dismissTop()).toBe(true); + await nextTick(); + expect(upper.emitted('dismiss')).toHaveLength(2); + expect(lower.emitted('dismiss')).toBeUndefined(); + + upper.unmount(); + lower.unmount(); + }); + + it('should hand a bottom sheet consumer the dismissal through attribute fallthrough', async () => { + const wrapper = mount(RuiBottomSheet, { + props: { modelValue: true, persistent: true }, + slots: { default: '

content

' }, + }); + await nextTick(); + + const { dismissTop } = useOverlayStack(); + expect(dismissTop()).toBe(true); + await nextTick(); + + expect(wrapper.findComponent(RuiDialog).emitted('dismiss')).toHaveLength(1); + expect(wrapper.props('modelValue')).toBe(true); + + wrapper.unmount(); + }); + + it('should leave the stack when the dialog is torn down while open', async () => { + const wrapper = mount(RuiDialog, { + props: { modelValue: true }, + slots: { default: '

content

' }, + }); + await nextTick(); + + const { dismissTop } = useOverlayStack(); + expect(dismissTop()).toBe(true); + + wrapper.unmount(); + expect(dismissTop()).toBe(false); + }); + + it('should reach the dialog that opened last', async () => { + const lower = mount(RuiDialog, { props: { modelValue: true }, slots: { default: '

lower

' } }); + await nextTick(); + const upper = mount(RuiDialog, { props: { modelValue: true }, slots: { default: '

upper

' } }); + await nextTick(); + + const { dismissTop } = useOverlayStack(); + expect(dismissTop()).toBe(true); + await nextTick(); + + expect(upper.emitted('dismiss')).toHaveLength(1); + expect(lower.emitted('dismiss')).toBeUndefined(); + + upper.unmount(); + lower.unmount(); + }); +}); diff --git a/packages/ui-library/src/components/overlays/dialog/RuiDialog.vue b/packages/ui-library/src/components/overlays/dialog/RuiDialog.vue index c1372172..035870ef 100644 --- a/packages/ui-library/src/components/overlays/dialog/RuiDialog.vue +++ b/packages/ui-library/src/components/overlays/dialog/RuiDialog.vue @@ -1,5 +1,6 @@