Skip to content

refactor(bundle-size): convert API classes to factory functions#531

Draft
layershifter wants to merge 48 commits into
microsoft:masterfrom
layershifter:refactor/api-factories
Draft

refactor(bundle-size): convert API classes to factory functions#531
layershifter wants to merge 48 commits into
microsoft:masterfrom
layershifter:refactor/api-factories

Conversation

@layershifter
Copy link
Copy Markdown
Member

Summary

Convert API classes behind get* entry points (and UncontrolledAPI) from class to factory functions returning a closed-over public surface. Class scaffolding (constructor + private field declarations + arrow-bound method properties) doesn't mangle as well as plain top-level functions and closures, so each getX consumer ships less code.

8 APIs converted, one commit per API:

  • RestorerAPI
  • OutlineAPI
  • DeloserAPI
  • GroupperAPI
  • MoverAPI
  • ModalizerAPI
  • CrossOriginAPI
  • UncontrolledAPI

Pattern

// before
export class RestorerAPI implements Types.RestorerAPI {
    private _tabster: TabsterCore;
    private _history: History;
    constructor(tabster: TabsterCore) { ... }
    dispose(): void { ... }
    private _onFocusIn = (...) => { ... };
    createRestorer(...) { ... }
}

// after
export function createRestorerAPI(tabster: TabsterCore): Types.RestorerAPI {
    const history = new History(tabster.getWindow);
    const onFocusIn = (...) => { ... };
    // ... wiring (event listeners, queueInit, subscriptions)
    return {
        createRestorer(...) { ... },
        dispose() { ... },
    };
}

