diff --git a/src/Consts.ts b/src/Consts.ts index d3d1d9ee..dee030aa 100644 --- a/src/Consts.ts +++ b/src/Consts.ts @@ -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, diff --git a/src/Mover.ts b/src/Mover.ts index 127305c2..0dde1794 100644 --- a/src/Mover.ts +++ b/src/Mover.ts @@ -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, @@ -31,6 +36,7 @@ import { TabsterPart, WeakHTMLElement, getDummyInputContainer, + getLastChild, } from "./Utils"; import { dom } from "./DOMAPI"; @@ -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; @@ -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 ( @@ -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)) { @@ -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 ( @@ -850,7 +905,7 @@ export class MoverAPI implements Types.MoverAPI { } } - if (!container) { + if (!moverElement || !mover) { return null; } @@ -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, }); @@ -905,7 +969,7 @@ export class MoverAPI implements Types.MoverAPI { } } else if (!next && isCyclic) { next = focusable.findFirst({ - container, + container: moverElement, useActiveModalizer: true, }); } @@ -915,7 +979,7 @@ export class MoverAPI implements Types.MoverAPI { ) { next = focusable.findPrev({ currentElement: fromElement, - container, + container: moverElement, useActiveModalizer: true, }); @@ -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, @@ -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) => { @@ -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) => { @@ -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)) { @@ -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)) { @@ -1086,7 +1150,7 @@ export class MoverAPI implements Types.MoverAPI { ); focusable.findElement({ currentElement: next, - container, + container: moverElement, useActiveModalizer: true, isBackward: true, acceptCondition: (el) => { @@ -1123,7 +1187,7 @@ export class MoverAPI implements Types.MoverAPI { let lastIntersection = 0; focusable.findAll({ - container, + container: moverElement, currentElement: fromElement, isBackward, onElement: (el) => { @@ -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, }) @@ -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; } diff --git a/src/Types.ts b/src/Types.ts index 562d39e2..315a0802 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -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; @@ -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 (
s with `tabindex={0}`), + * Up/Down arrow keys move between buttons inside each respective groupper. + * + *
+ *
+ * + * + *
+ *
+ * + * + *
+ *
+ */ + connected?: MoverConnected; } export interface Mover diff --git a/tests/Mover.test.tsx b/tests/Mover.test.tsx index 0f473c30..fa1bfc6f 100644 --- a/tests/Mover.test.tsx +++ b/tests/Mover.test.tsx @@ -7,6 +7,7 @@ import * as React from "react"; import { getTabsterAttribute, MoverDirections, + MoverConnections, MoverKeys, Types, Visibilities, @@ -3145,3 +3146,366 @@ describe("Mover with virtual children provided by getParent()", () => { }); }); }); + +describe("Mover connected with other Movers", () => { + beforeEach(async () => { + await BroTest.bootstrapTabsterPage({ mover: true, groupper: true }); + }); + + it("should move between connected Movers", async () => { + await new BroTest.BroTest( + ( +
+
+
+ + +
+ +
+ + +
+
+ +
+ ) + ) + .pressTab() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button1Button2"); + }) + .pressRight() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button3Button4"); + }) + .pressRight() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button1Button2"); + }) + .pressDown() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button1"); + }) + .pressDown() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button2"); + }) + .pressDown() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button1"); + }) + .pressRight() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button3Button4"); + }) + .pressDown() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button3Button4"); + }) + .pressUp() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button3Button4"); + }) + .pressRight() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button1Button2"); + }) + .pressUp() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button2"); + }) + .pressUp() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button1"); + }) + .pressTab() + .activeElement((el) => { + expect(el?.textContent).toEqual("After"); + }) + .pressTab(true) + .activeElement((el) => { + expect(el?.textContent).toEqual("Button1Button2"); + }); + }); + + it("should move between more levels of connected Movers", async () => { + await new BroTest.BroTest( + ( +
+
+
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+
+
+ ) + ) + .pressTab() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button1Button2Button3Button4"); + }) + .pressRight() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button5Button6Button7Button8"); + }) + .pressDown() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button5Button6"); + }) + .pressDown() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button7Button8"); + }) + .pressRight() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button7"); + }) + .pressRight() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button8"); + }) + .pressLeft() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button7"); + }) + .pressLeft() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button1Button2Button3Button4"); + }) + .pressDown() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button1Button2"); + }) + .pressDown() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button3Button4"); + }) + .pressDown() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button3Button4"); + }) + .pressLeft() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button3Button4"); + }) + .pressRight() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button3"); + }) + .pressDown() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button3"); + }) + .pressRight() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button4"); + }) + .pressRight() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button5Button6Button7Button8"); + }) + .pressLeft() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button1Button2Button3Button4"); + }) + .pressDown() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button1Button2"); + }) + .pressRight() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button1"); + }) + .pressRight() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button2"); + }) + .pressRight() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button5Button6Button7Button8"); + }); + }); + + it("should move between connected Movers in the same direction", async () => { + await new BroTest.BroTest( + ( +
+
+
+ + +
+ +
+ + +
+
+
+ ) + ) + .pressTab() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button1"); + }) + .pressDown() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button2"); + }) + .pressDown() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button3"); + }) + .pressDown() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button4"); + }) + .pressUp() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button3"); + }) + .pressUp() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button2"); + }) + .pressUp() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button1"); + }) + .pressUp() + .activeElement((el) => { + expect(el?.textContent).toEqual("Button1"); + }); + }); +});