# FocusManager

Source: /tuil/docs/reference/packages/focus/api/focus-manager
Locale: en

class exported by @mwillbanks/tuil-focus.



{/* Generated by tooling/docs/generate-reference.ts. */}

## class [#class]

Public class exported by `@mwillbanks/tuil-focus`.

```ts
export class FocusManager {
  readonly #nodes: Map<string, FocusNode>;
  readonly #scopes: Map<string, FocusScopeDefinition>;
  readonly #observers: Set<(change: FocusChange) => void>;
  readonly #history: (string | undefined)[];
  readonly #trapScopeIds: string[];
  #registrationOrder: number;
  #focusedId?: string;
  #activeScopeId?: string;

  constructor() {
    this.#nodes = new Map();
    this.#scopes = new Map();
    this.#observers = new Set();
    this.#history = [];
    this.#trapScopeIds = [];
    this.#registrationOrder = 0;
  }

  get focusedId(): string | undefined {
    return this.#focusedId;
  }

  get activeScopeId(): string | undefined {
    return this.#activeScopeId;
  }

  registerNode(node: FocusNodeInput): () => void {
    if (this.#nodes.has(node.id)) {
      throw new Error(`Focus node "${node.id}" is already registered`);
    }
    const registered = Object.freeze({
      ...node,
      order: node.order ?? this.#registrationOrder++,
    }) as FocusNode;
    this.#nodes.set(node.id, registered);
    const trapScopeId = this.#trapScopeIds.at(-1);
    const focused = this.#focusedId
      ? this.#nodes.get(this.#focusedId)
      : undefined;
    if (
      trapScopeId &&
      this.#belongsToScope(registered, trapScopeId) &&
      (!focused || !this.#belongsToScope(focused, trapScopeId))
    ) {
      this.first();
    }
    return this.#unregisterNode.bind(this, node.id);
  }

  updateNode(id: string, update: Partial<FocusNode>): void {
    const node = this.#nodes.get(id);
    if (!node) {
      throw new Error(`Focus node "${id}" is not registered`);
    }
    this.#nodes.set(id, Object.freeze({ ...node, ...update, id }));
    if (
      this.#focusedId === id &&
      (update.disabled === true || update.hidden === true)
    ) {
      this.next();
    }
  }

  registerScope(scope: FocusScopeDefinition): () => void {
    if (this.#scopes.has(scope.id)) {
      throw new Error(`Focus scope "${scope.id}" is already registered`);
    }
    this.#scopes.set(scope.id, Object.freeze({ ...scope }));
    return this.#unregisterScope.bind(this, scope.id, scope.parentId);
  }

  activateScope(id: string): void {
    if (!this.#scopes.has(id)) {
      throw new Error(`Focus scope "${id}" is not registered`);
    }
    this.#history.push(this.#focusedId);
    this.#activeScopeId = id;
    if (this.#scopes.get(id)?.trapped) {
      this.#removeTrap(id);
      this.#trapScopeIds.push(id);
    }
    const current = this.#focusedId
      ? this.#nodes.get(this.#focusedId)
      : undefined;
    if (!current || !this.#belongsToScope(current, id)) {
      const direct = [...this.#nodes.values()]
        .filter((node) => this.#isFocusable(node) && node.scopeId === id)
        .sort(
          (left, right) =>
            left.order - right.order || left.id.localeCompare(right.id),
        )[0];
      if (!direct || !this.focus(direct.id, "scope")) {
        this.first();
      }
    }
  }

  deactivateScope(id: string): void {
    const scope = this.#scopes.get(id);
    if (!scope) {
      return;
    }
    this.#removeTrap(id);
    this.#activeScopeId = scope.parentId;
    if (scope.restoreFocus) {
      this.restore();
    }
  }

  focus(id: string, reason = "programmatic"): boolean {
    const node = this.#nodes.get(id);
    if (!node || !this.#isFocusable(node)) {
      return false;
    }
    const trapScopeId = this.#trapScopeIds.at(-1);
    if (trapScopeId && !this.#belongsToScope(node, trapScopeId)) {
      return false;
    }
    this.#activeScopeId = node.scopeId ?? this.#activeScopeId;
    this.#setFocused(id, reason);
    return true;
  }

  next(): boolean {
    return this.#moveLinear(1, "next");
  }

  previous(): boolean {
    return this.#moveLinear(-1, "previous");
  }

  first(): boolean {
    const first = this.#candidates()[0];
    return first ? this.focus(first.id, "first") : false;
  }

  last(): boolean {
    const last = this.#candidates().at(-1);
    return last ? this.focus(last.id, "last") : false;
  }

  enter(): boolean {
    if (!this.#focusedId) {
      return false;
    }
    const child = this.#candidates().find(
      (node) => node.parentId === this.#focusedId,
    );
    return child ? this.focus(child.id, "enter") : false;
  }

  exit(): boolean {
    const focused = this.#focusedId
      ? this.#nodes.get(this.#focusedId)
      : undefined;
    if (focused?.parentId && this.focus(focused.parentId, "exit")) {
      return true;
    }
    const scope = focused?.scopeId
      ? this.#scopes.get(focused.scopeId)
      : undefined;
    if (scope?.parentId) {
      this.deactivateScope(scope.id);
      return this.first();
    }
    return false;
  }

  restore(): boolean {
    while (this.#history.length > 0) {
      const id = this.#history.pop();
      if (id && this.focus(id, "restore")) {
        return true;
      }
    }
    return this.first();
  }

  move(direction: FocusDirection, pageSize = 10): boolean {
    if (direction === "next") return this.next();
    if (direction === "previous") return this.previous();
    if (direction === "home") return this.first();
    if (direction === "end") return this.last();
    if (direction === "parent") return this.exit();
    if (direction === "child") return this.enter();
    if (direction === "pageUp" || direction === "pageDown") {
      const candidates = this.#candidates();
      const current = candidates.findIndex(
        (node) => node.id === this.#focusedId,
      );
      if (current < 0) {
        return this.first();
      }
      const delta = direction === "pageUp" ? -pageSize : pageSize;
      const target =
        candidates[
          Math.max(0, Math.min(candidates.length - 1, current + delta))
        ];
      return target ? this.focus(target.id, direction) : false;
    }
    return this.#moveDirectional(direction);
  }

  observe(observer: (change: FocusChange) => void): () => void {
    this.#observers.add(observer);
    return deleteOnDispose(this.#observers, observer);
  }

  nodes(): readonly FocusNode[] {
    return [...this.#nodes.values()];
  }

  #unregisterNode(id: string): void {
    this.#nodes.delete(id);
    if (this.#focusedId === id) {
      this.#setFocused(undefined, "unregister");
      this.first();
    }
  }

  #unregisterScope(id: string, parentId?: string): void {
    this.#scopes.delete(id);
    if (this.#activeScopeId === id) {
      this.#activeScopeId = parentId;
    }
    this.#removeTrap(id);
  }

  #moveLinear(delta: -1 | 1, reason: string): boolean {
    const candidates = this.#candidates();
    if (candidates.length === 0) {
      return false;
    }
    const current = candidates.findIndex((node) => node.id === this.#focusedId);
    let next =
      current < 0 ? (delta === 1 ? 0 : candidates.length - 1) : current + delta;
    const scope = this.#activeScopeId
      ? this.#scopes.get(this.#activeScopeId)
      : undefined;
    if (next < 0 || next >= candidates.length) {
      if (!scope?.loop) {
        return false;
      }
      next = (next + candidates.length) % candidates.length;
    }
    const target = candidates[next];
    return target ? this.focus(target.id, reason) : false;
  }

  #moveDirectional(direction: "up" | "down" | "left" | "right"): boolean {
    const current = this.#focusedId
      ? this.#nodes.get(this.#focusedId)
      : undefined;
    if (!current?.bounds) {
      return direction === "up" || direction === "left"
        ? this.previous()
        : this.next();
    }
    const origin = {
      x: current.bounds.x + current.bounds.width / 2,
      y: current.bounds.y + current.bounds.height / 2,
    };
    const candidates = this.#candidates()
      .filter((node) => node.id !== current.id && node.bounds)
      .map((node) => {
        const bounds = node.bounds as TerminalBounds;
        const center = {
          x: bounds.x + bounds.width / 2,
          y: bounds.y + bounds.height / 2,
        };
        const dx = center.x - origin.x;
        const dy = center.y - origin.y;
        const valid =
          (direction === "up" && dy < 0) ||
          (direction === "down" && dy > 0) ||
          (direction === "left" && dx < 0) ||
          (direction === "right" && dx > 0);
        const primary =
          direction === "up" || direction === "down"
            ? Math.abs(dy)
            : Math.abs(dx);
        const secondary =
          direction === "up" || direction === "down"
            ? Math.abs(dx)
            : Math.abs(dy);
        return { node, valid, score: primary + secondary * 2 };
      })
      .filter((candidate) => candidate.valid)
      .sort((left, right) => left.score - right.score);
    const target = candidates[0]?.node;
    return target ? this.focus(target.id, direction) : false;
  }

  #candidates(): FocusNode[] {
    return [...this.#nodes.values()]
      .filter(
        (node) =>
          this.#isFocusable(node) &&
          (this.#trapScopeIds.length > 0
            ? this.#belongsToScope(node, this.#trapScopeIds.at(-1) as string)
            : this.#activeScopeId
              ? node.scopeId === this.#activeScopeId
              : true),
      )
      .sort(
        (left, right) =>
          left.order - right.order || left.id.localeCompare(right.id),
      );
  }

  #belongsToScope(node: FocusNode, scopeId: string): boolean {
    let current = node.scopeId;
    while (current) {
      if (current === scopeId) {
        return true;
      }
      current = this.#scopes.get(current)?.parentId;
    }
    return false;
  }

  #isFocusable(node: FocusNode): boolean {
    return !node.disabled && !node.hidden;
  }

  #removeTrap(id: string): void {
    const index = this.#trapScopeIds.lastIndexOf(id);
    if (index >= 0) {
      this.#trapScopeIds.splice(index, 1);
    }
  }

  #setFocused(currentId: string | undefined, reason: string): void {
    if (this.#focusedId === currentId) {
      return;
    }
    const previousId = this.#focusedId;
    this.#focusedId = currentId;
    const change = Object.freeze({ previousId, currentId, reason });
    for (const observer of this.#observers) {
      observer(change);
    }
  }
}
```

