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
70 changes: 70 additions & 0 deletions packages/ui-kit/e2e/fixtures/mobile-nested-input.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>UI Kit e2e — mobile with a non-flippable nested level</title>
</head>
<body>
<div style="position: relative; width: 100%; padding: 12px;">
<button id="before">Before</button>
<div style="position: relative;">
<button id="trigger">Open mobile popover</button>
</div>
</div>

<script type="module">
import { PopoverMobile, PopoverItemType } from '/src/index.ts';

window.__activated = [];

/**
* Records item activation so tests can assert it without relying on visuals
* @param {string} name - name of the activated item
*/
const activate = (name) => () => window.__activated.push(name);

const nestedInput = document.createElement('input');

nestedInput.setAttribute('aria-label', 'Nested input');

const popover = new PopoverMobile({
scopeElement: document.body,
items: [
{
title: 'Simple item',
name: 'simple',
onActivate: activate('simple'),
},
{
title: 'Has children',
name: 'with-children',
children: {
/** The nested level is a form, not a menu: its keys belong to the input */
isFlippable: false,
items: [
{
type: PopoverItemType.Html,
element: nestedInput,
name: 'input-item',
},
/** A second stop, so an arrow press has somewhere to move the focus to */
{
title: 'Child A',
name: 'child-a',
onActivate: activate('child-a'),
},
],
},
},
],
});

document.body.appendChild(popover.getElement());
document.getElementById('trigger').addEventListener('click', () => popover.show());

window.popover = popover;
document.body.dataset.ready = 'true';
</script>
</body>
</html>
61 changes: 61 additions & 0 deletions packages/ui-kit/e2e/fixtures/mobile-self-closing-children.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>UI Kit e2e — mobile with a self-closing nested level</title>
</head>
<body>
<div style="position: relative; width: 100%; padding: 12px;">
<div style="position: relative;">
<button id="trigger">Open mobile popover</button>
</div>
</div>

<script type="module">
import { PopoverMobile } from '/src/index.ts';

window.__activated = [];

/**
* Records item activation so tests can assert it without relying on visuals
* @param {string} name - name of the activated item
*/
const activate = (name) => () => window.__activated.push(name);

const popover = new PopoverMobile({
scopeElement: document.body,
items: [
{
title: 'Simple item',
name: 'simple',
onActivate: activate('simple'),
},
{
title: 'Closes itself',
name: 'self-closing',
children: {
/**
* Closes the level it was just handed, synchronously, the way a tool that decides it
* has nothing to show would. The popover has to end up back on the root level
*/
onOpen: (close) => close(),
items: [
{
title: 'Never seen',
name: 'never-seen',
onActivate: activate('never-seen'),
},
],
},
},
],
});

document.body.appendChild(popover.getElement());
document.getElementById('trigger').addEventListener('click', () => popover.show());

window.popover = popover;
document.body.dataset.ready = 'true';
</script>
</body>
</html>
70 changes: 69 additions & 1 deletion packages/ui-kit/e2e/tests/header-and-search.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import { hidePopover, showPopover } from './utils';
import { addItem, hidePopover, removeItemByName, showPopover } from './utils';

