Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 18 additions & 131 deletions src/Instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,26 +123,6 @@ export function updateTabsterByAttribute(
const sys = newTabsterProps.sys;

switch (key) {
case "deloser":
if (tabsterOnElement.deloser) {
tabsterOnElement.deloser.setProps(
newTabsterProps.deloser as Types.DeloserProps
);
} else {
if (tabster.deloser) {
tabsterOnElement.deloser =
tabster.deloser.createDeloser(
element,
newTabsterProps.deloser as Types.DeloserProps
);
} else if (__DEV__) {
console.error(
"Deloser API used before initialization, please call `getDeloser()`"
);
}
}
break;

case "root":
if (tabsterOnElement.root) {
tabsterOnElement.root.setProps(
Expand All @@ -158,117 +138,10 @@ export function updateTabsterByAttribute(
tabster.root.onRoot(tabsterOnElement.root);
break;

case "modalizer":
{
let newModalizerProps: Types.ModalizerProps | undefined;
const modalizerAPI = tabster.modalizer;

if (tabsterOnElement.modalizer) {
const props =
newTabsterProps.modalizer as Types.ModalizerProps;
const newModalizerId = props.id;
if (
newModalizerId &&
oldTabsterProps?.modalizer?.id !== newModalizerId
) {
// Modalizer id is changed, given the modalizers have complex logic and could be
// composite, it is easier to recreate the Modalizer instance than to implement
// the id update.
tabsterOnElement.modalizer.dispose();
newModalizerProps = props;
} else {
tabsterOnElement.modalizer.setProps(props);
}
} else {
if (modalizerAPI) {
newModalizerProps = newTabsterProps.modalizer;
} else if (__DEV__) {
console.error(
"Modalizer API used before initialization, please call `getModalizer()`"
);
}
}

if (modalizerAPI && newModalizerProps) {
tabsterOnElement.modalizer =
modalizerAPI.createModalizer(
element,
newModalizerProps,
sys
);
}
}

break;

case "restorer":
if (tabsterOnElement.restorer) {
tabsterOnElement.restorer.setProps(
newTabsterProps.restorer as Types.RestorerProps
);
} else {
if (tabster.restorer) {
if (newTabsterProps.restorer) {
tabsterOnElement.restorer =
tabster.restorer.createRestorer(
element,
newTabsterProps.restorer
);
}
} else if (__DEV__) {
console.error(
"Restorer API used before initialization, please call `getRestorer()`"
);
}
}

break;

case "focusable":
tabsterOnElement.focusable = newTabsterProps.focusable;
break;

case "groupper":
if (tabsterOnElement.groupper) {
tabsterOnElement.groupper.setProps(
newTabsterProps.groupper as Types.GroupperProps
);
} else {
if (tabster.groupper) {
tabsterOnElement.groupper =
tabster.groupper.createGroupper(
element,
newTabsterProps.groupper as Types.GroupperProps,
sys
);
} else if (__DEV__) {
console.error(
"Groupper API used before initialization, please call `getGroupper()`"
);
}
}
break;

case "mover":
if (tabsterOnElement.mover) {
tabsterOnElement.mover.setProps(
newTabsterProps.mover as Types.MoverProps
);
} else {
if (tabster.mover) {
tabsterOnElement.mover = tabster.mover.createMover(
element,
newTabsterProps.mover as Types.MoverProps,
sys
);
} else if (__DEV__) {
console.error(
"Mover API used before initialization, please call `getMover()`"
);
}
}
break;

case "observed":
if (tabster.observedElement) {
tabsterOnElement.observed = newTabsterProps.observed;
Expand Down Expand Up @@ -298,10 +171,24 @@ export function updateTabsterByAttribute(
tabsterOnElement.sys = newTabsterProps.sys;
break;

default:
console.error(
`Unknown key '${key}' in data-tabster attribute value.`
);
default: {
const handler = tabster.attrHandlers.get(key);
if (handler) {
tabsterOnElement[key] = handler(
element,
tabsterOnElement[key],
newTabsterProps[key],
oldTabsterProps?.[key],
sys
) as never;
} else if (__DEV__) {
console.error(
`${key} API used before initialization, please call \`get${
key[0].toUpperCase() + key.slice(1)
}()\``
);
}
}
}
}

Expand Down
11 changes: 11 additions & 0 deletions src/Tabster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ class TabsterCore implements Types.TabsterCore {
_noop = false;
controlTab: boolean;
rootDummyInputs: boolean;
// Variance gap: per-key handler types are contravariant in their
// parameters, so a fully-typed Map<K, TabsterAttrHandler<K>> can't unify
// them. Cast a plain Map to the typed view; the override on `set` keeps
// registration type-safe per key, while `get` falls back to the Map's
// value type (the type-erased shape).
attrHandlers = new Map() as Types.TabsterAttrHandlerRegistry;

// Core APIs
keyboardNavigation: Types.KeyboardNavigationState;
Expand Down Expand Up @@ -203,6 +209,11 @@ class TabsterCore implements Types.TabsterCore {

this._dummyObserver.dispose();

// Drop handler closures — they capture the API instances we just
// disposed, and any post-dispose updateTabsterByAttribute call would
// otherwise dispatch to those zombies.
this.attrHandlers.clear();

clearElementCache(this.getWindow);

this._storage = new WeakMap();
Expand Down
55 changes: 55 additions & 0 deletions src/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1298,7 +1298,62 @@ export interface DummyInputObserver {
): void;
}

/**
* @internal
* Per-attribute-key handler invoked by `updateTabsterByAttribute`. Subsystems
* register a handler from their `get*` file when they're first instantiated,
* so the create-or-setProps logic only enters the bundle when the subsystem
* itself does.
*
* `existing` is the current `TabsterOnElement[K]` (the live instance, if any).
* `newProps`/`oldProps` are typed against the same key on `TabsterAttributeProps`.
* Returns the instance that should occupy `TabsterOnElement[K]` after this
* call — either the (possibly-mutated) `existing` or a freshly created one.
*/
export type TabsterAttrHandler<K extends keyof TabsterAttributeProps> = (
element: HTMLElement,
existing: TabsterOnElement[K],
newProps: NonNullable<TabsterAttributeProps[K]>,
oldProps: TabsterAttributeProps[K],
sys: SysProps | undefined
) => NonNullable<TabsterOnElement[K]>;

/**
* @internal
* Type-erased handler shape used internally for storage and dispatch.
* Callers should use the generic `TabsterAttrHandler<K>` for type-safe
* registration.
*/
export type AnyTabsterAttrHandler = (
element: HTMLElement,
existing: unknown,
newProps: unknown,
oldProps: unknown,
sys: SysProps | undefined
) => NonNullable<unknown>;

/**
* @internal
* Typed view over `Map<keyof TabsterAttributeProps, AnyTabsterAttrHandler>`.
* Only `set` is overridden so that registration is generic per key — the
* handler's `existing`/`newProps`/`oldProps`/return types are inferred from
* the key. `get`/`clear` come from `Map`. The call site (Instance.ts)
* iterates over `keyof TabsterAttributeProps` and gets back the type-erased
* `AnyTabsterAttrHandler` shape.
*/
export interface TabsterAttrHandlerRegistry extends Map<
keyof TabsterAttributeProps,
AnyTabsterAttrHandler
> {
set<K extends keyof TabsterAttributeProps>(
key: K,
handler: TabsterAttrHandler<K>
): this;
}

interface TabsterCoreInternal {
/** @internal */
attrHandlers: TabsterAttrHandlerRegistry;
/** @internal */
groupper?: GroupperAPI;
/** @internal */
Expand Down
13 changes: 12 additions & 1 deletion src/get/getDeloser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,18 @@ export function getDeloser(
const tabsterCore = tabster.core;

if (!tabsterCore.deloser) {
tabsterCore.deloser = new DeloserAPI(tabsterCore, props);
const api = new DeloserAPI(tabsterCore, props);
tabsterCore.deloser = api;
tabsterCore.attrHandlers.set(
"deloser",
(element, existingDeloser, newProps) => {
if (existingDeloser) {
existingDeloser.setProps(newProps);
return existingDeloser;
}
return api.createDeloser(element, newProps);
}
);
}

return tabsterCore.deloser;
Expand Down
14 changes: 11 additions & 3 deletions src/get/getGroupper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,17 @@ export function getGroupper(tabster: Types.Tabster): Types.GroupperAPI {
const tabsterCore = tabster.core;

if (!tabsterCore.groupper) {
tabsterCore.groupper = new GroupperAPI(
tabsterCore,
tabsterCore.getWindow
const api = new GroupperAPI(tabsterCore, tabsterCore.getWindow);
tabsterCore.groupper = api;
tabsterCore.attrHandlers.set(
"groupper",
(element, existingGroupper, newProps, _oldProps, sys) => {
if (existingGroupper) {
existingGroupper.setProps(newProps);
return existingGroupper;
}
return api.createGroupper(element, newProps, sys);
}
);
}

Expand Down
21 changes: 20 additions & 1 deletion src/get/getModalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,30 @@ export function getModalizer(
const tabsterCore = tabster.core;

if (!tabsterCore.modalizer) {
tabsterCore.modalizer = new ModalizerAPI(
const api = new ModalizerAPI(
tabsterCore,
alwaysAccessibleSelector,
accessibleCheck
);
tabsterCore.modalizer = api;
tabsterCore.attrHandlers.set(
"modalizer",
(element, existingModalizer, newProps, oldProps, sys) => {
if (existingModalizer) {
if (newProps.id && oldProps?.id !== newProps.id) {
// Modalizer id is changed, given the modalizers have
// complex logic and could be composite, it is easier
// to recreate the Modalizer instance than to implement
// the id update.
existingModalizer.dispose();
return api.createModalizer(element, newProps, sys);
}
existingModalizer.setProps(newProps);
return existingModalizer;
}
return api.createModalizer(element, newProps, sys);
}
);
}

return tabsterCore.modalizer;
Expand Down
13 changes: 12 additions & 1 deletion src/get/getMover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,18 @@ export function getMover(tabster: Types.Tabster): Types.MoverAPI {
const tabsterCore = tabster.core;

if (!tabsterCore.mover) {
tabsterCore.mover = new MoverAPI(tabsterCore, tabsterCore.getWindow);
const api = new MoverAPI(tabsterCore, tabsterCore.getWindow);
tabsterCore.mover = api;
tabsterCore.attrHandlers.set(
"mover",
(element, existingMover, newProps, _oldProps, sys) => {
if (existingMover) {
existingMover.setProps(newProps);
return existingMover;
}
return api.createMover(element, newProps, sys);
}
);
}

return tabsterCore.mover;
Expand Down
13 changes: 12 additions & 1 deletion src/get/getRestorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,18 @@ import type * as Types from "../Types.js";
export function getRestorer(tabster: Types.Tabster): Types.RestorerAPI {
const tabsterCore = tabster.core;
if (!tabsterCore.restorer) {
tabsterCore.restorer = new RestorerAPI(tabsterCore);
const api = new RestorerAPI(tabsterCore);
tabsterCore.restorer = api;
tabsterCore.attrHandlers.set(
"restorer",
(element, existingRestorer, newProps) => {
if (existingRestorer) {
existingRestorer.setProps(newProps);
return existingRestorer;
}
return api.createRestorer(element, newProps);
}
);
}

return tabsterCore.restorer;
Expand Down
Loading