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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ node_modules/

# IDE files
.idea/

# e2e artifacts
test-results/
playwright-report/
Binary file modified .yarn/install-state.gz
Binary file not shown.
2 changes: 1 addition & 1 deletion packages/dom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"description": "Utils useful for work with dom for Editor.js tools development",
"repository": "https://github.com/editor-js/utils/tree/main/packages/dom",
"link": "https://github.com/editor-js/utils/tree/main/packages/dom",
"version": "1.1.0",
"version": "1.2.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
Expand Down
7 changes: 7 additions & 0 deletions packages/dom/src/domIterator/domIterator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export class DomIterator {
this.focusedCssClass = focusedCssClass;
}

/**
* Returns the list of items being iterated
*/
public get allItems(): HTMLElement[] {
return this.items;
}

/**
* Returns Focused button Node
*/
Expand Down
51 changes: 51 additions & 0 deletions packages/dom/src/flipper/flipper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ export interface FlipperOptions {
* Callback to set caret to the current element if possible. If not provided, caret is not set
*/
setCaret?: (item: HTMLElement) => void;

/**
* If true, flipper moves real DOM focus to the current item and maintains a roving tabindex
* over the items: the current one becomes tabbable, the rest do not.
*
* Off by default, since moving focus may conflict with the caret position management
* of the module that uses the flipper.
*/
focusItems?: boolean;
}

/**
Expand Down Expand Up @@ -81,6 +90,11 @@ export class Flipper {
*/
private setCaret?: (item: HTMLElement) => void;

/**
* True if flipper should move real DOM focus to the current item
*/
private readonly focusItems: boolean;

/**
* @param options - different constructing settings
*/
Expand All @@ -89,6 +103,7 @@ export class Flipper {
this.activateCallback = options.activateCallback;
this.allowedKeys = options.allowedKeys || Flipper.usedKeys;
this.setCaret = options.setCaret;
this.focusItems = options.focusItems === true;
}

/**
Expand Down Expand Up @@ -121,6 +136,7 @@ export class Flipper {

if (cursorPosition !== undefined) {
this.iterator.setCursor(cursorPosition);
this.updateFocus();
}

/**
Expand All @@ -141,6 +157,7 @@ export class Flipper {
public deactivate(): void {
this.activated = false;
this.dropCursor();
this.resetFocus();

document.removeEventListener('keydown', this.onKeyDown);
}
Expand Down Expand Up @@ -298,10 +315,44 @@ export class Flipper {
}
}

/**
* Moves real DOM focus to the current item and makes it the only tabbable one.
* Does nothing unless the flipper is constructed with the 'focusItems' option
*/
private updateFocus(): void {
if (!this.focusItems) {
return;
}

const currentItem = this.iterator.currentItem;

this.iterator.allItems.forEach((item) => {
item.tabIndex = item === currentItem ? 0 : -1;
});

/** Scrolling is handled separately, right after the flip */
currentItem?.focus({ preventScroll: true });
}

/**
* Makes all the items untabbable. Called once the flipper is deactivated,
* so that items of a closed popover do not catch the Tab key
*/
private resetFocus(): void {
if (!this.focusItems) {
return;
}

this.iterator.allItems.forEach((item) => {
item.tabIndex = -1;
});
}

/**
* Fired after flipping in any direction
*/
private flipCallback(): void {
this.updateFocus();
this.setCaretToCurrentItem();

if (this.iterator.currentItem) {
Expand Down
38 changes: 38 additions & 0 deletions packages/ui-kit/e2e/fixtures/inline-selection.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>UI Kit e2e — inline popover over a selection</title></head>
<body>
<div id="editable" contenteditable="true" style="width:400px;padding:12px;border:1px solid #ccc">Some bold text here</div>
<div id="anchor" style="position:relative"></div>
<script type="module">
import { PopoverInline, PopoverEvent } from '/src/index.ts';
const icon = '<svg width="16" height="16" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>';
const popover = new PopoverInline({
scopeElement: document.body,
items: [
{ icon, title: 'Bold', name: 'bold', toggle: true, onActivate: () => { document.execCommand('bold'); } },
{ icon, title: 'Italic', name: 'italic', toggle: true, onActivate: () => {} },
],
});
document.getElementById('anchor').appendChild(popover.getElement());

const editable = document.getElementById('editable');

/**
* The popover never moves real DOM focus here (it would drop the text selection), so it's up
* to the consumer to point aria-activedescendant at the highlighted item from whichever
* element actually holds focus - the editable itself in this case
*/
popover.on(PopoverEvent.ActiveDescendantChanged, (id) => {
if (id === null) {
editable.removeAttribute('aria-activedescendant');
} else {
editable.setAttribute('aria-activedescendant', id);
}
});

window.popover = popover;
document.body.dataset.ready = 'true';
</script>
</body>
</html>
66 changes: 66 additions & 0 deletions packages/ui-kit/e2e/fixtures/inline.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>UI Kit e2e — inline</title>
</head>
<body>
<div style="position: relative; width: 500px; padding: 12px;">
<button id="before">Before</button>
<div style="position: relative;">
<button id="trigger">Open inline toolbar</button>
</div>
</div>

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

const icon = '<svg width="16" height="16" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>';

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 PopoverInline({
scopeElement: document.body,
items: [
/**
* Icon-only item that gets its name from the title param
*/
{
icon,
title: 'Bold',
name: 'bold',
toggle: true,
isActive: () => true,
onActivate: activate('bold'),
},
/**
* Icon-only item that has no title at all and gets its name from the hint
*/
{
icon,
name: 'italic',
toggle: true,
isActive: () => false,
hint: {
title: 'Italic',
description: 'CMD + I',
},
onActivate: activate('italic'),
},
],
});

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

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

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

const icon = '<svg width="16" height="16" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>';

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 customElement = document.createElement('div');
const customButton = document.createElement('button');

customButton.textContent = 'Custom control';
customElement.appendChild(customButton);

const popover = new PopoverDesktop({
scopeElement: document.body,
searchable: true,
items: [
{
icon,
title: 'Simple Item',
name: 'simple',
onActivate: activate('simple'),
},
{
icon,
title: 'Align Left',
name: 'align-left',
toggle: 'align',
isActive: true,
onActivate: activate('align-left'),
},
{
icon,
title: 'Align Center',
name: 'align-center',
toggle: 'align',
onActivate: activate('align-center'),
},
{
icon,
title: 'Bold',
name: 'bold',
toggle: true,
hint: { title: 'Bold', description: 'CMD + B' },
onActivate: activate('bold'),
},
{
icon,
title: 'Disabled Item',
name: 'disabled',
isDisabled: true,
onActivate: activate('disabled'),
},
{
icon,
title: 'Has children',
name: 'with-children',
children: {
items: [
{
icon,
title: 'Child A',
name: 'child-a',
onActivate: activate('child-a'),
},
{
icon,
title: 'Child B',
name: 'child-b',
onActivate: activate('child-b'),
},
],
},
},
{
icon,
title: 'Delete',
name: 'delete',
confirmation: {
icon,
title: 'Are you sure?',
name: 'delete-confirm',
onActivate: activate('delete-confirm'),
},
},
{
icon,
title: 'Visible title',
name: 'custom-semantics',
ariaLabel: 'Custom accessible name',
role: 'button',
onActivate: activate('custom-semantics'),
},
{ type: PopoverItemType.Separator },
{
type: PopoverItemType.Html,
element: customElement,
name: 'html-item',
},
],
});

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

window.popover = popover;
document.body.dataset.ready = 'true';
</script>
</body>
</html>
Loading
Loading