Private fields → closure variables. Arrow-bound private methods → local const arrows. Constructor body → factory body. Public API → returned object. The interface in Types.ts is unchanged; consumers in src/get/*.ts swap new XAPI(...) for createXAPI(...).

Static methods

DeloserAPI had static methods (getDeloser, getHistory, forceRestoreFocus) used from CrossOrigin.ts. Kept under a same-named const DeloserAPI = { ... } namespace so call sites continue to work unchanged. Internal state needed by the statics is exposed via a _-prefixed internal interface.

Bundle-size impact

Measured against master (post-#529). All getX fixtures shed bytes; per-API factory savings were ~570 B – 2.3 kB minified depending on API size.

Fixture Master This PR Δ min Δ gzip
all exports 113.84 kB / 30.94 kB 105.63 kB / 30.17 kB −8.21 kB −773 B
createTabster (core) 39.76 kB / 11.89 kB 39.65 kB / 11.88 kB −108 B −13 B
getCrossOrigin 110.62 kB / 30.24 kB 103.00 kB / 29.56 kB −7.62 kB −683 B
getDeloser 49.27 kB / 14.23 kB 48.02 kB / 14.15 kB −1.24 kB −83 B
getGroupper 47.00 kB / 13.59 kB 45.91 kB / 13.54 kB −1.09 kB −54 B
getModalizer 49.07 kB / 14.38 kB 47.61 kB / 14.23 kB −1.46 kB −153 B
getMover 54.65 kB / 15.89 kB 53.57 kB / 15.82 kB −1.09 kB −67 B
getObservedElement 45.56 kB / 13.48 kB 45.46 kB / 13.47 kB −108 B −12 B
getOutline 48.87 kB / 14.22 kB 46.60 kB / 13.96 kB −2.27 kB −255 B
getRestorer 42.52 kB / 12.52 kB 41.84 kB / 12.46 kB −677 B −56 B

OutlineAPI was the biggest individual win — many small private helper methods compress better as closure-captured locals. getCrossOrigin shows the cumulative effect since it bundles every other API.

Out of scope

Three groups of remaining classes were intentionally not converted:

  • Subscribable familyKeyboardNavigationState, FocusedElementState, ObservedElementAPI, plus the two CrossOrigin-internal subscribable classes all extend a shared Subscribable<A,B> base. Converting requires a coordinated migration of the base and all 5 subclasses in one PR. Estimated additional savings: ~500 B in core. Worth doing as a follow-up.
  • RootAPI — has heavy external static usage (RootAPI.getTabsterContext, RootAPI.getRoot called from Modalizer/Focusable/etc.). Doable with the same const-namespace trick used for DeloserAPI, but the blast radius warrants its own PR.
  • FocusableAPI — no external statics, mechanical conversion. Skipped only to keep this PR focused; trivial follow-up.

Verification

  • npm run type-check (lib + tests + stories) passes
  • npm run lint:check passes
  • npm run format:check passes
  • Full test suite — runs in CI

Behavior changes

None. The public interfaces in Types.ts are unchanged; only the runtime construction differs (new XAPI(...)createXAPI(...)). All call sites are in src/get/*.ts and src/Tabster.ts.

🤖 Generated with Claude Code

layershifter and others added 8 commits May 4, 2026 17:54
Class scaffolding (constructor + private field declarations + this-binding
on arrow methods) doesn't mangle as well as plain top-level functions and
closures. Switching RestorerAPI to a factory shaves ~570 B minified /
~45 B gzipped on getRestorer.

Pattern: closures replace private fields; the returned object exposes
only the public methods the interface requires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Static methods (getDeloser, getHistory, forceRestoreFocus) are kept
under a same-named const namespace, so external call sites in
CrossOrigin.ts continue to work unchanged. The internal state needed
by those statics is exposed via an internal interface with _-prefixed
accessors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 4, 2026

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
tabster
all exports
113.84 kB
30.937 kB
90.654 kB
27.848 kB
-23.186 kB
-3.089 kB
tabster
createTabster (core)
39.761 kB
11.892 kB
18.093 kB
6.352 kB
-21.668 kB
-5.54 kB
tabster
findAllFocusable
0 B
0 B
21.206 kB
7.332 kB
🆕 New entry
tabster
findLastFocusable
0 B
0 B
21.251 kB
7.349 kB
🆕 New entry
tabster
findNextFocusable
0 B
0 B
21.237 kB
7.343 kB
🆕 New entry
tabster
findPrevFocusable
0 B
0 B
21.251 kB
7.347 kB
🆕 New entry
tabster
getCrossOrigin
110.623 kB
30.244 kB
88.178 kB
27.162 kB
-22.445 kB
-3.082 kB
tabster
getDeloser
49.268 kB
14.234 kB
30.668 kB
10.048 kB
-18.6 kB
-4.186 kB
tabster
getGroupper
47 kB
13.593 kB
34.122 kB
11.424 kB
-12.878 kB
-2.169 kB
tabster
getModalizer
49.072 kB
14.378 kB
35.553 kB
11.889 kB
-13.519 kB
-2.489 kB
tabster
getModalizer (with dummy inputs)
0 B
0 B
38.108 kB
12.59 kB
🆕 New entry
tabster
getMover
54.653 kB
15.887 kB
41.065 kB
13.516 kB
-13.588 kB
-2.371 kB
tabster
getObservedElement
45.564 kB
13.484 kB
22.295 kB
7.795 kB
-23.269 kB
-5.689 kB
tabster
getOutline
48.867 kB
14.216 kB
24.519 kB
8.366 kB
-24.348 kB
-5.85 kB
tabster
getRestorer
42.518 kB
12.52 kB
20.498 kB
7.076 kB
-22.02 kB
-5.444 kB

🤖 This report was generated against a579ebbd50e37f1565551549fe57bbc9ddafab64

layershifter and others added 21 commits May 4, 2026 18:21
Coordinated migration of the Subscribable abstract class and all 5
consumers — none can be done in isolation since the class hierarchy
goes away.

- `Subscribable` → `createSubscribable<A,B>()` factory returning a
  `SubscribableCore` interface. The public surface (subscribe/
  subscribeFirst/unsubscribe) matches `Types.Subscribable`; setVal/
  getVal/trigger are exposed for the composing factory only.
- `KeyboardNavigationState` → `createKeyboardNavigationState`
- `FocusedElementState` → `createFocusedElementState`. External
  statics (findNextTabbable, forgetMemorized, isTabbing) preserved
  under a same-named const namespace.
- `ObservedElementAPI` → `createObservedElementAPI`
- `CrossOriginFocusedElementState` → `createCrossOriginFocusedElementState`,
  with `setVal` static kept under same-named const namespace
- `CrossOriginObservedElementState` → `createCrossOriginObservedElementState`,
  with `trigger` static kept under same-named const namespace

Bundle-size impact (vs branch's previous tip):

| Fixture            |     Δ min |  Δ gzip |
|--------------------|----------:|--------:|
| all exports        | -2.367 kB |  -111 B |
| createTabster      | -1.151 kB |  -126 B |
| getCrossOrigin     | -2.367 kB |  -242 B |
| getDeloser         | -1.161 kB |  -128 B |
| getGroupper        | -1.161 kB |  -144 B |
| getModalizer       | -1.161 kB |  -162 B |
| getMover           | -1.161 kB |  -107 B |
| getObservedElement | -2.304 kB |  -227 B |
| getOutline         | -1.161 kB |  -128 B |
| getRestorer        | -1.161 kB |  -119 B |

Most of the ~1.16 kB drop on every getX is the core's Subscribable +
KeyboardNav + FocusedElement boilerplate that lives in every bundle.
ObservedElement and CrossOrigin pick up an additional ~1.1 kB each
from their own factory conversion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds each fixture in bundle-size/ with webpack + source-map devtool,
then walks every byte of the minified output and asks the source map
which original module each byte came from. Aggregates per-source
totals and prints a sorted breakdown.

Tool tells us where the bytes are actually going post-minification —
not pre-mangle source size, but real bundle attribution. Useful for
deciding which modules are worth optimizing next.

Usage:
  node scripts/analyze-bundle.mjs              # all fixtures
  node scripts/analyze-bundle.mjs createTabster  # one fixture

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DummyInput was 23.6% of the core bundle (the single largest module).
Converting the four classes here in one coordinated pass:

- DummyInput            → createDummyInput
- DummyInputManager     → createDummyInputManager (interface + factory),
                          with externally-used statics moveWithPhantomDummy
                          and addPhantomDummyWithTarget kept under a same-
                          named const namespace
- DummyInputManagerCore → createDummyInputManagerCore. The class's
                          constructor-return-existing-instance trick (used
                          when an element already has dummy inputs from
                          another subsystem) becomes a natural early-return
                          in a factory.
- DummyInputObserver    → createDummyInputObserver

The four DummyManager subclasses (RootDummyManager, ModalizerDummyManager,
MoverDummyManager, GroupperDummyManager) are migrated from `extends
DummyInputManager` to composing `createDummyInputManager` and calling
`manager.setHandlers(...)` directly. They become free functions
(createRootDummyManager etc.) returning the same DummyInputManager
interface, so the consuming `dummyManager` field on Root/Modalizer/Mover/
Groupper is now typed as the interface rather than the subclass.

Bundle-size impact (vs branch's previous tip):

| Fixture            |     Δ min |  Δ gzip |
|--------------------|----------:|--------:|
| all exports        | -3.486 kB |  -485 B |
| createTabster      | -3.266 kB |  -413 B |
| getCrossOrigin     | -3.486 kB |  -491 B |
| getDeloser         | -3.265 kB |  -432 B |
| getGroupper        | -3.279 kB |  -415 B |
| getModalizer       | -3.283 kB |  -457 B |
| getMover           | -4.612 kB |  -471 B |
| getObservedElement | -3.264 kB |  -433 B |
| getOutline         | -3.271 kB |  -439 B |
| getRestorer        | -3.264 kB |  -429 B |

The ~3.3 kB drop on every fixture is the core DummyInput conversion;
getMover gets an extra ~1.3 kB because MoverDummyManager was its own
subclass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bytes mode tells you which source files made it into the bundle;
this new identifiers mode tells you which symbols are still long after
minification. Terser preserves property names by default, so anything
accessed as `.foo` keeps its full string per call site — those are the
actionable rename targets.

Output sorts by `count × length` and tags each name as:
  property  every occurrence is a property access (`.foo` / `["foo"]`)
  free      none are — bare references (locals, imports, globals)
  mixed     a mix of both

Findings on the current branch (createTabster, 35 kB):

  count  len  total   kind      name
     36    7   252 B  mixed     dispose
     21   12   252 B  mixed     nodeContains
     20   12   240 B  mixed     uncontrolled
     25    8   200 B  mixed     _tabster
      7   21   147 B  mixed     _forgetMemorizedTimer
      9   16   144 B  mixed     _setFocusedTimer
      8   14   112 B  mixed     _dummyObserver

The `_`-prefixed names are private fields on Tabster/TabsterCore/
Focusable/Root (still classes). Either enable Terser's
`mangle.properties` with a `^_` regex, or finish the class→factory
migration and they'll become closure-captured locals that mangle to
single chars.

Usage:
  node scripts/analyze-bundle.mjs                          # bytes only
  node scripts/analyze-bundle.mjs createTabster --mode=identifiers
  node scripts/analyze-bundle.mjs --mode=both              # both reports

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ith addListener/removeListener helpers

Two thin wrappers in Utils.ts:

  addListener(target, type, handler, options?)
  removeListener(target, type, handler, options?)

Each call site shrinks from `el.addEventListener(...)` (16 chars
preserved by the minifier as a property name) to a function call
whose name mangles to a single character. Helpers also accept
nullable targets via optional chaining, so `el?.addEventListener(...)`
patterns become a flat `addListener(el, ...)` call.

Bundle-size impact (vs branch's previous tip):

| Fixture            |    Δ min |  Δ gzip |
|--------------------|---------:|--------:|
| all exports        |   -683 B |   -28 B |
| createTabster      |   -211 B |    -2 B |
| getCrossOrigin     |   -586 B |   -36 B |
| getDeloser         |   -244 B |    -2 B |
| getGroupper        |   -312 B |   -12 B |
| getModalizer       |   -247 B |    +1 B |
| getMover           |   -290 B |    -2 B |
| getObservedElement |   -202 B |    +1 B |
| getOutline         |   -272 B |    -6 B |
| getRestorer        |   -316 B |   -12 B |

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…th createTimer()

createTimer() returns a small handle (set/clear/isActive) wrapping a
single timer id. set() auto-cancels any pending schedule; clear() is
idempotent; the callback auto-clears the id when it fires. That kills
the 5-line `if (this._fooTimer) { win.clearTimeout(this._fooTimer);
this._fooTimer = undefined; }` boilerplate that lived in 15+ places
and inlined `setTimeout`/`clearTimeout` (10–12 char property names) at
each one.

Migrated:
  - Outline:       updateTimer
  - Modalizer:     restoreModalizerFocusTimer, hiddenUpdateTimer
  - Mover:         ignoredInputTimer
  - Deloser:       restoreFocusTimer
  - CrossOrigin:   blurTimer (factory), _pingTimer (internal class)
  - Groupper:      updateTimer
  - DummyInput:    disposeTimer (also drops the clearDisposeTimeout
                   closure that wrapped manual id-tracking),
                   addTimer (DummyInputManagerCore)
  - Tabster:       _initTimer, _forgetMemorizedTimer (TabsterCore)
  - Root:          _setFocusedTimer

Skipped: DummyInputObserver's updateTimer / updateDummyInputsTimer,
because they call `this._win?.()` after dispose — the helper assumes
getWindow() throws if disposed. Not worth a special case.

Bundle-size impact (vs branch's previous tip):

| Fixture            |   Δ min |  Δ gzip |
|--------------------|--------:|--------:|
| all exports        |  -748 B |  -183 B |
| createTabster      |  -248 B |    -3 B |
| getCrossOrigin     |  -748 B |  -126 B |
| getDeloser         |  -328 B |   -19 B |
| getGroupper        |  -299 B |   -31 B |
| getModalizer       |  -341 B |    -7 B |
| getMover           |  -340 B |   -42 B |
| getObservedElement |  -245 B |   -13 B |
| getOutline         |  -347 B |    -9 B |
| getRestorer        |  -253 B |    -7 B |

Net code-quality win: ~80 lines of `if (timer) { clearTimeout(timer);
timer = undefined; }` collapse to one-liners.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Window

Per feedback, the timer is now just \`{ id }\` and the helpers are free
functions. Two wins:

1) No window plumbing. \`createTimer()\` takes no args. setTimer/clearTimer
   call the global setTimeout/clearTimeout. The window-binding inside the
   helper was load-bearing only for cross-window scenarios, but the timers
   here are always in the main window context.

2) Each call site shrinks. With methods (\`timer.clear()\`), the property
   name \`.clear\` was preserved by the minifier (~5 chars per call site).
   With free functions, \`clearTimer\` mangles to a single character at
   the call site (the function name is local). Same for setTimer/
   isTimerActive.

API:

  const t = createTimer();
  setTimer(t, callback, delay);
  clearTimer(t);
  isTimerActive(t);

Migrated all 30+ call sites and dropped the \`createTimer(getWindow)\`
arg from each.

Bundle-size impact (vs branch's previous tip):

| Fixture            |  Δ min |  Δ gzip |
|--------------------|-------:|--------:|
| all exports        |  -86 B |  +10 B  |
| createTabster      |  -38 B |   +5 B  |
| getCrossOrigin     |  -86 B |  +27 B  |
| getDeloser         |  -49 B |  +16 B  |
| getGroupper        |  -40 B |  +19 B  |
| getModalizer       |  -33 B |  +22 B  |
| getMover           |  -38 B |  +20 B  |
| getObservedElement |  -33 B |  +12 B  |
| getOutline         |  -51 B |   +7 B  |
| getRestorer        |  -31 B |  +13 B  |

The minified gain is real per call site; gzip ticks up slightly because
the new exported function names \`setTimer\`/\`clearTimer\` are less
gzip-friendly than the previously-shared \`.set\`/\`.clear\` patterns —
but minified is what matters for CDN size and parse cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add per-method fixtures (findAll, findLast, findNext, findPrev) so any
future split of FocusableAPI into tree-shakable parts shows up as
divergence from the createTabster baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Breaking change. Removes `tabster.focusable.X(...)` and replaces it
with free-function exports: findFirstFocusable, findLastFocusable,
findNextFocusable, findPrevFocusable, findDefaultFocusable,
findAllFocusable, findFocusable, isFocusable, isElementVisible,
isElementAccessible, getFocusableProps. The 11 functions take the
Tabster wrapper as the first argument, internal `_*` variants take
TabsterCore for cross-module use.

Also threads `window` through setTimer/clearTimer so timers route
through the per-tabster window's setTimeout/clearTimeout. Migrated
remaining raw .setTimeout/.clearTimeout call sites to use the helpers.

Bundle-size impact (vs prior):
- createTabster (core): 34.74 → 33.80 kB (-940 B)
- all exports: 98.26 → 96.47 kB (-1.79 kB)
- Per-getX fixtures: -180 B to -1.16 kB

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t helper

Adds dispatchEvent(target, event) helper in Utils.ts that mangles to a
single char and migrates ~21 internal call sites. Mirrors the pattern
used for addListener/removeListener/setTimer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the public getAllObservedElements method from ObservedElementAPI.
The use case is covered by onObservedElementChange, which streams adds /
removes / name changes as they happen — callers can maintain their own
map if needed.

Removes the corresponding test, the storybook APIDemonstration story,
and the createObservedWrapperWithAPIDemo helper.

Bundle-size: getObservedElement -244 B.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… class

Move the three static methods (getTabsterContext, getRoot, getRootByUId —
last one was dead code) out of the RootAPI class body and into a new
src/Context.ts. The 30+ internal call sites now import a free function
instead of going through `RootAPI.getTabsterContext(...)` on a class
that ships ~310 lines of unrelated instance code.

The RootAPI class still ships (it's the runtime backing for tabster.root),
but consumer modules that only want to walk the context tree no longer
pull the entire class definition through the import graph.

_autoRoot and _autoRootCreate are no longer `private` — Context.ts
reaches into them for the auto-root fallback. Marked /** @internal */.

