@astryxdesign/core 0.7.2 → 0.7.3-canary.b1e1936

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,266 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 3
4
+ kind: module
5
+ id: module:Table/useTableVirtualization
6
+ authority: draft
7
+ archive_reason: null
8
+ superseded_by: null
9
+ approved_by: null
10
+ approved_at: null
11
+ owners: [cixzhang]
12
+ review_triggers:
13
+ [
14
+ public-api,
15
+ behavior,
16
+ layout,
17
+ theming,
18
+ accessibility,
19
+ interaction,
20
+ performance,
21
+ react-runtime,
22
+ ]
23
+ verified_by:
24
+ [
25
+ packages/core/src/Table/plugins/virtualization/useTableVirtualization.test.tsx,
26
+ packages/core/src/Table/__tests__/TableVirtualization.a11y.chromium.spec.ts,
27
+ packages/core/src/Table/Table.perf.test.tsx,
28
+ scripts/check-knowledge.mjs,
29
+ ]
30
+ parent_component: component:Table
31
+ references:
32
+ [
33
+ architecture:component-theming-surface,
34
+ architecture:interaction-modality,
35
+ architecture:public-component-api,
36
+ architecture:react-component-runtime,
37
+ spec:AST-002,
38
+ spec:AST-009,
39
+ spec:AST-020,
40
+ spec:AST-025,
41
+ ]
42
+ ---
43
+
44
+ # useTableVirtualization module contract
45
+
46
+ ## Contract at a glance
47
+
48
+ | Area | Contract |
49
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
50
+ | Public contract | Candidate fixed-height row-windowing module with caller-owned data, one validated stable unique Table row identity, a vertical viewport, row height, and an explicit server-rendered initial range. OQ1 keeps parent-owned phases, a dedicated component, and an atomic props bundle open. Caller-configurable overscan is not admitted unless OQ12 proves a semantic need. |
51
+ | Behavior | Render one contiguous visible row window plus an internally bounded overscan and, when necessary, one separately bounded retained-focus row subtree, while preserving native table structure, full-table row metadata, scroll geometry, stable identity, and supported plugin/core appearance. |
52
+ | End-user impact | Large tables keep bounded DOM and responsive scrolling while preserving supported row state and keyboard focus. The accessibility tree exposes truthful full-table positions for mounted rows; this contract does not claim virtual-cursor discovery of unmounted rows. |
53
+ | Builder impact | Builders opt into a fixed-height contract, identify the vertical viewport, and supply stable row keys. Variable-height content and unsupported plugin combinations remain explicit rather than silently degrading. |
54
+ | Compatibility/readiness | Additive, unreleased virtualization capability; no automatic correction to existing nonvirtualized paginated rowIndexStart/rowCount callers. Any shared correction needs a separately approved Table authority/compatibility decision under OQ9. This draft is not implementation authority; parent integration, viewport, fixed height, SSR, focus, anchoring, plugin/appearance compatibility, and budgets remain owner decisions. |
55
+ | Review checks | Reject invalid table DOM, block rewrites of semantic elements, render-then-hide, mismatched/duplicate/index keys, incomplete full-table ARIA totals or positions, unenforceable fixed height, silent clipping/drift, lost focus, unbounded focus retention, spacer-relative striping/dividers, recycled uncontrolled state, scroll jumps, unsupported combinations that appear to work, or unmeasured performance claims. |
56
+ | Governing rules | `component:Table` owns shared plugin phases and row rendering; its DEC-1 and [`spec:AST-025`](../../../../../../docs/specs/AST-025/spec.md) FR18/FR19 constrain shared viewport ownership. `architecture:react-component-runtime` owns measurement/resource lifetime; `architecture:public-component-api` and `spec:AST-002` own public caller burden. |
57
+
58
+ This table is a review projection; the body below is authoritative.
59
+
60
+ ## Intent
61
+
62
+ `useTableVirtualization` bounds the number of mounted body rows in a large data-driven
63
+ Table while preserving semantic table structure and the caller's row identities. It owns
64
+ visible-range calculation, overscan, spacer geometry, absolute row-position metadata,
65
+ focus retention, supported-plugin composition, resize/scroll coordination, performance
66
+ budgets, and implementation evidence.
67
+
68
+ Row virtualization is distinct from infinite loading and pagination. It limits mounted DOM
69
+ for data the caller already owns; it does not fetch, page, cache, or discard product data.
70
+ Column virtualization is a separate contract.
71
+
72
+ ## Compatibility and migration
73
+
74
+ - Released default preserved: `yes` for the additive virtualization capability; absent opt-in, Table renders every supplied row and this module does not change existing nonvirtualized paginated `rowIndexStart`/`rowCount` output.
75
+ - Compatibility class: additive public capability plus the smallest approved parent integration. Full-table metadata is required for virtualized mode. Any shared correction affecting existing nonvirtualized callers requires a separately approved `component:Table` authority/compatibility decision; this module does not automatically authorize a global correction.
76
+ - Migration decision: `module:Table/useTableVirtualization/DEC-1` after the protocol and interaction questions are approved. OQ9 keeps the metadata integration and public compatibility scope explicit; any migration for existing callers belongs to the separately approved Table decision.
77
+
78
+ Consumer migration instructions belong in consumer docs and release notes.
79
+
80
+ ## Ownership boundary
81
+
82
+ **Owns**
83
+
84
+ - The public virtualization capability and its fixed-height row-window contract, independent of the API shape chosen in OQ1.
85
+ - Visible-range and overscan calculation, top/bottom spacer geometry, and vertical viewport coordination.
86
+ - Stable row identity through window changes and explicit DOM-recycling policy.
87
+ - Virtualized full-table `aria-rowindex`/`aria-rowcount`, focus retention, keyboard/assistive-technology behavior, and evidence; admitted detail-row representation remains with the parent/detail owner.
88
+ - Compatibility declarations for selection, sorting, filtering, activation, expansion/tree/grouping, sticky columns, context menus, and infinite loading.
89
+ - Resize/scroll resource lifetime and measurable performance budgets.
90
+
91
+ **Does not own / non-goals**
92
+
93
+ - Aggregate plugin protocol and shared render phases — owned by `component:Table`.
94
+ - A global correction or migration of existing nonvirtualized paginated `rowIndexStart`/`rowCount` callers; OQ9 requires a separate Table authority/compatibility decision for any such shared change.
95
+ - Expanded detail representation or expansion admission; the parent/detail owner owns the shape, and OQ6 must admit the combination before it is supported.
96
+ - Product data, fetching, caching, pagination, infinite loading, or server cursors.
97
+ - Column virtualization.
98
+ - Variable-height row measurement in v1 unless owner evidence admits it explicitly.
99
+ - Product scroll restoration, route state, or imperative navigation to arbitrary data not owned by the caller.
100
+ - Cell renderer correctness for unstable keys or content that violates the declared fixed-height contract.
101
+ - A new theme target. Spacer geometry is not theme anatomy, and visible rows use existing Table targets.
102
+
103
+ ## Public API and concepts
104
+
105
+ No TypeScript shape is admitted yet. OQ1 chooses parent-owned phases, a dedicated
106
+ component, or one atomic Table-compatible props bundle. Whichever shape is approved,
107
+ the integration MUST:
108
+
109
+ - accept mutable-compatible `T[]` data supported by current `TableProps<T>`;
110
+ - keep data and the exact validated stable unique `idKey` together for window identity and Table reconciliation, so callers cannot accidentally omit or disagree with it;
111
+ - provide one canonical logical offset/count source from which ARIA metadata and `useTableRowIndex` derive their respective positions rather than requiring duplicate caller-maintained offsets; and
112
+ - define duplicate/invalid-key behavior before exposing the capability.
113
+
114
+ The current parent protocol cannot safely express data windowing, semantic spacer rows,
115
+ identity, and full-table metadata without an approved integration; OQ1 owns that boundary.
116
+
117
+ | Concept | Closed values or states | Meaning | Default | Owner | Stability |
118
+ | ------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ------------------------------------- | --------- |
119
+ | Data ownership | Complete caller-owned ordered `T[]` | Supplies the logical dataset from which mounted rows are derived. | Required | Caller through this module | Candidate |
120
+ | Row identity | Validated stable unique `string` or `number` key | Drives both window identity and Table reconciliation; duplicate/invalid keys follow one fail-safe policy. | Required | Parent/module boundary | Unsettled |
121
+ | Row measurement | Positive finite total row block size in CSS pixels | Defines deterministic geometry under an enforceable fixed-height content/appearance mode. | Required in v1 | `module:Table/useTableVirtualization` | Unsettled |
122
+ | Visible range | Contiguous half-open logical index interval | Names the rows intersecting the viewport before internal overscan. | Derived | `module:Table/useTableVirtualization` | Candidate |
123
+ | Focus island | Absent or one separately bounded retained row subtree | Keeps the currently focused row and, only if OQ6 admits it, owned detail mounted without stretching the visible range across the dataset. | Absent without remote focus | `module:Table/useTableVirtualization` | Unsettled |
124
+ | Overscan | Internally bounded non-negative row count | Absorbs ordinary scroll/keyboard movement without becoming a caller tuning API unless OQ12 admits it. | Internal | `module:Table/useTableVirtualization` | Unsettled |
125
+ | Spacer geometry | One or more inaccessible logical-space segments | Preserves full scroll extent around the visible range and optional focus island. | Derived | `module:Table/useTableVirtualization` | Candidate |
126
+ | Initial range | Explicit positive row count or other approved seed | Provides deterministic server/hydration output before a client viewport is measurable. | Unsettled | Caller/module boundary | Unsettled |
127
+ | Recycling policy | Stable keyed mount/unmount; no cross-key DOM reuse | Avoids moving uncontrolled/focused state between logical rows. | No cross-key recycling | `module:Table/useTableVirtualization` | Candidate |
128
+ | Appearance metadata | Absolute logical parity and logical-final-row state | Preserves striping/dividers independently of spacer/window DOM position. | Derived | Parent/module boundary | Unsettled |
129
+
130
+ ## Behavioral contract
131
+
132
+ | ID | Candidate invariant | Basis | Draft review state |
133
+ | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------- |
134
+ | FR1 | Stable callbacks, data identity, viewport, valid row height, initial-range inputs, and unchanged internal overscan preserve stable module output where their observable result is unchanged. | Parent identity and runtime architecture | Settled candidate |
135
+ | FR2 | Mounted data consists of one contiguous visible range plus internally bounded overscan and, when focus would otherwise unmount, at most one separately bounded retained-focus row subtree. Empty data mounts no data/spacer/focus-island row. | Window and focus bounds | Human interaction decision |
136
+ | FR3 | Spacer segments plus mounted row heights equal the full logical body height, including around an optional focus island. The same logical offset before/after a no-op render produces the same visible range without a jump. | Scroll continuity | Settled candidate |
137
+ | FR4 | Native `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<th>`, and `<td>` semantics remain valid. Spacer representation is absent from accessibility semantics and does not require block/absolute/role rewrites of structural table elements. | HTML and accessibility boundary | Architecture decision pending |
138
+ | FR5 | Virtualized full-table metadata counts the complete logical table, not just mounted rows, and absolutely indexes represented header/data rows and, if OQ6 admits expansion, every represented semantic detail row. Inaccessible spacers count as neither. The parent/detail owner defines the logical sequence and detail positions; semantic detail rows cannot be silently omitted. Without detail rows, data indices are `headerRowCount + logicalDataIndex + 1` and the total is `headerRowCount + logicalDataRowCount`; these formulas cannot omit admitted semantic detail rows. OQ9 governs integration and any separately approved shared compatibility correction. | WAI-ARIA row metadata; parent/detail ownership and compatibility | Parent API and detail admission decisions pending |
139
+ | FR6 | One validated stable unique canonical key drives both window identity and Table reconciliation. The approved integration makes that key inseparable from its data regardless of API shape. Duplicate/invalid keys disable virtualization and fall back to full rendering in all builds, with one development warning. | React identity and fail-safe behavior | Parent/API decision required |
140
+ | FR7 | A focused row that leaves the ordinary window becomes the one bounded focus island rather than stretching the contiguous range or moving focus to the document body. A focused owned detail subtree participates only if OQ6 admits its representation, metadata, and focus behavior. The island is released on blur, removal, or return to the ordinary range. | Keyboard/focus correctness | Human interaction and detail admission decisions |
141
+ | FR8 | Sorting/filtering/data replacement recompute current logical order and reconcile scroll/focus anchors according to one explicit policy. Stale geometry or prior indices cannot expose another row under a retained key. | Controlled data and identity | Scroll-anchor policy unsettled |
142
+ | FR9 | Sticky columns, selection, sorting, filtering, context menus, and `useTableRowIndex` preserve state/meaning for mounted rows. One parent logical-offset source drives ARIA and visible row numbering. Activation, expansion, tree, grouping, infinite loading, and custom rows are explicit compatibility decisions in OQ6; an unapproved activation contract is not an existing Table capability or a promise of supported composition. | Plugin composition | Compatibility matrix decision pending |
143
+ | FR10 | Server and first client render the same explicit initial range. Hydration does not render the full dataset then collapse it or render an empty body and repair it in an Effect. | SSR/hydration and runtime architecture | Initial-range policy unsettled |
144
+ | FR11 | Viewport resize, row-height/data-length change, and scroll events update the range without synchronous layout read/write loops. Stable no-op renders do not reconnect resources or reset scroll. | Runtime/performance boundary | Settled candidate |
145
+ | FR12 | Imperative scrolling, if admitted, targets a logical canonical key/index with defined alignment and unavailable-row behavior. It remains separate from activation, focus, navigation, and fetching. | Public responsibility boundary | Human API decision |
146
+ | FR13 | Completion proves bounded mounted data rows and recorded frame/render/memory budgets. Hiding offscreen mounted rows or rendering all rows before slicing is not virtualization. | Performance outcome | Settled candidate |
147
+ | FR14 | V1 accepts only a positive finite total row block size and an owner-approved fixed-height Table mode that defines text overflow, density/theme padding, custom cell content, interactive controls, plugin output, precedence, overflow detection, and explicit rejection/fallback. Invalid height disables virtualization and falls back to full rendering. | Enforceable geometry contract | Human API/design decision |
148
+ | FR15 | If striping or row dividers are supported, their parity/final-row treatment derives from absolute logical row metadata, not spacer-relative `:nth-child`/`:last-child`; middle windows match the equivalent full Table. Otherwise the options are explicitly rejected. | Core Table appearance compatibility | Parent appearance decision |
149
+ | FR16 | Accessibility-tree claims cover truthful metadata for mounted rows only. This contract does not claim that virtual-cursor users can discover unmounted rows; any admitted AT navigation/announcement outcome requires its own interaction and `spec:AST-009` matrix. | Evidence boundary | Settled limitation |
150
+
151
+ ### Transformation and precedence order
152
+
153
+ - **ORD1 — Logical data and identity.** Approved sorting/filtering/data-order owners establish the logical `T[]`; the parent validates one canonical stable unique key and exposes one logical offset/count source. Any admitted semantic detail rows follow the parent/detail owner's sequence and position contract in FR5.
154
+ - **ORD2 — Admission inputs.** Validate viewport, positive finite row block size, approved fixed-height mode/content, dataset length, initial range, and internal overscan before calculating a window. Invalid identity/height falls back to full rendering.
155
+ - **ORD3 — Mounted set.** Derive the contiguous visible range from current scroll geometry, expand by internal overscan, and add at most one separately bounded focus island when required.
156
+ - **ORD4 — Semantic body.** Render inaccessible spacer segments, keyed data rows, any admitted semantic detail rows, and any focus island with FR5 full-table indices through the approved parent integration, regardless of API shape. Preserve valid table semantics and exclude spacers, not semantic detail rows, from row counts.
157
+ - **ORD5 — Element plugins.** Existing row/cell transforms run only for mounted logical data/focus-island rows unless their current contracts explicitly include spacer anatomy; `useTableRowIndex` consumes the same logical offset as ARIA metadata.
158
+ - **ORD6 — Core appearance.** Virtualized row rendering receives absolute logical parity/final-row metadata before striping/divider styles resolve, so spacers and window edges do not alter appearance.
159
+ - **ORD7 — State changes.** Data/order/height/viewport changes apply the approved anchor policy before exposing the next range; stale work cannot overwrite current geometry.
160
+ - **ORD8 — Parent protocol.** The module does not privately bypass BaseTable. Any new data/body/identity/appearance phase belongs to `component:Table` and must be approved before implementation; OQ1 does not require a phase-shaped API.
161
+
162
+ ### Performance and resources
163
+
164
+ - **PR1 — Bounded DOM.** Mounted data is bounded by visible rows plus internal overscan and at most one separately bounded focus-retained row subtree, independent of total dataset length.
165
+ - **PR2 — Fixed-height arithmetic.** Correct range/geometry behavior uses the validated declared block size and viewport geometry and does not require per-row measurement or observer fan-out; diagnostics/fallback for contract violations remain an OQ10 outcome.
166
+ - **PR3 — One viewport subscription.** At most one scroll subscription and one resize/viewport observer exist per mounted instance; updates are scheduled without more than one pending frame of range work.
167
+ - **PR4 — Stable no-op.** Unchanged inputs and a scroll offset that remains within the current effective range do not rebuild every row or reset stable module output identity.
168
+ - **PR5 — Measured budgets.** Implementation records mounted row count, initial render cost, no-op rerender cost, scroll-frame work, memory trend, and supported-plugin overhead at representative 1k, 10k, and 100k logical rows.
169
+ - **PR6 — Cleanup.** Unmount and viewport replacement release listeners, observers, animation-frame work, retained elements, and stale geometry.
170
+
171
+ ## Accessibility contract
172
+
173
+ - **AR1 — Semantic table.** Native table structure and header/cell associations remain intact; virtualization does not convert Table into an ARIA grid or generic block list.
174
+ - **AR2 — Full-table position exposure.** The browser accessibility tree exposes each represented semantic row's absolute full-table `aria-rowindex` and full-table `aria-rowcount` under FR5, including represented detail rows only if OQ6 admits expansion. Positions never use window-relative ordinals, and inaccessible spacers count as neither. This is a computed-semantics claim, not a promise that unmounted rows are discoverable by virtual cursor.
175
+ - **AR3 — Hidden spacers.** Spacer geometry has no accessible row/cell content, name, selection, current state, or focus stop.
176
+ - **AR4 — Focus retention.** One focused row may remain as the separately bounded focus island through range shifts; an owned detail subtree participates only under OQ6's admitted representation, counts, indices, positions, and focus contract. The island is released on blur/removal/return and never causes an unbounded range or silent focus move to the document body.
177
+ - **AR5 — Keyboard traversal.** Supported Table controls and row interactions remain reachable without requiring pointer scrolling or exposing thousands of tab stops.
178
+ - **AR6 — Announcements.** Scrolling does not generate live-region noise. Any explicit “row X of Y” claim uses the semantic row metadata rather than repeated status messages.
179
+ - **AR7 — Zoom, reflow, and fixed-height admission.** At supported zoom/text settings, the approved fixed-height mode cannot silently clip required content. Content or plugin output outside that mode follows the explicit rejection/full-render fallback chosen in OQ10.
180
+ - **AR8 — Evidence boundary.** DOM/accessibility-tree and real-browser keyboard tests prove static semantics, focus, and computed full-table metadata. Real AT is added only if an approved outcome depends on announcement, virtual-cursor advancement/discovery, or a recorded divergence under `spec:AST-009`; no such unmounted-row discovery claim exists in this draft.
181
+
182
+ ## Design relationships
183
+
184
+ | Anatomy or state | Design requirement | Representation authority | Module contract |
185
+ | ---------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
186
+ | Mounted data row | Looks and behaves like the same logical row in a non-virtualized Table. | Existing row/cell targets and supported plugins | FR2-FR16, AR1-AR8 |
187
+ | Spacer segments | Preserve geometry without becoming visible/accessibility data anatomy. | This module; not a public theme target | FR3-FR5, AR1-AR3 |
188
+ | Vertical viewport | Preserves Table's shared scroll owner; nested owners obey nearest-effective-axis ownership and default chaining. | `component:Table/DEC-1` and [`spec:AST-025`](../../../../../../docs/specs/AST-025/spec.md) FR18/FR19; integration pending OQ2 | FR3, FR11, PR3 |
189
+ | Focus island | Keeps at most one focused row subtree mounted outside the ordinary window; detail participates only if OQ6 admits it. | This module plus the focused descendant/detail owner | FR2, FR5, FR7, AR2, AR4-AR5 |
190
+ | Logical row appearance | Preserves striping parity and final-row dividers from absolute logical metadata. | Parent Table appearance plus this module | FR15, ORD6 |
191
+ | Unsupported tall row | Uses the explicit rejection/full-render fallback rather than clipping under a fixed-height promise. | Parent/module boundary pending OQ10 | FR14, AR7 |
192
+
193
+ The module adds no new theming target. Data rows retain current Table targets. Spacer geometry
194
+ is structural implementation detail and cannot accept arbitrary theme paint or sizing.
195
+
196
+ ## Parent and system relationships
197
+
198
+ - `component:Table` owns BaseTable's data/render phases, semantic body/header construction, canonical key reconciliation, row memoization, plugin ordering, core striping/divider appearance, existing body-relative `rowIndexStart`/`rowCount` behavior, and stable expansion/detail representation. This module requires an approved window/spacer/identity/appearance integration with FR5 full-table metadata. OQ9 keeps any shared correction for existing nonvirtualized paginated callers outside the additive module until a separate Table authority/compatibility decision approves it.
199
+ - `component:Table/DEC-1` keeps one Table-owned viewport with shared scroll behavior on the existing Scroll region and a private observed content box. [`spec:AST-025`](../../../../../../docs/specs/AST-025/spec.md) FR18 makes the nearest effective owner on the applicable axis win, permits orthogonal nesting, and requires deliberate same-axis nesting with chaining by default. FR19 lets Table integrate on its existing wrapper so column sizing, keyboard access, scroll shadows, sticky columns, and sticky headers share one viewport; it does not require a separate public ScrollableArea. An external owner is valid only when Table explicitly accepts and coordinates it. OQ2 chooses integration within these constraints, not a parallel scroll owner or overflow detector.
200
+ - `architecture:react-component-runtime` owns scroll/resize resource lifetime, current values, scheduling, node replacement, StrictMode replay, and cleanup.
201
+ - `architecture:public-component-api` and `spec:AST-002` require intent-named inputs and reject leaking an internal virtualizer or tuning knob without proven caller need.
202
+ - `spec:AST-009` and `spec:AST-020` own the evidence boundary. This draft claims computed accessibility-tree metadata for mounted rows, not AT discovery of unmounted rows.
203
+ - `useTableRowIndex` and every supported state plugin consume the same parent-owned canonical identity/logical offset rather than a caller-maintained duplicate. Expansion/tree/grouping/infinite-loading/custom-row behavior composes only if its owning current contract and OQ6 explicitly admit it; semantic detail rows then participate in FR5 counts/indices/positions without transferring representation ownership to virtualization.
204
+
205
+ ## Verification map
206
+
207
+ | Contract | Verification | Representative states | Mutation or failure expectation |
208
+ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
209
+ | FR1-FR6, FR14-FR15, ORD1-ORD6 | Deterministic range/geometry/identity/appearance tests plus Table renders inspecting native DOM, keys, spacer semantics, full-table ARIA, striping, and dividers | Empty; first/middle/last window; focus island; duplicate keys; invalid height; striped/divided; admitted detail rows | Visible rows drop, geometry drifts, DOM is invalid, keys recycle, ARIA excludes represented headers/detail rows, fallback fails, or appearance shifts. |
210
+ | FR7-FR9, FR12, FR16, AR1-AR8 | Real-browser keyboard/accessibility-tree tests for scrolling, focus island, data replacement, zoom, controls, row index, and approved plugins | Focus in row/control; detail only if admitted; offscreen scroll; sort/filter; sticky/selection/activation/index | Focus/body/semantics/state/numbering fail, admitted detail counts/indices/positions are wrong, unmounted discovery is overclaimed, clipping is silent, or unsupported combos appear valid. |
211
+ | FR10, SSR/hydration | Server markup plus hydration tests at explicit initial ranges and viewport changes | Empty, smaller/larger than initial, changed client viewport | Full data renders then collapses, markup mismatches, initial rows disappear, or an Effect repairs known-wrong output. |
212
+ | FR11, PR2-PR4, PR6; OQ2 | Fake viewport/scroll/resize harness under StrictMode, no-op rerenders, viewport replacement, unmount, and rapid scroll, plus real-browser shared-owner and chaining integration | Stable, changed and detached viewport; repeated scroll events; orthogonal/deliberate same-axis nesting; sticky columns/headers | Resources leak/churn, stale work commits, scroll resets, synchronous layout loops occur, owners diverge, an uncoordinated external owner is accepted, or chaining fails. |
213
+ | FR13, PR1, PR5 | Performance suite and browser trace recording visible/overscan/focus-island DOM, commits, frame work, and memory at 1k/10k/100k logical rows | Core-only and every supported plugin/appearance combination | Mounted rows scale with total data, focus retention is unbounded, budgets regress, or claimed support adds unbounded work. |
214
+ | Public API and docs | Public-subpath compile/contract fixtures for the OQ1-approved phases, component, or atomic props bundle; `.doc.mjs`, generated inventory, Storybook, and fixed-height recipes | Mutable-compatible `T[]`, unique key, viewport, initial range, fixed-height mode, support matrix | Caller can omit/disagree on identity, readonly data mismatches Table, tuning leaks without admission, or docs imply unsupported behavior. |
215
+ | Compatibility scope; OQ9 | Representative nonvirtualized paginated `rowIndexStart`/`rowCount` fixtures alongside virtualized full-table metadata fixtures | Existing first/later pages and unknown totals; virtualized header/data and any admitted detail sequence | Additive virtualization silently changes existing callers or a shared correction proceeds without separate Table authority/compatibility approval. |
216
+ | Structural ownership | `pnpm check:knowledge` | Draft module and parent backlink | A missing, orphaned, misnamed, wrong-kind, or duplicate module record passes validation. |
217
+
218
+ ## Decision log
219
+
220
+ ### DEC-1 — V1 is fixed-height row virtualization
221
+
222
+ **Reference:** `module:Table/useTableVirtualization/DEC-1`
223
+ **Decider:** pending owner approval
224
+
225
+ V1 must prove bounded DOM and semantic correctness from one positive finite total row block size under an owner-approved fixed-height Table mode. That mode defines text overflow, density/theme padding, custom content/controls, plugin output, precedence, diagnostics, and full-render fallback. Variable-height measurement remains a separate contract.
226
+
227
+ Rejected: silently measuring some rows, assuming an average height, clipping arbitrary Table content without an admitted mode, and calling hidden or `content-visibility` rows virtualized while they remain mounted.
228
+
229
+ ### DEC-2 — Preserve native table semantics
230
+
231
+ **Reference:** `module:Table/useTableVirtualization/DEC-2`
232
+ **Decider:** pending owner approval
233
+
234
+ Virtualization must use valid semantic table structure and FR5 full-table metadata: represented headers and data are counted/indexed, as are represented semantic detail rows if OQ6 admits expansion; inaccessible spacers are excluded. The parent/detail owner defines detail shape and positions. Reuse of existing body-relative `rowIndexStart`/`rowCount` is unresolved in OQ9; any shared correction affecting nonvirtualized paginated callers requires separate Table authority/compatibility approval, not automatic correction by this additive module. Structural elements remain native rather than block-positioned or role-replaced.
235
+
236
+ Rejected: incomplete full-table totals, `display:block` on `<table>/<tbody>/<tr>`, absolutely positioned generic row divs, and spacer rows announced as data.
237
+
238
+ ### DEC-3 — Stable keys; no cross-row DOM recycling
239
+
240
+ **Reference:** `module:Table/useTableVirtualization/DEC-3`
241
+ **Decider:** pending owner approval
242
+
243
+ V1 uses one validated stable unique canonical key for both window identity and Table reconciliation. The approved integration makes that key inseparable from its data whether OQ1 selects parent-owned phases, a dedicated component, or an atomic props bundle. Duplicate/invalid keys disable virtualization and fall back to normal full rendering in every build, with one development warning. Rows outside the approved mounted set may unmount, but no keyed DOM/component instance is reused for another logical key.
244
+
245
+ Rejected: separate window/Table extractors, optional identity that falls back to window-local indices, duplicate keys that recycle multiple rows, and recycling uncontrolled input, focus, or component state across logical rows.
246
+
247
+ ## Open questions
248
+
249
+ - **OQ1 — Which parent integration represents windowing, identity, metadata, and spacers?** (`human-api`) Choose parent-owned phases, a dedicated component, or one atomic Table-compatible props bundle. Whichever shape is chosen must keep mutable-compatible `T[]`, the exact validated unique `idKey`, logical offset/count, and semantic spacer ownership together so callers cannot omit or disagree with identity/metadata. No hook or return-object shape is admitted by this question.
250
+ - **OQ2 — Who owns the vertical viewport?** (`human-api`) Decide a Table-owned block viewport versus a caller-supplied ref explicitly accepted and coordinated by Table, subject to `component:Table/DEC-1` and [`spec:AST-025`](../../../../../../docs/specs/AST-025/spec.md) FR18/FR19. Preserve the shared scroll owner for column sizing, keyboard access, scroll shadows, sticky columns, and sticky headers; no separate public ScrollableArea is required. The nearest effective owner on the applicable axis wins; orthogonal nesting may coexist, and any same-axis nesting is deliberate in Table's contract and preserves chaining by default. An arbitrary external wrapper does not satisfy ownership without explicit Table coordination; do not create a parallel owner or overflow detector.
251
+ - **OQ3 — What is the initial SSR range?** (`human-api`) Decide whether an initial row count is required, has a documented default, or derives from explicit server-known geometry. Full-render-then-collapse and empty-then-repair are rejected.
252
+ - **OQ4 — How is the bounded focus island represented?** (`human-design`) Define at most one discontiguous retained row and, only if OQ6 admits it, its owner-defined detail subtree, spacer segmentation, release on blur/removal/return, scroll/focus behavior, and what happens when another off-window row receives focus. Detail shape remains the parent/detail owner's, not virtualization's.
253
+ - **OQ5 — What happens when data order changes?** (`human-api`) Decide start-offset, focused/active-key, first-visible-key, or another scroll-anchor policy for sorting, filtering, insertion, deletion, and replacement.
254
+ - **OQ6 — Which plugins and core appearance modes compose in v1?** (`human-api`) Confirm sticky columns, selection, sorting, filtering, context menus, and `useTableRowIndex` with one logical offset. Decide whether activation, expansion, tree, grouped rows, infinite loading, custom rows, striping, and each divider mode are supported, constrained, or rejected with dedicated evidence. Activation requires its own approved current module contract before composition can be admitted; an open draft supplies neither capability nor authority. Expansion admission requires the parent/detail owner to define detail representation, full-table row counts, absolute indices and positions for every represented semantic detail row, fixed-height geometry compatibility, and bounded focus-island behavior under OQ4. Neither silently omitting semantic detail rows nor mentioning retained detail admits expansion.
255
+ - **OQ7 — Is imperative `scrollToRow` public in v1?** (`human-api`) Admit it only if callers need an operation beyond setting scroll position, and define canonical key/index, alignment, unavailable-row, focus, and data-not-loaded behavior.
256
+ - **OQ8 — What objective budgets gate completion?** (`human-api`) Approve mounted-row, focus-island, render, frame, and memory budgets at representative data sizes so “smooth” cannot substitute for evidence.
257
+ - **OQ9 — How does the parent integrate full-table ARIA metadata, and what is the public compatibility scope?** (`human-api`) Define represented header indices/count, data offsets, any OQ6-admitted semantic detail counts/indices/positions, and spacer exclusion. Decide whether a virtualized-only metadata path is sufficient or a shared correction is necessary before reusing body-relative `rowIndexStart`/`rowCount`. Any shared correction affecting existing nonvirtualized paginated callers requires a separately approved Table authority/compatibility decision that defines their output, docs, and migration. This additive module does not choose or authorize that global correction.
258
+ - **OQ10 — What exactly constitutes fixed-height mode?** (`human-api`) Define positive-finite validation, total block-size semantics, `textOverflow`, density/theme padding precedence, allowed custom renderers/interactive content/plugins, overflow diagnostics, and the explicit rejection or full-render fallback. Native row height is a minimum, so a numeric prop alone is insufficient.
259
+ - **OQ11 — How are striping and dividers made logical-position-aware?** (`human-design`) If supported, replace spacer-relative `:nth-child`/`:last-child` meaning with parent-supplied absolute parity/final-data-row state while preserving ordinary non-virtualized CSS behavior and middle-window pixels.
260
+ - **OQ12 — Is caller-configurable overscan admitted?** (`human-api`) Keep overscan internal unless two non-derivable caller situations prove an API need under `spec:AST-002`; if admitted, define semantic outcome, bounds, invalid values, default, and decision burden rather than exposing an engine-tuning knob.
261
+
262
+ ## Content boundary
263
+
264
+ This record does not duplicate consumer signatures/examples, the parent Table pipeline,
265
+ general virtualizer algorithms, current usage audits, implementation steps, data-fetching,
266
+ column virtualization, or variable-height contracts. It links to their canonical owners.
@@ -166,23 +166,23 @@ pending.
166
166
 
