@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 +26 -72
- package/dist/components/collections/table.d.ts +56 -0
- package/dist/components/collections/table.js +219 -35
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.js +2 -0
- package/dist/core/offset-window.d.ts +70 -0
- package/dist/core/offset-window.js +134 -0
- package/dist/core/virtualize.d.ts +71 -0
- package/dist/core/virtualize.js +67 -0
- package/dist/patterns/audit/audit-log.d.ts +104 -3
- package/dist/patterns/audit/audit-log.js +295 -7
- package/dist/patterns/audit/window.d.ts +116 -0
- package/dist/patterns/audit/window.js +132 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
##
|
|
30
|
-
|
|
31
|
-
[](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
|
-

|
|
38
|
-
|
|
39
|
-
## Setup
|
|
40
|
-
|
|
41
|
-
This package uses Bun as its package manager and task runner.
|
|
29
|
+
## Install
|
|
42
30
|
|
|
43
31
|
```bash
|
|
44
|
-
|
|
32
|
+
npm install @unofficialbox/box-open-elements
|
|
45
33
|
```
|
|
46
34
|
|
|
47
|
-
|
|
35
|
+
Importing the root registers the full `box-*` catalog; flat entrypoints register only the imported component:
|
|
48
36
|
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
catalog. Flat component entrypoints register only the imported component:
|
|
78
|
+
See [docs/api-guidelines.md](./docs/api-guidelines.md).
|
|
129
79
|
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
595
|
+
nextIndex = lastIndex;
|
|
484
596
|
else
|
|
485
597
|
return;
|
|
486
598
|
event.preventDefault();
|
|
487
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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();
|
package/dist/core/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/core/index.js
CHANGED
|
@@ -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";
|