Bundle-size:
- createTabster (core): 33.80 → 33.38 kB (-418 B)
- all exports: 96.47 → 95.40 kB (-1.07 kB)
- Each getX fixture: -350 to -560 B

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s Set

TabsterCore.dispose() previously hard-coded eight `tabster.X?.dispose()`
calls — one per extended API. Each `getX` factory now adds itself to
`tabster.disposers` (a Set<Disposable>), and dispose() iterates and
clears the set instead.

Adding a new extended API no longer requires editing TabsterCore.dispose.

Bundle-size:
- createTabster (core): 33.38 → 33.26 kB (-123 B)
- Each getX fixture: -92 to -104 B
- all exports / getCrossOrigin: +46 to +65 B (each of 8 APIs adds a
  `.add()` call; small overhead when all are loaded together)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new dimensions for bundle introspection:

- --mode=functions  per-method drilldown using @babel/parser; attributes
                    source-mapped bytes to the enclosing function so we
                    can see which methods inside a heavy file dominate.

- --mode=exports    rebuilds with usedExports=true, concatenateModules=false;
                    dumps each tabster module's provided/used/unused
                    exports to surface API surface that survived
                    tree-shaking but is never called.

- --diff <a> <b>    builds two fixtures and prints the per-source delta
                    (composes with --mode=functions or --mode=exports for
                    per-method or per-export deltas).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… dispatch