## Members [#members]

| Member               | Type                                                          | Required | Description                                                                                                    | Related types                                                                       |
| -------------------- | ------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `#nodes`             | `Map<string, FocusNode>`                                      | Yes      | The `#nodes` member uses the `Map<string, FocusNode>` contract.                                                | [`FocusNode`](/tuil/docs/reference/packages/focus/api/focus-node)                        |
| `#scopes`            | `Map<string, FocusScopeDefinition>`                           | Yes      | The `#scopes` member uses the `Map<string, FocusScopeDefinition>` contract.                                    | [`FocusScopeDefinition`](/tuil/docs/reference/packages/focus/api/focus-scope-definition) |
| `#observers`         | `Set<(change: FocusChange) => void>`                          | Yes      | The `#observers` member uses the `Set<(change: FocusChange) => void>` contract.                                | [`FocusChange`](/tuil/docs/reference/packages/focus/api/focus-change)                    |
| `#history`           | `(string \| undefined)[]`                                     | Yes      | The `#history` member uses the `(string \| undefined)[]` contract.                                             | —                                                                                   |
| `#trapScopeIds`      | `string[]`                                                    | Yes      | The `#trapScopeIds` member uses the `string[]` contract.                                                       | —                                                                                   |
| `#registrationOrder` | `number`                                                      | Yes      | The `#registrationOrder` member uses the `number` contract.                                                    | —                                                                                   |
| `#focusedId`         | `string \| undefined`                                         | No       | The `#focusedId` member uses the `string \| undefined` contract.                                               | —                                                                                   |
| `#activeScopeId`     | `string \| undefined`                                         | No       | The `#activeScopeId` member uses the `string \| undefined` contract.                                           | —                                                                                   |
| `__constructor`      | `any`                                                         | Yes      | The `__constructor` member uses the `any` contract.                                                            | —                                                                                   |
| `focusedId`          | `string \| undefined`                                         | Yes      | The `focusedId` member uses the `string \| undefined` contract.                                                | —                                                                                   |
| `activeScopeId`      | `string \| undefined`                                         | Yes      | The `activeScopeId` member uses the `string \| undefined` contract.                                            | —                                                                                   |
| `registerNode`       | `(node: FocusNodeInput) => () => void`                        | Yes      | The `registerNode` member uses the `(node: FocusNodeInput) => () => void` contract.                            | [`FocusNodeInput`](/tuil/docs/reference/packages/focus/api/focus-node-input)             |
| `updateNode`         | `(id: string, update: Partial<FocusNode>) => void`            | Yes      | The `updateNode` member uses the `(id: string, update: Partial<FocusNode>) => void` contract.                  | [`FocusNode`](/tuil/docs/reference/packages/focus/api/focus-node)                        |
| `registerScope`      | `(scope: FocusScopeDefinition) => () => void`                 | Yes      | The `registerScope` member uses the `(scope: FocusScopeDefinition) => () => void` contract.                    | [`FocusScopeDefinition`](/tuil/docs/reference/packages/focus/api/focus-scope-definition) |
| `activateScope`      | `(id: string) => void`                                        | Yes      | The `activateScope` member uses the `(id: string) => void` contract.                                           | —                                                                                   |
| `deactivateScope`    | `(id: string) => void`                                        | Yes      | The `deactivateScope` member uses the `(id: string) => void` contract.                                         | —                                                                                   |
| `focus`              | `(id: string, reason?: string) => boolean`                    | Yes      | The `focus` member uses the `(id: string, reason?: string) => boolean` contract.                               | —                                                                                   |
| `next`               | `() => boolean`                                               | Yes      | The `next` member uses the `() => boolean` contract.                                                           | —                                                                                   |
| `previous`           | `() => boolean`                                               | Yes      | The `previous` member uses the `() => boolean` contract.                                                       | —                                                                                   |
| `first`              | `() => boolean`                                               | Yes      | The `first` member uses the `() => boolean` contract.                                                          | —                                                                                   |
| `last`               | `() => boolean`                                               | Yes      | The `last` member uses the `() => boolean` contract.                                                           | —                                                                                   |
| `enter`              | `() => boolean`                                               | Yes      | The `enter` member uses the `() => boolean` contract.                                                          | —                                                                                   |
| `exit`               | `() => boolean`                                               | Yes      | The `exit` member uses the `() => boolean` contract.                                                           | —                                                                                   |
| `restore`            | `() => boolean`                                               | Yes      | The `restore` member uses the `() => boolean` contract.                                                        | —                                                                                   |
| `move`               | `(direction: FocusDirection, pageSize?: number) => boolean`   | Yes      | The `move` member uses the `(direction: FocusDirection, pageSize?: number) => boolean` contract.               | [`FocusDirection`](/tuil/docs/reference/packages/focus/api/focus-direction)              |
| `observe`            | `(observer: (change: FocusChange) => void) => () => void`     | Yes      | The `observe` member uses the `(observer: (change: FocusChange) => void) => () => void` contract.              | [`FocusChange`](/tuil/docs/reference/packages/focus/api/focus-change)                    |
| `nodes`              | `() => readonly FocusNode[]`                                  | Yes      | The `nodes` member uses the `() => readonly FocusNode[]` contract.                                             | [`FocusNode`](/tuil/docs/reference/packages/focus/api/focus-node)                        |
| `#unregisterNode`    | `(id: string) => void`                                        | Yes      | The `#unregisterNode` member uses the `(id: string) => void` contract.                                         | —                                                                                   |
| `#unregisterScope`   | `(id: string, parentId?: string) => void`                     | Yes      | The `#unregisterScope` member uses the `(id: string, parentId?: string) => void` contract.                     | —                                                                                   |
| `#moveLinear`        | `(delta: -1 \| 1, reason: string) => boolean`                 | Yes      | The `#moveLinear` member uses the `(delta: -1 \| 1, reason: string) => boolean` contract.                      | —                                                                                   |
| `#moveDirectional`   | `(direction: "up" \| "down" \| "left" \| "right") => boolean` | Yes      | The `#moveDirectional` member uses the `(direction: "up" \| "down" \| "left" \| "right") => boolean` contract. | —                                                                                   |
| `#candidates`        | `() => FocusNode[]`                                           | Yes      | The `#candidates` member uses the `() => FocusNode[]` contract.                                                | [`FocusNode`](/tuil/docs/reference/packages/focus/api/focus-node)                        |
| `#belongsToScope`    | `(node: FocusNode, scopeId: string) => boolean`               | Yes      | The `#belongsToScope` member uses the `(node: FocusNode, scopeId: string) => boolean` contract.                | [`FocusNode`](/tuil/docs/reference/packages/focus/api/focus-node)                        |
| `#isFocusable`       | `(node: FocusNode) => boolean`                                | Yes      | The `#isFocusable` member uses the `(node: FocusNode) => boolean` contract.                                    | [`FocusNode`](/tuil/docs/reference/packages/focus/api/focus-node)                        |
| `#removeTrap`        | `(id: string) => void`                                        | Yes      | The `#removeTrap` member uses the `(id: string) => void` contract.                                             | —                                                                                   |
| `#setFocused`        | `(currentId: string \| undefined, reason: string) => void`    | Yes      | The `#setFocused` member uses the `(currentId: string \| undefined, reason: string) => void` contract.         | —                                                                                   |

## Parameters [#parameters]

This declaration has no public members.

## Returns [#returns]

This declaration does not return a value.

## Throws [#throws]

No thrown errors are documented for this declaration.

## Related types [#related-types]

* [`FocusChange`](/tuil/docs/reference/packages/focus/api/focus-change)
* [`FocusDirection`](/tuil/docs/reference/packages/focus/api/focus-direction)
* [`FocusNode`](/tuil/docs/reference/packages/focus/api/focus-node)
* [`FocusNodeInput`](/tuil/docs/reference/packages/focus/api/focus-node-input)
* [`FocusScopeDefinition`](/tuil/docs/reference/packages/focus/api/focus-scope-definition)

## Source [#source]

[View the secondary source reference](https://github.com/mwillbanks/tuil/blob/main/packages/focus/src/index.ts)

## Package [#package]

[@mwillbanks/tuil-focus](/tuil/docs/reference/packages/focus)
