5-way navigation for React and SolidJS
0

Configure Feed

Select the types of activity you want to include in your feed.

core: rename chained handler to composedH handler

authored by

Pavel Hrách and committed by
Pavel Hrách
(May 22, 2026, 12:51 AM +0200) ef97bb75 f95d1bc6

+297 -291
+1 -1
docs/what-is-fiveway.md
··· 28 28 29 29 Handlers, along with nodes, are the core of fiveway. Every node has an associated handler that defines its behavior. 30 30 In their simplest form, handlers are just functions that accept **actions** and return node IDs. fiveway provides a set of handlers for common scenarios. 31 - These handlers can be composed and chained together with custom handlers to create unique custom behavior. 31 + These handlers can be composed together with custom handlers to create unique custom behavior. 32 32 33 33 When given an action, the navigation tree passes that action to the focused node and its handler. The handler can either return a node that should get focused 34 34 or pass the action to another (usually parent) node and its handler.
+26 -20
docs/api/index.md
··· 96 96 function dispatchAction(tree: NavigationTree, action: NavigationAction, node?: NodeId): void; 97 97 ``` 98 98 99 - Runs the current focus node’s handler chain with `action`. If the handler chain returns a target id, focus moves there via `focusNode`. 99 + Runs the focused node’s handler with `action`. If the handler returns a target id, focus moves there via `focusNode`. 100 100 101 101 Typical source of actions: keyboard mapping (see [DOM](#dom)) or framework helpers. 102 102 ··· 257 257 258 258 ## Handlers {#handlers} 259 259 260 - Handlers implement navigation behavior. Each node has a `NavigationHandler`; composite behavior is built with `chainedHandler` and specialized handlers. 260 + Handlers implement navigation behavior. Each node has a `NavigationHandler`; composite behavior is built with `composeHandlers` and specialized handlers. 261 261 262 262 ### `HandlerNext` 263 263 ··· 277 277 ) => NodeId | null; 278 278 ``` 279 279 280 - ### `chainedHandler` 280 + ### `composeHandlers` 281 281 282 282 ```ts 283 - type ChainedHandler = NavigationHandler & { 284 - prepend(another: NavigationHandler | ChainedHandler): ChainedHandler; 283 + type HandlerLink = { 284 + handler: NavigationHandler; 285 + next: HandlerLink | null; 285 286 }; 286 287 287 - function chainedHandler(handlers: NavigationHandler | NavigationHandler[] | null): ChainedHandler; 288 + type ComposedHandler = NavigationHandler & { 289 + link: HandlerLink | null; 290 + compose(handler: NavigationHandler | ComposedHandler): ComposedHandler; 291 + }; 292 + 293 + function composeHandlers(handlers: NavigationHandler | NavigationHandler[] | null): ComposedHandler; 288 294 ``` 289 295 290 - Combines handlers so each receives `next` wired to the rest of the chain. Use `.prepend()` to add behavior at the front (for example `selectHandler` on items). 296 + Combines handlers so each receives `next` wired to the handlers that follow. Use `.compose()` to add behavior at the front (for example `selectHandler` on items). 291 297 292 298 ### `defaultHandler` 293 299 294 300 ```ts 295 - const defaultHandler: ChainedHandler; 301 + const defaultHandler: ComposedHandler; 296 302 ``` 297 303 298 - Chains `focusHandler()` with `parentHandler` — typical leaf and general-purpose default. 304 + Composes `focusHandler()` with `parentHandler` — typical leaf and general-purpose default. 299 305 300 306 ### `containerHandler` 301 307 302 308 ```ts 303 - const containerHandler: ChainedHandler; 309 + const containerHandler: ComposedHandler; 304 310 ``` 305 311 306 312 Like `defaultHandler`, but empty containers do not keep focus (`focusHandler({ focusWhenEmpty: false })`). ··· 316 322 ### `itemHandler` 317 323 318 324 ```ts 319 - function itemHandler(onSelect?: () => void): ChainedHandler; 325 + function itemHandler(onSelect?: () => void): ComposedHandler; 320 326 ``` 321 327 322 - If `onSelect` is provided, prepends `selectHandler(onSelect)` to `defaultHandler`; otherwise returns `defaultHandler`. 328 + If `onSelect` is provided, composes `selectHandler(onSelect)` onto `defaultHandler` via `.compose()`; otherwise returns `defaultHandler`. 323 329 324 330 ### `focusHandler` 325 331 ··· 334 340 function focusHandler(config?: FocusHandlerOptions): NavigationHandler; 335 341 ``` 336 342 337 - Resolves `focus` actions by walking children (respecting `initialHandler` metadata when direction is initial). When `focusWhenEmpty` is `false`, an empty container does not receive focus (used by `containerHandler`, grid, and spatial chains). 343 + Resolves `focus` actions by walking children (respecting `initialHandler` metadata when direction is initial). When `focusWhenEmpty` is `false`, an empty container does not receive focus (used by `containerHandler`, grid, and spatial handlers). 338 344 339 345 ### `initialHandler` 340 346 ··· 373 379 ### `verticalHandler` / `horizontalHandler` 374 380 375 381 ```ts 376 - const verticalHandler: ChainedHandler; 377 - const horizontalHandler: ChainedHandler; 382 + const verticalHandler: ComposedHandler; 383 + const horizontalHandler: ComposedHandler; 378 384 ``` 379 385 380 - Prebuilt chains: `focusHandler` with direction mapping, movement handler, and `parentHandler`. 386 + Prebuilt compositions: `focusHandler` with direction mapping, movement handler, and `parentHandler`. 381 387 382 388 ### `GridItem` 383 389 ··· 406 412 ```ts 407 413 function gridHandler(config?: { 408 414 distance?: (direction: NavigationDirection) => (a: GridItem, b: GridItem) => number | null; 409 - }): ChainedHandler; 415 + }): ComposedHandler; 410 416 ``` 411 417 412 - Chains `focusHandler({ focusWhenEmpty: false })`, `gridMovement` (with optional `distance`), and `parentHandler`. 418 + Composes `focusHandler({ focusWhenEmpty: false })`, `gridMovement` (with optional `distance`), and `parentHandler`. 413 419 414 420 Optional `distance` overrides how the nearest cell is chosen for each arrow direction; defaults use row/column heuristics. 415 421 ··· 443 449 ### `spatialHandler` 444 450 445 451 ```ts 446 - const spatialHandler: ChainedHandler; 452 + const spatialHandler: ComposedHandler; 447 453 ``` 448 454 449 455 Combines spatial movement with defaults for arrow-key style navigation using rects. ··· 466 472 function selectNode(tree: NavigationTree, nodeId: NodeId, options?: SelectNodeOptions): void; 467 473 ``` 468 474 469 - By default focuses `nodeId` first (`focus` defaults to `true`), then runs the `select` action through that node’s handler chain. 475 + By default focuses `nodeId` first (`focus` defaults to `true`), then runs the `select` action through that node’s handler. 470 476 471 477 ## Metadata & introspection {#metadata-and-introspection} 472 478
+1 -1
docs/api/react.md
··· 168 168 ### `ElementHandler` 169 169 170 170 ```ts 171 - type ElementHandler = ChainedHandler & { 171 + type ElementHandler = ComposedHandler & { 172 172 register: (e: HTMLElement | null) => void; 173 173 }; 174 174 ```
+1 -1
docs/api/solid.md
··· 159 159 160 160 ### `ElementHandler` 161 161 162 - Same shape as [React](/api/react): `ChainedHandler` plus `register`. 162 + Same shape as [React](/api/react): `ComposedHandler` plus `register`. 163 163 164 164 ### `createElementHandler` 165 165
+3 -3
docs/guide/handlers.md
··· 56 56 ### Extending built-in handlers 57 57 58 58 Built-in handlers can be extended to add additional functionality or modify the current behavior. 59 - The simplest way to do this is by prepending a handler to an existing one: 59 + The simplest way to do this is by composing a handler onto an existing one with `.compose()`: 60 60 61 61 ```ts 62 62 import { defaultHandler } from "@fiveway/core"; 63 63 64 64 // defaultHandler is a basic composed handler 65 65 // that can be extended with custom functionality 66 - const customHandler = defaultHandler.prepend((node, action, next) => { 66 + const customHandler = defaultHandler.compose((node, action, next) => { 67 67 if (action.kind === "move" && action.direction === "up") { 68 68 console.log("moving up"); 69 69 } ··· 80 80 Sometimes you might want to create a completely new handler by combining multiple primitive handlers together. This is a more advanced approach used by the library itself. 81 81 82 82 ```ts 83 - export const horizontalHandler = chainedHandler([ 83 + export const horizontalHandler = composeHandlers([ 84 84 focusHandler({ skipEmpty: true, direction: horizontalFocusDirection }), 85 85 horizontalMovementHandler, 86 86 parentHandler,
+1 -1
examples/react-example/src/ExampleBox.tsx
··· 20 20 export function ExampleBox(props: ExampleBoxProps) { 21 21 const nav = useNavigationNode({ 22 22 id: props.navId, 23 - handler: containerHandler.prepend(gridItemHandler(props.gridPos)), 23 + handler: containerHandler.compose(gridItemHandler(props.gridPos)), 24 24 }); 25 25 26 26 return (
+2 -2
examples/react-example/src/ListExample.tsx
··· 1 1 import { 2 2 horizontalHandler, 3 3 verticalHandler, 4 - type ChainedHandler, 4 + type ComposedHandler, 5 5 type NavigationHandler, 6 6 useNavigationNode, 7 7 } from "@fiveway/react"; ··· 11 11 12 12 export function ListExample(props: { 13 13 direction: "vertical" | "horizontal"; 14 - handler?: (h: ChainedHandler) => NavigationHandler; 14 + handler?: (h: ComposedHandler) => NavigationHandler; 15 15 }) { 16 16 const handler = props.direction === "vertical" ? verticalHandler : horizontalHandler; 17 17
+3 -3
examples/react-example/src/NavItem.tsx
··· 1 1 import { 2 2 itemHandler, 3 - type ChainedHandler, 3 + type ComposedHandler, 4 4 type NavigationHandler, 5 5 type NodeId, 6 6 useElementHandler, ··· 14 14 order?: number; 15 15 onSelect?: () => void; 16 16 label: string; 17 - handler?: ChainedHandler; 17 + handler?: ComposedHandler; 18 18 }; 19 19 20 20 const goBackHandler: NavigationHandler = (_, action, next) => { ··· 30 30 const nav = useNavigationNode({ 31 31 id: props.navId, 32 32 order: props.order, 33 - handler: (props.handler ?? itemHandler()).prepend(goBackHandler).prepend(elementHandler), 33 + handler: (props.handler ?? itemHandler()).compose(goBackHandler).compose(elementHandler), 34 34 }); 35 35 36 36 return (
+4 -4
examples/react-example/src/Showcase.tsx
··· 24 24 const { tree } = useNavigationContext(); 25 25 const nav = useNavigationNode({ 26 26 id: "showcase", 27 - handler: gridHandler().prepend(initialHandler("start")), 27 + handler: gridHandler().compose(initialHandler("start")), 28 28 }); 29 29 30 30 useOnFocusChange(nav.id, (id) => { ··· 89 89 label="Start" 90 90 handler={itemHandler(() => { 91 91 nav.focus("vertical-list"); 92 - }).prepend(gridItemHandler({ row: 0, col: 0 }))} 92 + }).compose(gridItemHandler({ row: 0, col: 0 }))} 93 93 ></NavItem> 94 94 </div> 95 95 ··· 120 120 > 121 121 <ListExample 122 122 direction="horizontal" 123 - handler={(h) => h.prepend(initialHandler("item3"))} 123 + handler={(h) => h.compose(initialHandler("item3"))} 124 124 /> 125 125 </ExampleBox> 126 126 ··· 133 133 <ListExample 134 134 direction="horizontal" 135 135 handler={(h) => 136 - h.prepend(captureHandler).prepend((n, a, next) => { 136 + h.compose(captureHandler).compose((n, a, next) => { 137 137 if (a.kind === "move" && a.direction === "back") { 138 138 nav.focus(); 139 139 return null;
+1 -1
examples/react-example/src/SpatialExample.tsx
··· 8 8 export function SpatialExample() { 9 9 const nav = useNavigationNode({ 10 10 id: "spatial", 11 - handler: spatialHandler.prepend(captureHandler), 11 + handler: spatialHandler.compose(captureHandler), 12 12 }); 13 13 14 14 return (
+1 -1
examples/solid-example/src/ExampleBox.tsx
··· 20 20 export function ExampleBox(props: ExampleBoxProps) { 21 21 const nav = createNavigationNode({ 22 22 id: props.navId, 23 - handler: containerHandler.prepend(gridItemHandler(props.gridPos)), 23 + handler: containerHandler.compose(gridItemHandler(props.gridPos)), 24 24 }); 25 25 26 26 return (
+2 -2
examples/solid-example/src/ListExample.tsx
··· 2 2 createNavigationNode, 3 3 horizontalHandler, 4 4 verticalHandler, 5 - type ChainedHandler, 5 + type ComposedHandler, 6 6 type NavigationHandler, 7 7 } from "@fiveway/solid"; 8 8 import { For } from "solid-js"; ··· 12 12 13 13 export function ListExample(props: { 14 14 direction: "vertical" | "horizontal"; 15 - handler?: (h: ChainedHandler) => NavigationHandler; 15 + handler?: (h: ComposedHandler) => NavigationHandler; 16 16 }) { 17 17 const baseHandler = props.direction === "vertical" ? verticalHandler : horizontalHandler; 18 18
+3 -3
examples/solid-example/src/NavItem.tsx
··· 2 2 createElementHandler, 3 3 createNavigationNode, 4 4 itemHandler, 5 - type ChainedHandler, 5 + type ComposedHandler, 6 6 type NavigationHandler, 7 7 type NodeId, 8 8 } from "@fiveway/solid"; ··· 13 13 navId: NodeId; 14 14 order?: number; 15 15 label: string; 16 - handler?: ChainedHandler; 16 + handler?: ComposedHandler; 17 17 }; 18 18 19 19 const goBackHandler: NavigationHandler = (_, action, next) => { ··· 29 29 const nav = createNavigationNode({ 30 30 id: props.navId, 31 31 order: props.order, 32 - handler: (props.handler ?? itemHandler()).prepend(goBackHandler).prepend(elementHandler), 32 + handler: (props.handler ?? itemHandler()).compose(goBackHandler).compose(elementHandler), 33 33 }); 34 34 35 35 return (
+4 -4
examples/solid-example/src/Showcase.tsx
··· 24 24 const { tree } = useNavigationContext(); 25 25 const nav = createNavigationNode({ 26 26 id: "showcase", 27 - handler: gridHandler().prepend(initialHandler("start")), 27 + handler: gridHandler().compose(initialHandler("start")), 28 28 }); 29 29 30 30 useOnFocusChange(nav, (id) => { ··· 89 89 label="Start" 90 90 handler={itemHandler(() => { 91 91 nav.focus("vertical-list"); 92 - }).prepend(gridItemHandler({ row: 0, col: 0 }))} 92 + }).compose(gridItemHandler({ row: 0, col: 0 }))} 93 93 /> 94 94 </div> 95 95 ··· 120 120 > 121 121 <ListExample 122 122 direction="horizontal" 123 - handler={(h) => h.prepend(initialHandler("item3"))} 123 + handler={(h) => h.compose(initialHandler("item3"))} 124 124 /> 125 125 </ExampleBox> 126 126 ··· 133 133 <ListExample 134 134 direction="horizontal" 135 135 handler={(h) => 136 - h.prepend(captureHandler).prepend((n, a, next) => { 136 + h.compose(captureHandler).compose((n, a, next) => { 137 137 if (a.kind === "move" && a.direction === "back") { 138 138 nav.focus(); 139 139 return null;
+1 -1
examples/solid-example/src/SpatialExample.tsx
··· 8 8 export function SpatialExample() { 9 9 const nav = createNavigationNode({ 10 10 id: "spatial", 11 - handler: spatialHandler.prepend(captureHandler), 11 + handler: spatialHandler.compose(captureHandler), 12 12 }); 13 13 14 14 return (
+4 -4
packages/fiveway-react/src/element.ts
··· 1 1 import { 2 2 type NavigationTree, 3 3 type NavigationAction, 4 - type ChainedHandler, 4 + type ComposedHandler, 5 5 dispatchAction, 6 - chainedHandler, 6 + composeHandlers, 7 7 registerListener, 8 8 spatialItemHandler, 9 9 } from "@fiveway/core"; 10 10 import { defaultEventMapping, elementHandler } from "@fiveway/core/dom"; 11 11 import { useRef, useMemo, useEffect } from "react"; 12 12 13 - export type ElementHandler = ChainedHandler & { 13 + export type ElementHandler = ComposedHandler & { 14 14 register: (e: HTMLElement | null) => void; 15 15 }; 16 16 ··· 18 18 const elementRef = useRef<HTMLElement | null>(null); 19 19 20 20 return useMemo(() => { 21 - const handler = chainedHandler([ 21 + const handler = composeHandlers([ 22 22 elementHandler(() => elementRef.current), 23 23 spatialItemHandler(() => { 24 24 return elementRef.current?.getBoundingClientRect() ?? null;
+4 -4
packages/fiveway-solid/src/element.ts
··· 1 1 import { 2 - chainedHandler, 2 + composeHandlers, 3 3 dispatchAction, 4 4 spatialItemHandler, 5 5 registerListener, 6 - type ChainedHandler, 6 + type ComposedHandler, 7 7 type NavigationAction, 8 8 type NavigationTree, 9 9 } from "@fiveway/core"; 10 10 import { defaultEventMapping, elementHandler } from "@fiveway/core/dom"; 11 11 import { createEffect, createSignal, onCleanup } from "solid-js"; 12 12 13 - export type ElementHandler = ChainedHandler & { 13 + export type ElementHandler = ComposedHandler & { 14 14 register: (e: HTMLElement | null) => void; 15 15 }; 16 16 ··· 19 19 const position = () => element()?.getBoundingClientRect() ?? null; 20 20 21 21 // handlers doen't need to be reactive 22 - const handler = chainedHandler([ 22 + const handler = composeHandlers([ 23 23 // eslint-disable-next-line solid/reactivity 24 24 elementHandler(element), 25 25 // eslint-disable-next-line solid/reactivity
+1 -1
packages/fiveway/src/index.ts
··· 54 54 itemHandler, 55 55 } from "./handler/handler.ts"; 56 56 57 - export { type ChainedHandler, chainedHandler } from "./handler/chained.ts"; 57 + export { type ComposedHandler, composeHandlers } from "./handler/composed.ts"; 58 58 59 59 export { 60 60 type FocusDirection,
+2 -2
packages/fiveway/src/inspector.test.ts
··· 23 23 expect(inspectHandler(tree, "#/test")).toEqual([{ name: "test" }]); 24 24 }); 25 25 26 - test("chain handler adds fallback info to link handlers", () => { 26 + test("composed handler adds fallback info to link handlers", () => { 27 27 const tree = createNavigationTree(); 28 28 29 29 const handlerWithoutInfo: NavigationHandler = (_node, _action, next) => { ··· 35 35 createNode({ 36 36 id: "test", 37 37 parent: "#", 38 - handler: defaultHandler.prepend(handlerWithoutInfo), 38 + handler: defaultHandler.compose(handlerWithoutInfo), 39 39 }), 40 40 ); 41 41
+2 -2
examples/react-example/src/virtual/VirtualGridExample.tsx
··· 30 30 31 31 const nav = useNavigationNode({ 32 32 id: "virtual-grid", 33 - handler: gridHandler().prepend((node, action, next) => { 33 + handler: gridHandler().compose((node, action, next) => { 34 34 if (action.kind === "focus") { 35 35 const item = items[listPosition - (listPosition % cols)]; 36 36 if (item == null) { ··· 79 79 navId={item.id} 80 80 label={item.label} 81 81 order={item.order} 82 - handler={itemHandler().prepend(gridItemHandler(gridPosition))} 82 + handler={itemHandler().compose(gridItemHandler(gridPosition))} 83 83 /> 84 84 ); 85 85 })}
+1 -1
examples/react-example/src/virtual/VirtualListExample.tsx
··· 14 14 15 15 const nav = useNavigationNode({ 16 16 id: "virtual-list", 17 - handler: verticalHandler.prepend((node, action, next) => { 17 + handler: verticalHandler.compose((node, action, next) => { 18 18 if (action.kind === "focus") { 19 19 const item = items[listPosition]; 20 20 if (item == null) {
+2 -2
examples/solid-example/src/virtual/VirtualGridExample.tsx
··· 33 33 34 34 const nav = createNavigationNode({ 35 35 id: "virtual-grid", 36 - handler: gridHandler().prepend((node, action, next) => { 36 + handler: gridHandler().compose((node, action, next) => { 37 37 if (action.kind === "focus") { 38 38 const lp = listPosition(); 39 39 const item = items[lp - (lp % cols)]; ··· 85 85 navId={item.id} 86 86 label={item.label} 87 87 order={item.order} 88 - handler={itemHandler().prepend(gridItemHandler(gridPosition))} 88 + handler={itemHandler().compose(gridItemHandler(gridPosition))} 89 89 /> 90 90 ); 91 91 }}
+1 -1
examples/solid-example/src/virtual/VirtualListExample.tsx
··· 19 19 20 20 const nav = createNavigationNode({ 21 21 id: "virtual-list", 22 - handler: verticalHandler.prepend((node, action, next) => { 22 + handler: verticalHandler.compose((node, action, next) => { 23 23 if (action.kind === "focus") { 24 24 const item = items[listPosition()]; 25 25 if (item == null) {
-66
packages/fiveway/src/handler/chained.test.ts
··· 1 - import { test, expect } from "vite-plus/test"; 2 - 3 - import { 4 - type NavigationHandler, 5 - createNode, 6 - createNavigationTree, 7 - insertNode, 8 - chainedHandler, 9 - defaultHandler, 10 - dataHandler, 11 - dispatchAction, 12 - } from "../index.ts"; 13 - 14 - test("chainedHandler", () => { 15 - const tree = createNavigationTree(); 16 - const logs: string[] = []; 17 - 18 - const logHandler = 19 - (msg: string): NavigationHandler => 20 - (n, a, next) => { 21 - if (a.kind === "query" && a.key === "log") { 22 - logs.push(n.id + ":" + msg); 23 - } 24 - return next(); 25 - }; 26 - 27 - const subChain = chainedHandler() 28 - .prepend(logHandler("4")) 29 - .prepend(logHandler("3")) 30 - .prepend(logHandler("2")); 31 - 32 - const handler = chainedHandler() 33 - .prepend(logHandler("5")) 34 - .prepend(subChain) 35 - .prepend(logHandler("1")); 36 - 37 - const node = createNode({ id: "node1", parent: "#", handler: defaultHandler.prepend(handler) }); 38 - insertNode(tree, node); 39 - 40 - dispatchAction(tree, { kind: "query", key: "log", value: null }); 41 - 42 - expect(logs).toEqual(["#/node1:1", "#/node1:2", "#/node1:3", "#/node1:4", "#/node1:5"]); 43 - }); 44 - 45 - test("chainedHandler: meta", () => { 46 - const tree = createNavigationTree(); 47 - 48 - const testHandler = dataHandler("test"); 49 - 50 - const node = createNode({ 51 - id: "node", 52 - parent: "#", 53 - handler: defaultHandler.prepend(testHandler("test-value")), 54 - }); 55 - insertNode(tree, node); 56 - 57 - const node2 = createNode({ 58 - id: "node2", 59 - parent: "#", 60 - handler: defaultHandler.prepend(testHandler(() => "test-value")), 61 - }); 62 - insertNode(tree, node2); 63 - 64 - expect(testHandler.query(tree, node.id)).toBe("test-value"); 65 - expect(testHandler.query(tree, node2.id)).toBe("test-value"); 66 - });
-132
packages/fiveway/src/handler/chained.ts
··· 1 - import { type NavigationAction } from "../action.ts"; 2 - import { describeHandler, INSPECT_HANLDER, type HandlerDescription } from "../inspector.ts"; 3 - import { type NodeId } from "../tree/id.ts"; 4 - import { type NavtreeNode } from "../tree/node.ts"; 5 - import { type NavigationHandler } from "./handler.ts"; 6 - 7 - export type ChainedHandler = NavigationHandler & { 8 - chain: ChainLink | null; 9 - prepend(another: NavigationHandler | ChainedHandler): ChainedHandler; 10 - }; 11 - 12 - type ChainLink = { 13 - handler: NavigationHandler; 14 - next: ChainLink | null; 15 - }; 16 - 17 - /** 18 - * Take handlers and combines them into one so the next handler function 19 - * automatically passes action to the next handler 20 - * 21 - * @param handlers handlers that will be called in order first to last 22 - * @returns handler that will pipe navigation actions through via the next function 23 - */ 24 - function createChainedHandler( 25 - chain: ChainLink | NavigationHandler | NavigationHandler[] | null = null, 26 - ): ChainedHandler { 27 - if (typeof chain === "function") { 28 - chain = { handler: chain, next: null }; 29 - } else if (Array.isArray(chain)) { 30 - chain = createChain(chain); 31 - } 32 - 33 - const chainedHandler: ChainedHandler = (node, action, next) => { 34 - const runLink = ( 35 - link: ChainLink | null, 36 - id?: NodeId, 37 - newAction?: NavigationAction, 38 - ): NodeId | null => { 39 - if (id != null && id !== node.id) { 40 - return next(id, newAction ?? action); 41 - } 42 - 43 - if (link == null) { 44 - return next(); 45 - } 46 - 47 - if (import.meta.env.FIVEWAY_INSPECTOR ?? import.meta.env.DEV) { 48 - describeLinkHandler(link.handler, node, action); 49 - } 50 - 51 - return link.handler(node, newAction ?? action, runLink.bind(null, link.next)); 52 - }; 53 - 54 - return runLink(chain); 55 - }; 56 - 57 - chainedHandler.chain = chain; 58 - 59 - chainedHandler.prepend = (prepended) => { 60 - if ("chain" in prepended) { 61 - if (prepended.chain === null) { 62 - return chainedHandler; 63 - } 64 - 65 - const cloned = cloneChain(prepended.chain); 66 - appendChain(cloned, chain); 67 - return createChainedHandler(cloned); 68 - } 69 - 70 - return createChainedHandler({ handler: prepended, next: chain }); 71 - }; 72 - 73 - return chainedHandler; 74 - } 75 - 76 - function createChain(handlers: (NavigationHandler | ChainedHandler)[]): ChainLink | null { 77 - if (handlers.length === 0) { 78 - return null; 79 - } 80 - 81 - let chain = null; 82 - for (let i = handlers.length - 1; i >= 0; i--) { 83 - chain = { handler: handlers[i]!, next: chain }; 84 - } 85 - 86 - return chain; 87 - } 88 - 89 - function appendChain(chain: ChainLink, next: ChainLink | null) { 90 - let current = chain; 91 - while (current.next !== null) { 92 - current = current.next; 93 - } 94 - 95 - current.next = next; 96 - } 97 - 98 - function cloneChain(original: ChainLink) { 99 - const cloned: ChainLink = { handler: original.handler, next: null }; 100 - 101 - let current = original; 102 - let currentCloned = cloned; 103 - 104 - while (current.next !== null) { 105 - currentCloned.next = { handler: current.next.handler, next: null }; 106 - 107 - current = current.next; 108 - currentCloned = currentCloned.next; 109 - } 110 - 111 - return cloned; 112 - } 113 - 114 - export function describeLinkHandler( 115 - handler: NavigationHandler, 116 - node: NavtreeNode, 117 - action: NavigationAction, 118 - ): void { 119 - if (action.kind !== "query" || action.key !== INSPECT_HANLDER) { 120 - return; 121 - } 122 - 123 - const value: Array<HandlerDescription> = []; 124 - handler(node, { kind: "query", key: INSPECT_HANLDER, value }, () => null); 125 - if (value.length === 0) { 126 - describeHandler(action, { 127 - name: handler.name !== "" ? handler.name : "custom", 128 - }); 129 - } 130 - } 131 - 132 - export { createChainedHandler as chainedHandler };
+66
packages/fiveway/src/handler/composed.test.ts
··· 1 + import { test, expect } from "vite-plus/test"; 2 + 3 + import { 4 + type NavigationHandler, 5 + createNode, 6 + createNavigationTree, 7 + insertNode, 8 + composeHandlers, 9 + defaultHandler, 10 + dataHandler, 11 + dispatchAction, 12 + } from "../index.ts"; 13 + 14 + test("composedHandler", () => { 15 + const tree = createNavigationTree(); 16 + const logs: string[] = []; 17 + 18 + const logHandler = 19 + (msg: string): NavigationHandler => 20 + (n, a, next) => { 21 + if (a.kind === "query" && a.key === "log") { 22 + logs.push(n.id + ":" + msg); 23 + } 24 + return next(); 25 + }; 26 + 27 + const subComposition = composeHandlers() 28 + .compose(logHandler("4")) 29 + .compose(logHandler("3")) 30 + .compose(logHandler("2")); 31 + 32 + const handler = composeHandlers() 33 + .compose(logHandler("5")) 34 + .compose(subComposition) 35 + .compose(logHandler("1")); 36 + 37 + const node = createNode({ id: "node1", parent: "#", handler: defaultHandler.compose(handler) }); 38 + insertNode(tree, node); 39 + 40 + dispatchAction(tree, { kind: "query", key: "log", value: null }); 41 + 42 + expect(logs).toEqual(["#/node1:1", "#/node1:2", "#/node1:3", "#/node1:4", "#/node1:5"]); 43 + }); 44 + 45 + test("composedHandler: meta", () => { 46 + const tree = createNavigationTree(); 47 + 48 + const testHandler = dataHandler("test"); 49 + 50 + const node = createNode({ 51 + id: "node", 52 + parent: "#", 53 + handler: defaultHandler.compose(testHandler("test-value")), 54 + }); 55 + insertNode(tree, node); 56 + 57 + const node2 = createNode({ 58 + id: "node2", 59 + parent: "#", 60 + handler: defaultHandler.compose(testHandler(() => "test-value")), 61 + }); 62 + insertNode(tree, node2); 63 + 64 + expect(testHandler.query(tree, node.id)).toBe("test-value"); 65 + expect(testHandler.query(tree, node2.id)).toBe("test-value"); 66 + });
+132
packages/fiveway/src/handler/composed.ts
··· 1 + import { type NavigationAction } from "../action.ts"; 2 + import { describeHandler, INSPECT_HANLDER, type HandlerDescription } from "../inspector.ts"; 3 + import { type NodeId } from "../tree/id.ts"; 4 + import { type NavtreeNode } from "../tree/node.ts"; 5 + import { type NavigationHandler } from "./handler.ts"; 6 + 7 + export type ComposedHandler = NavigationHandler & { 8 + link: HandlerLink | null; 9 + compose(handler: NavigationHandler | ComposedHandler): ComposedHandler; 10 + }; 11 + 12 + type HandlerLink = { 13 + handler: NavigationHandler; 14 + next: HandlerLink | null; 15 + }; 16 + 17 + /** 18 + * Take handlers and combines them into one so the next handler function 19 + * automatically passes action to the next handler 20 + * 21 + * @param handlers handlers that will be called in order first to last 22 + * @returns handler that will pipe navigation actions through via the next function 23 + */ 24 + function composeHandlers( 25 + link: HandlerLink | NavigationHandler | NavigationHandler[] | null = null, 26 + ): ComposedHandler { 27 + if (typeof link === "function") { 28 + link = { handler: link, next: null }; 29 + } else if (Array.isArray(link)) { 30 + link = createChain(link); 31 + } 32 + 33 + const composedHandler: ComposedHandler = (node, action, next) => { 34 + const runLink = ( 35 + link: HandlerLink | null, 36 + id?: NodeId, 37 + newAction?: NavigationAction, 38 + ): NodeId | null => { 39 + if (id != null && id !== node.id) { 40 + return next(id, newAction ?? action); 41 + } 42 + 43 + if (link == null) { 44 + return next(); 45 + } 46 + 47 + if (import.meta.env.FIVEWAY_INSPECTOR ?? import.meta.env.DEV) { 48 + describeLinkHandler(link.handler, node, action); 49 + } 50 + 51 + return link.handler(node, newAction ?? action, runLink.bind(null, link.next)); 52 + }; 53 + 54 + return runLink(link); 55 + }; 56 + 57 + composedHandler.link = link; 58 + 59 + composedHandler.compose = (prepended) => { 60 + if ("link" in prepended) { 61 + if (prepended.link === null) { 62 + return composedHandler; 63 + } 64 + 65 + const cloned = cloneChain(prepended.link); 66 + appendChain(cloned, link); 67 + return composeHandlers(cloned); 68 + } 69 + 70 + return composeHandlers({ handler: prepended, next: link }); 71 + }; 72 + 73 + return composedHandler; 74 + } 75 + 76 + function createChain(handlers: (NavigationHandler | ComposedHandler)[]): HandlerLink | null { 77 + if (handlers.length === 0) { 78 + return null; 79 + } 80 + 81 + let chain = null; 82 + for (let i = handlers.length - 1; i >= 0; i--) { 83 + chain = { handler: handlers[i]!, next: chain }; 84 + } 85 + 86 + return chain; 87 + } 88 + 89 + function appendChain(chain: HandlerLink, next: HandlerLink | null) { 90 + let current = chain; 91 + while (current.next !== null) { 92 + current = current.next; 93 + } 94 + 95 + current.next = next; 96 + } 97 + 98 + function cloneChain(original: HandlerLink) { 99 + const cloned: HandlerLink = { handler: original.handler, next: null }; 100 + 101 + let current = original; 102 + let currentCloned = cloned; 103 + 104 + while (current.next !== null) { 105 + currentCloned.next = { handler: current.next.handler, next: null }; 106 + 107 + current = current.next; 108 + currentCloned = currentCloned.next; 109 + } 110 + 111 + return cloned; 112 + } 113 + 114 + export function describeLinkHandler( 115 + handler: NavigationHandler, 116 + node: NavtreeNode, 117 + action: NavigationAction, 118 + ): void { 119 + if (action.kind !== "query" || action.key !== INSPECT_HANLDER) { 120 + return; 121 + } 122 + 123 + const value: Array<HandlerDescription> = []; 124 + handler(node, { kind: "query", key: INSPECT_HANLDER, value }, () => null); 125 + if (value.length === 0) { 126 + describeHandler(action, { 127 + name: handler.name !== "" ? handler.name : "custom", 128 + }); 129 + } 130 + } 131 + 132 + export { composeHandlers };
+3 -3
packages/fiveway/src/handler/directional.ts
··· 2 2 import { describeHandler } from "../inspector.ts"; 3 3 import { type NodeId, childLocalId } from "../tree/id.ts"; 4 4 import { type NavtreeNode } from "../tree/node.ts"; 5 - import { type ChainedHandler, chainedHandler } from "./chained.ts"; 5 + import { type ComposedHandler, composeHandlers } from "./composed.ts"; 6 6 import { focusHandler } from "./focus.ts"; 7 7 import { type HandlerNext, parentHandler } from "./handler.ts"; 8 8 ··· 47 47 } 48 48 } 49 49 50 - export const verticalHandler: ChainedHandler = chainedHandler([ 50 + export const verticalHandler: ComposedHandler = composeHandlers([ 51 51 focusHandler({ focusWhenEmpty: false, direction: verticalFocusDirection }), 52 52 verticalMovementHandler, 53 53 parentHandler, ··· 94 94 } 95 95 } 96 96 97 - export const horizontalHandler: ChainedHandler = chainedHandler([ 97 + export const horizontalHandler: ComposedHandler = composeHandlers([ 98 98 focusHandler({ focusWhenEmpty: false, direction: horizontalFocusDirection }), 99 99 horizontalMovementHandler, 100 100 parentHandler,
+2 -2
packages/fiveway/src/handler/focus.test.ts
··· 63 63 const { tree, nodes } = createTreeFromSpec({ 64 64 id: "container", 65 65 66 - handler: verticalHandler.prepend(initialHandler("item2")), 66 + handler: verticalHandler.compose(initialHandler("item2")), 67 67 children: [ 68 68 { 69 69 id: "item1", ··· 116 116 children: [ 117 117 { 118 118 id: "list", 119 - handler: verticalHandler.prepend(captureHandler), 119 + handler: verticalHandler.compose(captureHandler), 120 120 children: [{ id: "item1" }, { id: "item2" }], 121 121 }, 122 122 { id: "outside" },
+4 -4
packages/fiveway/src/handler/focus.ts
··· 14 14 15 15 export const initialHandler: DataHandler<string> = dataHandler<string>("core:initial"); 16 16 17 - function createFocusHandler(config: FocusHandlerOptions = {}): NavigationHandler { 18 - const focusWhenEmpty = config.focusWhenEmpty ?? true; 17 + function createFocusHandler(options: FocusHandlerOptions = {}): NavigationHandler { 18 + const focusWhenEmpty = options.focusWhenEmpty ?? true; 19 19 20 20 const focusHandler: NavigationHandler = (node, action, next) => { 21 21 if (import.meta.env.FIVEWAY_INSPECTOR ?? import.meta.env.DEV) { 22 22 describeHandler(action, { 23 23 name: "core:focus", 24 24 focusWhenEmpty, 25 - direction: config.direction != null ? "custom" : "default", 25 + direction: options.direction != null ? "custom" : "default", 26 26 }); 27 27 } 28 28 ··· 38 38 return null; 39 39 } 40 40 41 - const focusDirection = config.direction?.(action.direction) ?? null; 41 + const focusDirection = options.direction?.(action.direction) ?? null; 42 42 if (focusDirection === null) { 43 43 const initialChild = findInitialChild(node); 44 44 if (initialChild !== null) {
+1 -1
packages/fiveway/src/handler/grid.test.ts
··· 25 25 const node = createNode({ 26 26 id: `item-${row}-${col}`, 27 27 parent: nodes.grid.id, 28 - handler: defaultHandler.prepend(gridItemHandler({ row, col })), 28 + handler: defaultHandler.compose(gridItemHandler({ row, col })), 29 29 }); 30 30 insertNode(tree, node); 31 31 }
+8 -8
packages/fiveway/src/handler/grid.ts
··· 2 2 import { describeHandler } from "../inspector.ts"; 3 3 import { type NodeId, childLocalId } from "../tree/id.ts"; 4 4 import { traverseNodes } from "../tree/tree.ts"; 5 - import { type ChainedHandler, chainedHandler } from "./chained.ts"; 5 + import { type ComposedHandler, composeHandlers } from "./composed.ts"; 6 6 import { focusHandler } from "./focus.ts"; 7 7 import { type NavigationHandler, parentHandler } from "./handler.ts"; 8 8 import { type DataHandler, dataHandler } from "./metadata.ts"; ··· 90 90 direction: NavigationDirection, 91 91 ) => (a: GridItem, b: GridItem) => number | null; 92 92 93 - type GridHandlerConfig = { 93 + type GridHandlerOptions = { 94 94 distance?: DistanceFunction; 95 95 }; 96 96 97 - function createGridMovement(config: GridHandlerConfig = {}): NavigationHandler { 97 + function createGridMovement(options: GridHandlerOptions = {}): NavigationHandler { 98 98 const gridMovement: NavigationHandler = (node, action, next) => { 99 99 if (import.meta.env.FIVEWAY_INSPECTOR ?? import.meta.env.DEV) { 100 100 describeHandler(action, { name: "core:grid" }); ··· 115 115 } 116 116 117 117 const getDistance = 118 - config.distance != null 119 - ? config.distance(action.direction) 118 + options.distance != null 119 + ? options.distance(action.direction) 120 120 : defaultDistance(action.direction); 121 121 122 122 let closestId: NodeId | null = null; ··· 154 154 155 155 export { createGridMovement as gridMovement }; 156 156 157 - export const gridHandler = (config: GridHandlerConfig = {}): ChainedHandler => 158 - chainedHandler([ 157 + export const gridHandler = (options: GridHandlerOptions = {}): ComposedHandler => 158 + composeHandlers([ 159 159 focusHandler({ focusWhenEmpty: false }), 160 - createGridMovement(config), 160 + createGridMovement(options), 161 161 parentHandler, 162 162 ]);
+5 -5
packages/fiveway/src/handler/handler.ts
··· 3 3 import { type NodeId } from "../tree/id.ts"; 4 4 import { type NavtreeNode } from "../tree/node.ts"; 5 5 import { type NavigationTree } from "../tree/tree.ts"; 6 - import { chainedHandler, type ChainedHandler } from "./chained.ts"; 6 + import { composeHandlers, type ComposedHandler } from "./composed.ts"; 7 7 import { focusHandler } from "./focus.ts"; 8 8 import { selectHandler } from "./select.ts"; 9 9 ··· 52 52 return next(); 53 53 }; 54 54 55 - export const defaultHandler: ChainedHandler = chainedHandler([focusHandler(), parentHandler]); 55 + export const defaultHandler: ComposedHandler = composeHandlers([focusHandler(), parentHandler]); 56 56 57 - export const containerHandler: ChainedHandler = chainedHandler([ 57 + export const containerHandler: ComposedHandler = composeHandlers([ 58 58 focusHandler({ focusWhenEmpty: false }), 59 59 parentHandler, 60 60 ]); 61 61 62 - export const itemHandler = (onSelect?: () => void): ChainedHandler => { 62 + export const itemHandler = (onSelect?: () => void): ComposedHandler => { 63 63 if (onSelect == null) { 64 64 return defaultHandler; 65 65 } 66 66 67 - return defaultHandler.prepend(selectHandler(onSelect)); 67 + return defaultHandler.compose(selectHandler(onSelect)); 68 68 };
+1 -1
packages/fiveway/src/handler/metadata.test.ts
··· 15 15 const container = createNode({ 16 16 id: "test", 17 17 parent: "#", 18 - handler: containerHandler.prepend(meta(1)), 18 + handler: containerHandler.compose(meta(1)), 19 19 }); 20 20 insertNode(tree, container); 21 21
+1 -1
packages/fiveway/src/handler/select.test.ts
··· 17 17 const node = createNode({ 18 18 id: "test", 19 19 parent: "#", 20 - handler: defaultHandler.prepend(selectHandler(onSelect)), 20 + handler: defaultHandler.compose(selectHandler(onSelect)), 21 21 }); 22 22 insertNode(tree, node); 23 23
+1 -1
packages/fiveway/src/handler/spatial.test.ts
··· 41 41 const node = createNode({ 42 42 id: `item-${row}-${col}`, 43 43 parent: nodes.spatial.id, 44 - handler: defaultHandler.prepend(position), 44 + handler: defaultHandler.compose(position), 45 45 }); 46 46 47 47 insertNode(tree, node);
+2 -2
packages/fiveway/src/handler/spatial.ts
··· 2 2 import { describeHandler } from "../inspector.ts"; 3 3 import { type NodeId } from "../tree/id.ts"; 4 4 import { traverseNodes } from "../tree/tree.ts"; 5 - import { type ChainedHandler, chainedHandler } from "./chained.ts"; 5 + import { type ComposedHandler, composeHandlers } from "./composed.ts"; 6 6 import { focusHandler } from "./focus.ts"; 7 7 import { type NavigationHandler } from "./handler.ts"; 8 8 import { parentHandler } from "./handler.ts"; ··· 59 59 return closestId ?? next(); 60 60 }; 61 61 62 - export const spatialHandler: ChainedHandler = chainedHandler([ 62 + export const spatialHandler: ComposedHandler = composeHandlers([ 63 63 focusHandler({ focusWhenEmpty: false }), 64 64 spatialMovement, 65 65 parentHandler,