The function had explicit per-feature switches in both the add and remove
loops, so every consumer paid for knowledge of every feature even when
they didn't use it.

Add loop is now: plain-value keys (focusable/uncontrolled/sys) get a verbatim
assignment, otherwise dispatch to a handler registered by the feature's getX
factory. Three previously-inline cases now register handlers from where
their state lives:

- root: RootAPI constructor (always-on, paid by createTabster)
- outline: getOutline.ts (paid only when getOutline is called)
- observed stays inline because its post-assignment notification triggers
  synchronous waiters that read tabsterOnElement.observed back through
  storage — handlers can't express that ordering.

Remove loop collapses the 9-case switch to a generic "dispose if disposable
+ delete" pattern, with one observed-specific notification.

Drop RootAPI.onRoot — its rootById bookkeeping moves into createRoot and
_onRootDispose, which both already manage _roots; nothing else called it.
ObservedElementAPI.onObservedElementUpdate now takes the new observed
props as an argument instead of reading them back from storage, since the
remove path needs to call it after delete.

createTabster: 32.52 kB → 32.16 kB (-360 B / -1.1%)
Instance.js:    1.43 kB → 1.01 kB (-420 B / -29%)
Root.js:        2.81 kB → 2.88 kB (+74 B for handler registration +
                                   inlined rootById ops)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r resolver

The big mover/groupper containment-conflict block inside _acceptElement
was 50% of Focusable.js but only ran when at least one of those features
was loaded — createTabster paid for code it could never execute.

Move the block (containment resolution + mover/groupper.acceptElement
dispatch) into a new src/MoverGroupperResolver.ts and register it on
core.focusableContextResolver from getMover/getGroupper. Without those
gets, the resolver field is undefined and _acceptElement skips the call.

Drop the dead `!mover` short-circuit (block 1) and the redundant
`moverElement && groupperElement` guard (block 4) — both were always
satisfied by the time the surrounding `if` ran.

createTabster: 32.16 kB → 31.65 kB (-510 B)
Focusable.js:  3.92 kB → 3.41 kB (-510 B)
_acceptElement (within Focusable.js):
               1.97 kB → 1.46 kB (-510 B / -26%)

getMover / getGroupper add 598 B for MoverGroupperResolver.js, which is
roughly the bytes that previously lived in Focusable.js — they moved
rather than disappeared, but createTabster no longer carries them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The used/unused list reports what webpack's tree-shaker considers
reachable, not what survives Terser DCE in the production build.
Side-effect-free class definitions and unused functions get stripped
later, so a long "unused" list in this view is often already gone from
the actual bundle. Note the limitation in both the file header and the
report header so we cross-check with bytes mode before chasing a
phantom leak.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ummyInputs

The dummy-input factory and its dependencies (createDummyInputManager,
DummyInputManagerCore, focusedElement.getFirstOrLastTabbable,
keyboardNavigation.setNavigatingWithKeyboard, nativeFocus) accounted for
~3 kB inside Root.js + DummyInput.js even when an app didn't want
browser-Tab handling. Move the factory to a new src/RootDummyManager.ts
and gate Root.addDummyInputs() on tabsterCore.rootDummyManagerFactory,
which is registered by a new public getRootDummyInputs(tabster) helper.

Without that opt-in, Root.addDummyInputs() is a no-op even when
controlTab/rootDummyInputs is true. Apps that need browser-Tab handling
call getRootDummyInputs(tabster) once after createTabster (and after the
flag is set) — same shape as the other getX factories.

The internal RootAPI.onRoot handshake is gone (folded into createRoot
and _onRootDispose by an earlier commit), so the factory only needs the
core, the WeakHTMLElement, the setFocused callback, and sys.

Also wire test-setup.js and .storybook/preview.mjs to call
getRootDummyInputs when controlTab/rootDummyInputs is set so existing
tests/stories continue to behave the same.

createTabster: 31.65 kB → 28.54 kB (-3.11 kB / -9.8%)
DummyInput.js:  5.54 kB → 2.73 kB (-2.81 kB)
Root.js:        2.88 kB → 2.60 kB (-280 B)

Breaking change: controlTab=true (default) no longer auto-creates
dummy inputs; consumers that need browser Tab handling must now call
getRootDummyInputs(tabster) once.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ithDefaultAction

With dummy inputs now opt-in via getRootDummyInputs, the phantom-dummy
fallback path inside Root.moveOutWithDefaultAction has no remaining
audience: opted-in consumers always have a _dummyManager that handles
the move; opted-out consumers don't want Tabster managing Tab boundaries
at all.

Drop the fallback. Root.moveOutWithDefaultAction becomes a thin pass-
through that no-ops without a manager, and Root.ts loses its runtime
import of DummyInputManager (now type-only for the field). The other
phantom-dummy call site (FocusedElement.ts:298, used for iframes /
uncontrolled regions) is unaffected.

