Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions apps/example/e2e/overlay-stack.spec.ts
Original file line number Diff line number Diff line change
@@ -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$/);
});
});
1 change: 1 addition & 0 deletions apps/example/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
1 change: 1 addition & 0 deletions apps/example/src/components/AppSideNav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const route = useRoute();
class="relative"
>
<RouterLink
:data-id="typeof link.to === 'string' ? `link-${link.to.replace(/^\//, '') || 'index'}` : undefined"
class="block w-full pl-3.5 before:pointer-events-none before:absolute before:-left-1 before:top-1/2 before:h-1.5 before:w-1.5 before:-translate-y-1/2 before:rounded-full before:transition-all"
:class="{
'text-slate-500 dark:text-slate-400 before:hidden before:bg-slate-300 dark:before:bg-slate-700 hover:text-slate-600 dark:hover:text-slate-300 hover:before:block': link.to !== route.path,
Expand Down
7 changes: 7 additions & 0 deletions apps/example/src/pages/overlay-stack.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script lang="ts" setup>
import OverlayStackView from '@/views/OverlayStackView.vue';
</script>

<template>
<OverlayStackView />
</template>
15 changes: 15 additions & 0 deletions apps/example/src/route-map.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/overlay-stack': RouteRecordInfo<
'/overlay-stack',
'/overlay-stack',
Record<never, never>,
Record<never, never>,
| never
>,
'/progress': RouteRecordInfo<
'/progress',
'/progress',
Expand Down Expand Up @@ -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'
Expand Down
31 changes: 31 additions & 0 deletions apps/example/src/router/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useOverlayStack } from '@rotki/ui-library';
import { createRouter, createWebHistory } from 'vue-router';
import { routes } from 'vue-router/auto-routes';

Expand All @@ -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();
});
146 changes: 146 additions & 0 deletions apps/example/src/views/OverlayStackView.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<script lang="ts" setup>
import { RuiBottomSheet, RuiButton, RuiCard, RuiDialog, useOverlayStack } from '@rotki/ui-library';
import ComponentView from '@/components/ComponentView.vue';

const outer = ref<boolean>(false);
const inner = ref<boolean>(false);
const guarded = ref<boolean>(false);
const sheet = ref<boolean>(false);
const refusals = ref<number>(0);

const { hasOverlay } = useOverlayStack();

function onGuardedDismiss(): void {
set(refusals, get(refusals) + 1);
}
</script>

<template>
<ComponentView data-id="overlay-stack">
<template #title>
Overlay stack
</template>

<div class="flex flex-col gap-4 items-start">
<p
class="text-body-1"
data-id="has-overlay"
>
Something is covering the page: {{ hasOverlay }}
</p>

<div class="flex gap-3">
<RuiButton
color="primary"
data-id="open-outer"
@click="outer = true"
>
Open a dialog
</RuiButton>
<RuiButton
color="warning"
data-id="open-guarded"
@click="guarded = true"
>
Open a persistent one
</RuiButton>
<RuiButton
color="secondary"
data-id="open-sheet"
@click="sheet = true"
>
Open a bottom sheet
</RuiButton>
</div>

<p
class="text-body-2 text-rui-text-secondary"
data-id="refusals"
>
The persistent dialog refused {{ refusals }} times
</p>
</div>

<RuiDialog
v-model="outer"
data-id="outer-dialog"
max-width="500px"
>
<RuiCard>
<template #header>
Outer
</template>
<p class="mb-4">
Press back and this closes instead of leaving the page.
</p>
<div class="flex gap-3 items-center">
<RuiButton
color="primary"
data-id="open-inner"
@click="inner = true"
>
Open another on top
</RuiButton>
<RouterLink
class="text-rui-primary underline"
data-id="link-from-dialog"
to="/tables"
>
Go somewhere else
</RouterLink>
</div>

<RuiDialog
v-model="inner"
data-id="inner-dialog"
max-width="400px"
>
<RuiCard>
<template #header>
Inner
</template>
<p>Back takes this one first, then the outer one.</p>
</RuiCard>
</RuiDialog>
</RuiCard>
</RuiDialog>

<RuiBottomSheet
v-model="sheet"
data-id="sheet"
>
<RuiCard>
<template #header>
Bottom sheet
</template>
<p data-id="sheet-body">
A sheet is a dialog, so back reaches it the same way.
</p>
</RuiCard>
</RuiBottomSheet>

<RuiDialog
v-model="guarded"
data-id="guarded-dialog"
max-width="500px"
persistent
@dismiss="onGuardedDismiss()"
>
<RuiCard>
<template #header>
Persistent
</template>
<p class="mb-4">
Back is swallowed rather than passed through, and this stays up.
</p>
<RuiButton
color="primary"
data-id="close-guarded"
@click="guarded = false"
>
Close
</RuiButton>
</RuiCard>
</RuiDialog>
</ComponentView>
</template>
Loading
Loading