Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/young-pigs-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"tabspot": patch
---

fix: search for mover/grouper inside mover item
19 changes: 13 additions & 6 deletions src/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ function enclosingGrouper(node: FocusableNode): GrouperNode | null {

/** Find the focusable that serves as the anchor (entry point) of a grouper. */
function grouperAnchor(grouper: GrouperNode, compiled: CompiledRoot): FocusableNode | null {
if (grouper.implicit) return grouper.owner;
// An owned subgroup (implicit or configured group-inside-item) anchors on its
// owning focusable; only sibling groupers resolve to the preceding focusable.
if (grouper.owner) return grouper.owner;
const parent = grouper.parent;
// Walk through parent.children to find the previous focusable before the grouper.
// The grouper may not be a direct child of parent if it's wrapped in a mover, so
Expand Down Expand Up @@ -396,9 +398,12 @@ function handleArrow(
if (focusable.subGroup) {
const sub = focusable.subGroup;
const matchesExplicit = sub.opts.enterDirection === dir;
const matchesImplicit =
sub.implicit && !sub.opts.enterDirection && linearAxis(sub.mover) === directionAxis(dir);
if (matchesExplicit || matchesImplicit) {
// No enterDirection declared: an owned subgroup is entered by pressing in
// the subgroup's own axis (cross-axis to the parent level). Covers both
// implicit subgroups (inline mover) and configured group-inside-item.
const matchesCrossAxis =
!sub.opts.enterDirection && linearAxis(sub.mover) === directionAxis(dir);
if (matchesExplicit || matchesCrossAxis) {
return performMove(focusable, entryTarget(sub, deps.compiled), dir, rawEvent, deps);
}
}
Expand All @@ -412,8 +417,10 @@ function handleArrow(
const g = enclosingGrouper(focusable);
if (g) {
const explicitExit = g.opts.exitDirection === dir;
const implicitExit = g.implicit && !g.opts.exitDirection;
if (explicitExit || implicitExit) {
// Owned subgroups (implicit or group-inside-item) without an explicit
// exitDirection auto-exit when the cross-axis is pressed.
const crossAxisExit = !!g.owner && !g.opts.exitDirection;
if (explicitExit || crossAxisExit) {
return performMove(focusable, grouperAnchor(g, deps.compiled), dir, rawEvent, deps);
}
}
Expand Down
122 changes: 122 additions & 0 deletions src/tests/group-inside-item.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setTabspotAttributes, tabspot } from "../index.ts";
import type { TabspotInstance } from "../index.ts";
import { press } from "./fixtures/context.ts";

let instance: TabspotInstance;

function byId(id: string): HTMLElement {
return document.getElementById(id) as HTMLElement;
}

beforeEach(() => {
document.body.innerHTML = "";
});
afterEach(() => {
instance.destroy();
document.body.innerHTML = "";
});

describe("group inside item — ARIA tree (explicit enter/exit)", () => {
// The navigable item is the `<li role="treeitem">` itself; the `<span>` is a
// plain label and the nested `<ul role="group">` is the item's subgroup.
function mount(): void {
document.body.innerHTML = `
<ul id="tree" role="tree"
data-tabspot='{"root":{},"mover":{"axis":"vertical","items":"li.item"}}'>
<li id="toppings" role="treeitem" aria-expanded="false" class="item">
<span>Pizza Toppings</span>
<ul id="group" role="group"
data-tabspot='{"grouper":{"enterDirection":"right","exitDirection":"left"},"mover":{"axis":"vertical","items":"li"}}'>
<li id="cheese" role="treeitem">Cheese</li>
<li id="pepperoni" role="treeitem">Pepperoni</li>
<li id="onion" role="treeitem">Onion</li>
</ul>
</li>
</ul>`;
instance = tabspot();
setTabspotAttributes({
element: byId("tree"),
config: { root: {}, mover: { axis: "vertical", items: "li.item" } },
});
}

it("treats the treeitem as the item and the group children as a subgroup", () => {
mount();
// The label stays a plain span (never focusable / never a tab stop).
expect(byId("toppings").querySelector("span")!.getAttribute("tabindex")).toBeNull();
// Roving grants focusability: parent is the tab stop, subgroup items demoted.
expect(byId("toppings").getAttribute("tabindex")).toBe("0");
expect(byId("cheese").getAttribute("tabindex")).toBe("-1");
expect(byId("pepperoni").getAttribute("tabindex")).toBe("-1");
expect(byId("onion").getAttribute("tabindex")).toBe("-1");
});

it("enters the subgroup with the configured enterDirection (ArrowRight)", () => {
mount();
press(byId("toppings"), "ArrowRight");
expect(document.activeElement).toBe(byId("cheese"));
});

it("navigates within the subgroup along its own axis", () => {
mount();
press(byId("cheese"), "ArrowDown");
expect(document.activeElement).toBe(byId("pepperoni"));
press(byId("pepperoni"), "ArrowDown");
expect(document.activeElement).toBe(byId("onion"));
});

it("exits back to the owning item with the configured exitDirection (ArrowLeft)", () => {
mount();
press(byId("pepperoni"), "ArrowLeft");
expect(document.activeElement).toBe(byId("toppings"));
});

it("does not descend into the subgroup along the parent axis", () => {
mount();
// ArrowDown is the parent's axis: with no parent sibling it must NOT fall
// into the subgroup — the group is only reachable via enterDirection.
press(byId("toppings"), "ArrowDown");
expect(document.activeElement).toBe(byId("toppings"));
});
});

describe("group inside item — cross-axis fallback (no enter/exit declared)", () => {
// Horizontal parent, vertical subgroup: the cross-axis (vertical) doubles as
// the implicit enter gesture, so `grouper: {}` needs no directions.
function mount(): void {
document.body.innerHTML = `
<ul id="bar" data-tabspot='{"root":{},"mover":{"axis":"horizontal","items":"li.item"}}'>
<li id="a" class="item" tabindex="0">A</li>
<li id="b" class="item" tabindex="0">B
<ul id="bsub" data-tabspot='{"grouper":{},"mover":{"axis":"vertical","items":"li.sub"}}'>
<li id="b1" class="sub">B1</li>
<li id="b2" class="sub">B2</li>
</ul>
</li>
</ul>`;
instance = tabspot();
setTabspotAttributes({
element: byId("bar"),
config: { root: {}, mover: { axis: "horizontal", items: "li.item" } },
});
}

it("enters the subgroup by pressing in the subgroup's axis", () => {
mount();
press(byId("b"), "ArrowDown");
expect(document.activeElement).toBe(byId("b1"));
});

it("exits the subgroup by pressing the cross-axis", () => {
mount();
press(byId("b1"), "ArrowLeft");
expect(document.activeElement).toBe(byId("b"));
});

it("keeps parent siblings reachable along the parent axis", () => {
mount();
press(byId("a"), "ArrowRight");
expect(document.activeElement).toBe(byId("b"));
});
});
49 changes: 47 additions & 2 deletions src/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,8 @@ function collectChildren(
out.push(focusable);

if (cfg?.mover) {
// Focusable owns its own subgroup: implicit grouper + mover.
// Children inside are focusables descendants forming a subnivel.
// Focusable owns its own subgroup via an inline mover: implicit grouper.
// Children inside are focusable descendants forming a subnivel.
const sub: GrouperNode = {
kind: "grouper",
el,
Expand All @@ -320,6 +320,32 @@ function collectChildren(
sub.children = collectChildren(el, sub, ctx, level + 1);
ctx.defaultMover = prevDefault;
focusable.subGroup = sub;
} else {
// Group-inside-item: a configured grouper/mover nested *within* the
// focusable becomes its subgroup (the ARIA tree pattern — a `treeitem`
// wrapping its own `role="group"`). Entry/exit anchors on the owning
// focusable like an implicit grouper, but enter/exit directions and the
// mover come from the nested element's own config.
const subEl = findItemSubgroup(el, ctx);
if (subEl) {
const subCfg = readTabspotConfig(subEl)!;
const sub: GrouperNode = {
kind: "grouper",
el: subEl,
parent: focusable.parent, // structural marker; lives "under" the focusable
opts: subCfg.grouper ?? {},
mover: subCfg.mover ?? ctx.defaultMover,
implicit: false,
owner: focusable,
level: level + 1,
children: [],
};
const prevDefault = ctx.defaultMover;
if (subCfg.mover) ctx.defaultMover = subCfg.mover;
sub.children = collectChildren(subEl, sub, ctx, level + 1);
ctx.defaultMover = prevDefault;
focusable.subGroup = sub;
}
}
continue;
}
Expand All @@ -336,6 +362,25 @@ function isConfigured(el: HTMLElement): boolean {
return !!(cfg && (cfg.root || cfg.mover || cfg.grouper));
}

/**
* Find the configured grouper/mover element nested *inside* a focusable item
* (its subgroup), descending through non-configured wrappers (labels, divs).
* Stops at the first configured wrapper or nested item, so an incidental
* focusable inside the item (e.g. a row action) is never promoted to a sublevel
* — only an explicitly configured group is. Returns the first match, or null.
*/
function findItemSubgroup(el: HTMLElement, ctx: BuildCtx): HTMLElement | null {
for (const node of walk(
el,
(n) => n instanceof HTMLElement && (isConfigured(n) || isItem(n, ctx)),
)) {
if (!(node instanceof HTMLElement)) continue;
const cfg = readTabspotConfig(node);
if (cfg && (cfg.grouper || cfg.mover)) return node;
}
return null;
}

/**
* Whether `el` is a navigable item at the current scope. When the governing
* mover declares `items`, membership is the CSS selector (Tabspot then grants
Expand Down
Loading