createTabster: 28.54 kB → 28.45 kB (-90 B)
Root.js:        2.60 kB → 2.50 kB (-100 B)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the "controlTab: false" path where the consumer must opt into
dummy-input behavior via getRootDummyInputs. Today the diff against
plain getModalizer is only ~440 B (the getRootDummyInputs helper plus
RootDummyManager) because the per-Modalizer dummy code is still pulled
in statically via Modalizer.ts's createModalizerDummyManager. The
fixture will be the measurement target for upcoming refactors that
move the per-feature dummy factories behind the same opt-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each part used to inline its own per-instance create*DummyManager that
runs only when controlTab=false. With controlTab=true (the default), the
factory was dead code that still shipped — Terser couldn't prove it
unreachable since the gate is a runtime flag.

Move the three factories out to MoverDummyManager.ts /
GroupperDummyManager.ts / ModalizerDummyManager.ts. Register them on
tabsterCore.{mover,groupper,modalizer}DummyManagerFactory inside
getRootDummyInputs alongside the root factory; each part's constructor
calls the registered factory instead of an inline import. Without
getRootDummyInputs, the dummy code is absent from the bundle.

Also restore the phantom-dummy fallback for Root.moveOutWithDefaultAction
behind the same opt-in. With dummy inputs disabled the call no-ops
(consistent with the rest of the opt-in surface); when getRootDummyInputs
is called the registered moveOutOfRoot routes either through the root's
dummy manager or through DummyInputManager.moveWithPhantomDummy. This
restores the master behaviour for `controlTab: false` /
`rootDummyInputs: false` consumers without putting moveWithPhantomDummy
back into the always-on Root path.

Test setup now calls getRootDummyInputs unconditionally so
controlTab=false runs get the per-part dummy factories.

createTabster:                28.45 kB → 28.48 kB (+30 B for routing)
getMover:                     45.05 kB → 41.66 kB (-3.39 kB / -7.5%)
getGroupper:                  38.05 kB → 34.42 kB (-3.63 kB / -9.5%)
getModalizer:                 38.80 kB → 35.80 kB (-3.00 kB / -7.7%)
getModalizerWithDummyInputs:           40.19 kB (the controlTab=false
                                                  cost: +4.39 kB over
                                                  plain getModalizer)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
layershifter and others added 19 commits May 5, 2026 12:53
TabsterCore unconditionally created a DummyInputObserver in its
constructor — but the observer only earns its keep when dummy inputs
exist (it tracks their positions through layout/scroll changes and
notifies them on DOM mutations). Apps that don't opt into dummy inputs
were paying for ~900 B of observer code (createDummyInputObserver +
its dispose/domChanged/updatePositions/focusIn closure) that never
ran.

Move the createDummyInputObserver call into getRootDummyInputs and
make the field optional on TabsterCore. The two call sites that used
it (DummyInput.ts internals + MutationEvent.ts batch hook) already
guarded the property; switch to optional chaining on the observer
itself so the no-opt-in path is a no-op end to end. Disposal goes
through the disposers Set, which is how every other extended API
already cleans up.

createTabster: 28.48 kB → 27.60 kB (-880 B / -3.1%)
DummyInput.js: 2.73 kB → 1.93 kB (-800 B in createTabster baseline)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…stries

Two changes that work together to make the default consumer experience
honest while keeping the opt-out path slim:

1. createTabster auto-installs root-level dummy-input infrastructure
   when controlTab (default true) or rootDummyInputs is on. Before this,
   the default createTabster(window) silently produced a Tabster that
   couldn't drive Tab navigation until the consumer added a separate
   getRootDummyInputs(tabster) call. The flag's default now matches the
   actual default behaviour.

2. Drop the moverDummyManagerFactory / groupperDummyManagerFactory /
   modalizerDummyManagerFactory registry fields. They no longer serve a
   purpose: each part's getX always pairs with its dummy-manager file
   import (the bytes load with the feature regardless), and the
   "is dummy infrastructure installed" gate is already provided by
   tabster._dummyObserver. Mover/Groupper/Modalizer now import their
   factories directly and check `tabster._dummyObserver` before
   creating a dummy manager.

getRootDummyInputs is also slimmer — it only registers the root
factory, the observer, and moveOutOfRoot routing. Per-part factories
no longer travel with it, so a Modalizer-only consumer doesn't pay
for Mover/Groupper dummy code.

createTabster: 27.60 kB → 31.94 kB (+4.34 kB to honour the default;
                                    still -580 B vs master 32.52 kB)
getMover:      41.66 kB → 45.35 kB (+3.69 kB for auto-installed dummies)
getGroupper:   34.42 kB → 38.30 kB (+3.88 kB likewise)
getModalizer:  35.80 kB → 39.49 kB (+3.69 kB likewise)

The bytes regression on getMover/Groupper/Modalizer is the cost of
making controlTab=true "just work" without a follow-up call. Apps that
explicitly pass controlTab=false and skip getRootDummyInputs keep the
slim baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With dummy inputs now an opt-in driven by tabsterCore.rootDummyManagerFactory,
Root.addDummyInputs() is a no-op without a registered factory and idempotent
when one is present. The book-keeping that used to gate it is no longer
load-bearing:

- Root constructor: drop the `if (controlTab || rootDummyInputs)` guard
  and call `this.addDummyInputs()` unconditionally. Without a factory it
  returns early; with one it creates the manager exactly once.
- RootAPI: drop the `_forceDummy` field and the matching
  `if (this._forceDummy) newRoot.addDummyInputs()` in createRoot. Future
  roots already pick up dummies through their constructor.
- RootAPI.addDummyInputs: drop the `_forceDummy = true` write and just
  iterate existing roots — that's all the public method ever needed to
  do (the persistent flag was for newly-created roots, which the
  constructor already handles).

Also tighten Tabster.dispose by merging the two `if (win)` blocks into
one (no bytes change after Terser, but the dispose is easier to read in
source).

createTabster: 31.94 kB -> 31.83 kB (-110 B)
Root.js:        2.54 kB ->  2.44 kB (-100 B)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds with Terser DCE/inlining intact but mangle:false,
keep_fnames:true, keep_classnames:true, beautify:true. Writes
the output to bundle-size/.readable/<fixture>.js (gitignored)
so leak hunting can read original symbol names instead of
single-letter mangled identifiers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…erCore fields

Two leaks identified by the readable-mode analyzer were both in code
that runs on createTabster()'s default path even when no Mover/
Groupper/Modalizer is in use:

