@unofficialbox/box-open-elements 0.6.0 → 0.8.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
|
@@ -2,9 +2,16 @@ import { AUDIT_GROUP_BY_LABELS, AUDIT_GROUP_BY_VALUES, filterAuditEvents, groupA
|
|
|
2
2
|
import { auditToneColor, escapeHtml, formatAuditTimestamp, resolveReferenceTime, } from "./shared.js";
|
|
3
3
|
import { isSafeHref } from "../internal/safe-href.js";
|
|
4
4
|
import { isTimelineEventRecord, resolveTimelineTone } from "../timeline/types.js";
|
|
5
|
-
import { BaseElement } from "../../core/index.js";
|
|
5
|
+
import { BaseElement, sameRowWindow } from "../../core/index.js";
|
|
6
|
+
import { buildAuditIndex, flattenAuditRows, planAuditWindow } from "./window.js";
|
|
6
7
|
import { boeInputControlStyles, boePanel, boeRadius } from "../../foundations/geometry/index.js";
|
|
7
8
|
import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
|
|
9
|
+
/**
|
|
10
|
+
* How many times the element will adopt a fresh height measurement for one row
|
|
11
|
+
* set. A hard stop, not a tuning knob: without it a non-uniform list can adopt,
|
|
12
|
+
* re-window, re-measure and adopt again on every animation frame forever.
|
|
13
|
+
*/
|
|
14
|
+
const MAX_HEIGHT_ADOPTIONS = 3;
|
|
8
15
|
const DEFAULT_TAG_NAME = "box-audit-log";
|
|
9
16
|
/** Facet attribute ↔ `AuditFacets` key. One row per facet, no other mapping. */
|
|
10
17
|
const FACET_ATTRIBUTES = [
|
|
@@ -144,6 +151,20 @@ const elementStyles = `
|
|
|
144
151
|
gap: 0.5rem;
|
|
145
152
|
}
|
|
146
153
|
|
|
154
|
+
/* Windowing needs a bounded viewport to scroll within:
|
|
155
|
+
--boe-audit-height is the host's knob, defaulting to a page-sized
|
|
156
|
+
band. */
|
|
157
|
+
:host([virtualize]) [part="groups"] {
|
|
158
|
+
max-block-size: var(--boe-audit-height, 32rem);
|
|
159
|
+
overflow-y: auto;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
[part="spacer"] {
|
|
163
|
+
/* The grid gap must not apply to the spacers, or the scroll range
|
|
164
|
+
gains a gap's height at each end and the content drifts. */
|
|
165
|
+
margin-block: calc(-0.25rem);
|
|
166
|
+
}
|
|
167
|
+
|
|
147
168
|
[part="group"] {
|
|
148
169
|
border: 1px solid color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 70%, transparent);
|
|
149
170
|
border-radius: ${boeRadius.med};
|
|
@@ -321,8 +342,9 @@ const elementStyles = `
|
|
|
321
342
|
* sections with counts, narrowed by facets and a date range, drillable to one
|
|
322
343
|
* workflow run by correlation id, and exportable as CSV.
|
|
323
344
|
*
|
|
324
|
-
* Aggregation is client-side and pure.
|
|
325
|
-
*
|
|
345
|
+
* Aggregation is client-side and pure. `virtualize` windows the log for
|
|
346
|
+
* production-scale volumes; server-side paging remains a tracked depth
|
|
347
|
+
* limitation.
|
|
326
348
|
*/
|
|
327
349
|
export class AuditLog extends BaseElement {
|
|
328
350
|
static tagName = DEFAULT_TAG_NAME;
|
|
@@ -338,6 +360,9 @@ export class AuditLog extends BaseElement {
|
|
|
338
360
|
"group-by",
|
|
339
361
|
"heading",
|
|
340
362
|
"reference-time",
|
|
363
|
+
"virtualize",
|
|
364
|
+
"row-height",
|
|
365
|
+
"heading-height",
|
|
341
366
|
];
|
|
342
367
|
}
|
|
343
368
|
toolbarEl;
|
|
@@ -356,6 +381,15 @@ export class AuditLog extends BaseElement {
|
|
|
356
381
|
titleEl;
|
|
357
382
|
/** Section keys the reader has collapsed. Absent means expanded. */
|
|
358
383
|
collapsedGroups = new Set();
|
|
384
|
+
/** The row window the last render used; null when it rendered everything. */
|
|
385
|
+
lastWindow = null;
|
|
386
|
+
scrollFrame = 0;
|
|
387
|
+
measuredHeights = null;
|
|
388
|
+
measureFrame = 0;
|
|
389
|
+
/** Row set the adoption budget below is counted against. */
|
|
390
|
+
adoptedFor = null;
|
|
391
|
+
adoptionCount = 0;
|
|
392
|
+
viewportObserver = null;
|
|
359
393
|
contentSignature = "";
|
|
360
394
|
optionsSignature = "";
|
|
361
395
|
/** Raw `events` attribute the parse cache was built from. */
|
|
@@ -447,6 +481,16 @@ export class AuditLog extends BaseElement {
|
|
|
447
481
|
get visibleEvents() {
|
|
448
482
|
return filterAuditEvents(this.events, this.facets);
|
|
449
483
|
}
|
|
484
|
+
/**
|
|
485
|
+
* The sections the current facets and grouping produce.
|
|
486
|
+
*
|
|
487
|
+
* Public because windowing takes away the alternative: a host used to be
|
|
488
|
+
* able to read the sections off the DOM, and once only a slice is rendered,
|
|
489
|
+
* that stops being the whole picture.
|
|
490
|
+
*/
|
|
491
|
+
get groups() {
|
|
492
|
+
return this.currentGroups();
|
|
493
|
+
}
|
|
450
494
|
toggleGroup(key, expanded) {
|
|
451
495
|
const next = expanded ?? this.collapsedGroups.has(key);
|
|
452
496
|
if (next) {
|
|
@@ -494,6 +538,48 @@ export class AuditLog extends BaseElement {
|
|
|
494
538
|
}));
|
|
495
539
|
return csv;
|
|
496
540
|
}
|
|
541
|
+
/**
|
|
542
|
+
* Watch the scroller so a measurable — or resized — viewport re-plans.
|
|
543
|
+
*
|
|
544
|
+
* Separate from `setupListeners` because that runs *once*, on the element's
|
|
545
|
+
* first connection, while `disconnectedCallback` tears the observer down
|
|
546
|
+
* every time. A log removed and re-inserted would otherwise come back with
|
|
547
|
+
* its listeners intact and its observer gone, so resizing its container
|
|
548
|
+
* would silently stop re-planning. Idempotent, so both callers are safe.
|
|
549
|
+
*/
|
|
550
|
+
observeViewport() {
|
|
551
|
+
if (this.viewportObserver || typeof ResizeObserver === "undefined")
|
|
552
|
+
return;
|
|
553
|
+
this.viewportObserver = new ResizeObserver(() => {
|
|
554
|
+
if (!this.virtualize)
|
|
555
|
+
return;
|
|
556
|
+
const next = this.currentPlan().window;
|
|
557
|
+
if (this.lastWindow && sameRowWindow(this.lastWindow, next))
|
|
558
|
+
return;
|
|
559
|
+
this.update();
|
|
560
|
+
});
|
|
561
|
+
this.viewportObserver.observe(this.groupsEl);
|
|
562
|
+
}
|
|
563
|
+
connectedCallback() {
|
|
564
|
+
super.connectedCallback();
|
|
565
|
+
// Re-arm after a disconnect; no-op on the first connection, where
|
|
566
|
+
// setupListeners has already done it.
|
|
567
|
+
this.observeViewport();
|
|
568
|
+
}
|
|
569
|
+
disconnectedCallback() {
|
|
570
|
+
// The observer holds a reference to the scroller and the pending frames
|
|
571
|
+
// hold one to the element; a log removed mid-scroll should not keep either
|
|
572
|
+
// alive, or re-render into a shadow root nobody is looking at.
|
|
573
|
+
this.viewportObserver?.disconnect();
|
|
574
|
+
this.viewportObserver = null;
|
|
575
|
+
if (this.scrollFrame)
|
|
576
|
+
cancelAnimationFrame(this.scrollFrame);
|
|
577
|
+
if (this.measureFrame)
|
|
578
|
+
cancelAnimationFrame(this.measureFrame);
|
|
579
|
+
this.scrollFrame = 0;
|
|
580
|
+
this.measureFrame = 0;
|
|
581
|
+
super.disconnectedCallback?.();
|
|
582
|
+
}
|
|
497
583
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
498
584
|
// A different grouping dimension mints different section keys, so the
|
|
499
585
|
// collapse state from the old dimension cannot be carried over.
|
|
@@ -502,11 +588,160 @@ export class AuditLog extends BaseElement {
|
|
|
502
588
|
}
|
|
503
589
|
super.attributeChangedCallback(name, oldValue, newValue);
|
|
504
590
|
}
|
|
591
|
+
/**
|
|
592
|
+
* Render only the sections near the viewport. Opt-in for the same reason
|
|
593
|
+
* `box-table`'s is: a windowed surface needs a bounded height to scroll
|
|
594
|
+
* within, and silently windowing an unbounded one would render nothing.
|
|
595
|
+
*
|
|
596
|
+
* Unlike the table, this *can* window a grouped, variable-height surface —
|
|
597
|
+
* it plans over a cumulative offset index rather than a row count times a
|
|
598
|
+
* row height. Grouping is the whole point of an audit log, so refusing to
|
|
599
|
+
* window it would mean refusing the only shape it comes in.
|
|
600
|
+
*/
|
|
601
|
+
get virtualize() {
|
|
602
|
+
return this.hasAttribute("virtualize");
|
|
603
|
+
}
|
|
604
|
+
set virtualize(value) {
|
|
605
|
+
this.toggleAttribute("virtualize", Boolean(value));
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Starting height estimates, in pixels — not contracts. The real heights
|
|
609
|
+
* depend on density, font, and how much an event's summary wraps; after the
|
|
610
|
+
* first paint the element measures one of each and uses that instead.
|
|
611
|
+
*/
|
|
612
|
+
get rowHeight() {
|
|
613
|
+
return this.measuredHeights?.event ?? this.declaredHeight("row-height", 76);
|
|
614
|
+
}
|
|
615
|
+
get headingHeight() {
|
|
616
|
+
return this.measuredHeights?.heading ?? this.declaredHeight("heading-height", 40);
|
|
617
|
+
}
|
|
618
|
+
set rowHeight(value) {
|
|
619
|
+
this.measuredHeights = null;
|
|
620
|
+
this.setAttribute("row-height", String(value));
|
|
621
|
+
}
|
|
622
|
+
set headingHeight(value) {
|
|
623
|
+
this.measuredHeights = null;
|
|
624
|
+
this.setAttribute("heading-height", String(value));
|
|
625
|
+
}
|
|
626
|
+
declaredHeight(attribute, fallback) {
|
|
627
|
+
const raw = Number(this.getAttribute(attribute));
|
|
628
|
+
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
|
629
|
+
}
|
|
630
|
+
/** The window the last render used, or null when it rendered every section. */
|
|
631
|
+
get renderedWindow() {
|
|
632
|
+
return this.virtualize ? this.lastWindow : null;
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Adopt the real rendered heights, once, when they differ from the ones the
|
|
636
|
+
* last plan used. Re-planning with the measured heights settles immediately —
|
|
637
|
+
* the next measurement matches — so this cannot loop.
|
|
638
|
+
*/
|
|
639
|
+
/** Mean rendered height of the elements matching a part, or 0 if none. */
|
|
640
|
+
meanHeight(part) {
|
|
641
|
+
const nodes = Array.from(this.groupsEl.querySelectorAll(`[part="${part}"]`));
|
|
642
|
+
if (nodes.length === 0)
|
|
643
|
+
return 0;
|
|
644
|
+
const total = nodes.reduce((sum, node) => sum + node.getBoundingClientRect().height, 0);
|
|
645
|
+
return total / nodes.length;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Adopt the real rendered heights when they differ from the ones in use.
|
|
649
|
+
*
|
|
650
|
+
* Two things here exist because the obvious version ran away. Sampling *one*
|
|
651
|
+
* event row and adopting its height looks fine and is not: event rows are not
|
|
652
|
+
* uniform — `summary`, `evidence` and `correlationId` are each optional — so
|
|
653
|
+
* adopting the first rendered row's height moves the window, which renders a
|
|
654
|
+
* different first row, which measures differently, which moves the window.
|
|
655
|
+
* Measured in Chromium with every third event several lines taller: **55 full
|
|
656
|
+
* rebuilds in 0.9 seconds while nothing was scrolling**, the sampled height
|
|
657
|
+
* flipping between 33px and 166.5px.
|
|
658
|
+
*
|
|
659
|
+
* So: average across every rendered row rather than sampling one, which makes
|
|
660
|
+
* the estimate both more representative and far less sensitive to which rows
|
|
661
|
+
* happen to be on screen. And bound the adoptions per row set anyway, because
|
|
662
|
+
* "the average is stable" is an argument about typical data, and this is the
|
|
663
|
+
* code path where being wrong costs a frame-rate collapse rather than a
|
|
664
|
+
* slightly wrong scrollbar.
|
|
665
|
+
*/
|
|
666
|
+
measureRowHeights() {
|
|
667
|
+
if (!this.virtualize || this.measureFrame)
|
|
668
|
+
return;
|
|
669
|
+
const signature = this.heightSignature();
|
|
670
|
+
if (signature !== this.adoptedFor) {
|
|
671
|
+
this.adoptedFor = signature;
|
|
672
|
+
this.adoptionCount = 0;
|
|
673
|
+
}
|
|
674
|
+
if (this.adoptionCount >= MAX_HEIGHT_ADOPTIONS)
|
|
675
|
+
return;
|
|
676
|
+
const headingHeight = this.meanHeight("group-heading");
|
|
677
|
+
const eventHeight = this.meanHeight("event");
|
|
678
|
+
if (headingHeight <= 0 || eventHeight <= 0)
|
|
679
|
+
return;
|
|
680
|
+
if (Math.abs(headingHeight - this.headingHeight) < 1 &&
|
|
681
|
+
Math.abs(eventHeight - this.rowHeight) < 1) {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
this.adoptionCount++;
|
|
685
|
+
this.measuredHeights = { heading: headingHeight, event: eventHeight };
|
|
686
|
+
this.measureFrame = requestAnimationFrame(() => {
|
|
687
|
+
this.measureFrame = 0;
|
|
688
|
+
this.update();
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* What counts as "the same row set" for the adoption budget: change the data,
|
|
693
|
+
* the grouping, the filters or the collapse state and the rows genuinely
|
|
694
|
+
* differ, so measuring again is warranted.
|
|
695
|
+
*/
|
|
696
|
+
heightSignature() {
|
|
697
|
+
return JSON.stringify([
|
|
698
|
+
this.getAttribute("events") ?? "",
|
|
699
|
+
this.groupBy,
|
|
700
|
+
this.facets,
|
|
701
|
+
[...this.collapsedGroups].sort(),
|
|
702
|
+
]);
|
|
703
|
+
}
|
|
704
|
+
currentPlan() {
|
|
705
|
+
const { rows, index } = this.cachedRows();
|
|
706
|
+
return planAuditWindow(rows, { heading: this.headingHeight, event: this.rowHeight }, {
|
|
707
|
+
viewportHeight: this.groupsEl.clientHeight,
|
|
708
|
+
scrollTop: this.groupsEl.scrollTop,
|
|
709
|
+
contentHeight: this.groupsEl.scrollHeight,
|
|
710
|
+
}, index);
|
|
711
|
+
}
|
|
505
712
|
now() {
|
|
506
713
|
return resolveReferenceTime(this.getAttribute("reference-time"));
|
|
507
714
|
}
|
|
715
|
+
/**
|
|
716
|
+
* Grouping, flattening and indexing for the current data, computed once and
|
|
717
|
+
* reused until something that affects them changes.
|
|
718
|
+
*
|
|
719
|
+
* The scroll path asks "did the window move?" on every frame that is not
|
|
720
|
+
* skipped, and answering it used to re-parse the whole `events` attribute,
|
|
721
|
+
* re-filter, re-group, re-flatten and rebuild the offset array — then
|
|
722
|
+
* `update()` did all of it again. That is O(n) twice per frame on a surface
|
|
723
|
+
* whose entire purpose is not being O(n) at scale.
|
|
724
|
+
*/
|
|
725
|
+
rowCache = null;
|
|
726
|
+
cachedRows() {
|
|
727
|
+
const heights = { heading: this.headingHeight, event: this.rowHeight };
|
|
728
|
+
const signature = JSON.stringify([
|
|
729
|
+
this.getAttribute("events") ?? "",
|
|
730
|
+
this.groupBy,
|
|
731
|
+
this.facets,
|
|
732
|
+
this.referenceTime,
|
|
733
|
+
[...this.collapsedGroups].sort(),
|
|
734
|
+
heights,
|
|
735
|
+
]);
|
|
736
|
+
if (this.rowCache?.signature === signature)
|
|
737
|
+
return this.rowCache;
|
|
738
|
+
const groups = groupAuditEvents(this.visibleEvents, this.groupBy, this.now());
|
|
739
|
+
const rows = flattenAuditRows(groups, this.collapsedGroups);
|
|
740
|
+
this.rowCache = { signature, groups, rows, index: buildAuditIndex(rows, heights) };
|
|
741
|
+
return this.rowCache;
|
|
742
|
+
}
|
|
508
743
|
currentGroups() {
|
|
509
|
-
return
|
|
744
|
+
return this.cachedRows().groups;
|
|
510
745
|
}
|
|
511
746
|
patchFacets(patch) {
|
|
512
747
|
this.facets = { ...this.facets, ...patch };
|
|
@@ -545,9 +780,14 @@ export class AuditLog extends BaseElement {
|
|
|
545
780
|
</li>
|
|
546
781
|
`;
|
|
547
782
|
}
|
|
548
|
-
groupHtml(group, index) {
|
|
783
|
+
groupHtml(group, index, slice) {
|
|
549
784
|
const toggleId = `audit-group-toggle-${String(index)}`;
|
|
550
785
|
const bodyId = `audit-group-body-${String(index)}`;
|
|
786
|
+
// Windowed: only the events inside the window. The heading always renders
|
|
787
|
+
// for an intersecting section — [part="group-body"] is aria-labelledby the
|
|
788
|
+
// toggle inside it, so a section without its heading is both unlabelled and
|
|
789
|
+
// impossible to collapse.
|
|
790
|
+
const events = slice ? group.events.slice(slice.start, slice.end) : group.events;
|
|
551
791
|
const actors = group.actorCount
|
|
552
792
|
? ` · ${String(group.actorCount)} ${group.actorCount === 1 ? "actor" : "actors"}`
|
|
553
793
|
: "";
|
|
@@ -560,7 +800,7 @@ export class AuditLog extends BaseElement {
|
|
|
560
800
|
</button>
|
|
561
801
|
</h3>
|
|
562
802
|
<div part="group-body" id="${bodyId}" role="region" aria-labelledby="${toggleId}">
|
|
563
|
-
<ol part="events" role="list">${
|
|
803
|
+
<ol part="events" role="list"${slice && slice.start > 0 ? ` start="${String(slice.start + 1)}"` : ""}>${events.map(event => this.eventHtml(event)).join("")}</ol>
|
|
564
804
|
</div>
|
|
565
805
|
</section>
|
|
566
806
|
`;
|
|
@@ -621,6 +861,27 @@ export class AuditLog extends BaseElement {
|
|
|
621
861
|
this.emptyEl = root.querySelector('[part="empty"]');
|
|
622
862
|
}
|
|
623
863
|
setupListeners() {
|
|
864
|
+
// A windowed surface cannot bootstrap itself from an unmeasured viewport:
|
|
865
|
+
// an empty scroller is zero pixels tall, a zero-height viewport plans an
|
|
866
|
+
// empty window, and an empty window renders nothing to measure. Left alone
|
|
867
|
+
// the log stays blank until something else happens to trigger a render.
|
|
868
|
+
// The observer breaks that circle on the first layout, and keeps earning
|
|
869
|
+
// its place afterwards — a resized container changes how much fits.
|
|
870
|
+
this.observeViewport();
|
|
871
|
+
// Scroll fires continuously; coalesce to one re-render per frame, and skip
|
|
872
|
+
// entirely while the resolved slice is unchanged — most scroll events move
|
|
873
|
+
// the viewport within the sections already rendered.
|
|
874
|
+
this.groupsEl.addEventListener("scroll", () => {
|
|
875
|
+
if (!this.virtualize || this.scrollFrame)
|
|
876
|
+
return;
|
|
877
|
+
this.scrollFrame = requestAnimationFrame(() => {
|
|
878
|
+
this.scrollFrame = 0;
|
|
879
|
+
const next = this.currentPlan().window;
|
|
880
|
+
if (this.lastWindow && sameRowWindow(this.lastWindow, next))
|
|
881
|
+
return;
|
|
882
|
+
this.update();
|
|
883
|
+
});
|
|
884
|
+
}, { passive: true });
|
|
624
885
|
// The toolbar is built once and patched in place, so a select stays open
|
|
625
886
|
// and a half-typed date survives every re-render of the sections below.
|
|
626
887
|
this.toolbarEl.addEventListener("click", event => {
|
|
@@ -755,11 +1016,23 @@ export class AuditLog extends BaseElement {
|
|
|
755
1016
|
: `Showing ${String(visible.length)} of ${String(all.length)} events in ${String(groups.length)} ${groups.length === 1 ? "group" : "groups"}`;
|
|
756
1017
|
// The section markup depends on content only. Collapse state is applied
|
|
757
1018
|
// separately below, so expanding a section never rebuilds the log.
|
|
1019
|
+
// Windowed renders depend on the scroll position too, so the resolved
|
|
1020
|
+
// slice joins the signature — otherwise scrolling would keep the sections
|
|
1021
|
+
// that were already on screen and never draw the ones scrolled into view.
|
|
1022
|
+
const plan = this.virtualize ? this.currentPlan() : null;
|
|
1023
|
+
this.lastWindow = plan?.window ?? null;
|
|
758
1024
|
const contentSignature = JSON.stringify([
|
|
759
1025
|
this.getAttribute("events") ?? "",
|
|
760
1026
|
groupBy,
|
|
761
1027
|
facets,
|
|
762
1028
|
this.referenceTime,
|
|
1029
|
+
// The whole rendered geometry, not just the slice: collapsing a section
|
|
1030
|
+
// *below* the window leaves the slice and the top spacer untouched while
|
|
1031
|
+
// making the log shorter, and a stale bottom spacer outlives the content
|
|
1032
|
+
// it described.
|
|
1033
|
+
plan
|
|
1034
|
+
? [plan.window.startIndex, plan.window.endIndex, plan.paddingTop, plan.paddingBottom]
|
|
1035
|
+
: null,
|
|
763
1036
|
]);
|
|
764
1037
|
if (contentSignature !== this.contentSignature) {
|
|
765
1038
|
this.contentSignature = contentSignature;
|
|
@@ -771,7 +1044,19 @@ export class AuditLog extends BaseElement {
|
|
|
771
1044
|
eventId: active.getAttribute("data-event-id"),
|
|
772
1045
|
}
|
|
773
1046
|
: null;
|
|
774
|
-
|
|
1047
|
+
const spacer = (height, position) => height > 0
|
|
1048
|
+
? `<div part="spacer" data-position="${position}" aria-hidden="true" style="block-size:${String(height)}px;"></div>`
|
|
1049
|
+
: "";
|
|
1050
|
+
this.groupsEl.innerHTML = plan
|
|
1051
|
+
? spacer(plan.paddingTop, "top") +
|
|
1052
|
+
plan.groups
|
|
1053
|
+
.map(planned => this.groupHtml(groups[planned.groupIndex], planned.groupIndex, {
|
|
1054
|
+
start: planned.eventStart,
|
|
1055
|
+
end: planned.eventEnd,
|
|
1056
|
+
}))
|
|
1057
|
+
.join("") +
|
|
1058
|
+
spacer(plan.paddingBottom, "bottom")
|
|
1059
|
+
: groups.map((group, index) => this.groupHtml(group, index)).join("");
|
|
775
1060
|
if (focusKey?.part) {
|
|
776
1061
|
const restored = Array.from(this.groupsEl.querySelectorAll(`[part="${focusKey.part}"]`)).find(node => node.getAttribute("data-group-key") === focusKey.groupKey &&
|
|
777
1062
|
node.getAttribute("data-event-id") === focusKey.eventId);
|
|
@@ -789,6 +1074,9 @@ export class AuditLog extends BaseElement {
|
|
|
789
1074
|
}
|
|
790
1075
|
this.groupsEl.hidden = groups.length === 0;
|
|
791
1076
|
this.emptyEl.hidden = groups.length > 0;
|
|
1077
|
+
if (this.virtualize) {
|
|
1078
|
+
this.measureRowHeights();
|
|
1079
|
+
}
|
|
792
1080
|
}
|
|
793
1081
|
}
|
|
794
1082
|
AuditLog.register();
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windowing plan for the grouped audit log.
|
|
3
|
+
*
|
|
4
|
+
* `box-table` can refuse to window expandable rows because a flat table has an
|
|
5
|
+
* alternative: render everything. A ten-thousand-event audit log does not —
|
|
6
|
+
* grouping is the whole point of the surface, and grouped rows are exactly what
|
|
7
|
+
* fixed-height windowing cannot describe. So this is the case that needs the
|
|
8
|
+
* cumulative-offset engine (`src/core/offset-window.ts`) rather than an excuse.
|
|
9
|
+
*
|
|
10
|
+
* The log is windowed as one flat sequence of rows — each group contributes a
|
|
11
|
+
* heading row, then one row per event while it is expanded — because a single
|
|
12
|
+
* day's section can hold thousands of events on its own. Windowing whole groups
|
|
13
|
+
* would leave that case exactly as slow as it is now.
|
|
14
|
+
*
|
|
15
|
+
* The rendered DOM is still nested, so the flat window has to be turned back
|
|
16
|
+
* into sections. That is what `planAuditWindow` does, and the reason it exists
|
|
17
|
+
* as a pure function is the usual one: jsdom has no layout, so a DOM-level test
|
|
18
|
+
* of a windowed surface can only ever observe zeroes.
|
|
19
|
+
*
|
|
20
|
+
* ## The straddling group
|
|
21
|
+
*
|
|
22
|
+
* A group whose heading has scrolled above the viewport still needs its heading
|
|
23
|
+
* rendered: `[part="group-body"]` is `aria-labelledby` its toggle, and a
|
|
24
|
+
* section that arrives without its own heading is both unlabelled and
|
|
25
|
+
* impossible to collapse. So an intersecting group always renders its heading —
|
|
26
|
+
* which means rendering one heading the offset index has already accounted for
|
|
27
|
+
* in `paddingTop`.
|
|
28
|
+
*
|
|
29
|
+
* At most one group can straddle the top edge, so that is a single correction:
|
|
30
|
+
* take the heading's height back out of the top spacer. The events below then
|
|
31
|
+
* land where the scrollbar says they should, and the reclaimed heading sits
|
|
32
|
+
* directly above them.
|
|
33
|
+
*/
|
|
34
|
+
import type { OffsetIndex } from "../../core/offset-window.js";
|
|
35
|
+
import type { RowWindow } from "../../core/virtualize.js";
|
|
36
|
+
import type { AuditGroup } from "./types.js";
|
|
37
|
+
/** One row of the flattened log: a section heading, or an event inside one. */
|
|
38
|
+
export interface AuditRow {
|
|
39
|
+
kind: "heading" | "event";
|
|
40
|
+
/** Index into the groups array. */
|
|
41
|
+
groupIndex: number;
|
|
42
|
+
/** Index within that group's events; absent on a heading row. */
|
|
43
|
+
eventIndex?: number;
|
|
44
|
+
}
|
|
45
|
+
export interface AuditRowHeights {
|
|
46
|
+
heading: number;
|
|
47
|
+
event: number;
|
|
48
|
+
}
|
|
49
|
+
/** What to render for one group intersecting the window. */
|
|
50
|
+
export interface PlannedGroup {
|
|
51
|
+
groupIndex: number;
|
|
52
|
+
/** First event of this group to render, inclusive. */
|
|
53
|
+
eventStart: number;
|
|
54
|
+
/** Last event of this group to render, exclusive. */
|
|
55
|
+
eventEnd: number;
|
|
56
|
+
/**
|
|
57
|
+
* True when this group's own heading row is inside the window. False means
|
|
58
|
+
* the heading scrolled above the viewport and is being rendered anyway —
|
|
59
|
+
* `paddingTop` has already been corrected for it.
|
|
60
|
+
*/
|
|
61
|
+
headingInWindow: boolean;
|
|
62
|
+
}
|
|
63
|
+
export interface AuditWindowPlan {
|
|
64
|
+
groups: PlannedGroup[];
|
|
65
|
+
paddingTop: number;
|
|
66
|
+
paddingBottom: number;
|
|
67
|
+
totalHeight: number;
|
|
68
|
+
/** The underlying row window, for cheap "did the slice change?" comparisons. */
|
|
69
|
+
window: RowWindow;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The log as a flat row sequence. A collapsed group contributes its heading
|
|
73
|
+
* only — which is exactly why the heights vary and the fixed-height engine
|
|
74
|
+
* cannot describe this surface.
|
|
75
|
+
*/
|
|
76
|
+
export declare const flattenAuditRows: (groups: readonly AuditGroup[], collapsedKeys: ReadonlySet<string>) => AuditRow[];
|
|
77
|
+
export declare const auditRowHeights: (rows: readonly AuditRow[], heights: AuditRowHeights) => number[];
|
|
78
|
+
/**
|
|
79
|
+
* The offset index for a row set, so a caller can build it once and reuse it
|
|
80
|
+
* across scroll frames. The index only changes when the rows or the heights do.
|
|
81
|
+
*/
|
|
82
|
+
export declare const buildAuditIndex: (rows: readonly AuditRow[], heights: AuditRowHeights) => OffsetIndex;
|
|
83
|
+
/**
|
|
84
|
+
* Which sections to render, and the spacer heights around them.
|
|
85
|
+
*
|
|
86
|
+
* Returns an empty plan for an empty window rather than a plan with no groups
|
|
87
|
+
* and stale padding, so a caller can treat "nothing to render" uniformly.
|
|
88
|
+
*/
|
|
89
|
+
export declare const planAuditWindow: (rows: readonly AuditRow[], heights: AuditRowHeights, viewport: {
|
|
90
|
+
viewportHeight: number;
|
|
91
|
+
scrollTop: number;
|
|
92
|
+
overscan?: number;
|
|
93
|
+
/**
|
|
94
|
+
* The scroller's real `scrollHeight`, when the caller can measure it.
|
|
95
|
+
*
|
|
96
|
+
* Two heights per row kind is an estimate, and over thousands of rows the
|
|
97
|
+
* estimate and the rendered reality drift apart — measured at ~0.9% on a
|
|
98
|
+
* 2,000-event log, which sounds harmless and is not: if the estimate runs
|
|
99
|
+
* *long*, scrolling to the real bottom leaves the plan believing there is
|
|
100
|
+
* still a screenful below, and the last rows can never be reached. Content
|
|
101
|
+
* you cannot scroll to is worse than a slightly wrong scrollbar.
|
|
102
|
+
*
|
|
103
|
+
* Given the real height, the scroll position is read as a fraction of the
|
|
104
|
+
* real range and applied to the estimated one, so both ends line up: at the
|
|
105
|
+
* bottom of the scroller the plan is at the bottom of the log.
|
|
106
|
+
*/
|
|
107
|
+
contentHeight?: number;
|
|
108
|
+
},
|
|
109
|
+
/**
|
|
110
|
+
* A prebuilt index for these rows and heights, from `buildAuditIndex`.
|
|
111
|
+
*
|
|
112
|
+
* Optional purely for callers that plan once. The scroll path should pass one:
|
|
113
|
+
* building the index is O(n), and rebuilding it per frame would put the O(n)
|
|
114
|
+
* back that the binary search exists to remove.
|
|
115
|
+
*/
|
|
116
|
+
prebuilt?: OffsetIndex) => AuditWindowPlan;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windowing plan for the grouped audit log.
|
|
3
|
+
*
|
|
4
|
+
* `box-table` can refuse to window expandable rows because a flat table has an
|
|
5
|
+
* alternative: render everything. A ten-thousand-event audit log does not —
|
|
6
|
+
* grouping is the whole point of the surface, and grouped rows are exactly what
|
|
7
|
+
* fixed-height windowing cannot describe. So this is the case that needs the
|
|
8
|
+
* cumulative-offset engine (`src/core/offset-window.ts`) rather than an excuse.
|
|
9
|
+
*
|
|
10
|
+
* The log is windowed as one flat sequence of rows — each group contributes a
|
|
11
|
+
* heading row, then one row per event while it is expanded — because a single
|
|
12
|
+
* day's section can hold thousands of events on its own. Windowing whole groups
|
|
13
|
+
* would leave that case exactly as slow as it is now.
|
|
14
|
+
*
|
|
15
|
+
* The rendered DOM is still nested, so the flat window has to be turned back
|
|
16
|
+
* into sections. That is what `planAuditWindow` does, and the reason it exists
|
|
17
|
+
* as a pure function is the usual one: jsdom has no layout, so a DOM-level test
|
|
18
|
+
* of a windowed surface can only ever observe zeroes.
|
|
19
|
+
*
|
|
20
|
+
* ## The straddling group
|
|
21
|
+
*
|
|
22
|
+
* A group whose heading has scrolled above the viewport still needs its heading
|
|
23
|
+
* rendered: `[part="group-body"]` is `aria-labelledby` its toggle, and a
|
|
24
|
+
* section that arrives without its own heading is both unlabelled and
|
|
25
|
+
* impossible to collapse. So an intersecting group always renders its heading —
|
|
26
|
+
* which means rendering one heading the offset index has already accounted for
|
|
27
|
+
* in `paddingTop`.
|
|
28
|
+
*
|
|
29
|
+
* At most one group can straddle the top edge, so that is a single correction:
|
|
30
|
+
* take the heading's height back out of the top spacer. The events below then
|
|
31
|
+
* land where the scrollbar says they should, and the reclaimed heading sits
|
|
32
|
+
* directly above them.
|
|
33
|
+
*/
|
|
34
|
+
import { buildOffsetIndex, resolveOffsetWindow } from "../../core/offset-window.js";
|
|
35
|
+
/**
|
|
36
|
+
* The log as a flat row sequence. A collapsed group contributes its heading
|
|
37
|
+
* only — which is exactly why the heights vary and the fixed-height engine
|
|
38
|
+
* cannot describe this surface.
|
|
39
|
+
*/
|
|
40
|
+
export const flattenAuditRows = (groups, collapsedKeys) => {
|
|
41
|
+
const rows = [];
|
|
42
|
+
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
|
|
43
|
+
const group = groups[groupIndex];
|
|
44
|
+
rows.push({ kind: "heading", groupIndex });
|
|
45
|
+
if (collapsedKeys.has(group.key))
|
|
46
|
+
continue;
|
|
47
|
+
for (let eventIndex = 0; eventIndex < group.events.length; eventIndex++) {
|
|
48
|
+
rows.push({ kind: "event", groupIndex, eventIndex });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return rows;
|
|
52
|
+
};
|
|
53
|
+
export const auditRowHeights = (rows, heights) => rows.map(row => (row.kind === "heading" ? heights.heading : heights.event));
|
|
54
|
+
/**
|
|
55
|
+
* The scroll offset to plan at, given where the scroller actually is.
|
|
56
|
+
*
|
|
57
|
+
* Without a measured `contentHeight` this is the raw offset. With one, it is
|
|
58
|
+
* the same *fraction* of the estimated range as the reader is through the real
|
|
59
|
+
* one — which is what makes the two ends agree.
|
|
60
|
+
*/
|
|
61
|
+
const mapScrollToEstimate = (viewport, totalHeight) => {
|
|
62
|
+
const { contentHeight, scrollTop, viewportHeight } = viewport;
|
|
63
|
+
if (contentHeight === undefined || !Number.isFinite(contentHeight))
|
|
64
|
+
return scrollTop;
|
|
65
|
+
const realRange = contentHeight - viewportHeight;
|
|
66
|
+
const estimatedRange = totalHeight - viewportHeight;
|
|
67
|
+
// Nothing scrolls, or the estimate already agrees closely enough that the
|
|
68
|
+
// mapping would only add rounding noise.
|
|
69
|
+
if (realRange <= 0 || estimatedRange <= 0)
|
|
70
|
+
return scrollTop;
|
|
71
|
+
const fraction = Math.min(Math.max(0, scrollTop / realRange), 1);
|
|
72
|
+
return fraction * estimatedRange;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* The offset index for a row set, so a caller can build it once and reuse it
|
|
76
|
+
* across scroll frames. The index only changes when the rows or the heights do.
|
|
77
|
+
*/
|
|
78
|
+
export const buildAuditIndex = (rows, heights) => buildOffsetIndex(auditRowHeights(rows, heights));
|
|
79
|
+
/**
|
|
80
|
+
* Which sections to render, and the spacer heights around them.
|
|
81
|
+
*
|
|
82
|
+
* Returns an empty plan for an empty window rather than a plan with no groups
|
|
83
|
+
* and stale padding, so a caller can treat "nothing to render" uniformly.
|
|
84
|
+
*/
|
|
85
|
+
export const planAuditWindow = (rows, heights, viewport,
|
|
86
|
+
/**
|
|
87
|
+
* A prebuilt index for these rows and heights, from `buildAuditIndex`.
|
|
88
|
+
*
|
|
89
|
+
* Optional purely for callers that plan once. The scroll path should pass one:
|
|
90
|
+
* building the index is O(n), and rebuilding it per frame would put the O(n)
|
|
91
|
+
* back that the binary search exists to remove.
|
|
92
|
+
*/
|
|
93
|
+
prebuilt) => {
|
|
94
|
+
const index = prebuilt ?? buildAuditIndex(rows, heights);
|
|
95
|
+
const window = resolveOffsetWindow(index, {
|
|
96
|
+
...viewport,
|
|
97
|
+
scrollTop: mapScrollToEstimate(viewport, index.totalHeight),
|
|
98
|
+
});
|
|
99
|
+
const plan = {
|
|
100
|
+
groups: [],
|
|
101
|
+
paddingTop: window.paddingTop,
|
|
102
|
+
paddingBottom: window.paddingBottom,
|
|
103
|
+
totalHeight: window.totalHeight,
|
|
104
|
+
window,
|
|
105
|
+
};
|
|
106
|
+
if (window.endIndex <= window.startIndex)
|
|
107
|
+
return plan;
|
|
108
|
+
let current = null;
|
|
109
|
+
for (let rowIndex = window.startIndex; rowIndex < window.endIndex; rowIndex++) {
|
|
110
|
+
const row = rows[rowIndex];
|
|
111
|
+
if (!current || current.groupIndex !== row.groupIndex) {
|
|
112
|
+
current = {
|
|
113
|
+
groupIndex: row.groupIndex,
|
|
114
|
+
eventStart: row.kind === "event" ? row.eventIndex : 0,
|
|
115
|
+
eventEnd: row.kind === "event" ? row.eventIndex : 0,
|
|
116
|
+
headingInWindow: row.kind === "heading",
|
|
117
|
+
};
|
|
118
|
+
plan.groups.push(current);
|
|
119
|
+
}
|
|
120
|
+
if (row.kind === "event") {
|
|
121
|
+
current.eventEnd = row.eventIndex + 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// The straddling group: its heading is above the window but will be rendered,
|
|
125
|
+
// so give its height back from the top spacer. Only the first planned group
|
|
126
|
+
// can be in this position — every later one starts at its own heading.
|
|
127
|
+
const first = plan.groups[0];
|
|
128
|
+
if (first && !first.headingInWindow) {
|
|
129
|
+
plan.paddingTop = Math.max(0, plan.paddingTop - heights.heading);
|
|
130
|
+
}
|
|
131
|
+
return plan;
|
|
132
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unofficialbox/box-open-elements",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Box Open Elements: an open, framework-agnostic design system and web component library for Box-style experiences",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://unofficialbox.github.io/box-open-elements",
|