[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.

✨ focus: seed first descendant; root no longer in the ring

Charles Lowell (Jun 30, 2026, 8:40 AM +0300) 857c97b8 10a1f3b2

+93 -41
+4 -1
packages/freedom/src/lib/focus.ts
··· 85 85 } 86 86 87 87 export function useFocus(node: Node): void { 88 - node.set("focused", true); 88 + const first = focusChain(node).find((n) => n !== node); 89 + if (first) { 90 + focus(first); 91 + } 89 92 node.scope.around(NodeApi, { 90 93 remove([target], next) { 91 94 if (target.props.focused === true) {
+89 -40
packages/freedom/test/focus.test.ts
··· 10 10 } from "../src/index.ts"; 11 11 12 12 describe("Focus installation", () => { 13 - it("useFocus sets root as focused", () => { 13 + it("seeds the first focusable descendant", () => { 14 + const root = createRoot(); 15 + const a = root.node.createChild("A"); 16 + focusable(a); 17 + focusable(root.node.createChild("B")); 18 + useFocus(root.node); 19 + expect(current(root.node)).toBe(a); 20 + expect(a.props.focused).toBe(true); 21 + root.destroy(); 22 + }); 23 + 24 + it("focuses nothing on an empty container; current falls back to root", () => { 14 25 const root = createRoot(); 15 26 useFocus(root.node); 16 27 expect(current(root.node)).toBe(root.node); 17 - expect(root.node.props.focused).toBe(true); 28 + expect(root.node.props.focused).toBe(undefined); 29 + root.destroy(); 30 + }); 31 + 32 + it("does not enroll root in the ring", () => { 33 + const root = createRoot(); 34 + focusable(root.node.createChild("A")); 35 + focusable(root.node.createChild("B")); 36 + useFocus(root.node); // seeds A 37 + const names: string[] = []; 38 + for (let i = 0; i < 3; i++) { 39 + names.push(current(root.node).name); 40 + advance(root.node); 41 + } 42 + expect(names).toEqual(["A", "B", "A"]); // wraps A->B->A; root never appears 43 + root.destroy(); 44 + }); 45 + 46 + it("escape hatch: explicit focusable(root) keeps root in the ring", () => { 47 + const root = createRoot(); 48 + focusable(root.node); // explicit enrollment 49 + const a = root.node.createChild("A"); 50 + focusable(a); 51 + useFocus(root.node); // seeds A (first descendant, skipping root) 52 + expect(current(root.node)).toBe(a); 53 + advance(root.node); // A -> root (wrap now includes root) 54 + expect(current(root.node)).toBe(root.node); 18 55 root.destroy(); 19 56 }); 20 57 }); ··· 22 59 describe("focusable()", () => { 23 60 it("sets focused:false on the node", () => { 24 61 const root = createRoot(); 25 - useFocus(root.node); 26 62 const child = root.node.createChild("child"); 27 63 focusable(child); 28 64 expect(child.props.focused).toBe(false); ··· 31 67 32 68 it("is a no-op on an already-focusable node", () => { 33 69 const root = createRoot(); 34 - useFocus(root.node); 35 70 const child = root.node.createChild("child"); 36 71 focusable(child); 37 72 focusable(child); ··· 41 76 42 77 it("a node without focusable is skipped by the chain", () => { 43 78 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" 79 + root.node.createChild("skip"); // not focusable 80 + const here = root.node.createChild("here"); 81 + focusable(here); 82 + useFocus(root.node); // seeds "here", skipping "skip" 48 83 expect(current(root.node).name).toEqual("here"); 49 84 root.destroy(); 50 85 }); ··· 53 88 describe("Focus chain", () => { 54 89 it("depth-first order, flat children", () => { 55 90 const root = createRoot(); 56 - useFocus(root.node); 57 91 for (const name of ["A", "B", "C"]) { 58 92 focusable(root.node.createChild(name)); 59 93 } 94 + useFocus(root.node); // seeds A 60 95 const names: string[] = []; 61 96 for (let i = 0; i < 4; i++) { 62 97 names.push(current(root.node).name); 63 98 advance(root.node); 64 99 } 65 - expect(names).toEqual(["", "A", "B", "C"]); 100 + expect(names).toEqual(["A", "B", "C", "A"]); 66 101 root.destroy(); 67 102 }); 68 103 69 104 it("depth-first order, nested children", () => { 70 105 const root = createRoot(); 71 - useFocus(root.node); 72 106 const a = root.node.createChild("A"); 73 107 focusable(a); 74 108 focusable(a.createChild("A1")); 75 109 focusable(root.node.createChild("B")); 110 + useFocus(root.node); // seeds A 76 111 const names: string[] = []; 77 112 for (let i = 0; i < 4; i++) { 78 113 names.push(current(root.node).name); 79 114 advance(root.node); 80 115 } 81 - expect(names).toEqual(["", "A", "A1", "B"]); 116 + expect(names).toEqual(["A", "A1", "B", "A"]); 82 117 root.destroy(); 83 118 }); 84 119 }); ··· 86 121 describe("advance()", () => { 87 122 it("moves focus forward", () => { 88 123 const root = createRoot(); 89 - useFocus(root.node); 90 124 const a = root.node.createChild("A"); 91 125 focusable(a); 92 - expect(root.node.props.focused).toBe(true); 93 - advance(root.node); 94 - expect(root.node.props.focused).toBe(false); 126 + const b = root.node.createChild("B"); 127 + focusable(b); 128 + useFocus(root.node); // seeds A 95 129 expect(a.props.focused).toBe(true); 130 + advance(root.node); // A -> B 131 + expect(a.props.focused).toBe(false); 132 + expect(b.props.focused).toBe(true); 96 133 root.destroy(); 97 134 }); 98 135 99 - it("wraps from last to first (root)", () => { 136 + it("wraps from last to first", () => { 100 137 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); 138 + const a = root.node.createChild("A"); 139 + focusable(a); 140 + const b = root.node.createChild("B"); 141 + focusable(b); 142 + useFocus(root.node); // seeds A 143 + advance(root.node); // A -> B 144 + advance(root.node); // B -> A (wrap) 145 + expect(current(root.node)).toBe(a); 106 146 root.destroy(); 107 147 }); 108 148 109 149 it("single focusable node is a no-op", () => { 110 150 const root = createRoot(); 111 - useFocus(root.node); 151 + const a = root.node.createChild("A"); 152 + focusable(a); 153 + useFocus(root.node); // seeds A 154 + advance(root.node); // no-op (chain length 1) 155 + expect(current(root.node)).toBe(a); 156 + root.destroy(); 157 + }); 158 + 159 + it("nothing focused is a no-op", () => { 160 + const root = createRoot(); 161 + useFocus(root.node); // empty container 112 162 advance(root.node); 113 163 expect(current(root.node)).toBe(root.node); 114 164 root.destroy(); ··· 118 168 describe("retreat()", () => { 119 169 it("moves focus backward", () => { 120 170 const root = createRoot(); 121 - useFocus(root.node); 122 171 focusable(root.node.createChild("A")); 123 172 focusable(root.node.createChild("B")); 124 - advance(root.node); // root -> A 173 + useFocus(root.node); // seeds A 125 174 advance(root.node); // A -> B 126 175 expect(current(root.node).name).toEqual("B"); 127 176 retreat(root.node); // B -> A ··· 131 180 132 181 it("wraps from first to last", () => { 133 182 const root = createRoot(); 134 - useFocus(root.node); 135 183 focusable(root.node.createChild("A")); 136 184 const b = root.node.createChild("B"); 137 185 focusable(b); 138 - retreat(root.node); // root -> B (last) 186 + useFocus(root.node); // seeds A 187 + retreat(root.node); // A -> B (wrap to last) 139 188 expect(current(root.node)).toBe(b); 140 189 root.destroy(); 141 190 }); ··· 144 193 describe("focus(node)", () => { 145 194 it("explicitly focuses a node", () => { 146 195 const root = createRoot(); 147 - useFocus(root.node); 148 - focusable(root.node.createChild("A")); 196 + const a = root.node.createChild("A"); 197 + focusable(a); 149 198 const b = root.node.createChild("B"); 150 199 focusable(b); 200 + useFocus(root.node); // seeds A 151 201 focus(b); 152 202 expect(current(root.node)).toBe(b); 153 - expect(root.node.props.focused).toBe(false); 203 + expect(a.props.focused).toBe(false); 154 204 expect(b.props.focused).toBe(true); 155 205 root.destroy(); 156 206 }); 157 207 158 208 it("throws on a non-focusable node", () => { 159 209 const root = createRoot(); 160 - useFocus(root.node); 161 210 const child = root.node.createChild("nope"); 211 + useFocus(root.node); 162 212 expect(() => focus(child)).toThrow(); 163 213 root.destroy(); 164 214 }); 165 215 166 216 it("is a no-op when already focused", () => { 167 217 const root = createRoot(); 168 - useFocus(root.node); 169 - focus(root.node); 170 - expect(current(root.node)).toBe(root.node); 218 + const a = root.node.createChild("A"); 219 + focusable(a); 220 + useFocus(root.node); // seeds A 221 + focus(a); // already focused -> no-op 222 + expect(current(root.node)).toBe(a); 171 223 root.destroy(); 172 224 }); 173 225 }); ··· 175 227 describe("Focused node removal", () => { 176 228 it("removing the focused node advances focus", async () => { 177 229 const root = createRoot(); 178 - useFocus(root.node); 179 230 const a = root.node.createChild("A"); 180 231 focusable(a); 181 232 focusable(root.node.createChild("B")); 182 - focus(a); 233 + useFocus(root.node); // seeds A 183 234 expect(a.props.focused).toBe(true); 184 - 185 235 await a.remove(); 186 236 expect(current(root.node).name).toEqual("B"); 187 237 root.destroy(); ··· 189 239 190 240 it("removing a non-focused node does not move focus", async () => { 191 241 const root = createRoot(); 192 - useFocus(root.node); 193 242 focusable(root.node.createChild("A")); 194 243 const b = root.node.createChild("B"); 195 244 focusable(b); 196 - 245 + useFocus(root.node); // seeds A 197 246 await b.remove(); 198 - expect(current(root.node)).toBe(root.node); 247 + expect(current(root.node).name).toEqual("A"); 199 248 root.destroy(); 200 249 }); 201 250 });