167
167
  ### Representative states
168
168
 
169
- | State | Required invariant | Allowed variation |
170
- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
171
- | Empty field | The mounted inner input is the opening control; a full-width menu stays aligned to the outer field. | Placeholder and bootstrap results may vary. |
172
- | Focus bootstrap, choices remain | A committed selection is removed from the displayed cohort; the input and popup remain active on the next valid remaining choice. | Internal cohort storage and projection may vary. |
173
- | Focus bootstrap, selected choice was last | The nearest preceding remaining choice becomes active without changing the displayed order. | Option identity representation may vary. |
174
- | Focus bootstrap, no choices or max reached | The popup closes and no active descendant remains; pending work cannot reopen it. | Source cancellation mechanism may vary. |
175
- | Source pending or replaced | Busy semantics identify pending work; stale work cannot add choices or reopen a terminal popup. | A settled current cohort may be retained internally. |
176
- | Disabled | No choice can be committed; an open popup closes and clears its active descendant while disabled-reason focusability remains available where configured. | Native-disabled or focusable-disabled representation follows current Tokenizer behavior. |
177
- | One or many tokens | The same input remains after tokens and moves with wrapping; full-width placement does not follow that movement. | Token labels, wrapping rows, and custom content may vary. |
178
- | At `maxEntries` | The input identity remains mounted but its zero-width/hidden box is unusable as geometry; opening falls back to the outer field. | Search remains unavailable under current behavior. |
179
- | Token removal or clear | Input identity survives and current refocus behavior remains. | Token count and input position change. |
180
- | `unfocusedInline` | Blurred OverflowList and focused direct-token layouts resolve the same live input identity; a collapsed input is not used as geometry. | Visible token count may vary with width. |
181
- | `unfocusedLayer`, focused | The live wrapper/input may be reparented into the context layer without descriptor churn. | Corrective portal location may vary. |
182
- | `unfocusedLayer`, unfocused | The hidden-layer input is unusable for a new open; the ordinary-flow outer-field fallback is used. | Placeholder token count may vary. |
183
- | Default or custom token edit | The actual activated token/control is captured before edit state changes; if no usable descendant control exists, input then outer-field fallback applies. | Custom token markup may vary. |
184
- | LTR or RTL | Physical proximity selects an edge only for a narrower surface; logical start wins ties and full-width placement. | Collision may move the final rendered surface. |
185
- | SSR, hydration, or unmount | No server geometry read occurs; first client open prepares normally; unmount clears identities and pending show work. | Hydration timing may vary. |
169
+ | State | Required invariant | Allowed variation |
170
+ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
171
+ | Empty field | The mounted inner input is the opening control; a full-width menu stays aligned to the outer field. | Placeholder and bootstrap results may vary. |
172
+ | Focus bootstrap, choices remain | A committed selection is removed from the displayed cohort; the input and popup remain active on the next valid remaining choice. | Internal cohort storage and projection may vary. |
173
+ | Focus bootstrap, selected choice was last | The nearest preceding remaining choice becomes active without changing the displayed order. | Option identity representation may vary. |
174
+ | Focus bootstrap, no choices or max reached | The popup closes and no active descendant remains; pending work cannot reopen it. | Source cancellation mechanism may vary. |
175
+ | Source pending or replaced | Busy semantics identify pending work; stale work cannot add choices or reopen a terminal popup. | A settled current cohort may be retained internally. |
176
+ | Disabled | No choice can be committed; an open popup closes and clears its active descendant while disabled-reason focusability remains available where configured. | The disabled representation follows `spec:AST-085`. |
177
+ | One or many tokens | The same input remains after tokens and moves with wrapping; full-width placement does not follow that movement. | Token labels, wrapping rows, and custom content may vary. |
178
+ | At `maxEntries` | The input identity remains mounted but its zero-width/hidden box is unusable as geometry; opening falls back to the outer field. | Search remains unavailable under current behavior. |
179
+ | Token removal or clear | Input identity survives and current refocus behavior remains. | Token count and input position change. |
180
+ | `unfocusedInline` | Blurred OverflowList and focused direct-token layouts resolve the same live input identity; a collapsed input is not used as geometry. | Visible token count may vary with width. |
181
+ | `unfocusedLayer`, focused | The live wrapper/input may be reparented into the context layer without descriptor churn. | Corrective portal location may vary. |
182
+ | `unfocusedLayer`, unfocused | The hidden-layer input is unusable for a new open; the ordinary-flow outer-field fallback is used. | Placeholder token count may vary. |
183
+ | Default or custom token edit | The actual activated token/control is captured before edit state changes; if no usable descendant control exists, input then outer-field fallback applies. | Custom token markup may vary. |
184
+ | LTR or RTL | Physical proximity selects an edge only for a narrower surface; logical start wins ties and full-width placement. | Collision may move the final rendered surface. |
185
+ | SSR, hydration, or unmount | No server geometry read occurs; first client open prepares normally; unmount clears identities and pending show work. | Hydration timing may vary. |
186
186
 
