FocusManager
class exported by @mwillbanks/tuil-focus.
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
| Member | Type | Required | Description | Related types |
|---|---|---|---|---|
#nodes | Map<string, FocusNode> | Yes | The #nodes member uses the Map<string, FocusNode> contract. | FocusNode |
#scopes | Map<string, FocusScopeDefinition> | Yes | The #scopes member uses the Map<string, FocusScopeDefinition> contract. | FocusScopeDefinition |
#observers | Set<(change: FocusChange) => void> | Yes | The #observers member uses the Set<(change: FocusChange) => void> contract. | FocusChange |
#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 |
updateNode | (id: string, update: Partial<FocusNode>) => void | Yes | The updateNode member uses the (id: string, update: Partial<FocusNode>) => void contract. | FocusNode |
registerScope | (scope: FocusScopeDefinition) => () => void | Yes | The registerScope member uses the (scope: FocusScopeDefinition) => () => void contract. | FocusScopeDefinition |
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 |
observe | (observer: (change: FocusChange) => void) => () => void | Yes | The observe member uses the (observer: (change: FocusChange) => void) => () => void contract. | FocusChange |
nodes | () => readonly FocusNode[] | Yes | The nodes member uses the () => readonly FocusNode[] contract. | FocusNode |
#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 |
#belongsToScope | (node: FocusNode, scopeId: string) => boolean | Yes | The #belongsToScope member uses the (node: FocusNode, scopeId: string) => boolean contract. | FocusNode |
#isFocusable | (node: FocusNode) => boolean | Yes | The #isFocusable member uses the (node: FocusNode) => boolean contract. | FocusNode |
#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
This declaration has no public members.
Returns
This declaration does not return a value.
Throws
No thrown errors are documented for this declaration.
Related types
Source
View the secondary source reference