Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Mover): Connected Movers. #395

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
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
6 changes: 6 additions & 0 deletions src/Consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ export const MoverDirections = {
GridLinear: 4, // Two-dimentional movement depending on the visual placement. Allows linear movement.
} as const;

export const MoverConnections = {
All: 1, // Sets connectivity with both parent and child Movers.
Parent: 2, // Sets connectivity with parent Movers.
Child: 3, // Sets connectivity with child Movers.
} as const;

export const MoverKeys = {
ArrowUp: 1,
ArrowDown: 2,
Expand Down
140 changes: 117 additions & 23 deletions src/Mover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import { getTabsterOnElement } from "./Instance";
import { Keys } from "./Keys";
import { RootAPI } from "./Root";
import * as Types from "./Types";
import { Visibilities, MoverDirections, MoverKeys } from "./Consts";
import {
Visibilities,
MoverDirections,
MoverKeys,
MoverConnections,
} from "./Consts";
import {
MoverMemorizedElementEvent,
MoverMemorizedElementEventName,
Expand All @@ -31,6 +36,7 @@ import {
TabsterPart,
WeakHTMLElement,
getDummyInputContainer,
getLastChild,
} from "./Utils";
import { dom } from "./DOMAPI";

Expand Down Expand Up @@ -682,6 +688,21 @@ function getDistance(
: Math.sqrt(xDistance * xDistance + yDistance * yDistance);
}

function isMoverConnected(
parentMover: Types.Mover,
childMover: Types.Mover
): boolean {
const parentMoverConnected = parentMover.getProps().connected;
const childMoverConnected = childMover.getProps().connected;

return (
(parentMoverConnected === MoverConnections.All ||
parentMoverConnected === MoverConnections.Child) &&
(childMoverConnected === MoverConnections.All ||
childMoverConnected === MoverConnections.Parent)
);
}

export class MoverAPI implements Types.MoverAPI {
private _tabster: Types.TabsterCore;
private _win: Types.GetWindow;
Expand Down Expand Up @@ -806,11 +827,16 @@ export class MoverAPI implements Types.MoverAPI {
private _moveFocus(
fromElement: HTMLElement,
key: Types.MoverKey,
relatedEvent?: KeyboardEvent
relatedEvent?: KeyboardEvent,
connectedMover?: Types.Mover,
connectedMoverLookInside?: boolean
): HTMLElement | null {
const tabster = this._tabster;
const ctx = RootAPI.getTabsterContext(tabster, fromElement, {
checkRtl: true,
// No need to check RTL when going through the chain of
// connected Movers, because the first in chain handler has
// checked it already and flipped the key values accordingly.
checkRtl: !connectedMover,
});

if (
Expand All @@ -822,10 +848,39 @@ export class MoverAPI implements Types.MoverAPI {
return null;
}

const mover = ctx.mover;
const container = mover.getElement();
const mover = connectedMover || ctx.mover;
const moverElement = mover?.getElement();

if (!connectedMover && ctx.groupperBeforeMover) {
// Groupper has dominance over Mover when they are on the same node: the context
// of the element which has Mover and Groupper on the same node,
// will return the Groupper of that element and a parent Mover (if any).
// The code below pokes into that same node Mover and allows it to handle the key
// press if the Mover is connected with the parent Mover.
connectedMover = getTabsterOnElement(tabster, fromElement)?.mover;
const connectedMoverElement = connectedMover?.getElement();

if (
connectedMover &&
isMoverConnected(mover, connectedMover) &&
connectedMoverElement
) {
const next = this._moveFocus(
connectedMoverElement,
key,
relatedEvent,
connectedMover,
true
);

if (next) {
// The element is found in the inner Mover, we don't need to do anything else.
return next;
}

// Resuming the fromElement's context Mover handling.
}

if (ctx.groupperBeforeMover) {
const groupper = ctx.groupper;

if (groupper && !groupper.isActive(true)) {
Expand All @@ -834,7 +889,7 @@ export class MoverAPI implements Types.MoverAPI {
for (
let el: HTMLElement | null | undefined =
dom.getParentElement(groupper.getElement());
el && el !== container;
el && el !== moverElement;
el = dom.getParentElement(el)
) {
if (
Expand All @@ -850,7 +905,7 @@ export class MoverAPI implements Types.MoverAPI {
}
}

if (!container) {
if (!moverElement || !mover) {
return null;
}

Expand Down Expand Up @@ -889,9 +944,18 @@ export class MoverAPI implements Types.MoverAPI {
(key === MoverKeys.ArrowDown && isVertical) ||
(key === MoverKeys.ArrowRight && (isHorizontal || isGrid))
) {
if (!connectedMoverLookInside) {
// If we are poking inside the connected Mover, we want first focusable
// element insinde that connected Mover to be found.
// But if we are asking parent connected Mover to find next element,
// we want it to start looking past the current Mover, so, we're setting,
// the very last child of the current Mover as the starting point.
fromElement = getLastChild(fromElement) || fromElement;
}

next = focusable.findNext({
currentElement: fromElement,
container,
container: moverElement,
useActiveModalizer: true,
});

Expand All @@ -905,7 +969,7 @@ export class MoverAPI implements Types.MoverAPI {
}
} else if (!next && isCyclic) {
next = focusable.findFirst({
container,
container: moverElement,
useActiveModalizer: true,
});
}
Expand All @@ -915,7 +979,7 @@ export class MoverAPI implements Types.MoverAPI {
) {
next = focusable.findPrev({
currentElement: fromElement,
container,
container: moverElement,
useActiveModalizer: true,
});

Expand All @@ -929,14 +993,14 @@ export class MoverAPI implements Types.MoverAPI {
}
} else if (!next && isCyclic) {
next = focusable.findLast({
container,
container: moverElement,
useActiveModalizer: true,
});
}
} else if (key === MoverKeys.Home) {
if (isGrid) {
focusable.findElement({
container,
container: moverElement,
currentElement: fromElement,
useActiveModalizer: true,
isBackward: true,
Expand All @@ -962,14 +1026,14 @@ export class MoverAPI implements Types.MoverAPI {
});
} else {
next = focusable.findFirst({
container,
container: moverElement,
useActiveModalizer: true,
});
}
} else if (key === MoverKeys.End) {
if (isGrid) {
focusable.findElement({
container,
container: moverElement,
currentElement: fromElement,
useActiveModalizer: true,
acceptCondition: (el) => {
Expand All @@ -994,14 +1058,14 @@ export class MoverAPI implements Types.MoverAPI {
});
} else {
next = focusable.findLast({
container,
container: moverElement,
useActiveModalizer: true,
});
}
} else if (key === MoverKeys.PageUp) {
focusable.findElement({
currentElement: fromElement,
container,
container: moverElement,
useActiveModalizer: true,
isBackward: true,
acceptCondition: (el) => {
Expand Down Expand Up @@ -1031,7 +1095,7 @@ export class MoverAPI implements Types.MoverAPI {
);
focusable.findElement({
currentElement: next,
container,
container: moverElement,
useActiveModalizer: true,
acceptCondition: (el) => {
if (!focusable.isFocusable(el)) {
Expand All @@ -1057,7 +1121,7 @@ export class MoverAPI implements Types.MoverAPI {
} else if (key === MoverKeys.PageDown) {
focusable.findElement({
currentElement: fromElement,
container,
container: moverElement,
useActiveModalizer: true,
acceptCondition: (el) => {
if (!focusable.isFocusable(el)) {
Expand Down Expand Up @@ -1086,7 +1150,7 @@ export class MoverAPI implements Types.MoverAPI {
);
focusable.findElement({
currentElement: next,
container,
container: moverElement,
useActiveModalizer: true,
isBackward: true,
acceptCondition: (el) => {
Expand Down Expand Up @@ -1123,7 +1187,7 @@ export class MoverAPI implements Types.MoverAPI {
let lastIntersection = 0;

focusable.findAll({
container,
container: moverElement,
currentElement: fromElement,
isBackward,
onElement: (el) => {
Expand Down Expand Up @@ -1196,10 +1260,10 @@ export class MoverAPI implements Types.MoverAPI {
next &&
(!relatedEvent ||
(relatedEvent &&
container.dispatchEvent(
moverElement.dispatchEvent(
new TabsterMoveFocusEvent({
by: "mover",
owner: container,
owner: moverElement,
next,
relatedEvent,
})
Expand All @@ -1219,6 +1283,36 @@ export class MoverAPI implements Types.MoverAPI {
return next;
}

if (!connectedMover || connectedMover === ctx.mover) {
// Arrow key has no result in the current conext Mover, let's go up the DOM to
// see if there is a connected Mover that can handle the key.
for (
let el: HTMLElement | null = moverElement.parentElement;
el;
el = el.parentElement
) {
const moverOnElement = getTabsterOnElement(
this._tabster,
el
)?.mover;

if (moverOnElement) {
if (isMoverConnected(moverOnElement, mover)) {
// Found connected Mover, trying within its context.
return this._moveFocus(
moverElement,
key,
relatedEvent,
moverOnElement
);
}

// Closest Mover is not connected, stop going up.
break;
}
}
}

return null;
}

Expand Down
39 changes: 39 additions & 0 deletions src/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,11 @@ export type MoverDirections = typeof _MoverDirections;

export type MoverDirection = MoverDirections[keyof MoverDirections];

import { MoverConnections as _MoverConnections } from "./Consts";
export type MoverConnections = typeof _MoverConnections;

export type MoverConnected = MoverConnections[keyof MoverConnections];

export interface NextTabbable {
element: HTMLElement | null | undefined;
uncontrolled?: HTMLElement | null;
Expand Down Expand Up @@ -717,6 +722,40 @@ export interface MoverProps {
* @default 0.8
*/
visibilityTolerance?: number;
/**
* Movers could be connected via DOM hierarchy.
*
* Could be undefined, MoverConnections.All, MoverConnections.Parent or
* MoverConnections.Child.
*
* When nested Movers are mutually connected in the DOM (meaning that
* parent Mover DOM element has `connected` property set to Child and child
* Mover is connected to Parent), the focus is inside child Mover and pressing
* arrow key hasn't moved focus (for example Right arrow was pressed in a
* Vertical Mover), the parent connected Mover will proceed with handling
* the arrow key press within the parent Mover context.
*
* This allows to handle some complex navigation scenarios.
* For example we have grids where each cell has focusable subelements
* with vertical arrow keys navigation (i.e. Vertical Mover). In combination
* with Groupper for the parent Mover cells, we can navigate inside the cell
* with Up/Down arrows and navigate between cells with Left/Right arrows,
* without extra Esc/Enter to enter the Groupper. In the example below,
* Left/Right arrow keys move between Grouppers (<div>s with `tabindex={0}`),
* Up/Down arrow keys move between buttons inside each respective groupper.
*
* <div mover={connected: Child, direction: Horizontal}>
* <div tabindex={0} groupper mover={connected: Parent, direction: Vertical}>
* <button>Button1</button>
* <button>Button2</button>
* </div>
* <div tabindex={0} groupper mover={connected: Parent, direction: Vertical}>
* <button>Button3</button>
* <button>Button4</button>
* </div>
* </div>
*/
connected?: MoverConnected;
}

export interface Mover
Expand Down
Loading
Loading