187
187
  ### Transformation and precedence order
188
188
 
@@ -282,8 +282,8 @@ consumer docs, and Changeset together. Exact-head browser evidence must exercise
282
282
  next remaining option active.
283
283
  2. The fallback when the committed option was last, plus terminal closure for no
284
284
  remaining choice and `maxEntries`.
285
- 3. Escape, Tab or outside focus departure, transition to native-disabled and
286
- focusable-disabled states, and pending or stale source settlement without a
285
+ 3. Escape, Tab or outside focus departure, transition to the disabled state,
286
+ and pending or stale source settlement without a
287
287
  later reopen.
288
288
  4. The unchanged path when `hasEntriesOnFocus` is off and direct single-select
289
289
  BaseTypeahead selection still closes.
@@ -297,15 +297,15 @@ become Tokenizer policy.
297
297
 
298
298
  ## Verification map
299
299
 
300
- | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
301
- | ---------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
302
- | FR1–FR3 | Existing Tokenizer/BaseTypeahead unit tests plus source inspection | empty, tokens, remove, clear, max, wrapping, both overflow modes | Input remounts, suggestion menu changes anchor/width semantics, or overflow ownership is misstated. | `audit:Tokenizer/current` |
303
- | FR4–FR8 | Focused descriptor identity/capture tests when implemented | add, default/custom token edit, max, hidden layer, reparent, stale node, unmount, SSR | Descriptor leaks publicly, captures a transient result/pointer, misses custom controls, or uses unusable geometry. | `audit:Tokenizer/opening-anchor` |
304
- | FR9–FR12 | Layer unit tests and real-Chromium geometry checks when implemented | full/equal/narrow surface, start/end/tie, LTR/RTL, collision, stale fallback | Full-width follows inner input, tie is physical, collision runs early, or stale identity flashes. | `audit:Tokenizer/placement` |
305
- | FR13–FR15, AR5 | Focused Tokenizer/BaseTypeahead tests and exact-head Chromium interaction evidence | pointer/keyboard commit, first/middle/last choice, rejected/deferred control, loading, disabled, max, exhaustion, Escape, Tab, outside focus | Selected values remain available, order changes, focus leaves the combobox, active descendant names no option, stale work reopens, or a terminal state stays interactive. | `audit:Tokenizer/multi-select` |
306
- | PR1–PR5 | Instrumented reads, pending-frame cleanup tests, and first-frame Chromium capture | open, close before frame, reopen opposite side, resize while open | Render-time/repeated reads, observer/pointer tracking, uncancelled frame, or first-frame jump occurs. | `audit:Tokenizer/performance` |
307
- | AR1–AR5 | Existing interaction suites plus focused pointer/keyboard/browser checks | add/edit, focus/refocus, overflow, disabled, dismissal, retained bootstrap selection | Modality changes placement, semantics/focus regress, active descendant becomes invalid, or visible placement is wrong before fallback. | `audit:Tokenizer/accessibility` |
308
- | Record structure | `scripts/check-knowledge.mjs` | Template-v3 current record and current-only frontmatter links | Invalid metadata, authority, or relationship references fail repository validation. | `audit:Tokenizer/knowledge` |
300
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
301
+ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
302
+ | FR1–FR3 | Existing Tokenizer/BaseTypeahead unit tests plus source inspection | empty, tokens, remove, clear, max, wrapping, both overflow modes | Input remounts, suggestion menu changes anchor/width semantics, or overflow ownership is misstated. | `audit:Tokenizer/current` |
303
+ | FR4–FR8 | Focused descriptor identity/capture tests when implemented | add, default/custom token edit, max, hidden layer, reparent, stale node, unmount, SSR | Descriptor leaks publicly, captures a transient result/pointer, misses custom controls, or uses unusable geometry. | `audit:Tokenizer/opening-anchor` |
304
+ | FR9–FR12 | Layer unit tests and real-Chromium geometry checks when implemented | full/equal/narrow surface, start/end/tie, LTR/RTL, collision, stale fallback | Full-width follows inner input, tie is physical, collision runs early, or stale identity flashes. | `audit:Tokenizer/placement` |
305
+ | FR13–FR15, AR5 | Focused Tokenizer/BaseTypeahead tests and exact-head Chromium interaction evidence | pointer/keyboard commit, first/middle/last choice, rejected/deferred control, loading, disabled, max, exhaustion, Escape, Tab, outside focus | Selected values remain available, order changes, focus leaves the combobox, active descendant names no option, stale work reopens, or a terminal state stays interactive. | `audit:Tokenizer/multi-select` |
306
+ | PR1–PR5 | Instrumented reads, pending-frame cleanup tests, and first-frame Chromium capture | open, close before frame, reopen opposite side, resize while open | Render-time/repeated reads, observer/pointer tracking, uncancelled frame, or first-frame jump occurs. | `audit:Tokenizer/performance` |
307
+ | AR1–AR5 | Existing interaction suites plus focused pointer/keyboard/browser checks; native-disabled assertions pending conformance (`spec:AST-085`) | add/edit, focus/refocus, overflow, disabled, dismissal, retained bootstrap selection | Modality changes placement, semantics/focus regress, active descendant becomes invalid, or visible placement is wrong before fallback. | `audit:Tokenizer/accessibility` |
308
+ | Record structure | `scripts/check-knowledge.mjs` | Template-v3 current record and current-only frontmatter links | Invalid metadata, authority, or relationship references fail repository validation. | `audit:Tokenizer/knowledge` |
309
309
 
