tuil
ReferencePackages@mwillbanks/tuil-focusAPI

FocusManager

class exported by @mwillbanks/tuil-focus.

View rawEdit

class

Public class exported by @mwillbanks/tuil-focus.

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

MemberTypeRequiredDescriptionRelated types
#nodesMap<string, FocusNode>YesThe #nodes member uses the Map<string, FocusNode> contract.FocusNode
#scopesMap<string, FocusScopeDefinition>YesThe #scopes member uses the Map<string, FocusScopeDefinition> contract.FocusScopeDefinition
#observersSet<(change: FocusChange) => void>YesThe #observers member uses the Set<(change: FocusChange) => void> contract.FocusChange
#history(string | undefined)[]YesThe #history member uses the (string | undefined)[] contract.
#trapScopeIdsstring[]YesThe #trapScopeIds member uses the string[] contract.
#registrationOrdernumberYesThe #registrationOrder member uses the number contract.
#focusedIdstring | undefinedNoThe #focusedId member uses the string | undefined contract.
#activeScopeIdstring | undefinedNoThe #activeScopeId member uses the string | undefined contract.
__constructoranyYesThe __constructor member uses the any contract.
focusedIdstring | undefinedYesThe focusedId member uses the string | undefined contract.
activeScopeIdstring | undefinedYesThe activeScopeId member uses the string | undefined contract.
registerNode(node: FocusNodeInput) => () => voidYesThe registerNode member uses the (node: FocusNodeInput) => () => void contract.FocusNodeInput
updateNode(id: string, update: Partial<FocusNode>) => voidYesThe updateNode member uses the (id: string, update: Partial<FocusNode>) => void contract.FocusNode
registerScope(scope: FocusScopeDefinition) => () => voidYesThe registerScope member uses the (scope: FocusScopeDefinition) => () => void contract.FocusScopeDefinition
activateScope(id: string) => voidYesThe activateScope member uses the (id: string) => void contract.
deactivateScope(id: string) => voidYesThe deactivateScope member uses the (id: string) => void contract.
focus(id: string, reason?: string) => booleanYesThe focus member uses the (id: string, reason?: string) => boolean contract.
next() => booleanYesThe next member uses the () => boolean contract.
previous() => booleanYesThe previous member uses the () => boolean contract.
first() => booleanYesThe first member uses the () => boolean contract.
last() => booleanYesThe last member uses the () => boolean contract.
enter() => booleanYesThe enter member uses the () => boolean contract.
exit() => booleanYesThe exit member uses the () => boolean contract.
restore() => booleanYesThe restore member uses the () => boolean contract.
move(direction: FocusDirection, pageSize?: number) => booleanYesThe move member uses the (direction: FocusDirection, pageSize?: number) => boolean contract.FocusDirection
observe(observer: (change: FocusChange) => void) => () => voidYesThe observe member uses the (observer: (change: FocusChange) => void) => () => void contract.FocusChange
nodes() => readonly FocusNode[]YesThe nodes member uses the () => readonly FocusNode[] contract.FocusNode
#unregisterNode(id: string) => voidYesThe #unregisterNode member uses the (id: string) => void contract.
#unregisterScope(id: string, parentId?: string) => voidYesThe #unregisterScope member uses the (id: string, parentId?: string) => void contract.
#moveLinear(delta: -1 | 1, reason: string) => booleanYesThe #moveLinear member uses the (delta: -1 | 1, reason: string) => boolean contract.
#moveDirectional(direction: "up" | "down" | "left" | "right") => booleanYesThe #moveDirectional member uses the (direction: "up" | "down" | "left" | "right") => boolean contract.
#candidates() => FocusNode[]YesThe #candidates member uses the () => FocusNode[] contract.FocusNode
#belongsToScope(node: FocusNode, scopeId: string) => booleanYesThe #belongsToScope member uses the (node: FocusNode, scopeId: string) => boolean contract.FocusNode
#isFocusable(node: FocusNode) => booleanYesThe #isFocusable member uses the (node: FocusNode) => boolean contract.FocusNode
#removeTrap(id: string) => voidYesThe #removeTrap member uses the (id: string) => void contract.
#setFocused(currentId: string | undefined, reason: string) => voidYesThe #setFocused member uses the (currentId: string | undefined, reason: string) => void contract.

Parameters

This declaration has no public members.

Returns

This declaration does not return a value.

Throws

No thrown errors are documented for this declaration.

Source

View the secondary source reference

Package

@mwillbanks/tuil-focus

On this page