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
12 changes: 12 additions & 0 deletions .changeset/stale-swans-take.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"tabspot": minor
---

feat: replace root `manageEscape` and `manageHomeEnd` with a single `manageSpecialKeys` option.

`manageSpecialKeys` accepts either a boolean (`true` handles all special keys) or a per-key object toggling `Escape`, `Home`, `End`, `PageUp`, and `PageDown` individually. Keys use the exact `KeyboardEvent.key` strings; omitted keys default to off.

Migration:
- `{ manageEscape: true }` → `{ manageSpecialKeys: { Escape: true } }`
- `{ manageHomeEnd: true }` → `{ manageSpecialKeys: { Home: true, End: true, PageUp: true, PageDown: true } }`
- both true → `{ manageSpecialKeys: true }`
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const nav = document.querySelector<HTMLElement>("#main-nav")!;
setTabspotAttributes({
element: nav,
config: {
root: { manageEscape: true, manageHomeEnd: true },
root: { manageSpecialKeys: true },
mover: { axis: "vertical", cyclic: true },
},
});
Expand All @@ -60,7 +60,7 @@ For SSR use `getTabspotAttributes`, it returns the attributes to be rendered on
import { getTabspotAttributes } from "tabspot";

const attrs = getTabspotAttributes({
root: { manageEscape: true, manageHomeEnd: true },
root: { manageSpecialKeys: true },
mover: { axis: "vertical", cyclic: true },
});

