@unofficialbox/box-open-elements 0.6.0 → 0.7.0

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.
package/README.md CHANGED
@@ -10,96 +10,49 @@
10
10
 
11
11
  > **Community project — not affiliated with, authorized, or endorsed by Box, Inc.** “Box” is a trademark of Box, Inc. This library tracks Box’s public design language for interoperability; it ships no Box code.
12
12
 
13
- The library is organized around three layers:
13
+ Browse the full catalog on the **[live docs site](https://unofficialbox.github.io/box-open-elements)** — live previews, an events/properties inspector, per-framework code snippets (React / Angular / Vue / Svelte / HTML), and the foundations pages.
14
+
15
+ The library is organized around three layers (see [docs/taxonomy.md](./docs/taxonomy.md) for the canonical model):
14
16
 
15
17
  - **Foundations** — design decisions as data: tokens, color, typography, iconography, accessibility, theming
16
18
  - **Components** — accessible Web Components for single controls, organized by category
17
19
  - **Patterns** — combinations of components that address user objectives with sequences and flows: headless controllers, transport contracts, and composed workflow surfaces, grouped by Box noun
18
20
 
19
- See [docs/taxonomy.md](./docs/taxonomy.md) for the canonical model.
20
-
21
21
  Core implementation principles:
22
22
 
23
- - plain TypeScript modules, no React requirement in the core package
23
+ - plain TypeScript modules, no React requirement in the core package — zero runtime dependencies
24
24
  - state and business logic separate from rendering: controllers and stores, not framework components
25
25
  - standard DOM events where a UI layer needs them
26
26
  - accessibility semantics and keyboard support as part of the component contract
27
27
  - injected transport contracts instead of SDK coupling
28
28
 
29
- ## Preview
30
-
31
- [![The box-open-elements documentation site](https://raw.githubusercontent.com/unofficialbox/box-open-elements/main/docs/screenshots/docs-site/home.png)](https://unofficialbox.github.io/box-open-elements)
32
-
33
- The **[live docs site](https://unofficialbox.github.io/box-open-elements)** browses the full catalog with live previews, an events/properties inspector, per-framework code snippets (React / Angular / Vue / Svelte / HTML), and the foundations pages.
34
-
35
- Components track Box’s design language out of the box — tokens-driven, light and dark, keyboard-accessible:
36
-
37
- ![A selection of form components — text field, search, select, checkboxes, radios, and a pill-selector dropdown](https://raw.githubusercontent.com/unofficialbox/box-open-elements/main/docs/screenshots/gallery/forms.png)
38
-
39
- ## Setup
40
-
41
- This package uses Bun as its package manager and task runner.
29
+ ## Install
42
30
 
43
31
  ```bash
44
- bun install
32
+ npm install @unofficialbox/box-open-elements
45
33
  ```
46
34
 
47
- ## Common commands
35
+ Importing the root registers the full `box-*` catalog; flat entrypoints register only the imported component:
48
36
 
49
- ```bash
50
- bun run typecheck
51
- bun run test
52
- bun run test:coverage
53
- bun run build
54
- bun run verify
55
- bun run docs
37
+ ```ts
38
+ import { Accordion, Avatar, Button, Switch } from "@unofficialbox/box-open-elements";
39
+ import { TextField } from "@unofficialbox/box-open-elements/text-field";
56
40
  ```
57
41
 
58
- `bun run docs` builds the library and serves the component-documentation site at `http://localhost:4600` — browse the full catalog with live previews, events/properties inspectors, and foundations pages.
59
-
60
- `bun run verify` is the main safety check and runs typecheck, **coverage-gated** tests (`test:coverage`), and build in sequence.
61
-
62
- ## Current state
63
-
64
- Full catalog parity with the reference repo, plus every scoped gap the research surfaced:
65
-
66
- - `src/core` — typed event emitter, controller base class, `BaseElement` (in-place shadow DOM render contract), and `FormAssociatedElement` (native form participation + invalid state)
67
- - `src/foundations/tokens` — the design-system registry (tokens, icons, illustrations), the Box default bundle, and the Box dark bundle, retoned to Box's modernized Blueprint palette with an Inter typography baseline; shared interaction helpers (`interaction.ts`) for focus/hover/active/disabled
68
- - `src/foundations/motion` — shared duration/easing vocabulary and reduced-motion CSS helper
69
- - `src/foundations/profiles` — typed runtime profiles for density, geometry, typography, elevation, and motion
70
- - `src/foundations/icons` — the generated Box iconography manifest and alias layer
71
- - `tools/style-bridge` — CSS/SCSS → BOE token/selector bridge (`bun run style-bridge`; BUE explorer: `bun run style-bridge:bue-explorer`)
72
- - `src/components` — 78 components across all ten categories, including the Phase 5 gap fills (`box-chip`, `box-divider`, `box-calendar`, `box-tag-input`, `box-nav-sidebar`, `box-sidebar-toggle-button`, `box-grid-view`, `box-fieldset`, `box-section`, `box-error-mask`, `box-draggable-list`, `box-nudge`, `box-pill-cloud`, `box-pill-selector-dropdown`, `box-datalist-item`, `box-contact-datalist-item`, `box-category-selector`)
73
- - `src/patterns/content-explorer` — the full headless explorer stack (collection, navigation, selection, actions, facade controller, data-source contracts, Box transport, wire schemas) plus the `box-explorer-*` presentation adapters and the composed `box-content-explorer` surface
74
- - `src/patterns/{search,item,metadata,share,preview,file-request,task,governance,insights}` — all nine composition/workflow areas, including contracts and wire schemas for metadata and share, the provider-neutral preview adapter stack, the pluggable `box-preview-element`, and the share workflows (`box-presence`, `box-invite-collaborators-modal`, `box-unified-share-modal`, `box-access-stats`, `box-collaborator-avatars`)
75
- - `packages/box-server` — a dependency-free server-side adapter: CCG auth + REST client, Box-backed explorer/share/metadata data sources, DTO mappers, and framework-neutral route handlers (see [docs/integration/box-server.md](./docs/integration/box-server.md))
76
- - `packages/{react,angular,vue,svelte}` — lockstep `0.1.0` framework adapters for `Button`, `TextField`, `Select`, `Dialog`, and explorer selection composition. Progress is tracked in [docs/integration/framework-adapters.md](./docs/integration/framework-adapters.md).
77
- - `storybook/` — a Bun-native workshop: typed stories → identity-guarded extracted JSON → a self-contained, separately-deployable static site, with no Storybook/Vite runtime or consumer dependency (see [storybook/README.md](./storybook/README.md))
78
- - Docs site + CI — live GitHub Pages deploy, Storybook-backed variant dropdown, and a strict pixel-diff visual-regression gate in CI
79
-
80
- **Fidelity program**: complete — Batches 0–7, the medium/low audit nits, and the design-heavy leftovers have shipped.
81
-
82
- Everything in the [components catalog](./docs/components/catalog.md) and [patterns catalog](./docs/patterns/catalog.md) that carries a **built** marker is implemented here with dedicated tests. Remaining catalog entries are intentional generic-component gaps whose current implementations are explorer-bound; future additions are gap-driven rather than phase-gated ports.
42
+ ```html
43
+ <box-button label="Save" tone="primary"></box-button>
44
+ ```
83
45
 
84
- ## Example: foundations + components
46
+ Components consume foundation tokens (`--boe-token-*`) with safe fallbacks, so they render sensibly with no design system registered and restyle automatically when one is active:
85
47
 
86
48
  ```ts
87
- import {
88
- createThemeController,
89
- } from "@unofficialbox/box-open-elements/foundations/theming";
90
- import { Button } from "@unofficialbox/box-open-elements";
49
+ import { createThemeController } from "@unofficialbox/box-open-elements/foundations/theming";
91
50
 
92
51
  const theme = createThemeController();
93
52
  theme.start();
94
53
  ```
95
54
 
96
- ```html
97
- <box-button label="Save" tone="primary"></box-button>
98
- ```
99
-
100
- Components consume foundation tokens (`--boe-token-*`) with safe fallbacks, so they render sensibly with no design system registered and restyle automatically when one is active.
101
-
102
- ## Example: headless patterns
55
+ Workflow patterns begin as headless behavior and gain presentation adapters, so custom React components, Angular templates, Vue SFCs, Svelte components, Web Components, and plain DOM can all consume the same state:
103
56
 
104
57
  ```ts
105
58
  import { ExplorerSelectionController } from "@unofficialbox/box-open-elements/patterns/content-explorer/selection";
@@ -114,8 +67,6 @@ selection.setItems([{ id: "1" }, { id: "2" }]);
114
67
  selection.toggleSelection("1");
115
68
  ```
116
69
 
117
- Workflow patterns begin as headless behavior and gain presentation adapters, so custom React components, Angular templates, Vue SFCs, Svelte components, Web Components, and plain DOM can all consume the same state.
118
-
119
70
  ## Import contract
120
71
 
121
72
  - root exports: `@unofficialbox/box-open-elements`
@@ -124,15 +75,17 @@ Workflow patterns begin as headless behavior and gain presentation adapters, so
124
75
  - optimized component entrypoints: `@unofficialbox/box-open-elements/<name>`
125
76
  - patterns: `@unofficialbox/box-open-elements/patterns/<area>` and `@unofficialbox/box-open-elements/patterns/<area>/<module>`
126
77
 
127
- The root entrypoint is the concise convenience API and registers the full
128
- catalog. Flat component entrypoints register only the imported component:
78
+ See [docs/api-guidelines.md](./docs/api-guidelines.md).
129
79
 
130
- ```ts
131
- import { Accordion, Avatar, Button, Switch } from "@unofficialbox/box-open-elements";
132
- import { TextField } from "@unofficialbox/box-open-elements/text-field";
133
- ```
80
+ ## Developing
134
81
 
135
- See [docs/api-guidelines.md](./docs/api-guidelines.md).
82
+ This repo uses Bun as its package manager and task runner.
83
+
84
+ ```bash
85
+ bun install
86
+ bun run verify # the main safety gate: typecheck, coverage-gated tests, build
87
+ bun run docs # build + serve the docs site at http://localhost:4600
88
+ ```
136
89
 
137
90
  ## Documentation
138
91
 
@@ -141,6 +94,7 @@ The docs index is at [docs/README.md](./docs/README.md). The most important entr
141
94
  - [Taxonomy](./docs/taxonomy.md)
142
95
  - [Architecture](./docs/architecture.md)
143
96
  - [API Guidelines](./docs/api-guidelines.md)
97
+ - [Components catalog](./docs/components/catalog.md) and [patterns catalog](./docs/patterns/catalog.md)
144
98
  - [Using with React, Angular, Vue, and Svelte](./docs/integration/frameworks.md)
145
99
  - [Design Tokens](./docs/foundations/tokens.md)
146
100
  - [Content Explorer](./docs/patterns/content-explorer.md)
@@ -1,4 +1,5 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
+ import type { RowWindow } from "../../core/index.js";
2
3
  export interface TableColumn {
3
4
  key: string;
4
5
  label: string;
@@ -60,9 +61,21 @@ export declare class Table extends BaseElement {
60
61
  static get observedAttributes(): string[];
61
62
  private bodyEl;
62
63
  private headEl;
64
+ private shellEl;
63
65
  private selected;
64
66
  private anchorIndex;
65
67
  private readonly expandedIds;
68
+ private lastWindow;
69
+ /**
70
+ * Whether the last render actually windowed. Not the same as `virtualize`:
71
+ * the flag is suppressed for a collection the fixed-height window cannot
72
+ * describe (see `update`). Cached at render time so the scroll handler does
73
+ * not re-parse `rows` on every frame to answer the question.
74
+ */
75
+ private windowing;
76
+ private scrollFrame;
77
+ private measuredRowHeight;
78
+ private measureFrame;
66
79
  /** True while rows are being fetched: the body states "Loading rows…". */
67
80
  get loading(): boolean;
68
81
  set loading(value: boolean);
@@ -72,6 +85,42 @@ export declare class Table extends BaseElement {
72
85
  /** Non-empty puts the table in an error state stating this text. */
73
86
  get errorText(): string;
74
87
  set errorText(value: string);
88
+ /**
89
+ * Render only the rows near the viewport. Opt-in: a windowed table needs a
90
+ * bounded height to scroll within, and silently windowing an unbounded table
91
+ * would render nothing.
92
+ *
93
+ * A request, not a guarantee. The window arithmetic is fixed-height, so it is
94
+ * suppressed for a collection whose rows can change height — today that means
95
+ * any collection where a row declares `detail`. Read `renderedWindow` to see
96
+ * whether a render actually windowed.
97
+ */
98
+ get virtualize(): boolean;
99
+ set virtualize(value: boolean);
100
+ /**
101
+ * Row height in pixels for the window arithmetic — a starting estimate, not
102
+ * a contract. The real height depends on density, font, and zoom, and a
103
+ * declared value that is off by a fraction of a pixel makes the scroll range
104
+ * drift as you scroll (a 10k-row table measured ~55px of wobble at 37px
105
+ * declared). After the first paint the element measures a rendered row and
106
+ * uses that instead, so the estimate only has to be close enough to pick a
107
+ * sane first window.
108
+ */
109
+ get rowHeight(): number;
110
+ private get declaredRowHeight();
111
+ set rowHeight(value: number);
112
+ /**
113
+ * Adopt the real rendered row height, once, when it differs from the value
114
+ * the last window was computed with. Re-rendering with the measured height
115
+ * settles immediately: the next measurement matches, so this cannot loop.
116
+ */
117
+ private measureRowHeight;
118
+ /**
119
+ * The window the last render used, or null when it rendered every row — for
120
+ * tests and hosts. Null despite `virtualize` means the collection is one the
121
+ * fixed-height window cannot describe and windowing was suppressed.
122
+ */
123
+ get renderedWindow(): RowWindow | null;
75
124
  /** Ids of the rows whose detail is expanded (host-readable). */
76
125
  get expandedRows(): string[];
77
126
  get columns(): TableColumn[];
@@ -97,11 +146,18 @@ export declare class Table extends BaseElement {
97
146
  private rowElements;
98
147
  private toggleRowDetail;
99
148
  private onBodyKeydown;
149
+ /**
150
+ * Focus a row by its absolute index, bringing it into the window first when
151
+ * virtualizing — keyboard navigation must be able to reach a row that is not
152
+ * currently rendered, or Home/End stop at the edge of the window.
153
+ */
154
+ private focusRowByIndex;
100
155
  private toggleSort;
101
156
  private activateRow;
102
157
  private selectRange;
103
158
  private selectAll;
104
159
  private setSelection;
105
160
  private applySelectionState;
161
+ private currentWindow;
106
162
  protected update(): void;
107
163
  }
@@ -1,4 +1,4 @@
1
- import { BaseElement } from "../../core/index.js";
1
+ import { BaseElement, resolveRowWindow, sameRowWindow } from "../../core/index.js";
2
2
  import { boePanel, boeRadius, boeSpace } from "../../foundations/geometry/index.js";
3
3
  import { isSafeHref } from "../../patterns/internal/safe-href.js";
4
4
  const DEFAULT_TAG_NAME = "box-table";
@@ -113,6 +113,19 @@ const tableStyles = `
113
113
  box-shadow: inset 0 0 0 2px var(--boe-token-surface-surface-brand, #0061d5);
114
114
  }
115
115
 
116
+ /* Windowing needs a bounded viewport to scroll within: --boe-table-height is
117
+ the host's knob, defaulting to a sensible page-sized band. Sticky headers
118
+ already work inside this scroller. */
119
+ :host([virtualize]) [part="shell"] {
120
+ max-block-size: var(--boe-table-height, 26rem);
121
+ overflow-y: auto;
122
+ }
123
+
124
+ tr[part="spacer"] td {
125
+ border: 0;
126
+ padding: 0;
127
+ }
128
+
116
129
  [part="cell-badge"] {
117
130
  display: inline-block;
118
131
  padding: 0.08rem 0.5rem;
@@ -270,13 +283,27 @@ export class Table extends BaseElement {
270
283
  "sort-direction",
271
284
  "sort-key",
272
285
  "stacked",
286
+ "virtualize",
287
+ "row-height",
273
288
  ];
274
289
  }
275
290
  bodyEl;
276
291
  headEl;
292
+ shellEl;
277
293
  selected = new Set();
278
294
  anchorIndex = -1;
279
295
  expandedIds = new Set();
296
+ lastWindow = null;
297
+ /**
298
+ * Whether the last render actually windowed. Not the same as `virtualize`:
299
+ * the flag is suppressed for a collection the fixed-height window cannot
300
+ * describe (see `update`). Cached at render time so the scroll handler does
301
+ * not re-parse `rows` on every frame to answer the question.
302
+ */
303
+ windowing = false;
304
+ scrollFrame = 0;
305
+ measuredRowHeight = 0;
306
+ measureFrame = 0;
280
307
  /** True while rows are being fetched: the body states "Loading rows…". */
281
308
  get loading() {
282
309
  return this.hasAttribute("loading");
@@ -303,6 +330,73 @@ export class Table extends BaseElement {
303
330
  this.removeAttribute("error-text");
304
331
  }
305
332
  }
333
+ /**
334
+ * Render only the rows near the viewport. Opt-in: a windowed table needs a
335
+ * bounded height to scroll within, and silently windowing an unbounded table
336
+ * would render nothing.
337
+ *
338
+ * A request, not a guarantee. The window arithmetic is fixed-height, so it is
339
+ * suppressed for a collection whose rows can change height — today that means
340
+ * any collection where a row declares `detail`. Read `renderedWindow` to see
341
+ * whether a render actually windowed.
342
+ */
343
+ get virtualize() {
344
+ return this.hasAttribute("virtualize");
345
+ }
346
+ set virtualize(value) {
347
+ this.toggleAttribute("virtualize", Boolean(value));
348
+ }
349
+ /**
350
+ * Row height in pixels for the window arithmetic — a starting estimate, not
351
+ * a contract. The real height depends on density, font, and zoom, and a
352
+ * declared value that is off by a fraction of a pixel makes the scroll range
353
+ * drift as you scroll (a 10k-row table measured ~55px of wobble at 37px
354
+ * declared). After the first paint the element measures a rendered row and
355
+ * uses that instead, so the estimate only has to be close enough to pick a
356
+ * sane first window.
357
+ */
358
+ get rowHeight() {
359
+ if (this.measuredRowHeight > 0) {
360
+ return this.measuredRowHeight;
361
+ }
362
+ return this.declaredRowHeight;
363
+ }
364
+ get declaredRowHeight() {
365
+ const raw = Number(this.getAttribute("row-height"));
366
+ return Number.isFinite(raw) && raw > 0 ? raw : 37;
367
+ }
368
+ set rowHeight(value) {
369
+ // A new declaration supersedes the old measurement; the next paint
370
+ // measures again.
371
+ this.measuredRowHeight = 0;
372
+ this.setAttribute("row-height", String(value));
373
+ }
374
+ /**
375
+ * Adopt the real rendered row height, once, when it differs from the value
376
+ * the last window was computed with. Re-rendering with the measured height
377
+ * settles immediately: the next measurement matches, so this cannot loop.
378
+ */
379
+ measureRowHeight() {
380
+ if (!this.windowing || this.measureFrame)
381
+ return;
382
+ const row = this.bodyEl.querySelector('[part="row"]');
383
+ const height = row?.getBoundingClientRect().height ?? 0;
384
+ if (height <= 0 || Math.abs(height - this.rowHeight) < 0.5)
385
+ return;
386
+ this.measuredRowHeight = height;
387
+ this.measureFrame = requestAnimationFrame(() => {
388
+ this.measureFrame = 0;
389
+ this.update();
390
+ });
391
+ }
392
+ /**
393
+ * The window the last render used, or null when it rendered every row — for
394
+ * tests and hosts. Null despite `virtualize` means the collection is one the
395
+ * fixed-height window cannot describe and windowing was suppressed.
396
+ */
397
+ get renderedWindow() {
398
+ return this.windowing ? this.lastWindow : null;
399
+ }
306
400
  /** Ids of the rows whose detail is expanded (host-readable). */
307
401
  get expandedRows() {
308
402
  return [...this.expandedIds];
@@ -396,8 +490,23 @@ export class Table extends BaseElement {
396
490
  `;
397
491
  this.headEl = this.shadowRoot.querySelector('[part="header-row"]');
398
492
  this.bodyEl = this.shadowRoot.querySelector('[part="body"]');
493
+ this.shellEl = this.shadowRoot.querySelector('[part="shell"]');
399
494
  }
400
495
  setupListeners() {
496
+ // Scroll fires continuously; coalesce to one re-render per frame, and skip
497
+ // entirely while the resolved slice is unchanged — most scroll events move
498
+ // the viewport within the rows already rendered.
499
+ this.shellEl.addEventListener("scroll", () => {
500
+ if (!this.windowing || this.scrollFrame)
501
+ return;
502
+ this.scrollFrame = requestAnimationFrame(() => {
503
+ this.scrollFrame = 0;
504
+ const next = this.currentWindow();
505
+ if (this.lastWindow && sameRowWindow(this.lastWindow, next))
506
+ return;
507
+ this.update();
508
+ });
509
+ }, { passive: true });
401
510
  this.headEl.addEventListener("click", event => {
402
511
  const th = event.target.closest('th[part="sortable"]');
403
512
  if (!th)
@@ -448,8 +557,7 @@ export class Table extends BaseElement {
448
557
  // must not double as row selection.
449
558
  if (event.target.closest('[part="expander"]'))
450
559
  return;
451
- const rows = this.rowElements();
452
- if (rows.length === 0)
560
+ if (this.rowElements().length === 0)
453
561
  return;
454
562
  const currentRow = event.target.closest('[part="row"]');
455
563
  const index = currentRow ? Number(currentRow.dataset.index) : -1;
@@ -472,24 +580,46 @@ export class Table extends BaseElement {
472
580
  this.activateRow(index, { toggle: this.selectionMode === "multiple" });
473
581
  return;
474
582
  }
583
+ // Bounds come from the data, not the DOM: when windowing, the rendered
584
+ // rows are a slice and `rows.length` would stop End at the bottom of the
585
+ // window instead of the bottom of the collection.
586
+ const lastIndex = this.rows.length - 1;
475
587
  let nextIndex = index;
476
588
  if (event.key === "ArrowDown")
477
- nextIndex = Math.min(index + 1, rows.length - 1);
589
+ nextIndex = Math.min(index + 1, lastIndex);
478
590
  else if (event.key === "ArrowUp")
479
591
  nextIndex = Math.max(index - 1, 0);
480
592
  else if (event.key === "Home")
481
593
  nextIndex = 0;
482
594
  else if (event.key === "End")
483
- nextIndex = rows.length - 1;
595
+ nextIndex = lastIndex;
484
596
  else
485
597
  return;
486
598
  event.preventDefault();
487
- rows[nextIndex]?.focus();
599
+ this.focusRowByIndex(nextIndex);
488
600
  // Shift+Arrow extends the selection from the anchor.
489
601
  if (event.shiftKey && this.selectionMode === "multiple") {
490
602
  this.selectRange(this.anchorIndex < 0 ? index : this.anchorIndex, nextIndex);
491
603
  }
492
604
  }
605
+ /**
606
+ * Focus a row by its absolute index, bringing it into the window first when
607
+ * virtualizing — keyboard navigation must be able to reach a row that is not
608
+ * currently rendered, or Home/End stop at the edge of the window.
609
+ */
610
+ focusRowByIndex(index) {
611
+ const find = () => this.bodyEl.querySelector(`[part="row"][data-index="${String(index)}"]`);
612
+ let target = find();
613
+ if (!target && this.windowing && this.shellEl) {
614
+ // Scroll the row to the top of the viewport, then re-render the window
615
+ // synchronously: the scroll event's own re-render lands a frame later,
616
+ // and focus cannot wait for it.
617
+ this.shellEl.scrollTop = index * this.rowHeight;
618
+ this.update();
619
+ target = find();
620
+ }
621
+ target?.focus();
622
+ }
493
623
  toggleSort(key) {
494
624
  const currentKey = this.getAttribute("sort-key");
495
625
  const currentDir = this.getAttribute("sort-direction");
@@ -548,6 +678,14 @@ export class Table extends BaseElement {
548
678
  row.setAttribute("aria-selected", String(this.selected.has(id)));
549
679
  }
550
680
  }
681
+ currentWindow() {
682
+ return resolveRowWindow({
683
+ totalRows: this.rows.length,
684
+ rowHeight: this.rowHeight,
685
+ viewportHeight: this.shellEl?.clientHeight ?? 0,
686
+ scrollTop: this.shellEl?.scrollTop ?? 0,
687
+ });
688
+ }
551
689
  update() {
552
690
  if (!this.bodyEl || !this.headEl)
553
691
  return;
@@ -587,6 +725,11 @@ export class Table extends BaseElement {
587
725
  return `<th scope="col"${align}>${escapeHtml(column.label)}</th>`;
588
726
  })
589
727
  .join("");
728
+ // A state row is never windowed. Cleared up front so a table that drops
729
+ // into loading/error/empty does not leave the previous window's flag
730
+ // standing — the scroll handler and `renderedWindow` both read it.
731
+ this.windowing = false;
732
+ this.lastWindow = null;
590
733
  // Loading, error, and empty are stated in words in the body — a blank
591
734
  // grid reads as broken. Loading wins; a stale error must not outlive a
592
735
  // retry that is visibly in flight.
@@ -602,35 +745,76 @@ export class Table extends BaseElement {
602
745
  this.bodyEl.innerHTML = `<tr part="state-row" data-state="empty" role="row"><td role="${selectable ? "gridcell" : "cell"}" colspan="${String(columnCount)}">${escapeHtml(this.emptyText)}</td></tr>`;
603
746
  return;
604
747
  }
605
- this.bodyEl.innerHTML = rows
606
- .map((row, index) => {
607
- const selected = this.selected.has(row.id);
608
- const cellRole = selectable ? ' role="gridcell"' : ' role="cell"';
609
- const rowAttrs = selectable
610
- ? ` part="row" role="row" tabindex="${index === 0 ? "0" : "-1"}" aria-selected="${selected}"`
611
- : ' part="row" role="row"';
612
- const expanded = this.expandedIds.has(row.id);
613
- const expanderCell = expandable
614
- ? `<td${cellRole} part="expander-cell">${typeof row.detail === "string"
615
- ? `<button type="button" part="expander" data-row-id="${escapeHtml(row.id)}" aria-expanded="${String(expanded)}" aria-label="${expanded ? "Hide" : "Show"} details for row ${escapeHtml(row.id)}"></button>`
616
- : ""}</td>`
617
- : "";
618
- const cells = columns
619
- .map((column, columnIndex) => {
620
- const align = alignAttr(column.align);
621
- const value = this.cellValue(row, column, columnIndex);
622
- return `<td${align}${cellRole} data-label="${escapeHtml(column.label)}">${this.cellMarkup(value)}</td>`;
623
- })
624
- .join("");
625
- const rowMarkup = `<tr${rowAttrs} data-index="${index}" data-id="${escapeHtml(row.id)}">${expanderCell}${cells}</tr>`;
626
- // The detail row renders only while expanded, keeping the roving
627
- // tabindex and shift-range math on visible [part="row"] rows only.
628
- const detailRow = typeof row.detail === "string" && expanded
629
- ? `<tr part="detail-row" role="row" data-for="${escapeHtml(row.id)}"><td${cellRole} colspan="${String(columnCount)}">${escapeHtml(row.detail)}<slot name="detail-${escapeHtml(row.id)}"></slot></td></tr>`
630
- : "";
631
- return rowMarkup + detailRow;
632
- })
633
- .join("");
748
+ // Windowing renders only the rows near the viewport, padded with spacer
749
+ // rows so the scrollbar still describes the whole collection. Indices stay
750
+ // ABSOLUTE: selection, shift-range, and activateRow all address `rows`, and
751
+ // renumbering the slice would select the wrong record.
752
+ //
753
+ // `virtualize` is a request, not a guarantee. `resolveRowWindow` derives
754
+ // the whole scroll range from `rows.length * rowHeight`, so it can only
755
+ // describe a collection of uniform, fixed-height rows. An expanded row
756
+ // renders a second `<tr part="detail-row">` the arithmetic knows nothing
757
+ // about: the real content grows taller than the spacers claim, the scroll
758
+ // range no longer matches the scrollbar, and scrollTop stops mapping to the
759
+ // right absolute index — so a windowed table with expandable rows would
760
+ // scroll to the wrong record. Variable heights are a different algorithm (a
761
+ // cumulative offset index), not a flag on this one, so windowing is
762
+ // suppressed rather than approximated.
763
+ //
764
+ // The test is `expandable`, not "is anything expanded right now": deciding
765
+ // from transient UI state would flip windowing on and off mid-scroll, which
766
+ // jumps the viewport under the pointer. A collection that can expand never
767
+ // windows; `renderedWindow` returns null so a host can see that it didn't.
768
+ const virtualizing = this.virtualize && !expandable;
769
+ const rowWindow = virtualizing ? this.currentWindow() : null;
770
+ this.windowing = virtualizing;
771
+ this.lastWindow = rowWindow;
772
+ const visibleRows = rowWindow
773
+ ? rows.slice(rowWindow.startIndex, rowWindow.endIndex)
774
+ : rows;
775
+ const indexOffset = rowWindow?.startIndex ?? 0;
776
+ // The tab stop belongs to the first row that is actually rendered; row 0
777
+ // may be scrolled far out of the window.
778
+ const tabbableIndex = indexOffset;
779
+ const spacer = (height, position) => height > 0
780
+ ? `<tr part="spacer" data-position="${position}" aria-hidden="true"><td colspan="${String(columnCount)}" style="height:${String(height)}px;padding:0;border:0;"></td></tr>`
781
+ : "";
782
+ this.bodyEl.innerHTML =
783
+ (rowWindow ? spacer(rowWindow.paddingTop, "top") : "") +
784
+ visibleRows
785
+ .map((row, offset) => {
786
+ const index = offset + indexOffset;
787
+ const selected = this.selected.has(row.id);
788
+ const cellRole = selectable ? ' role="gridcell"' : ' role="cell"';
789
+ const rowAttrs = selectable
790
+ ? ` part="row" role="row" tabindex="${index === tabbableIndex ? "0" : "-1"}" aria-selected="${selected}"`
791
+ : ' part="row" role="row"';
792
+ const expanded = this.expandedIds.has(row.id);
793
+ const expanderCell = expandable
794
+ ? `<td${cellRole} part="expander-cell">${typeof row.detail === "string"
795
+ ? `<button type="button" part="expander" data-row-id="${escapeHtml(row.id)}" aria-expanded="${String(expanded)}" aria-label="${expanded ? "Hide" : "Show"} details for row ${escapeHtml(row.id)}"></button>`
796
+ : ""}</td>`
797
+ : "";
798
+ const cells = columns
799
+ .map((column, columnIndex) => {
800
+ const align = alignAttr(column.align);
801
+ const value = this.cellValue(row, column, columnIndex);
802
+ return `<td${align}${cellRole} data-label="${escapeHtml(column.label)}">${this.cellMarkup(value)}</td>`;
803
+ })
804
+ .join("");
805
+ const rowMarkup = `<tr${rowAttrs} data-index="${index}" data-id="${escapeHtml(row.id)}">${expanderCell}${cells}</tr>`;
806
+ // The detail row renders only while expanded, keeping the roving
807
+ // tabindex and shift-range math on visible [part="row"] rows only.
808
+ const detailRow = typeof row.detail === "string" && expanded
809
+ ? `<tr part="detail-row" role="row" data-for="${escapeHtml(row.id)}"><td${cellRole} colspan="${String(columnCount)}">${escapeHtml(row.detail)}<slot name="detail-${escapeHtml(row.id)}"></slot></td></tr>`
810
+ : "";
811
+ return rowMarkup + detailRow;
812
+ })
813
+ .join("") +
814
+ (rowWindow ? spacer(rowWindow.paddingBottom, "bottom") : "");
815
+ if (virtualizing) {
816
+ this.measureRowHeight();
817
+ }
634
818
  }
635
819
  }
636
820
  Table.register();
@@ -4,3 +4,7 @@ export type { EventMap, Unsubscribe } from "./event-emitter.js";
4
4
  export { BaseElement } from "./element.js";
5
5
  export { FORM_DESCRIPTION_ID, FORM_ERROR_MESSAGE_ID, FormAssociatedElement, boeFormFieldErrorStyles, boeFormFieldSupportStyles, formDataFromNamedValues, formDataFromRange, formDescriptionMarkup, formErrorMessageMarkup, getMirroredFormValue, rangeFromFormValue, stringValuesFromFormValue, } from "./form-associated.js";
6
6
  export type { FormValue } from "./form-associated.js";
7
+ export { resolveRowWindow, sameRowWindow } from "./virtualize.js";
8
+ export type { RowWindow, WindowMetrics } from "./virtualize.js";
9
+ export { buildOffsetIndex, offsetOfItem, resolveOffsetWindow } from "./offset-window.js";
10
+ export type { OffsetIndex, OffsetWindowMetrics } from "./offset-window.js";
@@ -2,3 +2,5 @@ export { Controller } from "./controller.js";
2
2
  export { TypedEventEmitter } from "./event-emitter.js";
3
3
  export { BaseElement } from "./element.js";
4
4
  export { FORM_DESCRIPTION_ID, FORM_ERROR_MESSAGE_ID, FormAssociatedElement, boeFormFieldErrorStyles, boeFormFieldSupportStyles, formDataFromNamedValues, formDataFromRange, formDescriptionMarkup, formErrorMessageMarkup, getMirroredFormValue, rangeFromFormValue, stringValuesFromFormValue, } from "./form-associated.js";
5
+ export { resolveRowWindow, sameRowWindow } from "./virtualize.js";
6
+ export { buildOffsetIndex, offsetOfItem, resolveOffsetWindow } from "./offset-window.js";