[READ-ONLY] Mirror of https://github.com/bombshell-dev/playground.
0

Configure Feed

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

♻️ rewrite focus as synchronous; remove old async API (FreedomApi/useTree/append/eval)

- focus.ts: sync functions taking a node; useFocus installs a remove-interceptor
via node.scope.around(NodeApi)
- NodeApi gains sync remove; Node.remove() returns the teardown Promise
- delete freedom.ts (FreedomApi/append/operations) and tree.ts (useTree)
- drop Component/Tree/Node.eval from types; mod.ts exports the new surface
- focus.test.ts rewritten imperatively (synchronous)

Charles Lowell (Jun 29, 2026, 9:41 AM +0300) 0b763be9 6d806d09

+225 -778
+66 -106
packages/freedom/src/lib/focus.ts
··· 1 1 // oxlint-disable bombshell-dev/no-generic-error 2 - // oxlint-disable max-params 3 - import type { Api, Operation } from "effection"; 4 - import { createApi } from "effection/experimental"; 5 2 import type { Node } from "./types.ts"; 6 - import { FreedomApi, set } from "./freedom.ts"; 7 - import { NodeContext, type NodeImpl } from "./node.ts"; 8 - 9 - export interface Focus { 10 - focusable(): Operation<void>; 11 - advance(): Operation<void>; 12 - retreat(): Operation<void>; 13 - focus(node: Node): Operation<void>; 14 - current(): Operation<Node>; 15 - } 3 + import { NodeApi } from "./node.ts"; 16 4 17 5 function findRoot(node: Node): Node { 18 6 let n = node; ··· 33 21 return result; 34 22 } 35 23 36 - function* setFocused( 37 - target: Node, 38 - value: boolean, 39 - self: NodeImpl, 40 - ): Operation<void> { 41 - if (target === self) { 42 - yield* set("focused", value); 43 - } else { 44 - yield* target.eval(() => set("focused", value)); 24 + function successorOf(node: Node): Node | undefined { 25 + const nodes = focusChain(findRoot(node)); 26 + if (nodes.length <= 1) { 27 + return undefined; 28 + } 29 + const idx = nodes.indexOf(node); 30 + if (idx === -1) { 31 + return undefined; 45 32 } 33 + return nodes[(idx + 1) % nodes.length]; 46 34 } 47 35 48 - export const FocusApi: Api<Focus> = createApi<Focus>("freedom:focus", { 49 - *focusable() { 50 - const node = yield* NodeContext.expect(); 51 - if (!("focused" in node.props)) { 52 - yield* set("focused", false); 53 - } 54 - }, 55 - 56 - *advance() { 57 - const self = yield* NodeContext.expect(); 58 - const r = findRoot(self); 59 - const nodes = focusChain(r); 60 - if (nodes.length <= 1) return; 61 - 62 - const idx = nodes.findIndex((n) => n.props.focused === true); 63 - if (idx === -1) return; 64 - 65 - const old = nodes[idx]; 66 - const next = nodes[(idx + 1) % nodes.length]; 67 - 68 - yield* setFocused(old, false, self); 69 - yield* setFocused(next, true, self); 70 - }, 71 - 72 - *retreat() { 73 - const self = yield* NodeContext.expect(); 74 - const r = findRoot(self); 75 - const nodes = focusChain(r); 76 - if (nodes.length <= 1) return; 77 - 78 - const idx = nodes.findIndex((n) => n.props.focused === true); 79 - if (idx === -1) return; 80 - 81 - const old = nodes[idx]; 82 - const prev = nodes[(idx - 1 + nodes.length) % nodes.length]; 83 - 84 - yield* setFocused(old, false, self); 85 - yield* setFocused(prev, true, self); 86 - }, 87 - 88 - *focus(target: Node) { 89 - if (!("focused" in target.props)) { 90 - throw new Error("Cannot focus a non-focusable node"); 91 - } 92 - if (target.props.focused === true) return; 93 - 94 - const self = yield* NodeContext.expect(); 95 - const r = findRoot(target); 96 - const nodes = focusChain(r); 97 - const old = nodes.find((n) => n.props.focused === true); 36 + export function focusable(node: Node): void { 37 + if (!("focused" in node.props)) { 38 + node.set("focused", false); 39 + } 40 + } 98 41 99 - if (old) { 100 - yield* setFocused(old, false, self); 101 - } 102 - yield* setFocused(target, true, self); 103 - }, 42 + export function current(node: Node): Node { 43 + const root = findRoot(node); 44 + return focusChain(root).find((n) => n.props.focused === true) ?? root; 45 + } 104 46 105 - *current() { 106 - const node = yield* NodeContext.expect(); 107 - const r = findRoot(node); 108 - const nodes = focusChain(r); 109 - const focused = nodes.find((n) => n.props.focused === true); 110 - if (focused) { 111 - return focused; 112 - } else { 113 - return r; 114 - } 115 - }, 116 - }); 47 + export function advance(node: Node): void { 48 + const nodes = focusChain(findRoot(node)); 49 + if (nodes.length <= 1) { 50 + return; 51 + } 52 + const idx = nodes.findIndex((n) => n.props.focused === true); 53 + if (idx === -1) { 54 + return; 55 + } 56 + nodes[idx].set("focused", false); 57 + nodes[(idx + 1) % nodes.length].set("focused", true); 58 + } 117 59 118 - export const focusable: typeof FocusApi.operations.focusable = 119 - FocusApi.operations.focusable; 120 - export const advance: typeof FocusApi.operations.advance = 121 - FocusApi.operations.advance; 122 - export const retreat: typeof FocusApi.operations.retreat = 123 - FocusApi.operations.retreat; 124 - export const focus: typeof FocusApi.operations.focus = 125 - FocusApi.operations.focus; 126 - export const current: typeof FocusApi.operations.current = 127 - FocusApi.operations.current; 60 + export function retreat(node: Node): void { 61 + const nodes = focusChain(findRoot(node)); 62 + if (nodes.length <= 1) { 63 + return; 64 + } 65 + const idx = nodes.findIndex((n) => n.props.focused === true); 66 + if (idx === -1) { 67 + return; 68 + } 69 + nodes[idx].set("focused", false); 70 + nodes[(idx - 1 + nodes.length) % nodes.length].set("focused", true); 71 + } 128 72 129 - export function* useFocus(): Operation<void> { 130 - yield* set("focused", true); 73 + export function focus(target: Node): void { 74 + if (!("focused" in target.props)) { 75 + throw new Error("Cannot focus a non-focusable node"); 76 + } 77 + if (target.props.focused === true) { 78 + return; 79 + } 80 + const old = focusChain(findRoot(target)).find((n) => n.props.focused === true); 81 + if (old) { 82 + old.set("focused", false); 83 + } 84 + target.set("focused", true); 85 + } 131 86 132 - yield* FreedomApi.around({ 133 - *remove([node], next) { 134 - if (node.props.focused === true) { 135 - yield* FocusApi.operations.advance(); 87 + export function useFocus(node: Node): void { 88 + node.set("focused", true); 89 + node.scope.around(NodeApi, { 90 + remove([target], next) { 91 + if (target.props.focused === true) { 92 + const successor = successorOf(target); 93 + if (successor && successor !== target) { 94 + focus(successor); 95 + } 136 96 } 137 - yield* next(node); 97 + return next(target); 138 98 }, 139 99 }); 140 100 }
-124
packages/freedom/src/lib/freedom.ts
··· 1 - // oxlint-disable require-yield 2 - // oxlint-disable bombshell-dev/no-generic-error 3 - import { 4 - type Api, 5 - type Operation, 6 - spawn, 7 - suspend, 8 - useScope, 9 - withResolvers, 10 - } from "effection"; 11 - import { createApi } from "effection/experimental"; 12 - import { 13 - type Component, 14 - createNodeData, 15 - type JsonValue, 16 - type Node, 17 - } from "./types.ts"; 18 - import { NodeContext, NodeImpl } from "./node.ts"; 19 - import { TreeContext } from "./state.ts"; 20 - import { validateJsonValue } from "./validate.ts"; 21 - 22 - const Halt = createNodeData<() => Operation<void>>( 23 - "freedom:halt", 24 - function* () { 25 - throw new Error("Cannot remove root node"); 26 - }, 27 - ); 28 - 29 - export interface Freedom { 30 - useNode(): Operation<Node>; 31 - get(key: string): Operation<JsonValue | undefined>; 32 - set(key: string, value: JsonValue): Operation<void>; 33 - update( 34 - key: string, 35 - fn: (prev: JsonValue | undefined) => JsonValue, 36 - ): Operation<void>; 37 - unset(key: string): Operation<void>; 38 - append(name: string, component: Component): Operation<Node>; 39 - remove(node: Node): Operation<void>; 40 - sort(fn: ((a: Node, b: Node) => number) | undefined): Operation<void>; 41 - } 42 - 43 - export const FreedomApi: Api<Freedom> = createApi<Freedom>("freedom:node", { 44 - useNode: () => NodeContext.expect(), 45 - 46 - *get(key: string): Operation<JsonValue | undefined> { 47 - const node = yield* NodeContext.expect(); 48 - return node._props[key]; 49 - }, 50 - 51 - *set(key: string, value: JsonValue) { 52 - validateJsonValue(value); 53 - const node = yield* NodeContext.expect(); 54 - node._props[key] = value; 55 - }, 56 - 57 - *update(key: string, fn: (prev: JsonValue | undefined) => JsonValue) { 58 - const node = yield* NodeContext.expect(); 59 - const prev = node._props[key]; 60 - const next = fn(prev); 61 - validateJsonValue(next); 62 - node._props[key] = next; 63 - }, 64 - 65 - *unset(key: string) { 66 - const node = yield* NodeContext.expect(); 67 - if (key in node._props) { 68 - delete node._props[key]; 69 - } 70 - }, 71 - 72 - *append(name: string, component: Component): Operation<Node> { 73 - const parent = yield* NodeContext.expect(); 74 - const tree = yield* TreeContext.expect(); 75 - const child = new NodeImpl(tree.nextId(), name, parent); 76 - const ready = withResolvers<void>(); 77 - 78 - const task = yield* spawn(function* () { 79 - parent._children.add(child); 80 - tree.nodes.set(child.id, child); 81 - yield* NodeContext.set(child); 82 - child.scope = yield* useScope(); 83 - ready.resolve(); 84 - try { 85 - yield* component(); 86 - yield* suspend(); 87 - } finally { 88 - parent._children.delete(child); 89 - tree.nodes.delete(child.id); 90 - tree.markDirty(); 91 - } 92 - }); 93 - child.data.set(Halt, task.halt); 94 - child.remove = () => FreedomApi.operations.remove(child); 95 - 96 - yield* ready.operation; 97 - return child; 98 - }, 99 - 100 - *remove(node: Node) { 101 - const halt = node.data.expect(Halt); 102 - yield* halt(); 103 - }, 104 - 105 - *sort(fn: ((a: Node, b: Node) => number) | undefined) { 106 - const node = yield* NodeContext.expect(); 107 - node._sortFn = fn; 108 - }, 109 - }); 110 - 111 - export const useNode: typeof FreedomApi.operations.useNode = 112 - FreedomApi.operations.useNode; 113 - export const get: typeof FreedomApi.operations.get = FreedomApi.operations.get; 114 - export const set: typeof FreedomApi.operations.set = FreedomApi.operations.set; 115 - export const update: typeof FreedomApi.operations.update = 116 - FreedomApi.operations.update; 117 - export const unset: typeof FreedomApi.operations.unset = 118 - FreedomApi.operations.unset; 119 - export const append: typeof FreedomApi.operations.append = 120 - FreedomApi.operations.append; 121 - export const remove: typeof FreedomApi.operations.remove = 122 - FreedomApi.operations.remove; 123 - export const sort: typeof FreedomApi.operations.sort = 124 - FreedomApi.operations.sort;
-18
packages/freedom/src/lib/mod.ts
··· 1 1 export type { 2 - Component, 3 2 JsonValue, 4 3 Node, 5 4 NodeData, 6 5 NodeDataKey, 7 6 Root, 8 - Tree, 9 7 } from "./types.ts"; 10 8 11 9 export { createNodeData } from "./types.ts"; 12 10 13 11 export { createRoot } from "./root.ts"; 14 12 export { NodeApi } from "./node.ts"; 15 - export { useTree } from "./tree.ts"; 16 - 17 - export { 18 - append, 19 - type Freedom, 20 - FreedomApi, 21 - get, 22 - remove, 23 - set, 24 - sort, 25 - unset, 26 - update, 27 - useNode, 28 - } from "./freedom.ts"; 29 13 30 14 export { type Dispatch, DispatchApi } from "./dispatch.ts"; 31 15 32 16 export { 33 17 advance, 34 18 current, 35 - type Focus, 36 19 focus, 37 20 focusable, 38 - FocusApi, 39 21 retreat, 40 22 useFocus, 41 23 } from "./focus.ts";
+12 -33
packages/freedom/src/lib/node.ts
··· 4 4 type Context, 5 5 createContext, 6 6 createScope, 7 - Err, 8 - Ok, 9 - type Operation, 10 - type Result, 11 7 type Scope, 12 8 } from "effection"; 13 9 import { createApi } from "effection/experimental"; ··· 83 79 return this._parent; 84 80 } 85 81 86 - *eval<T>(op: () => Operation<T>): Operation<Result<T>> { 87 - // Run `op` inline with the current routine's scope temporarily repointed at 88 - // this node's scope, so the op sees this node's contexts/middleware. 89 - const restore = (yield { 90 - description: "freedom: enter node scope", 91 - enter: ( 92 - resolve: (result: Result<() => void>) => void, 93 - routine: { scope: Scope }, 94 - ) => { 95 - const original = routine.scope; 96 - routine.scope = this.scope; 97 - resolve(Ok(() => { 98 - routine.scope = original; 99 - })); 100 - return (resolveExit: (result: Result<void>) => void) => 101 - resolveExit(Ok()); 102 - }, 103 - }) as () => void; 104 - try { 105 - return Ok(yield* op()); 106 - } catch (error) { 107 - return Err(error as Error); 108 - } finally { 109 - restore(); 110 - } 111 - } 112 - 113 82 get(key: string): JsonValue | undefined { 114 83 return NodeApi.invoke(this.scope, "get", [this, key]); 115 84 } ··· 138 107 return this.#dispose(); 139 108 } 140 109 141 - remove(): Operation<void> { 142 - throw new Error("Cannot remove root node"); 110 + remove(): Promise<void> { 111 + return NodeApi.invoke(this.scope, "remove", [this]); 143 112 } 144 113 } 145 114 ··· 181 150 sort(node: NodeImpl, fn: ((a: Node, b: Node) => number) | undefined): void { 182 151 node._sortFn = fn; 183 152 node.scope.expect(TreeContext).markDirty(); 153 + }, 154 + remove(node: NodeImpl): Promise<void> { 155 + if (!node._parent) { 156 + throw new Error("Cannot remove root node"); 157 + } 158 + const state = node.scope.expect(TreeContext); 159 + node._parent._children.delete(node); 160 + state.nodes.delete(node.id); 161 + state.markDirty(); 162 + return node.destroy(); 184 163 }, 185 164 }); 186 165
-111
packages/freedom/src/lib/tree.ts
··· 1 - import { 2 - createSignal, 3 - type Operation, 4 - resource, 5 - spawn, 6 - suspend, 7 - useScope, 8 - withResolvers, 9 - } from "effection"; 10 - import type { Component, Tree } from "./types.ts"; 11 - import { NodeContext, NodeImpl } from "./node.ts"; 12 - import { TreeContext, type TreeState } from "./state.ts"; 13 - import { DispatchApi } from "./dispatch.ts"; 14 - import { FreedomApi } from "./freedom.ts"; 15 - 16 - export function useTree(root: Component): Operation<Tree> { 17 - return resource<Tree>(function* (provide) { 18 - const output = createSignal<void, never>(); 19 - const events = createSignal<unknown, void>(); 20 - 21 - let counter = 0; 22 - const state: TreeState = { 23 - dirty: false, 24 - output, 25 - nodes: new Map(), 26 - nextId() { 27 - return `node-${++counter}`; 28 - }, 29 - markDirty() { 30 - state.dirty = true; 31 - }, 32 - }; 33 - 34 - yield* TreeContext.set(state); 35 - 36 - const rootNode = new NodeImpl(state.nextId(), "", undefined); 37 - rootNode.remove = () => FreedomApi.operations.remove(rootNode); 38 - state.nodes.set(rootNode.id, rootNode); 39 - 40 - const ready = withResolvers<void>(); 41 - 42 - // Spawn root node scope 43 - yield* spawn(function* () { 44 - rootNode.scope = yield* useScope(); 45 - yield* NodeContext.set(rootNode); 46 - 47 - // Mark dirty after every mutation 48 - yield* FreedomApi.around({ 49 - *set(args, next) { 50 - yield* next(...args); 51 - state.markDirty(); 52 - }, 53 - *update(args, next) { 54 - yield* next(...args); 55 - state.markDirty(); 56 - }, 57 - *unset(args, next) { 58 - yield* next(...args); 59 - state.markDirty(); 60 - }, 61 - *append(args, next) { 62 - const node = yield* next(...args); 63 - state.markDirty(); 64 - return node; 65 - }, 66 - *remove(args, next) { 67 - yield* next(...args); 68 - state.markDirty(); 69 - }, 70 - *sort(args, next) { 71 - yield* next(...args); 72 - state.markDirty(); 73 - }, 74 - }); 75 - 76 - // Subscribe to events, then spawn the event loop 77 - const sub = yield* events; 78 - yield* spawn(function* () { 79 - while (true) { 80 - const next = yield* sub.next(); 81 - if (next.done) { 82 - break; 83 - } 84 - const event = next.value; 85 - state.dirty = false; 86 - yield* rootNode.eval(() => DispatchApi.operations.dispatch(event)); 87 - if (state.dirty) { 88 - output.send(); 89 - } 90 - } 91 - }); 92 - 93 - ready.resolve(); 94 - 95 - yield* root(); 96 - yield* suspend(); 97 - }); 98 - 99 - yield* ready.operation; 100 - 101 - const tree: Tree = { 102 - dispatch(event: unknown) { 103 - events.send(event); 104 - }, 105 - root: rootNode, 106 - [Symbol.iterator]: output[Symbol.iterator], 107 - }; 108 - 109 - yield* provide(tree); 110 - }); 111 - }
+2 -10
packages/freedom/src/lib/types.ts
··· 1 - import type { Operation, Result, Scope, Stream } from "effection"; 1 + import type { Scope, Stream } from "effection"; 2 2 3 3 export type JsonValue = 4 4 | string ··· 7 7 | null 8 8 | JsonValue[] 9 9 | { [key: string]: JsonValue }; 10 - 11 - export type Component = () => Operation<void>; 12 10 13 11 export interface NodeDataKey<T> { 14 12 readonly symbol: symbol; ··· 43 41 createChild(name?: string): Node; 44 42 sort(fn?: (a: Node, b: Node) => number): void; 45 43 destroy(): Promise<void>; 46 - eval<T>(op: () => Operation<T>): Operation<Result<T>>; 47 - remove(): Operation<void>; 48 - } 49 - 50 - export interface Tree extends Stream<void, never> { 51 - dispatch(event: unknown): void; 52 - root: Node; 44 + remove(): Promise<void>; 53 45 } 54 46 55 47 export interface Root extends Stream<void, never> {
+145 -376
packages/freedom/test/focus.test.ts
··· 1 - import { describe, it } from "../test/suite.ts"; 2 - import { expect } from "../test/helpers.ts"; 3 - import { run } from "effection"; 1 + import { describe, expect, it } from "../test/suite.ts"; 4 2 import { 5 3 advance, 6 - append, 4 + createRoot, 7 5 current, 8 6 focus, 9 7 focusable, 10 8 retreat, 11 - set, 12 9 useFocus, 13 - useTree, 14 10 } from "../src/index.ts"; 15 11 16 12 describe("Focus installation", () => { 17 - it("FI1-FI3: useFocus sets root as focused", async () => { 18 - await run(function* () { 19 - let tree = yield* useTree(function* () { 20 - yield* useFocus(); 21 - }); 22 - 23 - yield* expect(tree.root).toEval(function* () { 24 - let node = yield* current(); 25 - expect(node).toBe(tree.root); 26 - expect(tree.root.props.focused).toBe(true); 27 - }); 28 - }); 13 + it("useFocus sets root as focused", () => { 14 + const root = createRoot(); 15 + useFocus(root.node); 16 + expect(current(root.node)).toBe(root.node); 17 + expect(root.node.props.focused).toBe(true); 18 + root.destroy(); 29 19 }); 30 20 }); 31 21 32 22 describe("focusable()", () => { 33 - it("FF1-FF2: focusable sets focused:false on the node", async () => { 34 - await run(function* () { 35 - let tree = yield* useTree(function* () { 36 - yield* useFocus(); 37 - yield* append("child", function* () { 38 - yield* focusable(); 39 - }); 40 - }); 41 - 42 - yield* expect(tree.root).toEval(function* () { 43 - let children = [...tree.root.children]; 44 - expect(children[0].props.focused).toBe(false); 45 - }); 46 - }); 23 + it("sets focused:false on the node", () => { 24 + const root = createRoot(); 25 + useFocus(root.node); 26 + const child = root.node.createChild("child"); 27 + focusable(child); 28 + expect(child.props.focused).toBe(false); 29 + root.destroy(); 47 30 }); 48 31 49 - it("FF3: focusable on already-focusable node is no-op", async () => { 50 - await run(function* () { 51 - let tree = yield* useTree(function* () { 52 - yield* useFocus(); 53 - yield* append("child", function* () { 54 - yield* focusable(); 55 - yield* focusable(); // second call 56 - }); 57 - }); 58 - 59 - yield* expect(tree.root).toEval(function* () { 60 - let children = [...tree.root.children]; 61 - expect(children[0].props.focused).toBe(false); 62 - }); 63 - }); 32 + it("is a no-op on an already-focusable node", () => { 33 + const root = createRoot(); 34 + useFocus(root.node); 35 + const child = root.node.createChild("child"); 36 + focusable(child); 37 + focusable(child); 38 + expect(child.props.focused).toBe(false); 39 + root.destroy(); 64 40 }); 65 41 66 - it("FF4: node without focusable is not in focus chain", async () => { 67 - await run(function* () { 68 - yield* useTree(function* () { 69 - yield* useFocus(); 70 - yield* append("nonfocusable", function* () { 71 - yield* set("label", "skip me"); 72 - }); 73 - yield* append("focusable", function* () { 74 - yield* focusable(); 75 - }); 76 - }); 77 - 78 - // advance from root should skip nonfocusable child 79 - // (eval sequences after component has run) 80 - }); 42 + it("a node without focusable is skipped by the chain", () => { 43 + const root = createRoot(); 44 + useFocus(root.node); 45 + root.node.createChild("skip"); 46 + focusable(root.node.createChild("here")); 47 + advance(root.node); // root -> here, skipping "skip" 48 + expect(current(root.node).name).toEqual("here"); 49 + root.destroy(); 81 50 }); 82 51 }); 83 52 84 53 describe("Focus chain", () => { 85 - it("FC1: depth-first order with flat children", async () => { 86 - await run(function* () { 87 - let tree = yield* useTree(function* () { 88 - yield* useFocus(); 89 - yield* append("A", function* () { 90 - yield* focusable(); 91 - }); 92 - yield* append("B", function* () { 93 - yield* focusable(); 94 - }); 95 - yield* append("C", function* () { 96 - yield* focusable(); 97 - }); 98 - }); 99 - 100 - yield* expect(tree.root).toEval(function* () { 101 - let names: string[] = []; 102 - for (let i = 0; i < 4; i++) { 103 - let node = yield* current(); 104 - names.push(node.name); 105 - yield* advance(); 106 - } 107 - expect(names).toEqual(["", "A", "B", "C"]); 108 - }); 109 - }); 54 + it("depth-first order, flat children", () => { 55 + const root = createRoot(); 56 + useFocus(root.node); 57 + for (const name of ["A", "B", "C"]) { 58 + focusable(root.node.createChild(name)); 59 + } 60 + const names: string[] = []; 61 + for (let i = 0; i < 4; i++) { 62 + names.push(current(root.node).name); 63 + advance(root.node); 64 + } 65 + expect(names).toEqual(["", "A", "B", "C"]); 66 + root.destroy(); 110 67 }); 111 68 112 - it("FC2: depth-first order with nested children", async () => { 113 - await run(function* () { 114 - let tree = yield* useTree(function* () { 115 - yield* useFocus(); 116 - yield* append("A", function* () { 117 - yield* focusable(); 118 - yield* append("A1", function* () { 119 - yield* focusable(); 120 - }); 121 - }); 122 - yield* append("B", function* () { 123 - yield* focusable(); 124 - }); 125 - }); 126 - 127 - yield* expect(tree.root).toEval(function* () { 128 - let names: string[] = []; 129 - for (let i = 0; i < 4; i++) { 130 - let node = yield* current(); 131 - names.push(node.name); 132 - yield* advance(); 133 - } 134 - expect(names).toEqual(["", "A", "A1", "B"]); 135 - }); 136 - }); 137 - }); 138 - 139 - it("FC3: non-focusable nodes are skipped", async () => { 140 - await run(function* () { 141 - let tree = yield* useTree(function* () { 142 - yield* useFocus(); 143 - yield* append("A", function* () { 144 - // not focusable 145 - }); 146 - yield* append("B", function* () { 147 - yield* focusable(); 148 - }); 149 - }); 150 - 151 - yield* expect(tree.root).toEval(function* () { 152 - let names: string[] = []; 153 - for (let i = 0; i < 2; i++) { 154 - let node = yield* current(); 155 - names.push(node.name); 156 - yield* advance(); 157 - } 158 - expect(names).toEqual(["", "B"]); 159 - }); 160 - }); 69 + it("depth-first order, nested children", () => { 70 + const root = createRoot(); 71 + useFocus(root.node); 72 + const a = root.node.createChild("A"); 73 + focusable(a); 74 + focusable(a.createChild("A1")); 75 + focusable(root.node.createChild("B")); 76 + const names: string[] = []; 77 + for (let i = 0; i < 4; i++) { 78 + names.push(current(root.node).name); 79 + advance(root.node); 80 + } 81 + expect(names).toEqual(["", "A", "A1", "B"]); 82 + root.destroy(); 161 83 }); 162 84 }); 163 85 164 86 describe("advance()", () => { 165 - it("FA1-FA3: moves focus forward", async () => { 166 - await run(function* () { 167 - let tree = yield* useTree(function* () { 168 - yield* useFocus(); 169 - yield* append("A", function* () { 170 - yield* focusable(); 171 - }); 172 - }); 173 - 174 - yield* expect(tree.root).toEval(function* () { 175 - expect(tree.root.props.focused).toBe(true); 176 - yield* advance(); 177 - expect(tree.root.props.focused).toBe(false); 178 - let children = [...tree.root.children]; 179 - expect(children[0].props.focused).toBe(true); 180 - }); 181 - }); 87 + it("moves focus forward", () => { 88 + const root = createRoot(); 89 + useFocus(root.node); 90 + const a = root.node.createChild("A"); 91 + focusable(a); 92 + expect(root.node.props.focused).toBe(true); 93 + advance(root.node); 94 + expect(root.node.props.focused).toBe(false); 95 + expect(a.props.focused).toBe(true); 96 + root.destroy(); 182 97 }); 183 98 184 - it("FA4: wraps from last to first", async () => { 185 - await run(function* () { 186 - let tree = yield* useTree(function* () { 187 - yield* useFocus(); 188 - yield* append("A", function* () { 189 - yield* focusable(); 190 - }); 191 - }); 192 - 193 - yield* expect(tree.root).toEval(function* () { 194 - yield* advance(); 195 - yield* advance(); 196 - let node = yield* current(); 197 - expect(node).toBe(tree.root); 198 - expect(tree.root.props.focused).toBe(true); 199 - }); 200 - }); 99 + it("wraps from last to first (root)", () => { 100 + const root = createRoot(); 101 + useFocus(root.node); 102 + focusable(root.node.createChild("A")); 103 + advance(root.node); // root -> A 104 + advance(root.node); // A -> root 105 + expect(current(root.node)).toBe(root.node); 106 + root.destroy(); 201 107 }); 202 108 203 - it("FA5: single focusable node is a no-op", async () => { 204 - await run(function* () { 205 - let tree = yield* useTree(function* () { 206 - yield* useFocus(); 207 - }); 208 - 209 - yield* expect(tree.root).toEval(function* () { 210 - yield* advance(); 211 - let node = yield* current(); 212 - expect(node).toBe(tree.root); 213 - expect(tree.root.props.focused).toBe(true); 214 - }); 215 - }); 109 + it("single focusable node is a no-op", () => { 110 + const root = createRoot(); 111 + useFocus(root.node); 112 + advance(root.node); 113 + expect(current(root.node)).toBe(root.node); 114 + root.destroy(); 216 115 }); 217 116 }); 218 117 219 118 describe("retreat()", () => { 220 - it("FR1: moves focus backward", async () => { 221 - await run(function* () { 222 - let tree = yield* useTree(function* () { 223 - yield* useFocus(); 224 - yield* append("A", function* () { 225 - yield* focusable(); 226 - }); 227 - yield* append("B", function* () { 228 - yield* focusable(); 229 - }); 230 - }); 231 - 232 - yield* expect(tree.root).toEval(function* () { 233 - yield* advance(); // root -> A 234 - yield* advance(); // A -> B 235 - let node = yield* current(); 236 - expect(node.name).toEqual("B"); 237 - 238 - yield* retreat(); // B -> A 239 - node = yield* current(); 240 - expect(node.name).toEqual("A"); 241 - }); 242 - }); 119 + it("moves focus backward", () => { 120 + const root = createRoot(); 121 + useFocus(root.node); 122 + focusable(root.node.createChild("A")); 123 + focusable(root.node.createChild("B")); 124 + advance(root.node); // root -> A 125 + advance(root.node); // A -> B 126 + expect(current(root.node).name).toEqual("B"); 127 + retreat(root.node); // B -> A 128 + expect(current(root.node).name).toEqual("A"); 129 + root.destroy(); 243 130 }); 244 131 245 - it("FR2: wraps from first to last", async () => { 246 - await run(function* () { 247 - let tree = yield* useTree(function* () { 248 - yield* useFocus(); 249 - yield* append("A", function* () { 250 - yield* focusable(); 251 - }); 252 - yield* append("B", function* () { 253 - yield* focusable(); 254 - }); 255 - }); 256 - 257 - yield* expect(tree.root).toEval(function* () { 258 - yield* retreat(); 259 - let node = yield* current(); 260 - expect(node.name).toEqual("B"); 261 - }); 262 - }); 263 - }); 264 - 265 - it("FR3: single node is a no-op", async () => { 266 - await run(function* () { 267 - let tree = yield* useTree(function* () { 268 - yield* useFocus(); 269 - }); 270 - 271 - yield* expect(tree.root).toEval(function* () { 272 - yield* retreat(); 273 - let node = yield* current(); 274 - expect(node).toBe(tree.root); 275 - }); 276 - }); 132 + it("wraps from first to last", () => { 133 + const root = createRoot(); 134 + useFocus(root.node); 135 + focusable(root.node.createChild("A")); 136 + const b = root.node.createChild("B"); 137 + focusable(b); 138 + retreat(root.node); // root -> B (last) 139 + expect(current(root.node)).toBe(b); 140 + root.destroy(); 277 141 }); 278 142 }); 279 143 280 144 describe("focus(node)", () => { 281 - it("FE1: explicit focus changes focused node", async () => { 282 - await run(function* () { 283 - let tree = yield* useTree(function* () { 284 - yield* useFocus(); 285 - yield* append("A", function* () { 286 - yield* focusable(); 287 - }); 288 - yield* append("B", function* () { 289 - yield* focusable(); 290 - }); 291 - }); 292 - 293 - yield* expect(tree.root).toEval(function* () { 294 - let children = [...tree.root.children]; 295 - let b = children[1]; 296 - yield* focus(b); 297 - let node = yield* current(); 298 - expect(node).toBe(b); 299 - expect(tree.root.props.focused).toBe(false); 300 - expect(b.props.focused).toBe(true); 301 - }); 302 - }); 303 - }); 304 - 305 - it("FE2: focus on non-focusable node raises error", async () => { 306 - await run(function* () { 307 - let tree = yield* useTree(function* () { 308 - yield* useFocus(); 309 - yield* append("nonfocusable", function* () {}); 310 - }); 311 - 312 - yield* expect(tree.root).toEval(function* () { 313 - let children = [...tree.root.children]; 314 - try { 315 - yield* focus(children[0]); 316 - expect(true).toBe(false); 317 - } catch (e) { 318 - expect((e as Error).message).toContain("non-focusable"); 319 - } 320 - }); 321 - }); 145 + it("explicitly focuses a node", () => { 146 + const root = createRoot(); 147 + useFocus(root.node); 148 + focusable(root.node.createChild("A")); 149 + const b = root.node.createChild("B"); 150 + focusable(b); 151 + focus(b); 152 + expect(current(root.node)).toBe(b); 153 + expect(root.node.props.focused).toBe(false); 154 + expect(b.props.focused).toBe(true); 155 + root.destroy(); 322 156 }); 323 157 324 - it("FE3: focus on already-focused node is no-op", async () => { 325 - await run(function* () { 326 - let tree = yield* useTree(function* () { 327 - yield* useFocus(); 328 - }); 329 - 330 - yield* expect(tree.root).toEval(function* () { 331 - yield* focus(tree.root); 332 - let node = yield* current(); 333 - expect(node).toBe(tree.root); 334 - expect(tree.root.props.focused).toBe(true); 335 - }); 336 - }); 158 + it("throws on a non-focusable node", () => { 159 + const root = createRoot(); 160 + useFocus(root.node); 161 + const child = root.node.createChild("nope"); 162 + expect(() => focus(child)).toThrow(); 163 + root.destroy(); 337 164 }); 338 - }); 339 165 340 - describe("current()", () => { 341 - it("CU1-CU3: returns the focused node", async () => { 342 - await run(function* () { 343 - let tree = yield* useTree(function* () { 344 - yield* useFocus(); 345 - yield* append("A", function* () { 346 - yield* focusable(); 347 - }); 348 - }); 349 - 350 - yield* expect(tree.root).toEval(function* () { 351 - let node = yield* current(); 352 - expect(node).toBe(tree.root); 353 - 354 - yield* advance(); 355 - node = yield* current(); 356 - expect(node.name).toEqual("A"); 357 - }); 358 - }); 166 + it("is a no-op when already focused", () => { 167 + const root = createRoot(); 168 + useFocus(root.node); 169 + focus(root.node); 170 + expect(current(root.node)).toBe(root.node); 171 + root.destroy(); 359 172 }); 360 173 }); 361 174 362 175 describe("Focused node removal", () => { 363 - it("FR1: removing focused node advances focus", async () => { 364 - await run(function* () { 365 - let tree = yield* useTree(function* () { 366 - yield* useFocus(); 367 - yield* append("A", function* () { 368 - yield* focusable(); 369 - }); 370 - yield* append("B", function* () { 371 - yield* focusable(); 372 - }); 373 - }); 374 - 375 - yield* expect(tree.root).toEval(function* () { 376 - let children = [...tree.root.children]; 377 - let a = children[0]; 378 - yield* focus(a); 379 - expect(a.props.focused).toBe(true); 380 - yield* a.remove(); 381 - 382 - let node = yield* current(); 383 - expect(node.name).toEqual("B"); 384 - }); 385 - }); 386 - }); 176 + it("removing the focused node advances focus", async () => { 177 + const root = createRoot(); 178 + useFocus(root.node); 179 + const a = root.node.createChild("A"); 180 + focusable(a); 181 + focusable(root.node.createChild("B")); 182 + focus(a); 183 + expect(a.props.focused).toBe(true); 387 184 388 - it("FR4: removing non-focused node does not move focus", async () => { 389 - await run(function* () { 390 - let tree = yield* useTree(function* () { 391 - yield* useFocus(); 392 - yield* append("A", function* () { 393 - yield* focusable(); 394 - }); 395 - yield* append("B", function* () { 396 - yield* focusable(); 397 - }); 398 - }); 399 - 400 - yield* expect(tree.root).toEval(function* () { 401 - let children = [...tree.root.children]; 402 - let b = children[1]; 403 - yield* b.remove(); 404 - let node = yield* current(); 405 - expect(node).toBe(tree.root); 406 - }); 407 - }); 185 + await a.remove(); 186 + expect(current(root.node).name).toEqual("B"); 187 + root.destroy(); 408 188 }); 409 189 410 - it("FR5: removing only non-root focusable returns focus to root", async () => { 411 - await run(function* () { 412 - let tree = yield* useTree(function* () { 413 - yield* useFocus(); 414 - yield* append("A", function* () { 415 - yield* focusable(); 416 - }); 417 - }); 418 - 419 - yield* expect(tree.root).toEval(function* () { 420 - let children = [...tree.root.children]; 421 - let a = children[0]; 422 - yield* focus(a); 423 - expect(a.props.focused).toBe(true); 424 - yield* a.remove(); 190 + it("removing a non-focused node does not move focus", async () => { 191 + const root = createRoot(); 192 + useFocus(root.node); 193 + focusable(root.node.createChild("A")); 194 + const b = root.node.createChild("B"); 195 + focusable(b); 425 196 426 - let node = yield* current(); 427 - expect(node).toBe(tree.root); 428 - expect(tree.root.props.focused).toBe(true); 429 - }); 430 - }); 197 + await b.remove(); 198 + expect(current(root.node)).toBe(root.node); 199 + root.destroy(); 431 200 }); 432 201 });