1. TabsterCore declared groupper/mover/outline/deloser/modalizer/
   observedElement/crossOrigin/restorer/_dummyObserver as class fields.
   Each compiled to a `this.x = void 0` write in the constructor (~15 B
   each). Removed the declarations and rely on `undefined`-on-read; the
   typed slots are still declared on Types.TabsterCore, so type safety
   is preserved.

2. FocusedElementState.findNextTabbable had a hardcoded if/else chain
   selecting between groupper/mover/modalizer plus a `callFindNext`
   closure with parent-context fallback. Replaced with a strategy
   registry — Mover/Groupper/Modalizer push their dispatcher onto
   tabsterCore.findNextTabbableStrategies from their getX factories.
   The parent-fallback helper is exported from FocusedElement.ts and
   only Mover/Groupper import it (Modalizer is a hard trap and skips
   the parent walk), so createTabster pays for none of it.

Bundle deltas vs prior commit:
  createTabster: 32.52 → 31.44 kB (-490 B)
  getModalizer:  39.38 → 39.14 kB (-160 B)
  getMover:      45.29 kB (no change — strategy registration costs
                 are offset by the closure removal)
  getGroupper:   38.24 kB (same)

Tests pass: 3 pre-existing failures in default/uncontrolled/
root-dummy-inputs modes; no regressions.

Note: a deeper refactor that moved the Mover/Groupper/Modalizer
context bookkeeping out of getTabsterContext was prototyped and
reverted — the indirection cost (+480 B per feature mode) outweighed
the createTabster save (-90 B).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onstructor

