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>
32 changes: 31 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, 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 Down
23 changes: 23 additions & 0 deletions packages/ui-kit/e2e/tests/mobile-dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,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
1 change: 1 addition & 0 deletions packages/ui-kit/e2e/tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const fixtures = {
inlineSelection: '/e2e/fixtures/inline-selection.html',
plainMenu: '/e2e/fixtures/plain-menu.html',
nestedInput: '/e2e/fixtures/nested-input.html',
mobileNestedInput: '/e2e/fixtures/mobile-nested-input.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
2 changes: 2 additions & 0 deletions packages/ui-kit/src/popover/popover-desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ export class PopoverDesktop extends PopoverAbstract {

if (this.search !== undefined) {
this.search.updateItems(this.itemsDefault);
this.search.reapplyQuery();
}
}

Expand All @@ -261,6 +262,7 @@ export class PopoverDesktop extends PopoverAbstract {

if (this.search !== undefined) {
this.search.updateItems(this.itemsDefault);
this.search.reapplyQuery();
}
}

Expand Down
45 changes: 38 additions & 7 deletions packages/ui-kit/src/popover/popover-mobile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
*/
private previouslyFocusedElement: HTMLElement | null = null;

/**
* Whether the items currently on screen take part in keyboard navigation.
* Nested levels opt out of it via children.isFlippable, and since they are rendered into the
* same panel rather than into a popover of their own, the flag has to travel with the level
*/
private isLevelFlippable = true;

/**
* Construct the instance
* @param params - popover params object
Expand Down Expand Up @@ -120,6 +127,14 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
this.history.push({ items: params.items });
}

/**
* The items share a single Tab stop only while the level they belong to is navigable.
* A level that opted out is walked by Tab like a plain list instead
*/
protected override get hasRovingTabindex(): boolean {
return this.isLevelFlippable && super.hasRovingTabindex;
}

/**
* Open popover
*/
Expand All @@ -133,7 +148,10 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {

this.scrollLocker.lock();

this.flipper?.activate(this.flippableElements);
if (this.isLevelFlippable) {
this.flipper?.activate(this.flippableElements);
}

this.toggleItemsTabbable(true);

this.listeners.on(document, 'keydown', this.handleKeyDown as (event: Event) => void, { capture: true });
Expand Down Expand Up @@ -169,6 +187,7 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
this.listeners.off(document, 'keydown', this.handleKeyDown as (event: Event) => void, { capture: true });

this.history.reset();
this.isLevelFlippable = this.history.currentIsFlippable;

this.isHidden = true;

Expand All @@ -193,15 +212,15 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
*/
protected override showNestedItems(item: PopoverItemDefault): void {
/** Show nested items */
this.updateItemsAndHeader(item.children, item.title);
this.updateItemsAndHeader(item.children, item.title, item.isChildrenFlippable);

const close = (parent?: boolean): void => {
if (parent === true) {
this.hide();
} else {
this.history.pop();

this.updateItemsAndHeader(this.history.currentItems, this.history.currentTitle);
this.updateItemsAndHeader(this.history.currentItems, this.history.currentTitle, this.history.currentIsFlippable);
}
};

Expand All @@ -210,6 +229,7 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
this.history.push({
title: item.title,
items: item.children,
isFlippable: item.isChildrenFlippable,
});
}

Expand Down Expand Up @@ -287,7 +307,7 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
* Flipper to point at, so the trap's own first stop is used instead
*/
private focusFirstElement(): void {
if (this.flippableElements.length > 0) {
if (this.isLevelFlippable && this.flippableElements.length > 0) {
this.flipper?.focusFirst();

return;
Expand Down Expand Up @@ -323,8 +343,9 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
* Removes rendered popover items and header and displays new ones
* @param items - new popover items
* @param title - new popover header text
* @param isFlippable - false if the new items opted out of keyboard navigation
*/
private updateItemsAndHeader(items: PopoverItemParams[], title?: string): void {
private updateItemsAndHeader(items: PopoverItemParams[], title?: string, isFlippable = true): void {
/** Re-render header */
if (this.header !== null && this.header !== undefined) {
this.header.destroy();
Expand All @@ -337,7 +358,7 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {
onBackButtonClick: () => {
this.history.pop();

this.updateItemsAndHeader(this.history.currentItems, this.history.currentTitle);
this.updateItemsAndHeader(this.history.currentItems, this.history.currentTitle, this.history.currentIsFlippable);
},
});
const headerEl = this.header.getElement();
Expand All @@ -358,14 +379,24 @@ export class PopoverMobile extends PopoverAbstract<PopoverMobileNodes> {

this.renderItems(this.items);

this.isLevelFlippable = isFlippable;

if (!this.isHidden) {
/**
* Deactivated before being re-activated, so that the Flipper drops its cursor while it
* still points into the old list - the new one may well be shorter than the position
* the cursor is left at
*/
this.flipper?.deactivate();
this.flipper?.activate(this.flippableElements);

/**
* A level that opted out of keyboard navigation leaves the Flipper deactivated, so it
* stops claiming the arrows and Enter: an item holding a text input needs those for
* itself. Its items become individual stops of the panel's Tab trap instead
*/
if (this.isLevelFlippable) {
this.flipper?.activate(this.flippableElements);
}

/** Element that was focused has just been removed, so focus is moved into the new list */
this.toggleItemsTabbable(true);
Expand Down
17 changes: 17 additions & 0 deletions packages/ui-kit/src/popover/utils/popover-states-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ interface PopoverStatesHistoryItem {
* Popover items
*/
items: PopoverItemParams[];

/**
* False if the items of this state opted out of keyboard navigation.
* Undefined is treated as navigable, which is the default for every level
*/
isFlippable?: boolean;
}

/**
Expand Down Expand Up @@ -61,6 +67,17 @@ export class PopoverStatesHistory {
return this.history[this.history.length - 1].items;
}

/**
* Whether the items of the current state take part in keyboard navigation
*/
public get currentIsFlippable(): boolean {
if (this.history.length === 0) {
return true;
}

return this.history[this.history.length - 1].isFlippable !== false;
}

/**
* Returns history to initial popover state
*/
Expand Down
Loading