Expand Down Expand Up @@ -88,8 +88,9 @@ Every element configured with Tabspot carries a single `data-tabspot` attribute
```ts
interface TabspotNodeOptions {
root?: {
manageEscape?: boolean;
manageHomeEnd?: boolean; // Home/End/PageUp/PageDown/Ctrl+Home/Ctrl+End
// true = handle all; or toggle each key. Esc exits; the rest jump.
manageSpecialKeys?: boolean | Partial<Record<RootSpecialKey, boolean>>;
// RootSpecialKey = "Escape" | "Home" | "End" | "PageUp" | "PageDown"
rtl?: "auto" | "ltr" | "rtl";
debug?: "basic" | "full";
};
Expand Down Expand Up @@ -131,7 +132,7 @@ type Activation =
### Levels and transitions

- **Mover**: its children stay at the parent's level. A linear Mover carries the axis + cyclic behavior for in-axis moves.
- **Grouper** opens a new level (`level + 1`). Enter via `grouper.enterDirection` from a sibling, or any cross-axis arrow when the grouper is implicit. Exit via `grouper.exitDirection` (gated by first/last + `enterExitOnLast`) or `Escape` (if `manageEscape`).
- **Grouper** opens a new level (`level + 1`). Enter via `grouper.enterDirection` from a sibling, or any cross-axis arrow when the grouper is implicit. Exit via `grouper.exitDirection` (gated by first/last + `enterExitOnLast`) or `Escape` (if `manageSpecialKeys.Escape`).
- A focusable that declares its own `mover` synthesizes an `implicit grouper` underneath.

## Grid movers (`layout: "grid"`)
Expand All @@ -151,7 +152,7 @@ A grid mover navigates its items as a 2-D matrix: **both** axes move. Rows are d
- **`flow: "contained"`** (default) — `ArrowLeft|Right` move within the current row; `ArrowUp|Down` within the column. Clamps at every edge.
- **`flow: "linear"`** — vertical is identical, but `ArrowLeft|Right` traverse the whole row-major sequence (end of a row continues into the next), clamping only at the global first/last cell.
- `cyclic: true` wraps within the row/column (or around the whole sequence for `flow: "linear"`).
- **Keys** (with `manageHomeEnd`): `Home`/`End` → start/end of the current row; `Ctrl+Home`/`Ctrl+End` → first/last cell of the grid; `PageUp`/`PageDown` → ±`pageSize` rows (default 5).
- **Keys** (gated per-key by `manageSpecialKeys`): `Home`/`End` → start/end of the current row; `Ctrl+Home`/`Ctrl+End` → first/last cell of the grid; `PageUp`/`PageDown` → ±`pageSize` rows (default 5).
- Row strategies handle markup `parent`-grouping can't: `{by:"selector",row:"tr"}` for `<td><button>` cells, `{by:"columns",count}` for CSS grids without row wrappers, `{by:"geometry"}` for flex-wrap layouts.

## `items` — declaring the navigable set
Expand Down
8 changes: 8 additions & 0 deletions src/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export const MANAGED_KEYS = [
] as const;
export type ManagedKey = (typeof MANAGED_KEYS)[number];

/**
* Special (non-arrow) keys whose handling is toggled at the root level:
* `Escape` exits the widget; the rest jump within it. Values are exact
* `KeyboardEvent.key` strings so dispatch is a direct lookup.
*/
export const ROOT_SPECIAL_KEYS = ["Escape", "Home", "End", "PageUp", "PageDown"] as const;
export type RootSpecialKey = (typeof ROOT_SPECIAL_KEYS)[number];

export const ACTIVATION_MODES = ["focus", "activedescendant", "marked", "controlled"] as const;
export type ActivationMode = (typeof ACTIVATION_MODES)[number];

Expand Down
25 changes: 16 additions & 9 deletions src/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import type {
GridRowStrategy,
ManagedKey,
MoverAxis,
RootSpecialKey,
TabspotEventListener,
TabspotGridMoverOptions,
TabspotMoverOptions,
TabspotNavigationEvent,
TabspotRootOptions,
} from "./types.ts";

type NavDirection = TabspotNavigationEvent["direction"];
Expand All @@ -35,6 +37,13 @@ function directionAxis(dir: EnterExitDirections): MoverAxis {
return HORIZONTAL.has(dir) ? "horizontal" : "vertical";
}

/** Resolve whether a root-level special key is handled (boolean or per-key form). */
function managesSpecialKey(rootOpts: TabspotRootOptions, key: RootSpecialKey): boolean {
const m = rootOpts.manageSpecialKeys;
if (typeof m === "boolean") return m;
return m?.[key] === true;
}

/** Type guard: a grid mover (2-D matrix), vs a linear (1-D axis) mover. */
function isGridMover(m: TabspotMoverOptions): m is TabspotGridMoverOptions {
return m.layout === "grid";
Expand Down Expand Up @@ -346,18 +355,16 @@ export function handleKeydown(event: KeyboardEvent, deps: NavigationDeps): boole
if (moverOnTarget?.ignoreKeys?.includes(key as ManagedKey)) return false;

// Escape
if (key === "Escape" && rootOpts.manageEscape) {
if (key === "Escape" && managesSpecialKey(rootOpts, "Escape")) {
return handleEscape(focusable, deps, event);
}

// Home / End / PageUp / PageDown (all gated by manageHomeEnd)
if (rootOpts.manageHomeEnd) {
if (key === "Home" || key === "End") {
return handleHomeEnd(focusable, key === "End", event.ctrlKey, deps, event);
}
if (key === "PageUp" || key === "PageDown") {
return handlePage(focusable, key === "PageDown", deps, event);
}
// Home / End / PageUp / PageDown (each gated individually by manageSpecialKeys)
if ((key === "Home" || key === "End") && managesSpecialKey(rootOpts, key)) {
return handleHomeEnd(focusable, key === "End", event.ctrlKey, deps, event);
}
if ((key === "PageUp" || key === "PageDown") && managesSpecialKey(rootOpts, key)) {
return handlePage(focusable, key === "PageDown", deps, event);
}

// Arrows
Expand Down
27 changes: 24 additions & 3 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
MANAGED_KEYS,
MOVER_AXES,
MOVER_LAYOUTS,
ROOT_SPECIAL_KEYS,
ROW_STRATEGIES,
RTL_MODES,
VISIBILITIES,
Expand All @@ -16,6 +17,7 @@ import type {
GridRowStrategy,
ManagedKey,
MoverAxis,
RootSpecialKey,
TabspotGridMoverOptions,
TabspotGrouperOptions,
TabspotLinearMoverOptions,
Expand All @@ -28,7 +30,8 @@ import type {

export const TABSPOT_ATTR = "data-tabspot";

const ROOT_KEYS = new Set(["manageEscape", "manageHomeEnd", "rtl", "debug"]);
const ROOT_KEYS = new Set(["manageSpecialKeys", "rtl", "debug"]);
const ROOT_SPECIAL_KEY_SET = new Set<string>(ROOT_SPECIAL_KEYS);
const LINEAR_MOVER_KEYS = new Set([
"layout",
"axis",
Expand Down Expand Up @@ -97,11 +100,29 @@ function rejectUnknownKeys(
}
}

function validateManageSpecialKeys(
raw: unknown,
): boolean | Partial<Record<RootSpecialKey, boolean>> {
if (typeof raw === "boolean") return raw;
if (!isPlainObject(raw)) {
throw new Error(
`"manageSpecialKeys" must be a boolean or an object of ${ROOT_SPECIAL_KEYS.join("|")} booleans`,
);
}
rejectUnknownKeys(raw, ROOT_SPECIAL_KEY_SET, "manageSpecialKeys");
const out: Partial<Record<RootSpecialKey, boolean>> = {};
for (const key of Object.keys(raw) as RootSpecialKey[]) {
out[key] = bool(raw[key], `manageSpecialKeys.${key}`);
}
return out;
}

function validateRoot(raw: Record<string, unknown>): TabspotRootOptions {
rejectUnknownKeys(raw, ROOT_KEYS, "root");
const out: TabspotRootOptions = {};
if ("manageEscape" in raw) out.manageEscape = bool(raw.manageEscape, "manageEscape");
if ("manageHomeEnd" in raw) out.manageHomeEnd = bool(raw.manageHomeEnd, "manageHomeEnd");
if ("manageSpecialKeys" in raw) {
out.manageSpecialKeys = validateManageSpecialKeys(raw.manageSpecialKeys);
}
if ("rtl" in raw) out.rtl = oneOf(raw.rtl, RTL_MODES, "rtl");
if ("debug" in raw) {
if (raw.debug !== "basic" && raw.debug !== "full") {
Expand Down
7 changes: 3 additions & 4 deletions src/tests/fixtures/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,20 +102,19 @@ export function mountContext(opts: TabspotOptions = {}): ContextRefs {
element: header,
config: {
root: {
manageEscape: true,
manageHomeEnd: true,
manageSpecialKeys: true,
},
mover: { axis: "vertical", cyclic: true },
},
});
setTabspotAttributes({
element: main,
config: { root: { manageEscape: true, manageHomeEnd: true }, mover: { axis: "horizontal" } },
config: { root: { manageSpecialKeys: true }, mover: { axis: "horizontal" } },
});
setTabspotAttributes({
element: footer,
config: {
root: { manageEscape: true, manageHomeEnd: true },
root: { manageSpecialKeys: true },
mover: { axis: "vertical", cyclic: true },
},
});
Expand Down
2 changes: 1 addition & 1 deletion src/tests/grid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function mountGrid(flow: GridFlow, cyclic = false): GridRefs {
const root = document.getElementById("grid") as HTMLElement;
setTabspotAttributes({
element: root,
config: { root: { manageHomeEnd: true }, mover: { layout: "grid", flow, cyclic } },
config: { root: { manageSpecialKeys: true }, mover: { layout: "grid", flow, cyclic } },
});

return {
Expand Down
37 changes: 33 additions & 4 deletions src/tests/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,43 @@ import { parseTabspotAttribute, serializeTabspotConfig, validateNodeOptions } fr
describe("parser", () => {
it("parses a valid root + mover config", () => {
const cfg = parseTabspotAttribute(
'{"root":{"manageEscape":true},"mover":{"axis":"vertical","cyclic":true}}',
'{"root":{"manageSpecialKeys":{"Escape":true}},"mover":{"axis":"vertical","cyclic":true}}',
);
expect(cfg).toEqual({
root: { manageEscape: true },
root: { manageSpecialKeys: { Escape: true } },
mover: { axis: "vertical", cyclic: true },
});
});

it("accepts the manageSpecialKeys boolean shorthand", () => {
expect(parseTabspotAttribute('{"root":{"manageSpecialKeys":true}}')).toEqual({
root: { manageSpecialKeys: true },
});
});

it("accepts per-key manageSpecialKeys toggles", () => {
expect(
parseTabspotAttribute('{"root":{"manageSpecialKeys":{"Home":true,"End":false,"PageDown":true}}}'),
).toEqual({
root: { manageSpecialKeys: { Home: true, End: false, PageDown: true } },
});
});

it("rejects unknown keys inside manageSpecialKeys", () => {
expect(parseTabspotAttribute('{"root":{"manageSpecialKeys":{"Enter":true}}}')).toBeNull();
expect(parseTabspotAttribute('{"root":{"manageSpecialKeys":{"ArrowUp":true}}}')).toBeNull();
});

it("rejects non-boolean manageSpecialKeys values", () => {
expect(parseTabspotAttribute('{"root":{"manageSpecialKeys":"true"}}')).toBeNull();
expect(parseTabspotAttribute('{"root":{"manageSpecialKeys":{"Home":"yes"}}}')).toBeNull();
});

it("rejects the removed manageEscape/manageHomeEnd options", () => {
expect(parseTabspotAttribute('{"root":{"manageEscape":true}}')).toBeNull();
expect(parseTabspotAttribute('{"root":{"manageHomeEnd":true}}')).toBeNull();
});

it("returns null on malformed JSON", () => {
expect(parseTabspotAttribute("not json")).toBeNull();
});
Expand Down Expand Up @@ -44,11 +73,11 @@ describe("parser", () => {

it("serializeTabspotConfig round-trips valid config", () => {
const json = serializeTabspotConfig({
root: { manageEscape: true },
root: { manageSpecialKeys: { Escape: true } },
mover: { axis: "horizontal", cyclic: true },
});
expect(parseTabspotAttribute(json)).toEqual({
root: { manageEscape: true },
root: { manageSpecialKeys: { Escape: true } },
mover: { axis: "horizontal", cyclic: true },
});
});
Expand Down
12 changes: 9 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
GridFlow,
ManagedKey,
MoverAxis,
RootSpecialKey,
RtlMode,
Visibility,
} from "./enums.ts";
Expand All @@ -25,6 +26,7 @@ export type {
ManagedKey,
MoverAxis,
MoverLayout,
RootSpecialKey,
RtlMode,
Visibility,
} from "./enums.ts";
Expand Down Expand Up @@ -161,9 +163,13 @@ export interface TabspotGrouperOptions {
}

export interface TabspotRootOptions {
manageEscape?: boolean;
/** Governs Home/End/PageUp/PageDown/Ctrl+Home/Ctrl+End. */
manageHomeEnd?: boolean;
/**
* Which special (non-arrow) keys Tabspot handles at the root level.
* `Escape` exits the widget; `Home`/`End` and `PageUp`/`PageDown` jump
* within it. Pass `true` to handle them all, or an object to toggle each
* key individually. Omitted keys (and the absent option) default to off.
*/
manageSpecialKeys?: boolean | Partial<Record<RootSpecialKey, boolean>>;
/** Directionality for horizontal arrows. Default `"auto"`. */
rtl?: RtlMode;
debug?: DebugLevel;
Expand Down
Loading