Same pattern we applied to TabsterCore, now extended to the part
subclasses: \`field: T;\` declarations that the constructor immediately
overwrites compile to a \`this.x = void 0\` initializer at runtime
(target ES2022 with \`useDefineForClassFields\` defaulting to true).
Prefixing them with \`declare\` keeps the type info for TS while
suppressing the runtime emit.

Touched: Root, RootAPI, Mover, Groupper, Modalizer, Deloser,
DeloserItem, DeloserHistoryByRootBase, DeloserHistory, Restorer's
History.

Babel needed \`allowDeclareFields: true\` on \`@babel/preset-typescript\`
for the test runner to parse the syntax (TSC accepts it natively).

Bundle deltas vs prior commit:
  createTabster: 31.44 → 31.37 kB (-70 B)
  getModalizer:  39.14 → 39.01 kB (-130 B)
  getMover:      45.29 → 45.07 kB (-220 B)
  getGroupper:   38.24 → 38.13 kB (-110 B)
  getDeloser:    39.42 → 39.21 kB (-210 B)

Tests: 3 pre-existing failures, no new regressions in default,
uncontrolled, or root-dummy-inputs modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removed webkit/moz/ms fullscreen detection. \`fullscreenchange\` /
\`document.fullscreenElement\` are unprefixed across all evergreen
browsers (Chrome ≥71, Firefox ≥64, Edge Chromium, Safari ≥16.4) — the
prefixed branches haven't been reachable in supported browsers for
years. With them gone, the runtime \`fullScreenEventName\` /
\`fullScreenElementName\` lookups collapse to a string literal and a
single property read.

Bundle delta:
  getOutline: 37.85 → 37.67 kB (-180 B)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continued the cleanup pass into the classes that the prior commit
missed: \`Tabster\` wrapper (5 fields), \`TabsterCore\` (13 typed-only
fields whose initial values come from the constructor), the
\`TabsterPart\` abstract base, \`WeakHTMLElement\`, and the
\`CrossOrigin\` transaction hierarchy plus \`OutlinePosition\`. All
were declarations TS wanted for type-checking but whose runtime
\`field;\` emit was redundant — the constructor body assigns them
immediately.

Bundle deltas vs prior commit:
  createTabster: 31.37 → 31.12 kB (-260 B)
  getModalizer:  39.01 → 38.77 kB (-240 B)
  getMover:      45.07 → 44.83 kB (-240 B)
  getGroupper:   38.13 → 37.89 kB (-240 B)
  getDeloser:    39.21 → 38.96 kB (-260 B)
  getOutline:    37.67 → 37.41 kB (-260 B)
  getCrossOrigin:        89.95 kB  (~ -250 B)

Tests pass: 3 pre-existing failures, no regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cleaner helpers across Utils.ts; smaller compiled output as a
side-effect.

- \`getInstanceContext\` and \`getWindowUId\` use \`??=\` for the lazy-init
  patterns instead of explicit if-not-set assignments.
- \`getElementUId\` does the same on \`element.__tabsterElementUID\`.
- \`disposeInstanceContext\` no longer wipes maps it's about to drop —
  the WeakHTMLElement entries die with the context object anyway.
- \`getUId\` builds the result with \`Array.from\` + concatenation instead
  of a manually-pushed string array.
- \`getRadioButtonGroup\` chains \`filter(isRadio)\` + \`find(checked)\`
  instead of mutating an array in a single pass.
- \`getTabsterAttributeOnElement\` skips the redundant \`hasAttribute\`
  probe — \`getAttribute\` already returns \`null\` when absent.
- \`clearElementCache\` flattens the conditional skip.
- \`MutationEvent.observeMutations\` uses \`for…of\` over NodeList instead
  of indexed loops.

Bundle deltas vs prior commit:
  createTabster: 31.12 → 30.85 kB (-280 B)
  getModalizer:  38.77 → 38.50 kB (-280 B)
  getMover:      44.83 → 44.55 kB (-280 B)
  getGroupper:   37.89 → 37.62 kB (-280 B)
  getDeloser:    38.96 → 38.69 kB (-280 B)
  getOutline:    37.41 → 37.13 kB (-280 B)
  getCrossOrigin: 90.07 → 89.63 kB (-440 B)

Tests pass: 3 pre-existing failures, no regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two of Focusable's hardcoded knowledge-of-Modalizer references are now
gone:

1. \`core.modalizer?.acceptElement(element, state)\` in
   \`_acceptElement\` was a special-cased first dispatch followed by
   \`core.focusableContextResolver?.()\` for Mover/Groupper. Generalized
   the slot into a \`focusableContextResolvers\` chain — Modalizer
   registers its own resolver via \`unshift\` (so its trap-out behaviour
   keeps precedence over containment checks), Mover/Groupper register
   the existing \`resolveMoverGroupperContext\` via \`push\`. Focusable
   iterates the chain without naming any specific feature.

2. \`core.modalizer?.isAugmented(el)\` in \`_isHidden\` was Modalizer's
   peek into the \`aria-hidden\` carve-out. Switched to checking
   Tabster's per-element augmentation storage via \`storageEntry(el)?.aug\`
   — \`augmentAttribute\` is the system-level mechanism Modalizer was
   already using under the hood, so the new check is a direct read of
   the same fact. Stored value is the *original* attribute (often
   \`null\`), so we probe presence with \`in\`, not falsiness.

The remaining two Modalizer references in Focusable
(\`core.modalizer?.activeId\`, \`ctx.modalizer?.userId\`) are state reads
for \`modalizerUserId\` plumbing; abstracting them through hooks
would cost more bytes than it saves and only moves the indirection.

Bundle deltas vs prior commit: ±30 B per fixture (essentially flat).
Tests pass: 3 pre-existing failures, no regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…vent field

Two micro-cleanups landed independently:

- \`Subscribable.createSubscribable\` shed its private \`callCallbacks\`
  helper — \`trigger\` already does the same thing, just inlined into
  the returned object.

- \`TabsterCustomEvent.details\` is marked \`@deprecated\` (kept for
  backwards compat with consumers reading \`.details\` instead of
  \`.detail\`). Adding \`declare\` drops the implicit
  \`this.details = void 0\` initializer SWC was emitting before the
  constructor's actual \`this.details = detail\` assignment overwrote
  it. The deprecated read path still works.

Bundle deltas vs prior commit:
  createTabster: 30.84 → 30.83 kB (-10 B)
  getModalizer:  38.58 → 38.56 kB (-20 B)
  getMover:      44.60 → 44.58 kB (-20 B)
  getCrossOrigin: 89.75 → 89.73 kB (-20 B)

Tests pass: 3 pre-existing failures, no regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five deprecated items removed:

1. \`TabsterCustomEvent.details\` — duplicate of native CustomEvent
   \`detail\`. Constructor body shed the \`this.details = detail\` line.

2. \`Deprecated.ts\` (and the \`export * from "./Deprecated.js"\` in
   \`index.ts\`). Dropped \`dispatchGroupperMoveFocusEvent\`,
   \`dispatchMoverMoveFocusEvent\`, \`dispatchMoverMemorizedElementEvent\`
   — thin wrappers around \`element.dispatchEvent(new ...Event(...))\`.

3. \`TabsterCoreProps.checkUncontrolledTrappingFocus\` — replaced by
   \`checkUncontrolledCompletely\`. The fallback \`||\` chain in
   TabsterCore.constructor is gone.

4. \`getModalizer(tabster, alwaysAccessibleSelector, accessibleCheck)\`
   second arg. Same effect achievable via the \`accessibleCheck\`
   callback. Dropped from \`getModalizer\`, \`createModalizerAPI\`, and
   the supporting \`querySelectorAll(body, selector)\` walk inside
   \`hiddenUpdateInternal\`. Tests for the selector mode removed; the
   \`accessibleCheck\` test updated to the new (2-arg) shape.

5. \`HTMLElementWithTabsterFlags.noDirectAriaHidden\` — added for FluentUI
   v9/v8 interop. Whole \`HTMLElementWithTabsterFlags\` interface gone
   (no other flags defined). \`Modalizer.walk\` no longer special-cases
   \`__tabsterElementFlags?.noDirectAriaHidden\`. Test removed.

Bundle deltas vs prior commit:
  createTabster: 30.83 → 30.78 kB (-50 B)
  getModalizer:  38.56 → 38.43 kB (-130 B)
  getMover:      44.58 → 44.54 kB (-40 B)
  getGroupper:   37.65 → 37.60 kB (-50 B)
  getOutline:    37.11 → 37.06 kB (-50 B)
  getCrossOrigin: 89.73 → 89.59 kB (-140 B)
  allExports:    92.19 → 92.05 kB (-140 B)

Tests: 3 pre-existing failures, no regressions across default,
uncontrolled, and root-dummy-inputs modes (2 deprecated-feature tests
removed alongside the dropped APIs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This partially reverts 86d6d8e — restores
\`HTMLElementWithTabsterFlags.noDirectAriaHidden\` and the
corresponding Modalizer \`walk\` carve-out plus its test. FluentUI v8
interop is still needed for consumers that haven't migrated; the
flag isn't really deprecated for them. The four other deprecated
items dropped in 86d6d8e stay gone.

Bundle deltas vs prior commit:
  getModalizer:  38.43 → 38.47 kB (+40 B)
  getCrossOrigin: 89.59 → 89.64 kB (+50 B)
  createTabster: unchanged

Tests: 3 pre-existing failures, no regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
\`patch-package\` postinstall hook applies three changes to
keyborg@2.14.0 covering both the ESM (\`dist/index.js\`) and CJS
(\`dist/index.cjs\`) bundles:

1. \`event.details = details\` — drop the \`@deprecated\` alias of
   \`event.detail\`. Tabster reads \`e.detail\` exclusively (verified
   across src/State/FocusedElement.ts and the rest of the codebase).

2. \`triggerKeys\` / \`dismissKeys\` props + the supporting
   \`shouldDismiss\` / \`scheduleDismiss\` / \`dismissTimer\`
   machinery. Tabster only ever calls \`createKeyborg(getWindow())\`
   with no props.

3. \`canOverrideNativeFocus\` runtime probe. Replaces the
   \`_canOverrideNativeFocus\` flag with the implicit-true assumption
   modern browsers (everything since IE9) already satisfy. The
   conditional \`details.isFocusedProgrammatically\` write becomes
   unconditional — semantically identical when override works.

Bundle deltas (createTabster default-mode):
  keyborg slice: 3.71 → 3.12 kB (-590 B, -16%)
  createTabster:  30.78 → 30.18 kB (-600 B)
  getModalizer:   38.47 → 37.87 kB (-600 B)
  getMover:       44.54 → 43.94 kB (-600 B)
  getCrossOrigin: 89.64 → 89.04 kB (-600 B)
  allExports:     92.09 → 91.50 kB (-590 B)

Tests pass: 3 pre-existing failures, no regressions across default,
uncontrolled, and root-dummy-inputs modes.

Stop-gap until upstream microsoft/keyborg can release the same
trims (the changes belong there, not as a Tabster-side fork).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`--mode=profile` builds every fixture in parallel, collapses per-source
byte attribution into a presence matrix, and reports core sources (in
every fixture, the irreducible floor), variable sources sorted by max
bytes, per-fixture above-floor deltas, and a near-core hint that flags
sources present in N-1 fixtures (proven tree-shakeable elsewhere).
Surfaces optimization targets that single-fixture views can't.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
\`tabster.focusedElement.{focusFirst,focusLast,focusDefault,resetFocus,
getFirstOrLastTabbable,requestAsyncFocus,cancelAsyncFocus,
getLastFocusedElement}\` were properties on a returned object literal,
so Terser couldn't DCE them out of consumers that didn't use them. They
now live as top-level exports (and \`_\`-prefixed internal variants taking
\`TabsterCore\`); internal callers in Deloser/Modalizer/Groupper/
Restorer/RootDummyManager import the internal forms directly. Public
consumers migrate from \`tabster.focusedElement.focusFirst(props)\` to
\`focusFirst(tabster, props)\` (re-exported from the package root).

\`Types.FocusedElementState\` is now a slim surface: \`subscribe\`/
\`subscribeFirst\`/\`unsubscribe\`, \`dispose\`, \`getFocusedElement\`,
\`focus\`. \`getFirstOrLastTabbable\`/\`requestAsyncFocus\`/
\`cancelAsyncFocus\` were already \`@internal\`; their public-facing
counterparts \`focusFirst\`/\`focusLast\`/\`focusDefault\`/\`resetFocus\`/
\`getLastFocusedElement\` move to the new top-level exports.

Saves ~12.7 kB suite-wide across the bundle-size fixtures (~1.17 kB on
core/find-only fixtures, smaller deltas on feature fixtures depending
on which moved helpers they actually pull in).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tab interception, root dummy inputs, and the \`DummyInputManager\` phantom
helpers used to be eagerly installed by every \`createTabster(win)\` call,
even for consumers that only used \`findFocusable\` or feature APIs. The
machinery is now in \`src/Tab.ts\` and only enters the bundle when the
consumer calls \`getRootDummyInputs(tabster)\`.

Specifically:

- New \`src/Tab.ts\` owns \`onKeyDown\`, \`findNextTabbable\`,
  \`getUncontrolledCompletelyContainer\`, the \`isTabbing\` flag, and
  \`installTabKeyHandler(tabster, getWindow)\`. \`FocusedElementState.findNextTabbable\`
  / \`FocusedElementState.isTabbing\` are gone — Mover/Groupper/Modalizer
  dummy managers import \`findNextTabbable\` directly; Mover reads
  \`isTabbing()\` as a function.
- \`Tabster.ts\` no longer imports \`getRootDummyInputs\` and no longer
  auto-calls it. \`controlTab\` defaults to \`false\` (slim baseline).
  \`FocusedElementState.forgetMemorized\` becomes \`_forgetMemorized\` (drops
  the const wrapper).
- \`getRootDummyInputs(tabster)\` now also installs the keydown handler;
  root dummies + keyhandler installation are gated on
  \`controlTab || rootDummyInputs\` so callers can use the function purely
  to register per-feature dummy infrastructure (factories + observer)
  needed by Mover/Groupper/Modalizer dummies in uncontrolled mode.
- Test wrapper (\`tests/test-setup.js\`) defaults \`controlTab: true\` and
  auto-calls \`getRootDummyInputs\` so existing tests that create their
  own Tabster instance still get controlled Tab behaviour.

Bundle savings vs. baseline (monosize):

  createTabster (core)            -12.68 kB  (-41%)
  findAllFocusable                 -9.63 kB  (-31%)
  getRestorer                     -12.34 kB  (-37%)
  getObservedElement              -12.74 kB  (-36%)
  getOutline                      -12.68 kB  (-34%)
  getDeloser                       -8.12 kB  (-21%)
  getMover                         -4.74 kB  (-11%)
  getGroupper                      -4.58 kB  (-12%)
  getModalizer                     -4.03 kB  (-10%)
  getCrossOrigin                   -3.79 kB  (-4.2%)
  all exports                      -3.83 kB  (-4.1%)
  getModalizer (with dummy inputs) -0.56 kB  (-1.4%)

\`getModalizerWithDummyInputs\` is the "consumer asks for Tab" baseline:
the fixture explicitly imports \`getRootDummyInputs\`, so most of the
machinery is back in. The 0.56 kB Δ there comes from the FocusedElement
api-method moves in the prior commit.

Apps that want the previous "Tab just works" behaviour should pair
\`createTabster(win)\` with \`getRootDummyInputs(tabster)\`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
\`getMover\`/\`getGroupper\`/\`getModalizer\` now lazy-create
\`tabster._dummyObserver\` themselves, so per-feature dummy-input
redirection works without requiring the consumer to also call
\`getRootDummyInputs\`. The observer setup lives in a new
\`ensureDummyInputObserver(tabster)\` helper in DummyInput.ts;
\`getRootDummyInputs\` shares the same helper.

Mover/Groupper/Modalizer constructors drop the
\`&& tabster._dummyObserver\` half of the gate (the per-feature
factories guarantee it exists by the time a part instance is built);
they still skip the per-part dummy when \`controlTab: true\` because
the keyhandler intercepts Tab and never lets focus reach a dummy.

\`getRootDummyInputs\` no longer has the inner
\`if (controlTab || rootDummyInputs)\` gate — calling it *is* the
opt-in for Tab control + root dummies, so it always installs the
keyhandler and adds root dummies. Test wrapper gates the call
externally so uncontrolled, no-root-dummies tests skip it and rely on
the per-feature dummies the get* factories install.

Bundle deltas vs. previous commit (per-feature fixtures +~1 kB; slim
baseline unchanged):

  createTabster         18.193 kB  unchanged
  findAllFocusable      21.306 kB  unchanged
  getMover              41.165 kB  +0.95 kB
  getGroupper           34.222 kB  +0.95 kB
  getModalizer          35.653 kB  +0.94 kB
  allExports            90.754 kB  +0.92 kB

Net vs. master baseline still strongly negative (createTabster -41%,
findFocusable family -31%).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
\`tabster.uncontrolled.isUncontrolledCompletely\` was a one-method
object wrapping a single user-supplied callback
(\`checkUncontrolledCompletely\`). The wrapper added byte overhead and
indirection for one internal caller (the Tab keyhandler) without any
public surface beyond the callback the consumer already passed to
\`createTabster\`.

Drop \`tabster.uncontrolled\` and \`Types.UncontrolledAPI\` entirely.
\`TabsterCore\` now exposes \`checkUncontrolledCompletely\` directly
(picked through from \`TabsterCoreProps\`); \`src/Tab.ts\` inlines the
"callback overrides element flag, undefined falls back" check at the
single call site. \`src/Uncontrolled.ts\` and \`createUncontrolledAPI\` are
removed.

Public consumers reading \`tabster.uncontrolled\` need to migrate — none
in this repo (tests/stories) referenced it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant