diff --git a/.changeset/young-pigs-wave.md b/.changeset/young-pigs-wave.md
new file mode 100644
index 0000000..d566af6
--- /dev/null
+++ b/.changeset/young-pigs-wave.md
@@ -0,0 +1,5 @@
+---
+"tabspot": patch
+---
+
+fix: search for mover/grouper inside mover item
diff --git a/src/navigation.ts b/src/navigation.ts
index 594037a..2a44d37 100644
--- a/src/navigation.ts
+++ b/src/navigation.ts
@@ -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
@@ -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);
}
}
@@ -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);
}
}
diff --git a/src/tests/group-inside-item.test.ts b/src/tests/group-inside-item.test.ts
new file mode 100644
index 0000000..79168ff
--- /dev/null
+++ b/src/tests/group-inside-item.test.ts
@@ -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 `
` itself; the `` is a
+ // plain label and the nested `` is the item's subgroup.
+ function mount(): void {
+ document.body.innerHTML = `
+
+ -
+ Pizza Toppings
+
+ - Cheese
+ - Pepperoni
+ - Onion
+
+
+
`;
+ 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 = `
+ `;
+ 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"));
+ });
+});
diff --git a/src/tree.ts b/src/tree.ts
index 1a5eea0..c02800b 100644
--- a/src/tree.ts
+++ b/src/tree.ts
@@ -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,
@@ -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;
}
@@ -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