diff --git a/src/ts/events.ts b/src/ts/events.ts index 68583b8..f667023 100644 --- a/src/ts/events.ts +++ b/src/ts/events.ts @@ -36,6 +36,9 @@ export class RegularViewEventModel extends RegularVirtualTableViewModel { private _memo_touch_startY?: number; private _memo_touch_startX?: number; private _last_clicked_time?: number; + private _scroll_dirty?: boolean; + private _scroll_left?: number; + private _scroll_top?: number; register_listeners() { // // TODO see `_on_click_or_dblclick` method jsdoc // this.addEventListener("dblclick", this._on_dblclick.bind(this)); @@ -56,18 +59,40 @@ export class RegularViewEventModel extends RegularVirtualTableViewModel { */ async _on_scroll(event: Event) { event.stopPropagation(); + this._scroll_left = this.scrollLeft; + this._scroll_top = this.scrollTop; if (this._scroll_pending) { + this._scroll_dirty = true; return; } this._scroll_pending = true; + const width = this._table_clip.clientWidth; + const height = this._table_clip.clientHeight; + const container_height = this.clientHeight; try { - await new Promise(requestAnimationFrame); + await throttle_tag(this, async () => { + do { + this._scroll_dirty = false; + const commit = await this.predraw(width, height, { + invalid_viewport: false, + cache: true, + throttle: false, + container_height, + scroll_left: this._scroll_left, + scroll_top: this._scroll_top, + }); + + if (!commit.inline) { + await new Promise(requestAnimationFrame); + commit(); + } + } while (this._scroll_dirty); + }); } finally { this._scroll_pending = false; } - await this.draw({ invalid_viewport: false, cache: true }); this.dispatchEvent(new CustomEvent("regular-table-scroll")); } @@ -232,7 +257,9 @@ export class RegularViewEventModel extends RegularVirtualTableViewModel { } td.classList.remove("rt-cell-clip"); - this.table_model.body._untagColumn(td); + if (!this._column_classes) { + this.table_model.body._untagColumn(td); + } } } @@ -384,16 +411,20 @@ export class RegularViewEventModel extends RegularVirtualTableViewModel { // Update header clipping class th.classList.toggle("rt-cell-clip", should_clip); - // Update body cell clipping classes. Clipped cells must carry the - // column tag so the override's `max-width` clamp reaches them. + // Update body cell clipping classes. Clipped cells must carry + // the `rt-col-{size_key}` class so the override's `max-width` + // clamp reaches them; under the opt-in `column_classes` + // annotation they already do. for (const row of this.table_model.body.cells) { const td = row[virtual_x]; if (td) { td.classList.toggle("rt-cell-clip", should_clip); - if (should_clip) { - this.table_model.body._tagColumn(td, size_key); - } else { - this.table_model.body._untagColumn(td); + if (!this._column_classes) { + if (should_clip) { + this.table_model.body._tagColumn(td, size_key); + } else { + this.table_model.body._untagColumn(td); + } } } } diff --git a/src/ts/regular-table.ts b/src/ts/regular-table.ts index 459ef97..ae30439 100644 --- a/src/ts/regular-table.ts +++ b/src/ts/regular-table.ts @@ -61,14 +61,23 @@ export class RegularTableElement extends RegularViewEventModel { this.register_listeners(); this.setAttribute("tabindex", "0"); this._initialized = true; - this.table_model = new RegularTableViewModel( - this._table_clip, - this._column_sizes, - this, - ); + this._create_table_model(); } } + private _create_table_model(): void { + this.table_model?.dispose(); + this.table_model = new RegularTableViewModel( + this._table_clip, + this._column_sizes, + this, + ); + + this.table_model._on_autosize_change = () => + this._autosize_fill_check(); + this.table_model.body._column_classes = this._column_classes; + } + /** * Reset column autosizing, such that column sizes will be recalculated * on the next draw() call. @@ -113,11 +122,7 @@ export class RegularTableElement extends RegularViewEventModel { * Clears the current renderer ``. */ clear(): void { - this.table_model = new RegularTableViewModel( - this._table_clip, - this._column_sizes, - this, - ); + this._create_table_model(); } /** @@ -336,6 +341,7 @@ export class RegularTableElement extends RegularViewEventModel { { virtual_mode = "both", preserve_state = false, + column_classes = false, }: SetDataListenerOptions = {}, ): void { console.assert( @@ -353,6 +359,11 @@ export class RegularTableElement extends RegularViewEventModel { this._view_cache.view = dataListener as any; } else { this._virtual_mode = virtual_mode; + this._column_classes = column_classes; + if (this.table_model) { + this.table_model.body._column_classes = column_classes; + } + this._invalid_schema = true; this._view_cache = { view: dataListener as any, diff --git a/src/ts/scroll_panel.ts b/src/ts/scroll_panel.ts index 7bd655f..a858857 100644 --- a/src/ts/scroll_panel.ts +++ b/src/ts/scroll_panel.ts @@ -100,6 +100,8 @@ export class RegularVirtualTableViewModel extends HTMLElement { protected table_model!: RegularTableViewModel; protected _style_callbacks!: Array; protected _scroll_pending?: boolean; + protected _num_columns?: number; + protected _column_classes?: boolean; private _probe_element?: [HTMLElement, HTMLElement]; /** @@ -171,13 +173,20 @@ export class RegularVirtualTableViewModel extends HTMLElement { scroll_left = this.scrollLeft, scroll_top = this.scrollTop, container_height = height, + throttle, } = options; const noop = (): PredrawCommit => Object.assign(() => true, { inline: true }); const inline = async (): Promise => { - await this.draw({ invalid_viewport, preserve_width, cache }); + await this.draw({ + invalid_viewport, + preserve_width, + cache, + throttle, + }); + return noop(); }; @@ -366,6 +375,7 @@ export class RegularVirtualTableViewModel extends HTMLElement { } const dims = await this.table_model._getDimState(this._view_cache); + this._num_columns = dims.num_columns || 0; this._column_sizes.row_height = dims.row_height || this._column_sizes.row_height; @@ -496,6 +506,41 @@ export class RegularVirtualTableViewModel extends HTMLElement { return end_col; } + protected _autosize_fill_check(): void { + if ( + this._start_col === undefined || + this._end_col === undefined || + !this._view_cache || + this._virtual_mode === "none" || + this._virtual_mode === "vertical" || + this._end_col >= (this._num_columns ?? 0) + ) { + return; + } + + const { indices, override } = this._column_sizes; + const row_headers_length = this._view_cache.row_headers_length; + let width = 0; + for (let i = 0; i < row_headers_length; i++) { + width += indices[i] || 0; + } + + for (let cidx = this._start_col; cidx < this._end_col; cidx++) { + const size_key = row_headers_length + cidx; + const w = override[size_key] ?? indices[size_key]; + if (w === undefined) { + return; + } + + width += w; + } + + const sub_cell_offset = indices[row_headers_length + this._start_col]; + if (width - (sub_cell_offset ?? 0) <= this._container_size.width) { + this.draw({ invalid_viewport: true }); + } + } + /** * Probe the shadow DOM to measure the default row height from CSS. */ diff --git a/src/ts/table.ts b/src/ts/table.ts index 39efce0..6fe64a4 100644 --- a/src/ts/table.ts +++ b/src/ts/table.ts @@ -27,6 +27,7 @@ import { ViewState, } from "./types"; import { ColumnSizes } from "./types"; +import { COLUMN_TAG_MAP } from "./view_model"; /** * Base class containing protected helper methods for table rendering. @@ -334,14 +335,13 @@ abstract class RegularTableViewModelBase { cont_body: BodyDrawResult | undefined, _virtual_x: number, ): void { - this.body.clean({ ridx: cont_body?.ridx || 0, cidx: _virtual_x }); + this.body.clean({ + ridx: cont_body?.ridx || 0, + cidx: _virtual_x, + drawn_ridx: cont_body?.drawn_ridx ?? cont_body?.ridx ?? 0, + }); this.header.clean(); } - - protected _carriageReturn() { - this.body._span_factory.reset(); - this.header._span_factory.reset(); - } } /** @@ -362,6 +362,17 @@ export class RegularTableViewModel extends RegularTableViewModelBase { private _scope_class: string; private _lastDataResponse?: DataResponse; private _lastViewport?: Viewport; + private _autosize_observer?: ResizeObserver; + private _observed_cells: WeakSet = new WeakSet(); + private _measured_overrides: Record = {}; + private _draw_in_flight = false; + + /** + * Invoked (post-layout, from the autosize `ResizeObserver`) after any + * observed column width amendment, so the host element can re-validate + * viewport fill without a draw-path DOM read. + */ + public _on_autosize_change?: () => void; constructor( table_clip: HTMLElement, @@ -383,15 +394,77 @@ export class RegularTableViewModel extends RegularTableViewModelBase { this._scope_class = `rt-scope-${_instance_counter++}`; this.table.classList.add(this._scope_class); + this._autosize_observer = new ResizeObserver( + this._on_autosize_entries.bind(this), + ); + this.header = new RegularHeaderViewModel( column_sizes, table_clip, thead, + (th) => this._observe_autosize(th), ); this.body = new RegularBodyViewModel(column_sizes, table_clip, tbody); } + dispose(): void { + this._autosize_observer?.disconnect(); + } + + private _observe_autosize(cell: HTMLTableCellElement): void { + if (!this._autosize_observer || this._observed_cells.has(cell)) { + return; + } + + this._observed_cells.add(cell); + this._autosize_observer.observe(cell); + } + + private _on_autosize_entries(entries: ResizeObserverEntry[]): void { + if (this._draw_in_flight) { + for (const entry of entries) { + this._autosize_observer!.unobserve(entry.target); + this._autosize_observer!.observe(entry.target); + } + + return; + } + + const zoom = this.table.currentCSSZoom ?? 1; + const amendments: Array<[number, number]> = []; + for (const entry of entries) { + const size_key = COLUMN_TAG_MAP.get(entry.target); + if (size_key === undefined) { + continue; + } + + if (this._column_sizes.override[size_key] !== undefined) { + continue; + } + + const width = entry.target.getBoundingClientRect().width / zoom; + if (!width) { + continue; + } + + if (this._column_sizes.indices[size_key] !== width) { + amendments.push([size_key, width]); + } + } + + if (amendments.length > 0) { + this._ensureColumnWidthSheet(); + for (const [size_key, width] of amendments) { + this._column_sizes.indices[size_key] = width; + this._column_sizes.auto[size_key] = width; + this._updateColumnRule(size_key); + } + + this._on_autosize_change?.(); + } + } + num_columns(): number { return this.header.num_columns(); } @@ -401,6 +474,26 @@ export class RegularTableViewModel extends RegularTableViewModelBase { '
'; } + private _needs_autosize(last_cells: CellTuple[]): boolean { + for (const [, metadata] of last_cells) { + const size_key = metadata?.size_key; + if (size_key === undefined) { + return true; + } + + const override_width = this._column_sizes.override[size_key]; + if (override_width !== undefined) { + if (this._measured_overrides[size_key] !== override_width) { + return true; + } + } else if (!this._column_sizes.indices[size_key]) { + return true; + } + } + + return false; + } + /** * Calculate amendments to auto size from this render pass. * Uses adoptedStyleSheets with :nth-child selectors for optimal performance. @@ -440,13 +533,17 @@ export class RegularTableViewModel extends RegularTableViewModelBase { if (metadata?.size_key !== undefined) { this._column_sizes.indices[metadata.size_key] = box.width / zoom; - if ( - box.width / zoom && - this._column_sizes.override[metadata.size_key] === undefined - ) { + const override_width = + this._column_sizes.override[metadata.size_key]; + if (box.width / zoom && override_width === undefined) { this._column_sizes.auto[metadata.size_key] = box.width / zoom; } + + if (override_width !== undefined) { + this._measured_overrides[metadata.size_key] = + override_width; + } } } } @@ -640,6 +737,7 @@ export class RegularTableViewModel extends RegularTableViewModelBase { view_response, ); + this._draw_in_flight = true; try { let step = render_pass.next(); while (!step.done) { @@ -651,6 +749,7 @@ export class RegularTableViewModel extends RegularTableViewModelBase { ); } } finally { + this._draw_in_flight = false; render_pass.return(false); } } @@ -799,6 +898,7 @@ export class RegularTableViewModel extends RegularTableViewModelBase { // Fetch missing columns if needed if (!view_response.data[dcidx]) { // Style the partially-renderd rows so there is no FOUT + this.header.flush_colspans(); this.updateColumnWidthStyles( viewport, view_cache.row_headers_length, @@ -830,17 +930,18 @@ export class RegularTableViewModel extends RegularTableViewModelBase { if (!view_response.data[dcidx]) { this._cleanupAfterDraw(cont_body, _virtual_x); - this._carriageReturn(); this.updateColumnWidthStyles( viewport, view_cache.row_headers_length, ); yield "style"; - this.autosize_cells( - last_cells, - this._column_sizes.row_height, - ); + if (this._needs_autosize(last_cells)) { + this.autosize_cells( + last_cells, + this._column_sizes.row_height, + ); + } return this._isViewportFilled( view_state, @@ -919,42 +1020,48 @@ export class RegularTableViewModel extends RegularTableViewModelBase { // estimate-based filled check above is final; a follow-up // draw (e.g. on resize mouseup) re-measures. if (preserve_width) { - this._carriageReturn(); return true; } // Recalculate after style listeners view_state.viewport_width = 0; - this.autosize_cells( - last_cells, - this._column_sizes.row_height, - ); + if (this._needs_autosize(last_cells)) { + this.autosize_cells( + last_cells, + this._column_sizes.row_height, + ); + } // Newly-visited columns are now measured; force a fresh // read if a later pass draws further unmeasured columns. unmeasured_col_width = 0; + const row_headers_length = + view_state.row_headers_length || 0; for (let i = 0; i < last_cells.length; i++) { + const size_key = + i < row_headers_length ? i : Math.floor(x0) + i; view_state.viewport_width += - this._column_sizes.indices[Math.floor(x0) + i] || 0; + this._column_sizes.indices[size_key] || 0; } if (this._isViewportFilled(view_state, container_width)) { - this._carriageReturn(); return true; } } } this._cleanupAfterDraw(cont_body, _virtual_x); - this._carriageReturn(); this.updateColumnWidthStyles( viewport, view_cache.row_headers_length, ); yield "style"; - this.autosize_cells(last_cells, this._column_sizes.row_height); + if (this._needs_autosize(last_cells)) { + this.autosize_cells(last_cells, this._column_sizes.row_height); + } + return true; } finally { this._cleanupAfterDraw(cont_body, _virtual_x); diff --git a/src/ts/tbody.ts b/src/ts/tbody.ts index 8c03453..8dc0c24 100644 --- a/src/ts/tbody.ts +++ b/src/ts/tbody.ts @@ -17,7 +17,7 @@ import { ColumnState, ViewState, } from "./types"; -import { ViewModel } from "./view_model"; +import { METADATA_MAP, ViewModel } from "./view_model"; /** * view model. @@ -25,6 +25,8 @@ import { ViewModel } from "./view_model"; * @class RegularBodyViewModel */ export class RegularBodyViewModel extends ViewModel { + public _column_classes?: boolean; + _draw_td( tagName: string, ridx: number, @@ -42,26 +44,14 @@ export class RegularBodyViewModel extends ViewModel { metadata.column_header = column_name; } - // Handle clipping class for overridden columns. A body cell carries - // its column tag only while clipped by an override — the `max-width` - // clamp must reach it. Unclipped cells need no width rule (the header - // cell's `min-width` floors the column), so leaving them untagged - // keeps the width rules' matched set to one cell per column. const override_width = this._column_sizes.override[key]; - if (override_width) { - const auto_width = this._column_sizes.auto[key] || 0; - const clip = auto_width > override_width; - if (td.classList.contains("rt-cell-clip") !== clip) { - td.classList.toggle("rt-cell-clip", clip); - } - - if (clip) { - this._tagColumn(td, key); - } else { - this._untagColumn(td); - } + const clip = + !!override_width && + (this._column_sizes.auto[key] || 0) > override_width; + td.classList.toggle("rt-cell-clip", clip); + if (this._column_classes || clip) { + this._tagColumn(td, key); } else { - td.classList.remove("rt-cell-clip"); this._untagColumn(td); } @@ -102,6 +92,8 @@ export class RegularBodyViewModel extends ViewModel { let ridx = 0; const cidx_offset: number[] = []; const loops = th ? (view_state.row_headers_length ?? 1) : 1; + const overdraw = (view_state.ridx_offset ?? 0) % 1 !== 0 ? 1 : 0; + let broke = false; const y0_floor = Math.floor(view_state.ridx_offset); const y1_ceil = Math.ceil(view_state.y1); const x1_ceil = Math.ceil(view_state.x1); @@ -111,6 +103,7 @@ export class RegularBodyViewModel extends ViewModel { for (let i = 0; i < loops; i++) { ridx = 0; + broke = false; const cidx_i = cidx + i; for (const val of column_data) { @@ -228,17 +221,69 @@ export class RegularBodyViewModel extends ViewModel { ridx++; metadata = obj ? obj.metadata : metadata; row_height = row_height || obj?.td.offsetHeight; - if (ridx * (row_height ?? 0) > container_height) { + if ((ridx - overdraw) * (row_height ?? 0) > container_height) { + broke = true; break; } } } - this._clean_rows(ridx); - return { tds, ridx, row_height }; + + let clean_ridx = broke ? ridx : ridx + overdraw; + if (clean_ridx > ridx) { + const stale = this.rows[ridx]; + const prev = this.rows[ridx - 1]; + const mergeable = + !!merge_headers && (view_state.row_headers_length ?? 0) > 0; + + if ( + mergeable || + !stale || + !prev || + stale.children.length !== prev.children.length || + stale.querySelector("[colspan],[rowspan]") !== null || + prev.querySelector("[colspan],[rowspan]") !== null + ) { + clean_ridx = ridx; + } + } + + this._clean_rows(clean_ridx); + return { tds, ridx: clean_ridx, drawn_ridx: ridx, row_height }; } - clean({ ridx, cidx }: { ridx: number; cidx: number }): void { + clean({ + ridx, + cidx, + drawn_ridx = ridx, + }: { + ridx: number; + cidx: number; + drawn_ridx?: number; + }): void { this._clean_rows(ridx); this._clean_columns(cidx); + + if (drawn_ridx < this.rows.length) { + const stale = this.rows[drawn_ridx]; + const prev = this.rows[drawn_ridx - 1]; + const last = (tr: HTMLTableRowElement | undefined) => + tr?.children[tr.children.length - 1] as + HTMLTableCellElement | undefined; + + const stale_last = last(stale); + const prev_last = last(prev); + const stale_meta = (stale_last && METADATA_MAP.get(stale_last)) as + { x?: number } | undefined; + const prev_meta = (prev_last && METADATA_MAP.get(prev_last)) as + { x?: number } | undefined; + if ( + !stale_meta || + !prev_meta || + stale_meta.x !== prev_meta.x || + stale.children.length !== prev!.children.length + ) { + this._clean_rows(drawn_ridx); + } + } } } diff --git a/src/ts/thead.ts b/src/ts/thead.ts index 21d6462..6ea5e47 100644 --- a/src/ts/thead.ts +++ b/src/ts/thead.ts @@ -20,6 +20,11 @@ import { CellMetadataBuilder, } from "./types"; +const HEADER_STATE: WeakMap< + HTMLTableCellElement, + { value: unknown; span: HTMLElement; resize: HTMLElement } +> = new WeakMap(); + /** * view model. This model accumulates state in the form of * column_sizes, which leverages autosize behavior across @@ -34,15 +39,18 @@ export class RegularHeaderViewModel extends ViewModel { number, ][]; private _offset_cache: number[]; + private _observe_autosize?: (th: HTMLTableCellElement) => void; constructor( column_sizes: ColumnSizes, container: HTMLElement, table: HTMLElement, + observe_autosize?: (th: HTMLTableCellElement) => void, ) { super(column_sizes, container, table); this._group_header_cache = []; this._offset_cache = []; + this._observe_autosize = observe_autosize; } _draw_group_th( @@ -52,19 +60,40 @@ export class RegularHeaderViewModel extends ViewModel { ): HTMLTableCellElement { const th = this._get_cell("TH", d, offset_cache[d] || 0); offset_cache[d] += 1; + let state = HEADER_STATE.get(th); + if ( + state !== undefined && + state.value === column && + th.lastChild === state.resize && + th.firstChild === + (column instanceof HTMLElement ? column : state.span) + ) { + return th; + } + th.removeAttribute("colspan"); th.textContent = ""; + if (state === undefined) { + const resize = document.createElement("span"); + resize.className = "rt-column-resize"; + state = { + value: undefined, + span: document.createElement("span"), + resize, + }; + + HEADER_STATE.set(th, state); + } + if (column instanceof HTMLElement) { th.appendChild(column); } else { - const span = this._span_factory.get(); - span.textContent = String(column ?? ""); - th.appendChild(span); + state.span.textContent = String(column ?? ""); + th.appendChild(state.span); } - const resizeSpan = this._span_factory.get(); - resizeSpan.className = "rt-column-resize"; - th.appendChild(resizeSpan); + th.appendChild(state.resize); + state.value = column; return th; } @@ -94,20 +123,16 @@ export class RegularHeaderViewModel extends ViewModel { metadata.size_key = Array.isArray(size_key) ? size_key[0] : size_key; if (!Array.isArray(size_key) || size_key.length <= 1) { this._tagColumn(th, metadata.size_key || 0); + this._observe_autosize?.(th); const override_width = this._column_sizes.override[metadata.size_key || 0]; const auto_width = this._column_sizes.auto[metadata.size_key || 0] || 0; - // Handle clipping class for overridden columns - if (override_width) { - th.classList.toggle( - "rt-cell-clip", - auto_width > override_width, - ); - } else { - th.classList.remove("rt-cell-clip"); - } + th.classList.toggle( + "rt-cell-clip", + !!override_width && auto_width > override_width, + ); } else { // A leaf header spanning multiple columns has no single width. this._untagColumn(th); @@ -159,11 +184,8 @@ export class RegularHeaderViewModel extends ViewModel { this._group_header_cache[d][0].row_header_x = Array.isArray(size_key) ? size_key[0] : size_key; } - th.setAttribute( - "colspan", - String(this._group_header_cache[d][2]), - ); } else { + this._flush_colspan(d); th = this._draw_group_th( this._offset_cache, d, @@ -232,7 +254,31 @@ export class RegularHeaderViewModel extends ViewModel { return output; } + private _flush_colspan(d: number): void { + const entry = this._group_header_cache[d]; + if (!entry) { + return; + } + + const [, th, count] = entry; + if (count > 1) { + const val = String(count); + if (th.getAttribute("colspan") !== val) { + th.setAttribute("colspan", val); + } + } else if (th.hasAttribute("colspan")) { + th.removeAttribute("colspan"); + } + } + + flush_colspans(): void { + for (let d = 0; d < this._group_header_cache.length; d++) { + this._flush_colspan(d); + } + } + clean(): void { + this.flush_colspans(); this._clean_columns(this._offset_cache); } diff --git a/src/ts/types.ts b/src/ts/types.ts index 4c405e7..0ce096a 100644 --- a/src/ts/types.ts +++ b/src/ts/types.ts @@ -82,7 +82,16 @@ export type DataListener = ( * `DataResponse.row_headers[y]`, this property is only generated for `` * from `row_headers`. * @property {number} size_key - The unique index of this column in a full - * ``, which is `x` + (Total Row Header Columns). + * `
`, which is `x` + (Total Row Header Columns). With the + * `column_classes` option of `setDataListener()` enabled, every rendered + * leaf header and body cell of the column carries a stable + * `rt-col-{size_key}` class; target it from your own stylesheet (e.g. a + * `CSSStyleSheet` adopted on the same root) to apply column-scoped + * declarations such as `text-align` or `color` without styling individual + * cells - the classes follow the columns as the table scrolls. + * `regular-table`'s own generated width rules target the same classes at + * higher specificity, so column sizing cannot be accidentally overridden by + * a bare `.rt-col-*` rule. * @property {(string|HTMLElement)[]} [row_header] - The `Array` for this `y` in * `DataResponse.row_headers`, if it was provided. * @property {(string|HTMLElement)[]} [column_header] - The `Array` for this `x` @@ -255,10 +264,17 @@ export interface DataResponse { * The `virtual_mode` options flag may be one of "both", "horizontal", * "vertical", or "none" indicating which dimensions of the table should be * virtualized (vs. rendering completely). + * @param {boolean} options.column_classes When set, every leaf header and + * body cell is annotated with a stable `rt-col-{size_key}` class identifying + * its logical column, which your own stylesheets can target to apply + * column-scoped declarations (see the `size_key` `MetaData` docs). Off by + * default - without it, only the cells regular-table's own column sizing + * requires carry the class. */ export interface SetDataListenerOptions { virtual_mode?: VirtualMode; preserve_state?: boolean; + column_classes?: boolean; } /** @@ -362,6 +378,7 @@ export interface PredrawOptions { scroll_left?: number; scroll_top?: number; container_height?: number; + throttle?: boolean; } /** @@ -449,6 +466,7 @@ export interface BodyDrawResult { tds: Array<{ td: HTMLTableCellElement; metadata: CellMetadata }>; row_height?: number; ridx?: number; + drawn_ridx?: number; } /** diff --git a/src/ts/view_model.ts b/src/ts/view_model.ts index edc0bcf..5d6f7d2 100644 --- a/src/ts/view_model.ts +++ b/src/ts/view_model.ts @@ -19,7 +19,7 @@ export const METADATA_MAP: WeakMap = new WeakMap(); // class, so `_tagColumn` can cheaply detect when a pooled cell changes columns // without a DOM read. Module-level so `thead`/`tbody` models and the event // model share one tracker. -const COLUMN_TAG_MAP: WeakMap = new WeakMap(); +export const COLUMN_TAG_MAP: WeakMap = new WeakMap(); /****************************************************************************** * @@ -27,35 +27,9 @@ const COLUMN_TAG_MAP: WeakMap = new WeakMap(); * */ -class ElemFactory { - private _name: string; - private _elements: HTMLElement[]; - private _index: number; - constructor(name: string) { - this._name = name; - this._elements = []; - this._index = 0; - } - - reset() { - this._index = 0; - } - - get() { - if (!this._elements[this._index]) { - this._elements[this._index] = document.createElement(this._name); - } - - const elem = this._elements[this._index]; - this._index += 1; - return elem; - } -} - export class ViewModel { protected _column_sizes: ColumnSizes; protected _container: HTMLElement; - public _span_factory: ElemFactory; public table: HTMLElement; public cells: (HTMLTableCellElement | undefined)[][]; public rows: HTMLTableRowElement[]; @@ -67,7 +41,6 @@ export class ViewModel { ) { this._column_sizes = column_sizes; this._container = container; - this._span_factory = new ElemFactory("span"); this.table = table; this.cells = []; this.rows = []; @@ -226,6 +199,8 @@ export class ViewModel { } _clean_rows(ridx: number): void { + ridx = Math.min(ridx, this.rows.length); + // Batch collect rows to remove, then remove all at once const toRemove: Element[] = []; for (let i = ridx; i < this.table.children.length; i++) { diff --git a/tests/columnClass.spec.js b/tests/columnClass.spec.js new file mode 100644 index 0000000..312164f --- /dev/null +++ b/tests/columnClass.spec.js @@ -0,0 +1,93 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░▀█▀░█▀█░█▀▄░█░░░█▀▀░▀▄░░░░░░░░░░ +// ░░░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░░█░░█▀█░█▀▄░█░░░█▀▀░░▄▀░░░░░░░░░ +// ░░░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░░▀░░▀░▀░▀▀░░▀▀▀░▀▀▀░▀░░░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * Copyright (c) 2020, the Regular Table Authors. This file is part * ┃ +// ┃ * of the Regular Table library, distributed under the terms of the * ┃ +// ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect } from "@playwright/test"; + +test.describe("`rt-col-{size_key}` column class contract", () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 600, height: 400 }); + await page.goto("/tests/api.html"); + await page.waitForSelector("regular-table table tbody tr td"); + }); + + test("body cells are not annotated by default", async ({ page }) => { + const result = await page.evaluate(async () => { + const table = document.querySelector("regular-table"); + await table.draw(); + let tagged = 0; + for (const td of table.querySelectorAll("tbody td")) { + if ( + Array.from(td.classList).some((c) => + c.startsWith("rt-col-"), + ) + ) { + tagged++; + } + } + + return tagged; + }); + + expect(result).toBe(0); + }); + + test("column_classes is applied correctly", async ({ page }) => { + const result = await page.evaluate(async () => { + const table = document.querySelector("regular-table"); + table.setDataListener(window.dataListener, { + column_classes: true, + }); + await table.draw(); + let checked = 0; + const missing = []; + for (const cell of table.querySelectorAll( + "tbody td, tbody th, thead tr:last-child th", + )) { + const meta = table.getMeta(cell); + if (meta?.size_key === undefined) { + continue; + } + + checked++; + if (!cell.classList.contains(`rt-col-${meta.size_key}`)) { + missing.push(cell.outerHTML.slice(0, 60)); + } + } + + return { checked, missing }; + }); + + expect(result.checked).toBeGreaterThan(50); + expect(result.missing).toEqual([]); + }); + + test("horizontal scroll respects column_classes", async ({ page }) => { + const result = await page.evaluate(async () => { + const table = document.querySelector("regular-table"); + table.setDataListener(window.dataListener, { + column_classes: true, + }); + table.scrollLeft = 500; + await table.draw(); + const mismatched = []; + for (const td of table.querySelectorAll("tbody td")) { + const meta = table.getMeta(td); + if (!td.classList.contains(`rt-col-${meta.size_key}`)) { + mismatched.push(meta.size_key); + } + } + + return mismatched; + }); + + expect(result).toEqual([]); + }); +}); diff --git a/tests/columnResize.spec.js b/tests/columnResize.spec.js index 9575894..587c2c3 100644 --- a/tests/columnResize.spec.js +++ b/tests/columnResize.spec.js @@ -16,6 +16,11 @@ test.describe("Column resize behavior", () => { await page.setViewportSize({ width: 600, height: 400 }); await page.goto("/tests/api.html"); await page.waitForSelector("regular-table table tbody tr td"); + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + }); }); test.describe("resize handle presence", () => { @@ -658,6 +663,13 @@ test.describe("Column resize behavior", () => { }) => { const table = page.locator("regular-table"); + const th = page.locator( + "regular-table thead tr:last-child th:nth-child(2)", + ); + const width_before = await th.evaluate( + (el) => el.getBoundingClientRect().width, + ); + const resizeHandle = page.locator( "regular-table thead tr:last-child th:nth-child(2) .rt-column-resize", ); @@ -677,7 +689,10 @@ test.describe("Column resize behavior", () => { return el.saveColumnSizes(); }); - expect(hasOverride).toStrictEqual({ 1: 143.34375 }); + expect(hasOverride[1]).toBeCloseTo( + width_before + (100 - box.width / 2), + 0, + ); }); }); }); diff --git a/tests/getMeta.spec.js b/tests/getMeta.spec.js index c5360f3..a6c9653 100644 --- a/tests/getMeta.spec.js +++ b/tests/getMeta.spec.js @@ -113,7 +113,7 @@ test.describe("getMeta()", () => { value: "16", x: 16, x0: 16, - x1: 24, + x1: 23, y: 0, y0: 0, y1: 8, diff --git a/tests/setDataListener.spec.js b/tests/setDataListener.spec.js index e7a8429..92f7efc 100644 --- a/tests/setDataListener.spec.js +++ b/tests/setDataListener.spec.js @@ -13,8 +13,14 @@ import { test, expect } from "@playwright/test"; test.describe("setDataListener()", () => { test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 400, height: 300 }); await page.goto("/tests/api.html"); await page.waitForSelector("regular-table table tbody tr td"); + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + }); }); test.describe("basic functionality", () => { @@ -76,8 +82,8 @@ test.describe("setDataListener()", () => { expect(callbackArgs.length).toBe(3); expect(callbackArgs).toStrictEqual([ { x0: 0, x1: 0, y0: 0, y1: 0 }, - { x0: 0, x1: 22, y0: 0, y1: 38 }, - { x0: 0, x1: 5, y0: 0, y1: 38 }, + { x0: 0, x1: 8, y0: 0, y1: 16 }, + { x0: 0, x1: 5, y0: 0, y1: 16 }, ]); }); diff --git a/tests/sub_cell_scrolling.spec.js b/tests/sub_cell_scrolling.spec.js index 603cd33..33ece74 100644 --- a/tests/sub_cell_scrolling.spec.js +++ b/tests/sub_cell_scrolling.spec.js @@ -80,9 +80,20 @@ test.describe("CSS variables and sub-cell scrolling", () => { }) => { const table = page.locator("regular-table"); + await table.evaluate(async (el) => { + const overrides = {}; + for (let i = 0; i < 40; i++) { + overrides[i] = 60; + } + + el.restoreColumnSizes(overrides); + await el.draw(); + await el.draw(); + }); + // Scroll horizontally by a fractional amount await table.evaluate(async (el) => { - el.scrollLeft = 180; + el.scrollLeft = 190; await el.draw(); await new Promise((resolve) => requestAnimationFrame(resolve)); }); @@ -104,9 +115,9 @@ test.describe("CSS variables and sub-cell scrolling", () => { }; }); - expect(parseFloat(cssVars.transformX)).toBeCloseTo(-59.3125, 3); + expect(parseFloat(cssVars.transformX)).toBeCloseTo(-10, 3); expect(parseFloat(cssVars.transformY)).toBeCloseTo(0, 3); - expect(parseFloat(cssVars.clipX)).toBeCloseTo(59.3125, 3); + expect(parseFloat(cssVars.clipX)).toBeCloseTo(10, 3); expect(parseFloat(cssVars.clipY)).toBeCloseTo(0, 3); }); @@ -115,6 +126,17 @@ test.describe("CSS variables and sub-cell scrolling", () => { }) => { const table = page.locator("regular-table"); + await table.evaluate(async (el) => { + const overrides = {}; + for (let i = 0; i < 40; i++) { + overrides[i] = 60; + } + + el.restoreColumnSizes(overrides); + await el.draw(); + await el.draw(); + }); + // Scroll both vertically and horizontally await table.evaluate(async (el) => { el.scrollTop = 300; @@ -140,9 +162,9 @@ test.describe("CSS variables and sub-cell scrolling", () => { }; }); - expect(parseFloat(cssVars.transformX)).toBeCloseTo(-38.969, 3); + expect(parseFloat(cssVars.transformX)).toBeCloseTo(-40, 3); expect(parseFloat(cssVars.transformY)).toBeCloseTo(-15.197, 3); - expect(parseFloat(cssVars.clipX)).toBeCloseTo(38.969, 3); + expect(parseFloat(cssVars.clipX)).toBeCloseTo(40, 3); expect(parseFloat(cssVars.clipY)).toBeCloseTo(15.197, 3); }); @@ -151,10 +173,21 @@ test.describe("CSS variables and sub-cell scrolling", () => { }) => { const table = page.locator("regular-table"); + await table.evaluate(async (el) => { + const overrides = {}; + for (let i = 0; i < 40; i++) { + overrides[i] = 60; + } + + el.restoreColumnSizes(overrides); + await el.draw(); + await el.draw(); + }); + // Scroll away from origin await table.evaluate(async (el) => { el.scrollTop = 400; - el.scrollLeft = 300; + el.scrollLeft = 310; await el.draw(); await new Promise((resolve) => requestAnimationFrame(resolve)); }); @@ -173,8 +206,8 @@ test.describe("CSS variables and sub-cell scrolling", () => { }; }); - expect(parseFloat(scrolledVars.transformX)).toBeCloseTo(-58.625, 3); - expect(parseFloat(scrolledVars.clipX)).toBeCloseTo(58.625, 3); + expect(parseFloat(scrolledVars.transformX)).toBeCloseTo(-10, 3); + expect(parseFloat(scrolledVars.clipX)).toBeCloseTo(10, 3); // Scroll back to origin await table.evaluate(async (el) => { @@ -338,6 +371,17 @@ test.describe("CSS variables and sub-cell scrolling", () => { }) => { const table = page.locator("regular-table"); + await table.evaluate(async (el) => { + const overrides = {}; + for (let i = 0; i < 40; i++) { + overrides[i] = 60; + } + + el.restoreColumnSizes(overrides); + await el.draw(); + await el.draw(); + }); + // Scroll to create sub-cell offsets await table.evaluate(async (el) => { el.scrollTop = 150; @@ -355,9 +399,9 @@ test.describe("CSS variables and sub-cell scrolling", () => { const firstCellMatch = firstCellClipPath.match( /polygon\(([\d.]+)px ([\d.]+)px, ([\d.]+)px [\d.]+%, [\d.]+% [\d.]+%, [\d.]+% ([\d.]+)px\)/, ); - expect(parseFloat(firstCellMatch[1])).toBeCloseTo(29.313, 3); + expect(parseFloat(firstCellMatch[1])).toBeCloseTo(30, 3); expect(parseFloat(firstCellMatch[2])).toBeCloseTo(17.098, 3); - expect(parseFloat(firstCellMatch[3])).toBeCloseTo(29.313, 3); + expect(parseFloat(firstCellMatch[3])).toBeCloseTo(30, 3); expect(parseFloat(firstCellMatch[4])).toBeCloseTo(17.098, 3); // Check that first row cells have clip-path @@ -368,9 +412,9 @@ test.describe("CSS variables and sub-cell scrolling", () => { const firstRowMatch = firstRowCellClipPath.match( /polygon\(([\d.]+)px ([\d.]+)px, ([\d.]+)px [\d.]+%, [\d.]+% [\d.]+%, [\d.]+% ([\d.]+)px\)/, ); - expect(parseFloat(firstRowMatch[1])).toBeCloseTo(29.313, 3); + expect(parseFloat(firstRowMatch[1])).toBeCloseTo(30, 3); expect(parseFloat(firstRowMatch[2])).toBeCloseTo(17.098, 3); - expect(parseFloat(firstRowMatch[3])).toBeCloseTo(29.313, 3); + expect(parseFloat(firstRowMatch[3])).toBeCloseTo(30, 3); expect(parseFloat(firstRowMatch[4])).toBeCloseTo(17.098, 3); // Check that first column cells have clip-path @@ -381,9 +425,9 @@ test.describe("CSS variables and sub-cell scrolling", () => { const firstColMatch = firstColCellClipPath.match( /polygon\(([\d.]+)px ([\d.]+)px, ([\d.]+)px [\d.]+%, [\d.]+% [\d.]+%, [\d.]+% ([\d.]+)px\)/, ); - expect(parseFloat(firstColMatch[1])).toBeCloseTo(29.313, 3); + expect(parseFloat(firstColMatch[1])).toBeCloseTo(30, 3); expect(parseFloat(firstColMatch[2])).toBeCloseTo(0, 3); - expect(parseFloat(firstColMatch[3])).toBeCloseTo(29.313, 3); + expect(parseFloat(firstColMatch[3])).toBeCloseTo(30, 3); expect(parseFloat(firstColMatch[4])).toBeCloseTo(0, 3); }); @@ -392,6 +436,17 @@ test.describe("CSS variables and sub-cell scrolling", () => { }) => { const table = page.locator("regular-table"); + await table.evaluate(async (el) => { + const overrides = {}; + for (let i = 0; i < 40; i++) { + overrides[i] = 60; + } + + el.restoreColumnSizes(overrides); + await el.draw(); + await el.draw(); + }); + // Scroll to create sub-cell offsets await table.evaluate(async (el) => { el.scrollTop = 175; @@ -408,7 +463,7 @@ test.describe("CSS variables and sub-cell scrolling", () => { const cellMatch = cellTransform.match( /matrix\([\d.]+, [\d.]+, [\d.]+, [\d.]+, ([-\d.]+), ([-\d.]+)\)/, ); - expect(parseFloat(cellMatch[1])).toBeCloseTo(-4.313, 3); + expect(parseFloat(cellMatch[1])).toBeCloseTo(-5, 3); expect(parseFloat(cellMatch[2])).toBeCloseTo(0, 3); // Check that tbody has transform applied @@ -431,7 +486,7 @@ test.describe("CSS variables and sub-cell scrolling", () => { const headerMatch = headerTransform.match( /matrix\([\d.]+, [\d.]+, [\d.]+, [\d.]+, ([-\d.]+), ([-\d.]+)\)/, ); - expect(parseFloat(headerMatch[1])).toBeCloseTo(-4.313, 3); + expect(parseFloat(headerMatch[1])).toBeCloseTo(-5, 3); expect(parseFloat(headerMatch[2])).toBeCloseTo(0, 3); }); });