test.describe('search input', () => {
test.beforeEach(async ({ page }) => {
Expand Down Expand Up @@ -94,6 +94,36 @@ test.describe('search input', () => {
await expect(page.getByRole('menuitem', { name: 'Simple Item' })).toBeFocused();
});

test('an item added while a query is typed is filtered by that query', async ({ page }) => {
/**
* Changing the item list leaves the results describing a list that no longer exists: the
* newcomer used to show up among the matches whether it matched or not, and the announced
* count went with it
*/
await page.getByRole('searchbox', { name: 'Search' }).fill('Align');
await expect(page.getByRole('menuitemradio')).toHaveCount(2);

await addItem(page, {
title: 'Align Right',
name: 'align-right',
toggle: 'align',
});

const matchesAfterAdding = 3;

await expect(page.getByRole('menuitemradio')).toHaveCount(matchesAfterAdding);
await expect(page.getByRole('status').first()).toHaveText(`${matchesAfterAdding} results`);

await addItem(page, {
title: 'Strikethrough',
name: 'strike',
});

/** Does not match, so it stays out of the results rather than joining them */
await expect(page.getByRole('menuitem', { name: 'Strikethrough' })).toHaveCount(0);
await expect(page.locator('[data-item-name="strike"]')).toBeHidden();
});

test('arrow navigation after clicking a result stays within the matches', async ({ page }) => {
await page.getByRole('searchbox', { name: 'Search' }).fill('Align');

Expand All @@ -114,6 +144,44 @@ test.describe('search input', () => {
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitemradio', { name: 'Align Center' })).toBeFocused();
});

test('keeps the navigation cursor on the focused result when an item is added', async ({ page }) => {
await page.getByRole('searchbox', { name: 'Search' }).fill('Align');

await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitemradio', { name: 'Align Left' })).toBeFocused();

/**
* Adding an item reapplies the query, which restarts the Flipper. Without a cursor to
* resume from, the next arrow press would silently start over from the top of the list
* while the focus stayed where the user left it
*/
await addItem(page, {
title: 'Added item',
name: 'added',
});

await expect(page.getByRole('menuitemradio', { name: 'Align Left' })).toBeFocused();

await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitemradio', { name: 'Align Center' })).toBeFocused();
});

test('keeps the navigation cursor on the focused result when an item is removed', async ({ page }) => {
await page.getByRole('searchbox', { name: 'Search' }).fill('Align');

await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitemradio', { name: 'Align Center' })).toBeFocused();

/** Filtered out by the query, so the results the user is navigating do not change */
await removeItemByName(page, 'bold');

await expect(page.getByRole('menuitemradio', { name: 'Align Center' })).toBeFocused();

await page.keyboard.press('ArrowUp');
await expect(page.getByRole('menuitemradio', { name: 'Align Left' })).toBeFocused();
});
});

test.describe('mobile popover header', () => {
Expand Down
111 changes: 111 additions & 0 deletions packages/ui-kit/e2e/tests/mobile-dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,29 @@ test.describe('mobile popover', () => {
await expect(back).toBeFocused();
});

test('a nested level with isFlippable false leaves its keys to its own controls', async ({ page }) => {
/**
* Nested levels render into the same panel as the root one, so the Flipper that navigates
* the root list would carry on claiming the arrows and Enter here too - and an item built
* around a text input needs both for itself
*/
await showPopover(page, 'mobileNestedInput');

await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');

const input = page.getByRole('textbox', { name: 'Nested input' });

await expect(input).toBeVisible();

await input.fill('editorjs');
await page.keyboard.press('ArrowDown');

/** The Flipper would have moved the focus off to the next item by now */
await expect(input).toBeFocused();
await expect(input).toHaveValue('editorjs');
});

test('Enter drills into a nested item', async ({ page }) => {
await showPopover(page, 'mobile');

Expand Down Expand Up @@ -311,6 +334,94 @@ test.describe('mobile dialog focus edge cases', () => {
});
});

test.describe('nested levels', () => {
test('returns to the root level when a nested one closes itself as it opens', async ({ page }) => {
await showPopover(page, 'mobileSelfClosingChildren');

await page.getByText('Closes itself').click();

/**
* The level pushes its state before rendering, so the close that arrives while it is still
* opening pops that state rather than the root one underneath it
*/
await expect(page.getByRole('menuitem', { name: 'Simple item' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Closes itself' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Never seen' })).toHaveCount(0);
});
});

test.describe('non-flippable nested level', () => {
test.beforeEach(async ({ page }) => {
await showPopover(page, 'mobileNestedInput');

await page.getByText('Has children').click();
});

test('leaves the arrows to the items, however the focus got there', async ({ page }) => {
const input = page.getByLabel('Nested input');

await input.focus();

/**
* Focus arriving on an item of a level that opted out must not move the navigation cursor
* there: that would re-activate the Flipper, and the arrows would stop reaching the input
*/
await page.keyboard.press('ArrowDown');
await expect(input).toBeFocused();

await page.keyboard.press('ArrowUp');
await expect(input).toBeFocused();
});

test('walks its items with Tab instead', async ({ page }) => {
await page.keyboard.press('Tab');
await expect(page.getByLabel('Nested input')).toBeFocused();

await page.keyboard.press('Tab');
await expect(page.getByRole('menuitem', { name: 'Child A' })).toBeFocused();
});
});

test.describe('reopening', () => {
test('comes back to the root level after being closed on a nested one', async ({ page }) => {
await showPopover(page, 'mobileNestedInput');

await page.getByText('Has children').click();

await expect(page.getByRole('menuitem', { name: 'Child A' })).toBeVisible();

await page.keyboard.press('Escape');
await callShow(page);

/**
* Closing resets the level history, so the panel has to come back up showing the root items
* rather than the level the user happened to be on when it was dismissed
*/
await expect(page.getByRole('menuitem', { name: 'Simple item' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Child A' })).toHaveCount(0);
});

test('does not restore keyboard navigation to a level that opted out of it', async ({ page }) => {
await showPopover(page, 'mobileNestedInput');

await page.getByText('Has children').click();
await page.keyboard.press('Escape');
await callShow(page);

/**
* The nested level is not navigable, and reopening used to leave its items on screen while
* restoring the root's own flippability - the arrows would then navigate a level whose
* items need those keys for themselves
*/
await expect(page.getByRole('menuitem', { name: 'Simple item' })).toBeFocused();

await page.keyboard.press('ArrowDown');

await expect(page.getByRole('menuitem', { name: 'Has children' })).toBeFocused();
await expect(page.getByLabel('Nested input')).toHaveCount(0);
});
});

test.describe('mobile dialog name', () => {
test('is named even without a label of its own', async ({ page }) => {
await showPopover(page, 'mobile');
Expand Down
2 changes: 2 additions & 0 deletions packages/ui-kit/e2e/tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const fixtures = {
confirmationToggle: '/e2e/fixtures/confirmation-toggle.html',
mobilePlain: '/e2e/fixtures/mobile-plain.html',
mobileEmpty: '/e2e/fixtures/mobile-empty.html',
mobileNestedInput: '/e2e/fixtures/mobile-nested-input.html',
mobileSelfClosingChildren: '/e2e/fixtures/mobile-self-closing-children.html',
} as const;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,24 @@ export class SearchInput extends EventsDispatcher<SearchInputEventMap> {
this.items = items;
}

/**
* Runs the query that is already typed in against the item list again.
*
* Adding or removing an item leaves the results describing a list that no longer exists:
* the newcomer shows up among the matches whether it matches or not, and the reported count
* is off. Re-running the query brings both back in sync without the user retyping it
*/
public reapplyQuery(): void {
if (this.searchQuery === undefined || this.searchQuery === '') {
return;
}

this.emit(SearchInputEvent.Search, {
query: this.searchQuery,
items: this.foundItems,
});
}

/**
* Returns search field element
*/
Expand Down
Loading
Loading