310
310
  ## Decision log
311
311
 
@@ -0,0 +1,74 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file TooltipHoverBridge.a11y.chromium.spec.ts
5
+ * @input The `Core/Tooltip` Default story in a built Storybook and real mouse
6
+ * input.
7
+ * @output Real-engine proof that a pointer moving from the trigger onto the
8
+ * tooltip in one step keeps the tooltip open (WCAG 2.2 1.4.13 Content on
9
+ * Hover or Focus, hoverable), and that leaving the tooltip still hides it.
10
+ * @position Run with `pnpm test:a11y-contract`. The defect lives in the order
11
+ * the engine dispatches mouseout and mouseleave on one pointer step, which
12
+ * only a real engine produces.
13
+ */
14
+
15
+ import {expect, test, type Page} from '@playwright/test';
16
+ import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
17
+ import {
18
+ DEFAULT_STORYBOOK_DIR,
19
+ serveStorybook,
20
+ type StaticServer,
21
+ } from '@astryxdesign/a11y-spec/storybook';
22
+
23
+ let storybook: StaticServer;
24
+
25
+ test.beforeAll(async () => {
26
+ storybook = await serveStorybook(
27
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
28
+ );
29
+ });
30
+
31
+ test.afterAll(async () => {
32
+ await storybook?.close();
33
+ });
34
+
35
+ const STORY = 'core-tooltip--default';
36
+
37
+ async function openTooltip(page: Page) {
38
+ await page.setViewportSize({width: 900, height: 600});
39
+ await page.goto(`${storybook.origin}/iframe.html?id=${STORY}&viewMode=story`);
40
+ await holdMotionStill(page);
41
+ const trigger = page.getByRole('button', {name: 'Hover me'});
42
+ await trigger.hover();
43
+ const tooltip = page.getByRole('tooltip');
44
+ await expect(tooltip).toBeVisible();
45
+ const t = await trigger.boundingBox();
46
+ const s = await tooltip.boundingBox();
47
+ if (t == null || s == null) {
48
+ throw new Error('the trigger or the tooltip has no layout box');
49
+ }
50
+ return {tooltip, s};
51
+ }
52
+
53
+ test('a one-step move from the trigger onto the tooltip keeps it open', async ({
54
+ page,
55
+ }) => {
56
+ const {tooltip, s} = await openTooltip(page);
57
+
58
+ // One mousemove event straight onto the tooltip, as a quick hand makes.
59
+ await page.mouse.move(s.x + s.width / 2, s.y + s.height / 2);
60
+ // Well past the 100ms hover bridge.
61
+ await page.waitForTimeout(400);
62
+
63
+ await expect(tooltip).toBeVisible();
64
+ });
65
+
66
+ test('leaving the tooltip for the page still hides it', async ({page}) => {
67
+ const {tooltip, s} = await openTooltip(page);
68
+
69
+ await page.mouse.move(s.x + s.width / 2, s.y + s.height / 2);
70
+ await page.waitForTimeout(200);
71
+ await page.mouse.move(5, 5);
72
+
73
+ await expect(tooltip).toBeHidden();
74
+ });
@@ -214,6 +214,29 @@ describe('useTooltip — hide delay', () => {
214
214
  expect(onHide).toHaveBeenCalledTimes(1);
215
215
  });
