From 472a333a522e68870b0f5797ba0432869d7361bd Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 4 Aug 2026 15:19:26 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20focus:=20navigate=20focusable=20hos?= =?UTF-8?q?t=20elements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ui/src/focus.test.ts | 266 +++++++++++++++++++++++++++++++++ packages/ui/src/focus.ts | 260 ++++++++++++++++++++++++++++++++ packages/ui/src/host-render.ts | 11 ++ 3 files changed, 537 insertions(+) create mode 100644 packages/ui/src/focus.test.ts create mode 100644 packages/ui/src/focus.ts diff --git a/packages/ui/src/focus.test.ts b/packages/ui/src/focus.test.ts new file mode 100644 index 0000000..d9d92aa --- /dev/null +++ b/packages/ui/src/focus.test.ts @@ -0,0 +1,266 @@ +import { lifecycle, type Node } from './core.ts'; +import { describe, expect, test } from 'vitest'; +import { FocusApi } from './focus.ts'; +import { createHost as createHostWithoutFocus, type Host } from './host.ts'; +import type { HostElement } from './elements.ts'; + +describe('focus', () => { + describe('focusability', () => { + test('uses the host root when no element has focus', () => { + const host = createHost(); + + expect(getFocus(host.root)).toBe(host.root); + destroy(host.root); + }); + + test('focuses the first focusable element connected to an empty host', () => { + const host = createHost(); + const input = focusableElement(host); + + host.insertBefore(host.element, input); + + expect(input.node).not.toBeNull(); + expect(getFocus(host.root)).toBe(input.node); + destroy(host.root); + }); + + test('does not replace the focus when another focusable element is connected', () => { + const host = createHost(); + const first = focusableElement(host); + const second = focusableElement(host); + + host.insertBefore(host.element, first); + host.insertBefore(host.element, second); + + expect(getFocus(host.root)).toBe(first.node); + destroy(host.root); + }); + + test('does not inherit focusability from an ancestor', () => { + const host = createHost(); + const parent = focusableElement(host); + const child = host.createElement('box'); + const sibling = focusableElement(host); + host.insertBefore(parent, child); + host.insertBefore(host.element, parent); + host.insertBefore(host.element, sibling); + + advanceFocus(host.root); + + expect(getFocus(host.root)).toBe(sibling.node); + expect(getFocus(host.root)).not.toBe(child.node); + destroy(host.root); + }); + }); + + describe('advanceFocus', () => { + test('moves focus forward in depth-first element order', () => { + const host = createHost(); + const first = focusableElement(host); + const container = host.createElement('box'); + const nested = focusableElement(host); + const last = focusableElement(host); + host.insertBefore(container, nested); + host.insertBefore(host.element, first); + host.insertBefore(host.element, container); + host.insertBefore(host.element, last); + + const visited: Node[] = []; + for (let index = 0; index < 4; index++) { + visited.push(getFocus(host.root)); + advanceFocus(host.root); + } + + expect(visited).toEqual([first.node, nested.node, last.node, first.node]); + destroy(host.root); + }); + + test('does nothing when there is only one focusable element', () => { + const host = createHost(); + const input = focusableElement(host); + host.insertBefore(host.element, input); + + advanceFocus(host.root); + + expect(getFocus(host.root)).toBe(input.node); + destroy(host.root); + }); + + test('uses current host order after a child moves', () => { + const host = createHost(); + const parent = host.createElement('box'); + const first = focusableElement(host); + const second = focusableElement(host); + const third = focusableElement(host); + host.insertBefore(parent, first); + host.insertBefore(parent, second); + host.insertBefore(parent, third); + host.insertBefore(host.element, parent); + + host.insertBefore(parent, third, second); + setFocus(first.node!); + advanceFocus(host.root); + + expect(getFocus(host.root)).toBe(third.node); + destroy(host.root); + }); + }); + + describe('retreatFocus', () => { + test('moves focus backward and wraps', () => { + const host = createHost(); + const first = focusableElement(host); + const second = focusableElement(host); + const last = focusableElement(host); + host.insertBefore(host.element, first); + host.insertBefore(host.element, second); + host.insertBefore(host.element, last); + + retreatFocus(host.root); + expect(getFocus(host.root)).toBe(last.node); + retreatFocus(host.root); + expect(getFocus(host.root)).toBe(second.node); + destroy(host.root); + }); + + test('does nothing when there is only one focusable element', () => { + const host = createHost(); + const input = focusableElement(host); + host.insertBefore(host.element, input); + + retreatFocus(host.root); + + expect(getFocus(host.root)).toBe(input.node); + destroy(host.root); + }); + }); + + describe('setFocus', () => { + test('focuses an explicit focusable element', () => { + const host = createHost(); + const first = focusableElement(host); + const second = focusableElement(host); + host.insertBefore(host.element, first); + host.insertBefore(host.element, second); + + setFocus(second.node!); + + expect(getFocus(host.root)).toBe(second.node); + destroy(host.root); + }); + + test('rejects a non-focusable element', () => { + const host = createHost(); + const element = host.createElement('box'); + host.insertBefore(host.element, element); + + expect(() => setFocus(element.node!)).toThrow(TypeError); + destroy(host.root); + }); + }); + + describe('lifecycle', () => { + test('advances focus before the focused element is detached', () => { + const host = createHost(); + const first = focusableElement(host); + const second = focusableElement(host); + host.insertBefore(host.element, first); + host.insertBefore(host.element, second); + + host.removeChild(host.element, first); + + expect(getFocus(host.root)).toBe(second.node); + destroy(host.root); + }); + + test('uses the host root after the only focusable element is detached', () => { + const host = createHost(); + const input = focusableElement(host); + host.insertBefore(host.element, input); + + host.removeChild(host.element, input); + + expect(getFocus(host.root)).toBe(host.root); + destroy(host.root); + }); + + test('does not move focus when a non-focused element is detached', () => { + const host = createHost(); + const first = focusableElement(host); + const second = focusableElement(host); + host.insertBefore(host.element, first); + host.insertBefore(host.element, second); + + host.removeChild(host.element, second); + + expect(getFocus(host.root)).toBe(first.node); + destroy(host.root); + }); + + test('moves focus outside a subtree before the subtree is detached', () => { + const host = createHost(); + const parent = host.createElement('box'); + const nested = focusableElement(host); + const sibling = focusableElement(host); + host.insertBefore(parent, nested); + host.insertBefore(host.element, parent); + host.insertBefore(host.element, sibling); + + host.removeChild(host.element, parent); + + expect(getFocus(host.root)).toBe(sibling.node); + destroy(host.root); + }); + }); + + test('keeps focus isolated between hosts', () => { + const firstHost = createHost(); + const first = focusableElement(firstHost); + firstHost.insertBefore(firstHost.element, first); + const secondHost = createHost(); + const second = focusableElement(secondHost); + secondHost.insertBefore(secondHost.element, second); + + expect(getFocus(firstHost.root)).toBe(first.node); + expect(getFocus(secondHost.root)).toBe(second.node); + + destroy(firstHost.root); + destroy(secondHost.root); + }); +}); + +function createHost(): Host { + const host = createHostWithoutFocus(); + FocusApi.init(host.root); + return host; +} + +function focusableElement(host: Host): HostElement { + const element = host.createElement('input'); + element.attach = (node) => setFocusable(node); + return element; +} + +function advanceFocus(node: Node): void { + FocusApi.at(node).advanceFocus(); +} + +function getFocus(node: Node): Node { + return FocusApi.at(node).getFocus(); +} + +function retreatFocus(node: Node): void { + FocusApi.at(node).retreatFocus(); +} + +function setFocus(node: Node): void { + FocusApi.at(node).setFocus(node); +} + +function setFocusable(node: Node): void { + FocusApi.at(node).setFocusable(); +} + +function destroy(node: Node): void { + lifecycle.destroy(node); +} diff --git a/packages/ui/src/focus.ts b/packages/ui/src/focus.ts new file mode 100644 index 0000000..4d4a79d --- /dev/null +++ b/packages/ui/src/focus.ts @@ -0,0 +1,260 @@ +import { createContext, type Node } from './core.ts'; +import { Api } from './core/api.ts'; +import { getElement, type HostElement, type HostElementChild } from './elements.ts'; +import { HostApi, type Host } from './host.ts'; + +export class FocusApi extends Api { + current: Node; + + constructor(location: Node) { + super(location); + this.current = location; + + HostApi.decorate(location, () => ({ + removeChild(parent, removed) { + const node = parent.node!; + const focus = FocusApi.at(node); + const host = getHost(node); + if (removed.type === 'element' && contains(removed.node!, focus.current)) { + const tail = lastElementOf(removed); + + const after = forward({ start: tail }); + const before = forward({ + start: host.element, + limit: removed, + }); + + const replacement = findFocus(concat(after, before)) ?? host.root; + + focus.setFocus(replacement); + } + + return super.removeChild(parent, removed); + }, + })); + } + + setFocusable(): void { + const { node } = this; + FocusableContext.set(node, true); + + const host = getHost(node); + + if (this.current === host.root) { + this.current = node; + } + } + + setFocus(node: Node): void { + if (!isFocusable(node) && node !== getHost(node).root) { + throw new UnfocusableElementError(node); + } + this.current = node; + } + + getFocus(): Node { + return this.current; + } + + advanceFocus(): void { + const current = this.current; + const element = getElement(current); + const host = getHost(this.node); + + const downward = forward({ start: element }); + const fromTop = forward({ start: host.element, limit: element }); + + const next = findFocus(concat(downward, fromTop)); + + if (next) { + this.setFocus(next); + } + } + + retreatFocus(): void { + const current = this.current; + const element = getElement(current); + const host = getHost(this.node); + + const upward = reverse({ start: element }); + + const fromBottom = reverse({ + start: host.element, + limit: element, + }); + + const previous = findFocus(concat(upward, fromBottom)); + + if (previous) { + this.setFocus(previous); + } + } +} + +function getHost(node: Node): Host { + return HostApi.at(node).getHost(); +} + +const FocusableContext = createContext('focusable', false); + +function isFocusable(node: Node): boolean { + return FocusableContext.hasOwn(node) && FocusableContext.expect(node); +} + +interface Range { + start: HostElementChild; + limit?: HostElement; +} + +function* forward(range: Range): Iterable { + type Tier = { + siblings: HostElementChild[]; + index: number; + }; + const tiers: Tier[] = [ + { + siblings: range.start.type === 'element' ? range.start.children : [], + index: 0, + }, + ]; + + let current = range.start; + while (current.parent) { + const siblings = current.parent.children; + tiers.unshift({ siblings, index: siblings.indexOf(current) + 1 }); + current = current.parent; + } + + while (tiers.length > 0) { + const tier = tiers[tiers.length - 1]!; + const child = tier.siblings[tier.index++]; + if (!child) { + tiers.pop(); + continue; + } + if (child === range.limit) { + break; + } + if (child.type === 'element') { + if (child !== range.start) { + yield child; + } + tiers.push({ siblings: child.children, index: 0 }); + } + } +} + +function* reverse(range: Range): Iterable { + interface Tier { + siblings: HostElementChild[]; + index: number; + owner?: HostElement; + } + + const tiers: Tier[] = []; + + if (!range.start.parent) { + // host.element acts as the position after the entire tree. + if (range.start.type === 'element') { + tiers.push({ + siblings: range.start.children, + index: range.start.children.length - 1, + }); + } + } else { + let current = range.start; + + while (current.parent) { + const parent = current.parent; + const siblings = parent.children; + + tiers.unshift({ + siblings, + index: siblings.indexOf(current) - 1, + // Yield the parent after its preceding children, except for host.element. + owner: parent.parent ? parent : undefined, + }); + + current = parent; + } + } + + while (tiers.length > 0) { + const tier = tiers[tiers.length - 1]!; + + if (tier.index < 0) { + tiers.pop(); + + if (tier.owner) { + if (tier.owner === range.limit) { + break; + } + + yield tier.owner; + } + + continue; + } + + const child = tier.siblings[tier.index--]; + + if (child?.type === 'element') { + tiers.push({ + siblings: child.children, + index: child.children.length - 1, + owner: child, + }); + } + } +} + +function* concat(...iterables: Iterable[]): Generator { + for (const iterable of iterables) { + yield* iterable; + } +} + +function findFocus(elements: Iterable): Node | undefined { + for (const element of elements) { + if (isFocusable(element.node!)) { + return element.node!; + } + } +} + +function lastElementOf(element: HostElement): HostElement { + const { children } = element; + let last: HostElement | undefined = void 0; + for (let i = children.length - 1; i >= 0; i--) { + const child = children[i]; + if (child && child.type === 'element') { + last = child; + break; + } + } + return last ? lastElementOf(last) : element; +} + +function contains(node: Node, descendant: Node): boolean { + if (node === descendant) { + return true; + } else { + for (const child of node.children) { + if (contains(child, descendant)) { + return true; + } + } + } + return false; +} + +class UnfocusableElementError extends TypeError { + readonly code = 'CLACK_UI_UNFOCUSABLE_ELEMENT'; + readonly node: Node; + + constructor(node: Node) { + super('attempted to focus an unfocusable element'); + this.name = 'UnfocusableElementError'; + this.node = node; + } +} diff --git a/packages/ui/src/host-render.ts b/packages/ui/src/host-render.ts index 8cd2e8e..d68f1b4 100644 --- a/packages/ui/src/host-render.ts +++ b/packages/ui/src/host-render.ts @@ -1,6 +1,7 @@ // oxlint-disable max-params import { RenderApi } from './render.ts'; import { type Host, HostApi } from './host.ts'; +import { FocusApi } from './focus.ts'; export function useHostRenderer(host: Host): void { HostApi.decorate(host.root, () => ({ @@ -39,4 +40,14 @@ export function useHostRenderer(host: Host): void { } }, })); + + FocusApi.decorate(host.root, () => ({ + setFocus(node) { + const original = this.current; + super.setFocus(node); + if (original !== node) { + RenderApi.at(node).request(); + } + }, + })); }