216
216
 
217
+ it('stays open when the pointer crosses onto the tooltip in one step', () => {
218
+ const onHide = vi.fn();
219
+ showTooltip(onHide);
220
+ const surface = tooltipElement();
221
+
222
+ // A one-step crossing dispatches in browser order: the trigger's mouseout
223
+ // (React derives the surface's onMouseEnter from it), then the trigger's
224
+ // native mouseleave, both pointing at the surface. fireEvent.mouseLeave
225
+ // would add a mouseout after the leave, so the leave is dispatched raw.
226
+ fireEvent.mouseOut(trigger(), {relatedTarget: surface});
227
+ act(() => {
228
+ trigger().dispatchEvent(
229
+ new MouseEvent('mouseleave', {relatedTarget: surface}),
230
+ );
231
+ });
232
+ tick(500);
233
+ expect(onHide).not.toHaveBeenCalled();
234
+
235
+ fireEvent.mouseOut(surface, {relatedTarget: document.body});
236
+ tick(100);
237
+ expect(onHide).toHaveBeenCalledTimes(1);
238
+ });
239
+
217
240
  it('uses an explicit hideDelay instead of the hover bridge', () => {
218
241
  const onHide = vi.fn();
219
242
  showTooltip(onHide, {hideDelay: 500});
@@ -397,15 +397,28 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn {
397
397
  scheduleShow();
398
398
  }, [isTouchPointerRef, scheduleShow]);
399
399
 
400
- const handleMouseLeave = useCallback(() => {
401
- // On touch the synthesized mouseleave arrives with the next tap elsewhere,
402
- // which the outside-tap dismissal already handles — and handling it here
403
- // too would close the tooltip behind the tap-open bookkeeping's back.
404
- if (isTouchPointerRef.current) {
405
- return;
406
- }
407
- scheduleHide();
408
- }, [isTouchPointerRef, scheduleHide]);
400
+ const handleMouseLeave = useCallback(
401
+ (event: Event) => {
402
+ // On touch the synthesized mouseleave arrives with the next tap elsewhere,
403
+ // which the outside-tap dismissal already handles — and handling it here
404
+ // too would close the tooltip behind the tap-open bookkeeping's back.
405
+ if (isTouchPointerRef.current) {
406
+ return;
407
+ }
408
+ // Leaving straight onto the tooltip: the surface's onMouseEnter already
409
+ // ran, because React dispatches it from the mouseout that precedes this
410
+ // mouseleave, so a hide scheduled now would never be cancelled. The
411
+ // surface's own mouse leave schedules the hide instead.
412
+ const next = (event as MouseEvent).relatedTarget as Node | null;
413
+ const surface =
414
+ next == null ? null : next.ownerDocument?.getElementById(layerId);
415
+ if (surface != null && surface.contains(next)) {
416
+ return;
417
+ }
418
+ scheduleHide();
419
+ },
420
+ [isTouchPointerRef, layerId, scheduleHide],
421
+ );
409
422
 
410
423
  const handleFocusIn = useCallback(
411
424
  (e: Event) => {