@ds-mo/ui 15.4.1 → 15.5.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/dist/.package-ready.json +1 -1
- package/dist/agent-patterns.json +1 -1
- package/dist/agent.json +51 -4
- package/dist/angular/ds-select.d.ts +1 -1
- package/dist/angular/ds-select.js +3 -3
- package/dist/angular/ds-table.d.ts +7 -1
- package/dist/angular/ds-table.js +11 -5
- package/dist/components/ds-agent-questionnaire.js +1 -1
- package/dist/components/ds-agent-response.js +1 -1
- package/dist/components/ds-bar-workflow.js +1 -1
- package/dist/components/ds-card-action-center.js +1 -1
- package/dist/components/ds-data-filter.js +1 -1
- package/dist/components/ds-data-group.js +1 -1
- package/dist/components/ds-data-preferences.js +1 -1
- package/dist/components/ds-data-saved-views.js +1 -1
- package/dist/components/ds-data-toolbar.js +1 -1
- package/dist/components/ds-divider.js +1 -1
- package/dist/components/ds-filter-menu.js +1 -1
- package/dist/components/ds-input-date.js +1 -1
- package/dist/components/ds-input-date.js.map +1 -1
- package/dist/components/ds-input-time.js +1 -1
- package/dist/components/ds-input-time.js.map +1 -1
- package/dist/components/ds-input.js +1 -1
- package/dist/components/ds-map-entity-marker.js +1 -1
- package/dist/components/ds-message-bubble.js +1 -1
- package/dist/components/ds-mobile-header.js +1 -1
- package/dist/components/ds-mobile-sheet-nav.js +1 -1
- package/dist/components/ds-modal.js +1 -1
- package/dist/components/ds-pagination.js +1 -1
- package/dist/components/ds-panel-sub-nav.js +1 -1
- package/dist/components/ds-panel-tool-search.js +1 -1
- package/dist/components/ds-panel-tools.js +1 -1
- package/dist/components/ds-radio-tile.js +1 -1
- package/dist/components/ds-radio.js +1 -1
- package/dist/components/ds-scroll-overlay.js +1 -1
- package/dist/components/ds-scroll-overlay.js.map +1 -1
- package/dist/components/ds-select.js +1 -1
- package/dist/components/ds-shell-app.js +1 -1
- package/dist/components/ds-shell-tools.js +1 -1
- package/dist/components/ds-tab-group.js +1 -1
- package/dist/components/ds-table.js +1 -1
- package/dist/components/ds-table.js.map +1 -1
- package/dist/components/ds-textarea.js +1 -1
- package/dist/components/ds-textarea.js.map +1 -1
- package/dist/components/{p-LENgfPDe.js → p-7MwoWtTJ.js} +2 -2
- package/dist/components/p-B95UP66y.js +2 -0
- package/dist/components/p-B95UP66y.js.map +1 -0
- package/dist/components/{p-jwVzQaTZ.js → p-BGSG6xIS.js} +2 -2
- package/dist/components/{p-CcZSzCfs.js → p-BO5URdZF.js} +2 -2
- package/dist/components/{p-dUti7zhW.js → p-Bq_ypMSy.js} +2 -2
- package/dist/components/{p-BkvAqrXg.js → p-C1ZfBbJ_.js} +2 -2
- package/dist/components/p-C1oczmAc.js +2 -0
- package/dist/components/p-C1oczmAc.js.map +1 -0
- package/dist/components/{p-BK_4OHJO.js → p-CA3iQ1ME.js} +2 -2
- package/dist/components/{p-DQfKS7dz.js → p-D9_iDlkT.js} +2 -2
- package/dist/components/{p-COD1C3Gj.js → p-DEYCF-oJ.js} +2 -2
- package/dist/components/p-DPFg5d4_.js +2 -0
- package/dist/components/p-DPFg5d4_.js.map +1 -0
- package/dist/components/{p-YYiDYQhS.js → p-DR_8IPn9.js} +2 -2
- package/dist/components/{p-B0BQCVkq.js → p-D_iZPukF.js} +2 -2
- package/dist/components/{p-DkxhNqT-.js → p-DjbT9kKm.js} +2 -2
- package/dist/components/{p-YDMY8T0d.js → p-PzYJqN8B.js} +2 -2
- package/dist/components/p-jp7hwRtC.js +2 -0
- package/dist/components/p-jp7hwRtC.js.map +1 -0
- package/dist/components/{p-D7wLvTHz.js → p-rmPIcWED.js} +2 -2
- package/dist/docs/components.json +416 -73
- package/dist/lint/index.js +1 -1
- package/dist/mcp-data/registry/registry.json +50 -3
- package/dist/mcp-data/registry/select.json +54 -3
- package/dist/mcp-data/registry/table.json +138 -12
- package/dist/react/ds-table.d.ts +3 -1
- package/dist/react/ds-table.js +2 -0
- package/dist/styles/table.css +399 -8
- package/dist/types/components/Select/Select.d.ts +6 -0
- package/dist/types/components/Table/Table.d.ts +35 -0
- package/dist/types/components/Table/index.d.ts +1 -1
- package/dist/types/components/Table/table-body-renderer.d.ts +1 -0
- package/dist/types/components/Table/table-cell-editor.d.ts +4 -0
- package/dist/types/components/Table/table-grid-model.d.ts +23 -0
- package/dist/types/components/Table/table-layout-controller.d.ts +1 -0
- package/dist/types/components/Table/table-review-fixtures.d.ts +10 -0
- package/dist/types/components/Table/table-row-view.d.ts +11 -2
- package/dist/types/components/Table/table-skeleton-view.d.ts +3 -1
- package/dist/types/components/Table/table-structure.d.ts +38 -0
- package/dist/types/components/Table/table-types.d.ts +71 -1
- package/dist/types/components/Table/table-virtual-controller.d.ts +1 -0
- package/dist/types/components.d.ts +78 -2
- package/dist/types/utils/select-controller.d.ts +1 -0
- package/dist/vue/ds-select.js +3 -0
- package/dist/vue/ds-table.js +8 -0
- package/package.json +1 -1
- package/dist/components/p-buM-T4vF.js +0 -2
- package/dist/components/p-buM-T4vF.js.map +0 -1
- /package/dist/components/{p-LENgfPDe.js.map → p-7MwoWtTJ.js.map} +0 -0
- /package/dist/components/{p-jwVzQaTZ.js.map → p-BGSG6xIS.js.map} +0 -0
- /package/dist/components/{p-CcZSzCfs.js.map → p-BO5URdZF.js.map} +0 -0
- /package/dist/components/{p-dUti7zhW.js.map → p-Bq_ypMSy.js.map} +0 -0
- /package/dist/components/{p-BkvAqrXg.js.map → p-C1ZfBbJ_.js.map} +0 -0
- /package/dist/components/{p-BK_4OHJO.js.map → p-CA3iQ1ME.js.map} +0 -0
- /package/dist/components/{p-DQfKS7dz.js.map → p-D9_iDlkT.js.map} +0 -0
- /package/dist/components/{p-COD1C3Gj.js.map → p-DEYCF-oJ.js.map} +0 -0
- /package/dist/components/{p-YYiDYQhS.js.map → p-DR_8IPn9.js.map} +0 -0
- /package/dist/components/{p-B0BQCVkq.js.map → p-D_iZPukF.js.map} +0 -0
- /package/dist/components/{p-DkxhNqT-.js.map → p-DjbT9kKm.js.map} +0 -0
- /package/dist/components/{p-YDMY8T0d.js.map → p-PzYJqN8B.js.map} +0 -0
- /package/dist/components/{p-D7wLvTHz.js.map → p-rmPIcWED.js.map} +0 -0
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"avoidWhen": [
|
|
23
23
|
"Content is primarily editorial prose; use semantic product-owned markup with the prose recipe instead.",
|
|
24
24
|
"A small label/value set reads more clearly as a list, description list, or card.",
|
|
25
|
-
"The interface requires tree-grid behavior, nested grouping,
|
|
25
|
+
"The interface requires tree-grid behavior, nested grouping, row reordering, formula evaluation, rich cell editors, or column virtualization; those remain outside the contract.",
|
|
26
26
|
"The application expects the component to fetch, slice pages, sort, group, or mutate records internally."
|
|
27
27
|
],
|
|
28
28
|
"commonlyComposedWith": [
|
|
@@ -58,6 +58,10 @@
|
|
|
58
58
|
"Static and infinite tables may show an optional result summary footer when displayedCount and totalCount are finite. Virtual mode shows a total-only summary and ignores displayedCount. Pagination mode instead renders a named Pagination control with a page-size picker, vertical divider, and page position; range changes are announced independently with complete phrasing. Unmodified Left and Right Arrow request the adjacent page while focus is inside a paginated table or on a containing page scroller that owns it; editable fields, choice controls, sliders, open menus, and modified key combinations retain their own keyboard behavior. A table-owned footer slot can replace summary copy, while footer-leading and footer-trailing remain available in the same compact-bar anatomy. Nested dialog footers are ignored."
|
|
59
59
|
],
|
|
60
60
|
"states": [
|
|
61
|
+
"Column super-groups describe adjacent visible fields. Hidden and reordered fields recompute the bands; unrelated fields, selection, elastic space, and pin boundaries split them. Compound sort controls remain in the leaf headers.",
|
|
62
|
+
"Cell presentation moves the complete content stack for deliberate middle or bottom alignment; the default remains first-track alignment. Semantic highlight borders require an accessible explanation, and flags require labels. Presentation metadata never changes the cell value or the shared text, icon, tag and action track anatomy.",
|
|
63
|
+
"Report merges name ordered row and column identities. Only a complete contiguous non-overlapping rectangle is accepted, and invalid definitions leave original values visible. Selection checkboxes are never merged. Merges cannot cross pin or elastic-spacer boundaries, and grouped or editable grids do not merge. Cross-row merges require non-windowed reports; column-only merges can use virtual rows. Applications recompute spans with their final data order and retain covered values.",
|
|
64
|
+
"Editing has two opt-in ungrouped modes for scalar values. Edit mode keeps normal table semantics and reveals a floating pencil on cell hover or keyboard focus; touch keeps the pencil visible. Grid mode adds per-cell hover/press feedback, keyboard focus and controlled rectangular ranges. Arrows and Home/End skip read-only cells; Shift+click or Shift+arrows extends the range without selecting read-only cells or opening an editor. A single click, Enter, F2 or typing opens the grid editor, while edit mode requires the pencil. Both modes share validation and controlled change events: Enter or Tab proposes a change; Escape or leaving the editor cancels. Tab advances to the next editable cell or pencil. Read-only content is muted without fading dividers, and those cells cannot receive cell selection or hover feedback. Native controls retain their own keyboard behavior. Plain TSV grid paste is bounded and rejected atomically for invalid, disabled, out-of-range or read-only destinations. Applications accept or reject proposed edits, own persistence and domain validation, and replace controlled data. Row checkbox selection remains independent of cell selection.",
|
|
61
65
|
"Columns, rows, groups, grouping, sorting, selected row IDs, collapsed group IDs, highlightTerms, highlightFieldIds, hiddenFieldIds, fieldOrder, dataMode, pagination, displayedCount, totalCount, and incremental-loading state are controlled inputs. Events communicate intent and never mutate those inputs. The optional header is one composable table-local surface: the component owns its 48px chrome, 8px inset on every side, and sticky placement while the application owns the leading slot's internal layout, controls, labels, filtering behavior, and action handling. highlightTerms only marks matching table-owned text and never filters rows, changes query semantics, or highlights Tag labels. highlightFieldIds optionally narrows that marking to matching column IDs or compound header-segment sort keys. Opting into dataModeSwitcher and columnCustomizer adds their trailing Menu triggers and requires caption visibility. When both are present, Customize precedes a decorative divider and the data-mode trigger remains the final action. The Customize trigger keeps one stable label and neutral resting foreground whether or not hiddenFieldIds or fieldOrder differ from the catalog default; the configured state remains visible inside the Menu. The table closes one trailing Menu before opening the other. The footer retains its compact-bar composition: application-owned leading status sits left while a controlled result summary or Pagination aligns right, before any trailing action. Content-sized tables remain natural for sparse or fixed-count results. height bounds the complete header, frame, and footer composition. fitViewport reserves the compact-state composition height in the nearest vertical scrollport while growing the visible table into that reservation as surrounding page chrome collapses.",
|
|
62
66
|
"Grouping supports one level. The application supplies groups in a fixed product-defined order and supplies ordered member rows independently. Group section headers show an emphasized text-body-medium label at the start and plain secondary loaded progress at the trailing edge beside the disclosure. A 20px tertiary divider centered on the label track marks the action-column boundary between the count and disclosure. The loaded value comes from the supplied member rows and the total comes from totalCount. A localized countLabel total phrase remains available for the complete assistive progress announcement. Virtual mode shows totals only, for example 166, because there is no loaded window. A collapsed section with no supplied member rows uses that same total-only copy so collapse does not advertise a zero loaded window, including 0 when no total is known. An expanded empty section still shows loaded progress, for example 0 of 166. Optional accessories occupy a second 24px track under the label, middle-dot separated, at most four items. An accessory with help uses the shared dotted underline and a bottom-start ds-tooltip from that copy; explicit line breaks create an unbulleted list. Without accessories the section stays the single-track row height; with accessories it matches a two-track body row, with the checkbox, trailing count, divider, and disclosure kept on the label track. An optional hero accessory sits before the label. The first supported hero is a safety-score fill. Its one-track presentation uses the default ds-score at sm and aligns to the label track on either row height. Setting hero tracks to 2 creates or spans the two-track header, renders the default ds-score at lg, and centers it across the label and accessory tracks. In selectable tables the two-track score starts at the selection-column boundary; selectionMode none omits that lane and uses the regular group-content start inset. A score hero does not use group intent coloring. Hero presentation does not change the 40 or 64 row-height contract beyond the explicitly requested second track. Groups without intent use the secondary background surface. Groups may declare an optional intent (brand, neutral, negative, warning, caution, positive) that paints an LTR gradient from the faint intent surface at the label edge into faint neutral at the trailing edge, plus a bold intent title color. Neutral groups remain solid faint neutral. Section separators are painted inside that viewport-width surface so translucent border tokens composite with each intent background. Internal sections keep their separators; a collapsed or empty section header yields its separator only when it is the table's final rendered item. The complete section header is the expand/collapse target and paints hover and press on that viewport-width surface. The section separator stays on the dedicated trailing-edge rule so the interaction wash cannot replace it. Trailing loaded progress sits on the label track beside a visually neutral icon-only ButtonUnfilled. The centered 20px tertiary divider at the action-column boundary separates the count from the 24px md double-inset disclosure. The disclosure keeps its accessible name and does not paint its own hover or press wash, showing ChevronUp while expanded and ChevronDown while collapsed. While any group is expanded, a ChevronDownUp action uses the xs sort-control recipe widened to a table-owned 24px lane and collapses every group. It appears at the inline-end edge of the trailing column declared with kind action when one exists; otherwise a button-hugging 24px by 16px medium-elevation surface floats at the visible scrollport's header edge so the action remains reachable while member columns scroll beneath it. With stickyHeader enabled, each contained section uses its one real section row as a native sticky header inside its real row-group boundary. The browser therefore owns pinning, push-off, scrolling, and resize layout in one pass without a duplicated lane, active-section state, transforms, or scroll-time geometry. Shared grid tracks retain exact header and member-cell alignment. The section surface also stays pinned to the visible horizontal scrollport and spans the smaller of the visible viewport or table width. Virtual grouped tables flatten headers and members into that same scrollport; expanding a section never introduces an inner scroller.",
|
|
63
67
|
"When columnCustomizer is false, columns render in catalog order. When it is true, columns remains the catalog: hiddenFieldIds and fieldOrder control visibility and data-column sequence, action columns stay last, cannot be hidden, and remain absent from the customizer Menu, and dsFieldsConfigChange reports the next hidden ids and data-column order without mutating those inputs. The customizer Menu live-applies show/hide and data-column reorder. The table does not persist preferences.",
|
|
@@ -67,7 +71,7 @@
|
|
|
67
71
|
"dataMode selects the top-level window: infinite appends ungrouped rows through dsLoadMore when hasMore is true, pagination renders the controlled page supplied by the application and emits dsPaginationChange, and virtual recycles row DOM over the full in-memory rows or groups the application already supplied. A bounded ungrouped infinite or pagination window with more than 50 supplied rows also recycles visible row DOM internally while preserving its selected data mode, complete supplied-window row count, selection scope, footer, and load behavior. Smaller and grouped non-virtual windows retain their supplied row DOM while deferring off-screen row content with token-aligned intrinsic heights. Infinite mode also covers fixed result sets by leaving hasMore false. In a grouped paginated table, pagination counts and windows only parent groups; each expanded parent can still append its own members through dsGroupLoadMore. Virtual mode does not fetch, ignores hasMore and load bands, and requires a bounded viewport (height, maxHeight, or fitViewport with a definite block size). Without that height it fails visibly instead of mounting every row. The application owns requests, cancellation, cursors, deduplication, slicing, and query identity.",
|
|
68
72
|
"When pagination enables Fit to page on a bounded table, the table proposes the number of complete top-level rows that fit below its column header. Grouped tables count group headers only; member rows never affect capacity. Selecting Fit captures that numeric snapshot. A changed fitIdentity requests one fresh snapshot for a new query shape, while viewport resizing alone does not change the active controlled page size.",
|
|
69
73
|
"Rows opt into complete-row activation with interactive and emit dsRowActivate without owning navigation. Selection paints through the shared selected ::before layer; interactive hover and press paint through the shared ::after layer.",
|
|
70
|
-
"
|
|
74
|
+
"Explicitly sized application columns can accumulate at both inline edges in visible order, after the independent selection lane. Only the inner boundary of each pinned stack owns the directional shadow. If the pinned lanes consume the complete viewport, they return to scrolling so every field remains reachable. Keep related pinned fields together in display order.",
|
|
71
75
|
"Cells accept scalar content, standard primary-and-secondary or dual-primary text primitives, declarative Tag, wrapping multiple-Tag content, icon-only, icon-and-text, 16:9 Image, or ButtonUnfilled Action content, or explicit Empty and Blank absence values. Text objects may declare an optional href (and target) on the primary track only; the table renders a native anchor with the shared brand text-action treatment when the URL is safe, and leaves scalars unlinked. Standard secondary copy defaults to the secondary foreground and may declare a semantic Text color when the application data carries intent; an optional tertiary track uses the same subdued body-small recipe as secondary. Secondary and tertiary may also be a short list of colorable runs, rendered on one line with the table's middle-dot separator. A run may supply help to add the shared dotted underline and a bottom-start Tooltip when compact visible copy summarizes supplementary details; explicit line breaks create a compact unbulleted list without obscuring the current cell's earlier tracks. Dual-primary copy keeps both tracks primary unless explicitly colored and does not gain a tertiary track. Body data cells share 8px outer padding; header and group rows keep their own geometry. Text tracks, including Empty, keep 4px inline padding so copy lines up with column-header labels; Tag and icon-only tracks stay on the 8px cell chrome alone. Icon-and-text cells (`kind: 'icon-text'`) place one md prefix icon beside the same copy stack as a text cell. The icon slot takes 2px padding on every side, then a 2px flex gap before the copy; text-track padding stays on the copy. Omit iconLabel when the glyph is decorative. 20px content (body-medium text, md icon, and the row checkbox) sits in a 24px track so it stays optically aligned with 24px Tag and Action content. Single-track text, selection, tag-only, icon-only, icon-and-text, Action, Empty, Blank, and 1-track Image cells resolve a 40px base contract while two-track cells grow to 64px and three-track cells grow to 88px. Every later track adds 24px, so four tracks resolve to 112px, five to 136px, and the sequence may continue. Tracks stack with no gap. Wrapping primary on a 1-track cell uses that same rhythm: two wrapped lines share the 64px row and three wrapped lines share the 88px row. Wrapping secondary keeps primary on the first track and consumes later tracks: two secondary lines share the 88px row and three secondary lines share a 112px fourth track. Keep the same track count in every row of a table or group when those rows should share one height. Tag cells use the inset Tag recipe: md double-inset is a 24px control for tag-only and tag-with-text, while text-with-tag uses sm single-inset at 20px so the second-track pill sits in the same 24px lane. For a compact ordered set of tags that must remain together on the primary track, choose the inline multiple-tag variation, optionally with supporting text below. Slash separators distinguish adjacent tags; labels truncate as the column narrows. Use wrapping tags when all labels must remain visible. Multiple-Tag cells use `kind: 'tags'`, a positive `tracks` count, and small single-inset Tags that wrap naturally at the column edge. Each wrap line is a 24px track with no stack gap, matching text. The tallest cell establishes the native row height, so all sibling cells match even at four, five, or more tracks; virtual tables use the declared count as the initial estimate and measure responsive wrapping after paint. Action cells primarily use an icon-only Ellipses treatment, render a 24px small ButtonUnfilled, default to unbordered, and emit row, column, and action identities through dsCellAction. When an action cell declares items, that Ellipses trigger opens the shared ds-menu for commands, dividers, inactive rows, and destructive rows instead of emitting on click; choosing a command emits dsCellAction with that item's actionId. Omit items to keep a single-shot control. Image cells declare tracks 1, 2, or 3 so the 16:9 preview fills that body-row content box (cell height minus 8px padding) and the column hugs the resulting width; omit tracks for the 1-track recipe, and keep image tracks aligned with the other cells in the table or group. The image is clipped to the 2px preview radius and the tertiary border paints as an inset overlay so it remains visible over image pixels. Image cells fall back to an accessible placeholder when no source is supplied. Empty means the data applies but has no value and renders an em dash with localized assistive text; Blank means the data is not applicable and renders no content. Nullish values remain backward-compatible Empty values. Stable type, variant, and track-count hooks keep every cell family independently styleable.",
|
|
72
76
|
"Score-and-text cells (`kind: score-text`) place the small 24px safety score at the top-right of the cell while the same one-, two-, or three-track copy stack as a standard text cell fills from the start edge. The score is top-aligned with the first text track, adds no inner padding, and defaults its accessible name prefix to Safety score. Score-and-text loading geometry uses the matching small score skeleton and declared copy tracks.",
|
|
73
77
|
"The public @ds-mo/ui/table.css export is the authoritative zero-specificity visual recipe. Override its --ds-table-* properties or target its stable element classes for product-specific styling.",
|
|
@@ -77,7 +81,8 @@
|
|
|
77
81
|
"Borderless toolbar compositions omit resting control borders while retaining focus and interaction feedback."
|
|
78
82
|
],
|
|
79
83
|
"responsiveBehavior": [
|
|
80
|
-
"
|
|
84
|
+
"Editable cells compose the shared borderless controls with cell-owned interaction and focus chrome. Only the active cell mounts a field. Single-select choices complete the edit immediately; multiline fields reserve Enter for a newline and use Ctrl/Command+Enter to commit. Date and time fields keep their picker keyboard behavior and emit canonical scalar values. Drafts stay local until a controlled change is proposed.",
|
|
85
|
+
"Horizontal scrolling is the default. Opted-in cards reuse the same native table in a narrow-container layout: a record is one card with vertically stacked labeled fields, inset separators aligned to the content origin, and space between labels and values. One row-owned interaction surface covers the card. Actions and selection sit together at the top-right, separated by a short divider. The content region owns card gutters and gaps; the toolbar and footer remain separate edge-to-edge surfaces. Page owners remove their outer table inset at the responsive breakpoint. Column headers remain available to assistive technology but are visually hidden and contain no interactive controls in card mode. Row selection and action controls preserve focus across resizing instead of mounting a duplicate mobile tree. Merged reports and grid interaction retain table layout. Card compositions do not use the duplicated document-flow sticky header.",
|
|
81
86
|
"Inside the named ds-table container, caption Search retains its standard maximum width and flexes smaller only when the complete row needs space. Filter, Group, and Sort controls that opt into collapseLabel become icon-only below 900px by omitting their labels and chevrons. Active filtering or grouping promotes the visible icon to primary. Sort keeps resting secondary chrome because its selection lives in the menu and headers. Customize switches to the icon-only Customize menu button below 900px and keeps its neutral resting foreground regardless of the controlled column configuration. The caption, frame, and footer remain within the table host at every width. The complete caption row scrolls together only when those compact controls no longer fit, while data columns scroll inside the viewport; table-owned trailing actions never stay pinned over the scrolling toolbar.",
|
|
82
87
|
"stickyHeader follows the active vertical scroll owner: a maxHeight, height, or fitViewport table keeps its native header and group sections inside one bounded table scrollport, while a document-flow table retains a semantic native header and renders a sticky control header whose columns follow the body's native inline scroll timeline. Consumers set --ds-table-sticky-header-offset when page chrome occupies an unbounded document scrollport edge. When the 48px table header bar is visible, it sticks at that external offset and the column header stacks immediately below it.",
|
|
83
88
|
"fitViewport uses viewportInsetBlockStart and viewportInsetBlockEnd to describe compact surrounding chrome and page gutters relative to the nearest vertical scroll owner. Before the table reaches that compact position, its host reserves the eventual height while the visible composition grows and vertical input remains owned by the outer page. Once settled, the internal table viewport becomes the vertical scroll owner. Bounded grouped tables use browser-native sticky section rows inside real block row-group boundaries, so scroll and resize do not run component JavaScript or trigger reactive section swaps. Document-flow tables retain the synchronized visual lane required by their separate horizontal scrollport.",
|
|
@@ -253,6 +258,22 @@
|
|
|
253
258
|
"mutable": false,
|
|
254
259
|
"description": "Shows a matching presentational title bar above the native table frame."
|
|
255
260
|
},
|
|
261
|
+
"cellRange": {
|
|
262
|
+
"type": "TableCellRange | null",
|
|
263
|
+
"resolvedType": "TableCellRange | null",
|
|
264
|
+
"default": "null",
|
|
265
|
+
"required": false,
|
|
266
|
+
"mutable": false,
|
|
267
|
+
"description": "Controlled rectangular cell selection, independent of row checkbox selection."
|
|
268
|
+
},
|
|
269
|
+
"cellSpans": {
|
|
270
|
+
"type": "TableCellSpan[]",
|
|
271
|
+
"resolvedType": "TableCellSpan[]",
|
|
272
|
+
"default": "[]",
|
|
273
|
+
"required": false,
|
|
274
|
+
"mutable": false,
|
|
275
|
+
"description": "Explicit report merges; invalid combinations leave the original cells visible."
|
|
276
|
+
},
|
|
256
277
|
"chromeLoading": {
|
|
257
278
|
"type": "boolean",
|
|
258
279
|
"resolvedType": "boolean",
|
|
@@ -536,6 +557,15 @@
|
|
|
536
557
|
"required": false,
|
|
537
558
|
"mutable": false
|
|
538
559
|
},
|
|
560
|
+
"interactionMode": {
|
|
561
|
+
"type": "'table' | 'edit' | 'grid'",
|
|
562
|
+
"resolvedType": "\"edit\" | \"grid\" | \"table\"",
|
|
563
|
+
"attribute": "interaction-mode",
|
|
564
|
+
"default": "'table'",
|
|
565
|
+
"required": false,
|
|
566
|
+
"mutable": false,
|
|
567
|
+
"description": "Table is read-only; edit uses per-cell pencil buttons; grid adds cell selection and keyboard navigation. Grouped tables remain read-only."
|
|
568
|
+
},
|
|
539
569
|
"loadIdentity": {
|
|
540
570
|
"type": "string | number",
|
|
541
571
|
"resolvedType": "number | string",
|
|
@@ -626,6 +656,15 @@
|
|
|
626
656
|
"required": false,
|
|
627
657
|
"mutable": false
|
|
628
658
|
},
|
|
659
|
+
"responsiveLayout": {
|
|
660
|
+
"type": "'scroll' | 'cards'",
|
|
661
|
+
"resolvedType": "\"cards\" | \"scroll\"",
|
|
662
|
+
"attribute": "responsive-layout",
|
|
663
|
+
"default": "'scroll'",
|
|
664
|
+
"required": false,
|
|
665
|
+
"mutable": false,
|
|
666
|
+
"description": "Opt-in native card presentation below the 768px table container breakpoint."
|
|
667
|
+
},
|
|
629
668
|
"resultSummaryLabel": {
|
|
630
669
|
"type": "string",
|
|
631
670
|
"resolvedType": "string",
|
|
@@ -768,6 +807,20 @@
|
|
|
768
807
|
"cancelable": true,
|
|
769
808
|
"composed": true
|
|
770
809
|
},
|
|
810
|
+
{
|
|
811
|
+
"name": "dsCellRangeChange",
|
|
812
|
+
"detail": "TableCellRange",
|
|
813
|
+
"bubbles": true,
|
|
814
|
+
"cancelable": true,
|
|
815
|
+
"composed": true
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
"name": "dsCellsChange",
|
|
819
|
+
"detail": "TableCellsChangeDetail",
|
|
820
|
+
"bubbles": true,
|
|
821
|
+
"cancelable": true,
|
|
822
|
+
"composed": true
|
|
823
|
+
},
|
|
771
824
|
{
|
|
772
825
|
"name": "dsCustomizeOptionChange",
|
|
773
826
|
"detail": "string",
|
|
@@ -875,6 +928,22 @@
|
|
|
875
928
|
"mutable": false,
|
|
876
929
|
"description": "Shows a matching presentational title bar above the native table frame."
|
|
877
930
|
},
|
|
931
|
+
"cellRange": {
|
|
932
|
+
"type": "TableCellRange | null",
|
|
933
|
+
"resolvedType": "TableCellRange | null",
|
|
934
|
+
"default": "null",
|
|
935
|
+
"required": false,
|
|
936
|
+
"mutable": false,
|
|
937
|
+
"description": "Controlled rectangular cell selection, independent of row checkbox selection."
|
|
938
|
+
},
|
|
939
|
+
"cellSpans": {
|
|
940
|
+
"type": "TableCellSpan[]",
|
|
941
|
+
"resolvedType": "TableCellSpan[]",
|
|
942
|
+
"default": "[]",
|
|
943
|
+
"required": false,
|
|
944
|
+
"mutable": false,
|
|
945
|
+
"description": "Explicit report merges; invalid combinations leave the original cells visible."
|
|
946
|
+
},
|
|
878
947
|
"chromeLoading": {
|
|
879
948
|
"type": "boolean",
|
|
880
949
|
"resolvedType": "boolean",
|
|
@@ -1158,6 +1227,15 @@
|
|
|
1158
1227
|
"required": false,
|
|
1159
1228
|
"mutable": false
|
|
1160
1229
|
},
|
|
1230
|
+
"interactionMode": {
|
|
1231
|
+
"type": "'table' | 'edit' | 'grid'",
|
|
1232
|
+
"resolvedType": "\"edit\" | \"grid\" | \"table\"",
|
|
1233
|
+
"attribute": "interaction-mode",
|
|
1234
|
+
"default": "'table'",
|
|
1235
|
+
"required": false,
|
|
1236
|
+
"mutable": false,
|
|
1237
|
+
"description": "Table is read-only; edit uses per-cell pencil buttons; grid adds cell selection and keyboard navigation. Grouped tables remain read-only."
|
|
1238
|
+
},
|
|
1161
1239
|
"loadIdentity": {
|
|
1162
1240
|
"type": "string | number",
|
|
1163
1241
|
"resolvedType": "number | string",
|
|
@@ -1248,6 +1326,15 @@
|
|
|
1248
1326
|
"required": false,
|
|
1249
1327
|
"mutable": false
|
|
1250
1328
|
},
|
|
1329
|
+
"responsiveLayout": {
|
|
1330
|
+
"type": "'scroll' | 'cards'",
|
|
1331
|
+
"resolvedType": "\"cards\" | \"scroll\"",
|
|
1332
|
+
"attribute": "responsive-layout",
|
|
1333
|
+
"default": "'scroll'",
|
|
1334
|
+
"required": false,
|
|
1335
|
+
"mutable": false,
|
|
1336
|
+
"description": "Opt-in native card presentation below the 768px table container breakpoint."
|
|
1337
|
+
},
|
|
1251
1338
|
"resultSummaryLabel": {
|
|
1252
1339
|
"type": "string",
|
|
1253
1340
|
"resolvedType": "string",
|
|
@@ -1390,6 +1477,20 @@
|
|
|
1390
1477
|
"cancelable": true,
|
|
1391
1478
|
"composed": true
|
|
1392
1479
|
},
|
|
1480
|
+
{
|
|
1481
|
+
"name": "dsCellRangeChange",
|
|
1482
|
+
"detail": "TableCellRange",
|
|
1483
|
+
"bubbles": true,
|
|
1484
|
+
"cancelable": true,
|
|
1485
|
+
"composed": true
|
|
1486
|
+
},
|
|
1487
|
+
{
|
|
1488
|
+
"name": "dsCellsChange",
|
|
1489
|
+
"detail": "TableCellsChangeDetail",
|
|
1490
|
+
"bubbles": true,
|
|
1491
|
+
"cancelable": true,
|
|
1492
|
+
"composed": true
|
|
1493
|
+
},
|
|
1393
1494
|
{
|
|
1394
1495
|
"name": "dsCustomizeOptionChange",
|
|
1395
1496
|
"detail": "string",
|
|
@@ -1494,7 +1595,7 @@
|
|
|
1494
1595
|
"import": "import { DsTable } from '@ds-mo/ui/angular/ds-table';",
|
|
1495
1596
|
"example": "<ds-table></ds-table>"
|
|
1496
1597
|
},
|
|
1497
|
-
"complexPropertyNote": "Assign these non-primitive values as JavaScript properties: collapsedGroupIds, columns, customizeOptions, fieldOrder, grouping, groups, hiddenFieldIds, highlightFieldIds, highlightTerms, pagination, rows, selectedRowIds, sort.",
|
|
1598
|
+
"complexPropertyNote": "Assign these non-primitive values as JavaScript properties: cellRange, cellSpans, collapsedGroupIds, columns, customizeOptions, fieldOrder, grouping, groups, hiddenFieldIds, highlightFieldIds, highlightTerms, pagination, rows, selectedRowIds, sort.",
|
|
1498
1599
|
"peerDependencies": {
|
|
1499
1600
|
"required": [
|
|
1500
1601
|
"@ds-mo/tokens ^6.6.1",
|
|
@@ -1515,24 +1616,29 @@
|
|
|
1515
1616
|
"divider",
|
|
1516
1617
|
"empty-state",
|
|
1517
1618
|
"icon",
|
|
1619
|
+
"input",
|
|
1620
|
+
"input-date",
|
|
1621
|
+
"input-time",
|
|
1518
1622
|
"loader",
|
|
1519
1623
|
"menu",
|
|
1520
1624
|
"pagination",
|
|
1521
1625
|
"score",
|
|
1626
|
+
"select",
|
|
1522
1627
|
"skeleton",
|
|
1523
1628
|
"tag",
|
|
1524
1629
|
"text",
|
|
1630
|
+
"textarea",
|
|
1525
1631
|
"tooltip"
|
|
1526
1632
|
],
|
|
1527
1633
|
"files": [
|
|
1528
1634
|
{
|
|
1529
1635
|
"path": "src/wc/components/Table/Table.css",
|
|
1530
|
-
"content": "@import '../../styles/table.css';\n@import '../../styles/control-elevation.css';\n@import '../../utils/chrome-header.css';\n@import '../../utils/radius.css';\n@import '../../utils/control-density.css';\n@import '../../utils/forced-colors.css';\n@import '../../utils/text-decoration.css';\n\n:host {\n display: block;\n box-sizing: border-box;\n min-width: 0;\n max-width: 100%;\n container-type: inline-size;\n container-name: ds-table;\n}\n\n:host(.table-host--bounded) {\n min-block-size: 0;\n}\n\n:host(.table-host--fixed-height) {\n block-size: var(--_table-fixed-block-size);\n}\n\n:host(.table-host--viewport-fit) {\n block-size: var(--_table-viewport-fit-reserved-block-size, auto);\n min-block-size: var(--_table-viewport-fit-reserved-block-size, auto);\n}\n\n::slotted([slot='header']) {\n inline-size: 100%;\n min-inline-size: 0;\n}\n\n/*\n * The public table recipe stays at zero specificity for product-owned native\n * markup. Inside the component, these classes sit on ds-text hosts whose reset\n * intentionally owns display, padding, and border with higher specificity.\n * Reassert only those collided properties so component geometry cannot depend\n * on whether ds-text or ds-table happens to hydrate first.\n */\nds-text.ds-table__header-label-box {\n padding-inline: var(--dimension-space-025);\n}\n\nds-text.ds-table__cell-track {\n display: flex;\n}\n\n.ds-table__cell--text-wrap ds-text.ds-table__cell-track--text,\n.ds-table__cell--icon-text-wrap ds-text.ds-table__cell-track--text,\n.ds-table__cell--score-text-wrap ds-text.ds-table__cell-track--text {\n display: block;\n}\n\nds-text.ds-table__cell-track--text {\n padding-inline: var(--_table-cell-track-padding-inline);\n}\n\nds-text.ds-table__cell-primary,\n.ds-table__cell--empty ds-text.ds-table__cell-track {\n padding-block: var(--dimension-space-025);\n}\n\n/*\n * The public :where() wrap recipe has zero specificity, so ds-text's\n * variant line-height would win inside the component. Reassert on wrapping\n * cell layout classes so extra primary and secondary lines occupy later tracks.\n */\n.ds-table__cell--text-wrap.ds-table__cell--text-single .ds-table__cell-primary,\n.ds-table__cell--text-wrap.ds-table__cell--text-multi .ds-table__cell-primary,\n.ds-table__cell--icon-text-wrap.ds-table__cell--icon-text-single .ds-table__cell-primary,\n.ds-table__cell--icon-text-wrap.ds-table__cell--icon-text-multi .ds-table__cell-primary,\n.ds-table__cell--score-text-wrap.ds-table__cell--score-text-single .ds-table__cell-primary,\n.ds-table__cell--score-text-wrap.ds-table__cell--score-text-multi .ds-table__cell-primary {\n line-height: var(--_table-wrap-primary-line-height);\n padding-block: var(--_table-wrap-primary-padding-block);\n}\n\n.ds-table__cell--text-wrap.ds-table__cell--text-multi .ds-table__cell-copy,\n.ds-table__cell--icon-text-wrap.ds-table__cell--icon-text-multi .ds-table__cell-copy,\n.ds-table__cell--score-text-wrap.ds-table__cell--score-text-multi .ds-table__cell-copy {\n gap: 0;\n}\n\n.ds-table__cell--text-wrap.ds-table__cell--text-multi .ds-table__cell-secondary,\n.ds-table__cell--icon-text-wrap.ds-table__cell--icon-text-multi .ds-table__cell-secondary,\n.ds-table__cell--score-text-wrap.ds-table__cell--score-text-multi .ds-table__cell-secondary {\n line-height: var(--_table-wrap-secondary-line-height);\n padding-block: var(--_table-wrap-secondary-padding-block);\n}\n\nds-text.ds-table__cell-secondary,\nds-text.ds-table__cell-tertiary {\n padding-block: var(--dimension-space-025);\n}\n\n.ds-table__cell--text-multi ds-text.ds-table__cell-secondary,\n.ds-table__cell--text-triple ds-text.ds-table__cell-secondary,\n.ds-table__cell--text-triple ds-text.ds-table__cell-tertiary,\n.ds-table__cell--icon-text-multi ds-text.ds-table__cell-secondary,\n.ds-table__cell--icon-text-triple ds-text.ds-table__cell-secondary,\n.ds-table__cell--icon-text-triple ds-text.ds-table__cell-tertiary,\n.ds-table__cell--score-text-multi ds-text.ds-table__cell-secondary,\n.ds-table__cell--score-text-triple ds-text.ds-table__cell-secondary,\n.ds-table__cell--score-text-triple ds-text.ds-table__cell-tertiary,\n.ds-table__cell--primary-text ds-text.ds-table__cell-secondary {\n min-block-size: var(--_table-cell-track-min-block-size);\n}\n\nds-text.ds-table__cell-run,\nds-text.ds-table__cell-run-separator {\n display: flex;\n}\n\nds-text.ds-table__group-label,\nds-text.ds-table__group-count,\nds-text.ds-table__group-accessory,\nds-text.ds-table__group-accessory-separator {\n display: flex;\n align-items: center;\n}\n\nds-text.ds-table__group-count {\n min-block-size: var(--_table-cell-track-min-block-size);\n padding-inline-end: var(--dimension-space-050);\n}\n\n.ds-table__cell-tag-stack--tag-with-text ds-text.ds-table__cell-tag-text,\n.ds-table__cell-tag-stack--text-with-tag ds-text.ds-table__cell-tag-text {\n padding-inline: var(--_table-cell-track-padding-inline);\n padding-block: var(--dimension-space-025);\n min-block-size: var(--_table-cell-track-min-block-size);\n}\n\nds-text.ds-table__load-content {\n display: inline-flex;\n}\n\nds-text.ds-table__bar-text {\n display: inline-flex;\n width: auto;\n max-width: 100%;\n padding-inline: var(--ds-control-label-inset, var(--dimension-space-025));\n}\n\nds-text.ds-table__footer-summary {\n margin-inline-start: auto;\n}\n\n.ds-table__bar-trailing ds-pagination {\n max-inline-size: 100%;\n}\n\n.ds-table__bar-status slot::slotted(*) {\n width: 100%;\n max-width: 100%;\n min-width: 0;\n}\n\nds-skeleton.ds-table__skeleton-image-shape {\n display: flex;\n width: 100%;\n height: 100%;\n --ds-control-height: 100%;\n}\n\nds-skeleton.ds-table__skeleton-checkbox {\n --ds-control-height: var(--dimension-iconography-sm);\n}\n\n.ds-table__skeleton-checkbox-canvas {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n inline-size: var(--dimension-size-300);\n block-size: var(--dimension-size-300);\n}\n\n.ds-table__caption-customizer {\n position: relative;\n display: flex;\n align-items: center;\n min-inline-size: 0;\n}\n\n.ds-table__caption-mode-switcher {\n display: flex;\n align-items: center;\n}\n\n.ds-table__caption-customizer--loading > ds-tooltip ds-button-unfilled {\n visibility: hidden;\n}\n\n.ds-table__caption-customizer > ds-skeleton {\n position: absolute;\n inset: 0;\n}\n\n.ds-table__caption-bar:has([slot='header-after']) {\n flex-direction: column;\n align-items: stretch;\n block-size: auto;\n padding: 0;\n gap: 0;\n}\n\n.ds-table__caption-bar:has([slot='header-after']) .ds-table__caption-content {\n flex: 0 0 auto;\n min-block-size: var(--dimension-size-600);\n padding: var(--dimension-space-100);\n}\n",
|
|
1636
|
+
"content": "@import '../../styles/table.css';\n@import '../../styles/control-elevation.css';\n@import '../../utils/chrome-header.css';\n@import '../../utils/radius.css';\n@import '../../utils/control-density.css';\n@import '../../utils/forced-colors.css';\n@import '../../utils/text-decoration.css';\n\n:host {\n display: block;\n box-sizing: border-box;\n min-width: 0;\n max-width: 100%;\n}\n\n/* Keep size containment away from the recycled scrollport (WebKit). */\n.ds-table__caption-bar {\n container-type: inline-size;\n container-name: ds-table;\n}\n\n:host(.table-host--bounded) {\n min-block-size: 0;\n}\n\n:host(.table-host--fixed-height) {\n block-size: var(--_table-fixed-block-size);\n}\n\n:host(.table-host--viewport-fit) {\n block-size: var(--_table-viewport-fit-reserved-block-size, auto);\n min-block-size: var(--_table-viewport-fit-reserved-block-size, auto);\n}\n\n::slotted([slot='header']) {\n inline-size: 100%;\n min-inline-size: 0;\n}\n\n/*\n * The public table recipe stays at zero specificity for product-owned native\n * markup. Inside the component, these classes sit on ds-text hosts whose reset\n * intentionally owns display, padding, and border with higher specificity.\n * Reassert only those collided properties so component geometry cannot depend\n * on whether ds-text or ds-table happens to hydrate first.\n */\nds-text.ds-table__header-label-box {\n padding-inline: var(--dimension-space-025);\n}\n\nds-text.ds-table__card-label {\n display: none;\n padding-inline: var(--_table-cell-track-padding-inline);\n}\n\n.ds-table--responsive-cards .ds-table__frame[data-narrow='true'] ds-text.ds-table__card-label {\n display: block;\n}\n\nds-text.ds-table__cell-track {\n display: flex;\n}\n\n.ds-table__cell--text-wrap ds-text.ds-table__cell-track--text,\n.ds-table__cell--icon-text-wrap ds-text.ds-table__cell-track--text,\n.ds-table__cell--score-text-wrap ds-text.ds-table__cell-track--text {\n display: block;\n}\n\nds-text.ds-table__cell-track--text {\n padding-inline: var(--_table-cell-track-padding-inline);\n}\n\nds-text.ds-table__cell-primary,\n.ds-table__cell--empty ds-text.ds-table__cell-track {\n padding-block: var(--dimension-space-025);\n}\n\n/*\n * The public :where() wrap recipe has zero specificity, so ds-text's\n * variant line-height would win inside the component. Reassert on wrapping\n * cell layout classes so extra primary and secondary lines occupy later tracks.\n */\n.ds-table__cell--text-wrap.ds-table__cell--text-single .ds-table__cell-primary,\n.ds-table__cell--text-wrap.ds-table__cell--text-multi .ds-table__cell-primary,\n.ds-table__cell--icon-text-wrap.ds-table__cell--icon-text-single .ds-table__cell-primary,\n.ds-table__cell--icon-text-wrap.ds-table__cell--icon-text-multi .ds-table__cell-primary,\n.ds-table__cell--score-text-wrap.ds-table__cell--score-text-single .ds-table__cell-primary,\n.ds-table__cell--score-text-wrap.ds-table__cell--score-text-multi .ds-table__cell-primary {\n line-height: var(--_table-wrap-primary-line-height);\n padding-block: var(--_table-wrap-primary-padding-block);\n}\n\n.ds-table__cell--text-wrap.ds-table__cell--text-multi .ds-table__cell-copy,\n.ds-table__cell--icon-text-wrap.ds-table__cell--icon-text-multi .ds-table__cell-copy,\n.ds-table__cell--score-text-wrap.ds-table__cell--score-text-multi .ds-table__cell-copy {\n gap: 0;\n}\n\n.ds-table__cell--text-wrap.ds-table__cell--text-multi .ds-table__cell-secondary,\n.ds-table__cell--icon-text-wrap.ds-table__cell--icon-text-multi .ds-table__cell-secondary,\n.ds-table__cell--score-text-wrap.ds-table__cell--score-text-multi .ds-table__cell-secondary {\n line-height: var(--_table-wrap-secondary-line-height);\n padding-block: var(--_table-wrap-secondary-padding-block);\n}\n\nds-text.ds-table__cell-secondary,\nds-text.ds-table__cell-tertiary {\n padding-block: var(--dimension-space-025);\n}\n\n.ds-table__cell--text-multi ds-text.ds-table__cell-secondary,\n.ds-table__cell--text-triple ds-text.ds-table__cell-secondary,\n.ds-table__cell--text-triple ds-text.ds-table__cell-tertiary,\n.ds-table__cell--icon-text-multi ds-text.ds-table__cell-secondary,\n.ds-table__cell--icon-text-triple ds-text.ds-table__cell-secondary,\n.ds-table__cell--icon-text-triple ds-text.ds-table__cell-tertiary,\n.ds-table__cell--score-text-multi ds-text.ds-table__cell-secondary,\n.ds-table__cell--score-text-triple ds-text.ds-table__cell-secondary,\n.ds-table__cell--score-text-triple ds-text.ds-table__cell-tertiary,\n.ds-table__cell--primary-text ds-text.ds-table__cell-secondary {\n min-block-size: var(--_table-cell-track-min-block-size);\n}\n\nds-text.ds-table__cell-run,\nds-text.ds-table__cell-run-separator {\n display: flex;\n}\n\nds-text.ds-table__group-label,\nds-text.ds-table__group-count,\nds-text.ds-table__group-accessory,\nds-text.ds-table__group-accessory-separator {\n display: flex;\n align-items: center;\n}\n\nds-text.ds-table__group-count {\n min-block-size: var(--_table-cell-track-min-block-size);\n padding-inline-end: var(--dimension-space-050);\n}\n\n.ds-table__cell-tag-stack--tag-with-text ds-text.ds-table__cell-tag-text,\n.ds-table__cell-tag-stack--text-with-tag ds-text.ds-table__cell-tag-text {\n padding-inline: var(--_table-cell-track-padding-inline);\n padding-block: var(--dimension-space-025);\n min-block-size: var(--_table-cell-track-min-block-size);\n}\n\nds-text.ds-table__load-content {\n display: inline-flex;\n}\n\nds-text.ds-table__bar-text {\n display: inline-flex;\n width: auto;\n max-width: 100%;\n padding-inline: var(--ds-control-label-inset, var(--dimension-space-025));\n}\n\nds-text.ds-table__footer-summary {\n margin-inline-start: auto;\n}\n\n.ds-table__bar-trailing ds-pagination {\n max-inline-size: 100%;\n}\n\n.ds-table__bar-status slot::slotted(*) {\n width: 100%;\n max-width: 100%;\n min-width: 0;\n}\n\nds-skeleton.ds-table__skeleton-image-shape {\n display: flex;\n width: 100%;\n height: 100%;\n --ds-control-height: 100%;\n}\n\nds-skeleton.ds-table__skeleton-checkbox {\n --ds-control-height: var(--dimension-iconography-sm);\n}\n\n.ds-table__skeleton-checkbox-canvas {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n inline-size: var(--dimension-size-300);\n block-size: var(--dimension-size-300);\n}\n\n.ds-table__caption-customizer {\n position: relative;\n display: flex;\n align-items: center;\n min-inline-size: 0;\n}\n\n.ds-table__caption-mode-switcher {\n display: flex;\n align-items: center;\n}\n\n.ds-table__caption-customizer--loading > ds-tooltip ds-button-unfilled {\n visibility: hidden;\n}\n\n.ds-table__caption-customizer > ds-skeleton {\n position: absolute;\n inset: 0;\n}\n\n.ds-table__caption-bar:has([slot='header-after']) {\n flex-direction: column;\n align-items: stretch;\n block-size: auto;\n padding: 0;\n gap: 0;\n}\n\n.ds-table__caption-bar:has([slot='header-after']) .ds-table__caption-content {\n flex: 0 0 auto;\n min-block-size: var(--dimension-size-600);\n padding: var(--dimension-space-100);\n}\n",
|
|
1531
1637
|
"type": "registry:ui"
|
|
1532
1638
|
},
|
|
1533
1639
|
{
|
|
1534
1640
|
"path": "src/wc/components/Table/Table.tsx",
|
|
1535
|
-
"content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Listen,\n Method,\n Prop,\n State,\n Watch,\n} from '@stencil/core';\nimport {\n deriveTableSelectionState,\n formatTableResultSummary,\n formatTableTotalSummary,\n hasOwnedTableFooterSlot,\n isTableCellAction,\n nextDataSortState,\n tableColumnSize,\n tableModelIssues,\n TABLE_GROUP_HERO_SCORE_PRESENTATION,\n tableRows,\n toggleAllLoadedTableRows,\n toggleTableGroupCollapsed,\n toggleTableGroupSelection,\n nextTableGroupsCollapsed,\n toggleTableRowSelection,\n} from './table-model';\nimport { createTableRenderModel, type TableRenderModel } from './table-render-model';\nimport {\n isRenderableTableActionMenu,\n nextTableActionMenuElementId,\n tableActionMenuSections,\n tableActionTriggerId,\n} from './table-action-menu';\nimport {\n nextTableColumnCustomizerElementId,\n resolveTableColumnOrder,\n resolveTableHiddenColumnIds,\n resolveTableVisibleColumns,\n tableColumnCustomizerMenuItems,\n toggleTableColumnHidden,\n} from './table-column-customizer';\nimport {\n nextTableDataModeSwitcherElementId,\n tableDataModeFromMenuItem,\n tableDataModeMenuItems,\n} from './table-data-mode-switcher';\nimport { resolveTableTruncateTrack, tableTruncateLabel } from './table-truncate';\nimport { renderTableRow as renderTableRowView } from './table-row-view';\nimport { TableBodyRenderer } from './table-body-renderer';\nimport { renderTableSkeletonBody } from './table-skeleton-view';\nimport { renderTableLoadContent } from './table-load-view';\nimport { TableLayoutController } from './table-layout-controller';\nimport { TableLoadController } from './table-load-controller';\nimport { TableGroupLoadController } from './table-group-load-controller';\nimport {\n estimateTableRowBlockSize,\n flattenTableVirtualItems,\n resolveTableVirtualPlan,\n TABLE_VIRTUAL_TOTAL_SUMMARY_LABEL,\n TABLE_VIRTUAL_VIEWPORT_REQUIRED_BODY,\n TABLE_VIRTUAL_VIEWPORT_REQUIRED_HEADING,\n type TableVirtualItem,\n type TableVirtualPlan,\n} from './table-virtual-model';\nimport { TableVirtualController } from './table-virtual-controller';\nimport type { PaginationChangeDetail } from '../Pagination/pagination-types';\nimport type { MenuItemData, MenuReorderDetail } from '../Menu/menu-types';\nimport { resolvePaginationState } from '../Pagination/pagination-model';\nimport { resolveCssLengthPx } from '../../utils/resolve-css-length-px';\nimport { isElementTruncated } from '../../utils/is-element-truncated';\nimport { observeTableCaptionCompact } from '../../utils/table-caption-compact';\nimport { resolveTableFitPageSize } from './table-pagination-fit';\nimport { createTableHighlightMatcher, type TableHighlightMatcher } from './table-highlight';\nimport {\n paginationShortcutBlockedByPath,\n shouldHandleContainingPagePaginationShortcut,\n} from './table-pagination-shortcut';\nimport {\n TableViewportFitController,\n type TableViewportFitMetrics,\n} from './table-viewport-fit-controller';\nimport type {\n TableCaptionVisibility,\n TableCellActionDetail,\n TableColumn,\n DataFieldsConfigChangeDetail,\n TableDataMode,\n TableDataModeChangeDetail,\n DataGroup,\n TableGroupCollapseChangeDetail,\n TableGroupLoadMoreDetail,\n DataGroupingState,\n TableLoadMoreDetail,\n TableLoadMoreMode,\n TablePaginationState,\n TableRow,\n TableRowActivateDetail,\n TableSelectionChangeDetail,\n TableSelectionMode,\n DataSortChangeDetail,\n DataSortState,\n} from './table-types';\n\nconst TABLE_FOOTER_SLOT_LEADING = 1;\nconst TABLE_FOOTER_SLOT_COPY = 2;\nconst TABLE_FOOTER_SLOT_TRAILING = 4;\nconst TABLE_NON_VIRTUAL_WINDOW_THRESHOLD = 50;\n\n@Component({\n tag: 'ds-table',\n styleUrls: ['../../utils/focus-ring.css', '../../utils/interaction-fill.css', 'Table.css'],\n scoped: true,\n})\nexport class Table {\n @Element() el!: HTMLElement;\n /** Stable column definitions. Assign through JavaScript. */\n @Prop() columns: TableColumn[] = [];\n /** Additional toggle options in the column customizer. */\n @Prop() customizeOptions: MenuItemData[] = [];\n @Event() dsCustomizeOptionChange!: EventEmitter<string>;\n /** Ungrouped row data. Ignored while grouping is active. Assign through JavaScript. */\n @Prop() rows: TableRow[] = [];\n /** One level of application-owned grouped data. Assign through JavaScript. */\n @Prop() groups: DataGroup[] = [];\n /** Controlled grouping column. Applications supply groups in their final fixed order. */\n @Prop() grouping: DataGroupingState | null = null;\n /** Controlled member-row sort state. */\n @Prop() sort: DataSortState | null = null;\n /** Controlled collapsed group identities. Groups not listed remain expanded. */\n @Prop() collapsedGroupIds: string[] = [];\n /** Literal terms to highlight in table-owned text cells. Applications still own filtering. */\n @Prop() highlightTerms: string[] = [];\n /** Optional DataSearch field identities that restrict which data-point tracks are highlighted. */\n @Prop() highlightFieldIds: string[] = [];\n\n /** Required accessible table name, retained as a native caption. */\n @Prop() caption!: string;\n /** Shows a matching presentational title bar above the native table frame. */\n @Prop() captionVisibility: TableCaptionVisibility = 'hidden';\n /**\n * Opt in to the table-owned column customizer. The `columns` prop remains the\n * catalog; hidden and ordered columns are controlled separately. The trigger\n * opens the shared Menu of live show/hide switch rows.\n */\n @Prop() columnCustomizer: boolean = false;\n /** Hide only the built-in Customize trigger when an external preferences surface owns it. */\n /** Remove borders from table-owned caption controls. */\n @Prop() captionControlsBorderless: boolean = false;\n @Prop() hideColumnCustomizerTrigger: boolean = false;\n /** Controlled hidden data-column identities. Action ids are ignored. */\n @Prop() hiddenFieldIds: string[] = [];\n /** Controlled data-column identities in display order. Omitted ids append in catalog order. */\n @Prop() fieldOrder: string[] = [];\n /**\n * Optional result summary footer. When both `displayedCount` and `totalCount`\n * are finite numbers, infinite mode shows “Displaying {displayed} of {total}”.\n * Virtual mode ignores `displayedCount` and derives its total from the\n * complete supplied rows; a mismatched `totalCount` emits a warning.\n */\n @Prop() displayedCount: number | undefined;\n @Prop() totalCount: number | undefined;\n /** Supports {displayed} and {total} placeholders. */\n @Prop() resultSummaryLabel: string = 'Displaying {displayed} of {total}';\n /** Supports the {total} placeholder. Used when dataMode is virtual. */\n @Prop() resultTotalSummaryLabel: string = TABLE_VIRTUAL_TOTAL_SUMMARY_LABEL;\n @Prop() stickyHeader: boolean = false;\n /** Maximum scroll-region height. Numbers resolve to CSS pixels. */\n @Prop() maxHeight: string | number | undefined;\n /** Fixed height for the complete header, table frame, and footer composition. */\n @Prop() height: string | number | undefined;\n /** Fit the complete table composition to its nearest vertical scrollport. */\n @Prop() fitViewport: boolean = false;\n /** Reserved space above a viewport-fitted table once surrounding chrome is compact. */\n @Prop() viewportInsetBlockStart: string | number = 0;\n /** Reserved space below a viewport-fitted table. */\n @Prop() viewportInsetBlockEnd: string | number = 0;\n /** Optional explicit label for the horizontal/vertical scroll region. */\n @Prop() scrollLabel: string | undefined;\n\n @Prop() selectionMode: TableSelectionMode = 'none';\n /** Controlled selected row identities. IDs outside the loaded rows are preserved. */\n @Prop() selectedRowIds: string[] = [];\n\n /**\n * Identity of the interactive row whose associated surface is visible.\n * Holds its pressed wash independently of checkbox selection. Keep the ID\n * through exit motion and clear it after close; the application owns toggling\n * and switching the surface. Off-window identities survive virtualization.\n */\n @Prop() surfaceOpenRowId: string | undefined;\n\n /** Initial loading state. Existing rows stay visible; incremental loading uses loadingMore. */\n @Prop() loading: boolean = false;\n /** Replace opted-in table-owned caption controls with same-size visual skeletons. */\n @Prop() chromeLoading: boolean = false;\n /** Initial-loading rows. Defaults to ten so bounded tables retain a useful filled viewport. */\n @Prop() skeletonRows: number = 10;\n @Prop() emptyHeading: string = 'No results';\n @Prop() emptyBody: string = 'No data is available.';\n /** Initial error state. Existing rows stay visible; incremental failures use loadMoreError. */\n @Prop() error: boolean = false;\n @Prop() errorHeading: string = 'Unable to load data';\n @Prop() errorBody: string = 'The data could not be loaded.';\n @Prop() emptyCellLabel: string = 'Not available';\n\n /** Top-level data-window strategy. Virtual mode recycles row DOM only. */\n @Prop() dataMode: TableDataMode = 'infinite';\n /** Opt in to the table-owned control for choosing between supported data modes. */\n @Prop() dataModeSwitcher: boolean = false;\n @Prop() dataModeSwitcherLabel: string = 'Change table variation';\n @Prop() dataModeMenuLabel: string = 'Table variation';\n @Prop() infiniteModeLabel: string = 'Infinite scroll';\n @Prop() paginationModeLabel: string = 'Pagination + Infinite groups';\n @Prop() virtualModeLabel: string = 'Virtual scroll';\n /** Controlled top-level pagination state. Required when dataMode is pagination. */\n @Prop() pagination: TablePaginationState | null = null;\n @Prop() loadMoreMode: TableLoadMoreMode = 'auto';\n @Prop() hasMore: boolean = false;\n @Prop() loadingMore: boolean = false;\n @Prop() loadMoreError: string | undefined;\n /** Reset key for a new query/group/sort dataset. */\n @Prop() loadIdentity: string | number = 'default';\n /** IntersectionObserver root margin in CSS pixels. */\n @Prop() loadMoreThreshold: number = 0;\n @Prop() loadMoreLabel: string = 'Load more';\n @Prop() retryLabel: string = 'Retry';\n @Prop() loadingMoreLabel: string = 'Loading more items';\n @Prop() endOfResultsLabel: string = 'All results loaded';\n /** Supports {count} and {total} placeholders. */\n @Prop() rowsLoadedLabel: string = '{count} more rows loaded. {total} rows loaded.';\n @Prop() groupLoadMoreLabel: string = 'Load more';\n /** Supports the {group} placeholder. */\n @Prop() groupLoadMoreAriaLabel: string = 'Load more {group} results';\n /** Supports the {group} placeholder. */\n @Prop() groupRetryLabel: string = 'Retry loading {group} results';\n /** Supports the {group} placeholder. */\n @Prop() groupLoadingMoreLabel: string = 'Loading more items';\n /** Supports the {group} placeholder. */\n @Prop() groupEndOfResultsLabel: string = 'All {group} results loaded';\n /** Supports {group}, {count}, {loaded}, and {total} placeholders. */\n @Prop() groupRowsLoadedLabel: string =\n '{count} more rows loaded in {group}. {loaded} of {total} rows loaded.';\n\n @Event() dsSortChange!: EventEmitter<DataSortChangeDetail>;\n @Event() dsGroupCollapseChange!: EventEmitter<TableGroupCollapseChangeDetail>;\n @Event() dsSelectionChange!: EventEmitter<TableSelectionChangeDetail>;\n @Event() dsLoadMore!: EventEmitter<TableLoadMoreDetail>;\n @Event() dsGroupLoadMore!: EventEmitter<TableGroupLoadMoreDetail>;\n @Event() dsPaginationChange!: EventEmitter<PaginationChangeDetail>;\n @Event() dsCellAction!: EventEmitter<TableCellActionDetail>;\n @Event() dsRowActivate!: EventEmitter<TableRowActivateDetail>;\n @Event() dsFieldsConfigChange!: EventEmitter<DataFieldsConfigChangeDetail>;\n @Event() dsDataModeChange!: EventEmitter<TableDataModeChangeDetail>;\n\n @State() private overflowStart = false;\n @State() private overflowEnd = false;\n @State() private scrollable = false;\n @State() private announcement = '';\n @State() private activeStickyGroupId: string | null = null;\n @State() private viewportFitSettled = false;\n @State() private headerPresent = false;\n @State() private headerUsesToolbar = false;\n @State() private footerSlotPresence = 0;\n @State() private fitPageSize: number | undefined;\n @State() private actionMenu: { rowId: string; columnId: string } | null = null;\n @State() private actionMenuInitialFocusVisible = false;\n @State() private actionMenuSurface: { rowId: string; columnId: string } | null = null;\n @State() private truncateTooltipLabel = '';\n @State() private captionCompact = false;\n @State() private columnCustomizerOpen = false;\n @State() private columnCustomizerSurfaceOpen = false;\n @State() private columnCustomizerInitialFocusVisible = false;\n @State() private dataModeSwitcherOpen = false;\n @State() private dataModeSwitcherSurfaceOpen = false;\n @State() private dataModeSwitcherInitialFocusVisible = false;\n @State() private virtualWindow: TableVirtualPlan | null = null;\n\n private readonly actionMenuElementId = nextTableActionMenuElementId();\n private readonly columnCustomizerElementId = nextTableColumnCustomizerElementId();\n private readonly dataModeSwitcherElementId = nextTableDataModeSwitcherElementId();\n private truncateTooltipEl?: HTMLDsTooltipElement;\n private truncateAnchor: HTMLElement | null = null;\n private truncateTooltipBound = false;\n private focusedRowId: string | null = null;\n private virtualItems: TableVirtualItem[] = [];\n private visibleColumnsCache: {\n columns: TableColumn[];\n columnCustomizer: boolean;\n hiddenFieldIds: string[];\n fieldOrder: string[];\n value: TableColumn[];\n } | null = null;\n private renderModelCache: {\n columns: TableColumn[];\n rows: TableRow[];\n groups: DataGroup[];\n grouped: boolean;\n selectionMode: TableSelectionMode;\n selectedRowIds: string[];\n collapsedGroupIds: string[];\n virtualCounts: boolean;\n value: TableRenderModel;\n } | null = null;\n private highlightMatcherCache: {\n terms: string[];\n value: TableHighlightMatcher;\n } | null = null;\n private virtualItemsCache: {\n columns: TableColumn[];\n rows: TableRow[];\n groups: DataGroup[];\n grouped: boolean;\n collapsedGroupIds: string[];\n } | null = null;\n private rootEl: HTMLElement | null = null;\n private viewportEl: HTMLElement | null = null;\n private frameEl: HTMLElement | null = null;\n private interactiveHeadEl: HTMLTableSectionElement | null = null;\n private collapseAllOverlayEl: HTMLElement | null = null;\n private stickyHeaderTableEl: HTMLTableElement | null = null;\n private tableEl: HTMLTableElement | null = null;\n private sentinelEl: HTMLElement | null = null;\n private incrementalWindowActive = false;\n private readonly groupSentinelEls = new Map<string, HTMLElement>();\n private previousModelWarning = '';\n private modelWarningQueued = false;\n private initialModelWarningFrame: number | undefined;\n private hasLoaded = false;\n private renderedModel: TableRenderModel | null = null;\n private stickyGroupConnected = false;\n private headerSlotObserver: MutationObserver | null = null;\n private footerSlotObserver: MutationObserver | null = null;\n private captionCompactDisconnect: (() => void) | undefined;\n private fitResizeObserver: ResizeObserver | null = null;\n private fitObservedViewport: HTMLElement | null = null;\n private fitObservedTable: HTMLTableElement | null = null;\n private fitMeasurementPending = false;\n private readonly bodyRenderer = new TableBodyRenderer();\n private readonly layoutController = new TableLayoutController({\n elements: () => ({\n viewport: this.viewportEl,\n contentTable: this.tableEl,\n stickyHeaderTable: this.stickyHeaderTableEl,\n collapseAllOverlay: this.collapseAllOverlayEl,\n frame: this.frameEl,\n interactiveHead: this.interactiveHeadEl,\n }),\n mode: () => ({\n documentStickyHeader: this.documentStickyHeader,\n floatingCollapseAll: this.renderedModel?.collapseAllHost?.mode === 'floating',\n clampVerticalOverscroll: this.fitViewport && this.viewportFitSettled,\n }),\n verticalEdgeWheel: deltaY => this.viewportFitController.scrollOuterBy(deltaY),\n overflowChanged: state => {\n if (state.start !== this.overflowStart) this.overflowStart = state.start;\n if (state.end !== this.overflowEnd) this.overflowEnd = state.end;\n if (state.scrollable !== this.scrollable) this.scrollable = state.scrollable;\n },\n });\n private readonly loadController = new TableLoadController({\n state: () => ({\n lazyLoading: this.dataMode === 'infinite' && !this.grouped,\n loadMoreMode: this.loadMoreMode,\n hasMore: this.hasMore,\n loadingMore: this.loadingMore,\n loadMoreError: this.loadMoreError,\n loadIdentity: this.loadIdentity,\n loadMoreThreshold: this.loadMoreThreshold,\n containedScroll: this.containedScroll,\n loadingMoreLabel: this.loadingMoreLabel,\n endOfResultsLabel: this.endOfResultsLabel,\n rowsLoadedLabel: this.rowsLoadedLabel,\n loadedRowCount: this.currentLoadedRowCount(),\n viewport: this.viewportEl,\n sentinel: this.sentinelEl,\n }),\n announce: message => {\n this.announcement = message;\n },\n request: detail => {\n this.dsLoadMore.emit(detail);\n },\n });\n private readonly groupLoadController = new TableGroupLoadController({\n state: () => ({\n enabled: this.grouped && this.dataMode !== 'virtual',\n loadMoreMode: this.loadMoreMode,\n loadMoreThreshold: this.loadMoreThreshold,\n containedScroll: this.containedScroll,\n groups: this.groups,\n viewport: this.viewportEl,\n sentinels: this.groupSentinelEls,\n loadingMoreLabel: this.groupLoadingMoreLabel,\n endOfResultsLabel: this.groupEndOfResultsLabel,\n rowsLoadedLabel: this.groupRowsLoadedLabel,\n }),\n announce: message => {\n this.announcement = message;\n },\n request: detail => {\n this.dsGroupLoadMore.emit(detail);\n },\n });\n private readonly virtualController = new TableVirtualController({\n state: () => ({\n enabled: this.rowWindowingEnabled,\n items: this.virtualItems,\n pinnedRowIds: this.virtualPinnedRowIds(),\n viewport: this.viewportEl,\n viewportSize: this.estimateVirtualViewportSize(),\n }),\n windowChanged: plan => {\n this.virtualWindow = plan;\n },\n });\n private readonly viewportFitController = new TableViewportFitController({\n enabled: () => this.fitViewport,\n elements: () => ({ host: this.el, surface: this.rootEl }),\n insets: () => ({\n blockStart: this.viewportInsetBlockStart,\n blockEnd: this.viewportInsetBlockEnd,\n }),\n fitChanged: (metrics: TableViewportFitMetrics | null) => {\n const settled = metrics?.settled ?? false;\n if (settled !== this.viewportFitSettled) this.viewportFitSettled = settled;\n },\n });\n\n componentWillLoad(): void {\n // Reserve the final viewport-fit height before the first paint. The fitted\n // surface is connected after render, but the host can already resolve its\n // owning scrollport and prevent an initial auto-height frame.\n this.viewportFitController.connect();\n this.incrementalWindowActive = this.hasIncrementalState;\n this.syncHeaderSlotPresence();\n this.syncFooterSlotPresence();\n this.loadController.initialize();\n this.groupLoadController.initialize();\n }\n\n componentDidLoad(): void {\n this.hasLoaded = true;\n this.layoutController.connect();\n this.loadController.connect();\n this.groupLoadController.connect();\n this.virtualController.connect();\n this.viewportFitController.connect();\n this.syncStickyGroupConnection();\n this.connectHeaderSlotObserver();\n this.connectFooterSlotObserver();\n this.connectFitObserver();\n this.connectCaptionCompactObserver();\n this.syncFitPageSize();\n this.connectTruncateTooltip();\n this.scheduleInitialModelIssueWarning();\n }\n\n componentDidRender(): void {\n this.layoutController.refresh(false);\n this.loadController.refresh();\n this.groupLoadController.refresh();\n this.virtualController.refresh();\n this.virtualController.collectMeasurements(this.tableEl);\n if (\n this.rowWindowingEnabled &&\n this.truncateAnchor &&\n this.tableEl &&\n !this.tableEl.contains(this.truncateAnchor)\n ) {\n this.dismissTruncateTooltip();\n }\n this.viewportFitController.refresh(false);\n this.syncStickyGroupConnection();\n this.connectFitObserver();\n this.connectCaptionCompactObserver();\n this.syncFitPageSize();\n if (this.stickyGroupConnected) this.updateStickyGroup();\n }\n\n /**\n * Scroll the nearest vertical owner only enough to reveal a supplied row.\n * Resolves false when the row is absent from the supplied, expanded table model.\n * This method never moves focus.\n */\n @Method()\n async scrollRowIntoView(rowId: string): Promise<boolean> {\n if (!rowId) return false;\n\n if (this.rowWindowingEnabled) {\n const headerSize = this.tableEl?.tHead?.getBoundingClientRect().height ?? 0;\n const revealed = this.virtualController.scrollRowIntoView(\n rowId,\n headerSize,\n this.stickyHeader ? headerSize : 0\n );\n if (!revealed) return false;\n await this.nextAnimationFrame();\n }\n\n const row = this.findRenderedRow(rowId);\n if (!row) return false;\n this.revealRenderedRow(row);\n return true;\n }\n\n connectedCallback(): void {\n if (!this.hasLoaded) return;\n this.layoutController.connect();\n this.loadController.connect();\n this.groupLoadController.connect();\n this.virtualController.connect();\n this.viewportFitController.connect();\n this.syncStickyGroupConnection();\n this.connectHeaderSlotObserver();\n this.connectFooterSlotObserver();\n this.connectFitObserver();\n this.connectCaptionCompactObserver();\n this.connectTruncateTooltip();\n }\n\n disconnectedCallback(): void {\n this.layoutController.disconnect();\n this.loadController.disconnect();\n this.groupLoadController.disconnect();\n this.virtualController.disconnect();\n this.viewportFitController.disconnect();\n this.disconnectStickyGroup();\n this.headerSlotObserver?.disconnect();\n this.headerSlotObserver = null;\n this.footerSlotObserver?.disconnect();\n this.footerSlotObserver = null;\n this.disconnectFitObserver();\n this.disconnectCaptionCompactObserver();\n this.disconnectTruncateTooltip();\n this.closeColumnCustomizer();\n if (this.initialModelWarningFrame !== undefined) {\n cancelAnimationFrame(this.initialModelWarningFrame);\n this.initialModelWarningFrame = undefined;\n }\n }\n\n private syncHeaderSlotPresence = () => {\n const header = this.el.querySelector<HTMLElement>('[slot=\"header\"]');\n this.headerPresent = !!header;\n this.headerUsesToolbar = header?.tagName === 'DS-DATA-TOOLBAR';\n };\n\n private connectHeaderSlotObserver(): void {\n if (this.headerSlotObserver || typeof MutationObserver === 'undefined') return;\n this.headerSlotObserver = new MutationObserver(this.syncHeaderSlotPresence);\n this.headerSlotObserver.observe(this.el, { childList: true });\n this.syncHeaderSlotPresence();\n }\n\n private syncFooterSlotPresence = () => {\n let presence = 0;\n if (hasOwnedTableFooterSlot(this.el, 'footer-leading')) presence |= TABLE_FOOTER_SLOT_LEADING;\n if (hasOwnedTableFooterSlot(this.el, 'footer')) presence |= TABLE_FOOTER_SLOT_COPY;\n if (hasOwnedTableFooterSlot(this.el, 'footer-trailing')) presence |= TABLE_FOOTER_SLOT_TRAILING;\n if (presence !== this.footerSlotPresence) this.footerSlotPresence = presence;\n };\n\n private connectFooterSlotObserver(): void {\n if (this.footerSlotObserver || typeof MutationObserver === 'undefined') return;\n this.footerSlotObserver = new MutationObserver(this.syncFooterSlotPresence);\n this.footerSlotObserver.observe(this.el, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: ['slot'],\n });\n this.syncFooterSlotPresence();\n }\n\n private connectStickyGroup(): void {\n if (this.stickyGroupConnected || typeof window === 'undefined') return;\n this.stickyGroupConnected = true;\n document.addEventListener('scroll', this.updateStickyGroup, {\n capture: true,\n passive: true,\n });\n window.addEventListener('resize', this.updateStickyGroup, { passive: true });\n this.updateStickyGroup();\n }\n\n private syncStickyGroupConnection(): void {\n if (this.documentStickyHeader && this.grouped) {\n this.connectStickyGroup();\n return;\n }\n\n this.disconnectStickyGroup();\n this.frameEl?.style.removeProperty('--_table-sticky-group-top');\n if (this.activeStickyGroupId !== null) {\n this.activeStickyGroupId = null;\n }\n }\n\n private disconnectStickyGroup(): void {\n if (!this.stickyGroupConnected || typeof window === 'undefined') return;\n this.stickyGroupConnected = false;\n document.removeEventListener('scroll', this.updateStickyGroup, true);\n window.removeEventListener('resize', this.updateStickyGroup);\n }\n\n private readonly updateStickyGroup = (): void => {\n if (!this.documentStickyHeader || !this.grouped || !this.frameEl || !this.tableEl) {\n if (this.activeStickyGroupId !== null) this.activeStickyGroupId = null;\n return;\n }\n\n const stickyHeader = this.frameEl.querySelector<HTMLElement>(\n '.ds-table__document-sticky-header'\n );\n if (!stickyHeader) return;\n const threshold = stickyHeader.getBoundingClientRect().bottom;\n let activeGroupId: string | null = null;\n let activeGroupTop = threshold;\n for (const body of this.tableEl.querySelectorAll<HTMLElement>('tbody[data-group-id]')) {\n const source = body.querySelector<HTMLElement>('.ds-table__group-cell');\n if (!source) continue;\n const sourceRect = source.getBoundingClientRect();\n const bodyRect = body.getBoundingClientRect();\n if (sourceRect.top <= threshold && bodyRect.bottom > threshold) {\n activeGroupId = body.dataset.groupId ?? null;\n // A sticky section is bounded by its own row group. As that boundary\n // approaches the sticky lane, move the outgoing section bar with it so\n // the incoming source bar pushes it away instead of rendering beneath it.\n activeGroupTop = Math.min(threshold, bodyRect.bottom - sourceRect.height);\n }\n }\n\n const frameRect = this.frameEl.getBoundingClientRect();\n this.frameEl.style.setProperty(\n '--_table-sticky-group-top',\n `${activeGroupTop - frameRect.top}px`\n );\n\n if (activeGroupId !== this.activeStickyGroupId) {\n this.activeStickyGroupId = activeGroupId;\n return;\n }\n };\n\n @Watch('columns')\n @Watch('grouping')\n @Watch('hiddenFieldIds')\n @Watch('fieldOrder')\n @Watch('columnCustomizer')\n handleStructureChange(): void {\n this.scheduleModelIssueWarning();\n this.loadController.structureChanged();\n this.groupLoadController.structureChanged();\n this.virtualController.invalidateMeasures();\n }\n\n @Watch('rows')\n @Watch('groups')\n handleDataChange(): void {\n if (this.grouped) this.groupLoadController.dataChanged();\n else this.loadController.dataChanged();\n this.scheduleModelIssueWarning();\n this.syncActionMenu();\n this.virtualController.invalidateMeasures();\n }\n\n @Watch('totalCount')\n handleTotalCountChange(): void {\n this.scheduleModelIssueWarning();\n }\n\n @Watch('caption')\n handleCaptionChange(): void {\n this.scheduleModelIssueWarning();\n }\n\n @Watch('loadIdentity')\n handleLoadIdentityChange(): void {\n this.incrementalWindowActive = this.hasIncrementalState;\n this.loadController.identityChanged();\n this.virtualController.resetToTop();\n }\n\n @Watch('sort')\n @Watch('grouping')\n handleVirtualIndexReset(): void {\n this.virtualController.resetToTop();\n }\n\n @Watch('loadMoreMode')\n @Watch('loadMoreThreshold')\n handleLazyConfigurationChange(): void {\n this.loadController.configurationChanged();\n this.groupLoadController.configurationChanged();\n }\n\n @Watch('dataMode')\n handleDataModeChange(): void {\n this.incrementalWindowActive = this.hasIncrementalState;\n this.scheduleModelIssueWarning();\n this.handleLazyConfigurationChange();\n this.virtualController.resetToTop();\n }\n\n @Watch('pagination')\n handlePaginationChange(\n pagination: TablePaginationState | null,\n previous: TablePaginationState | null\n ): void {\n if (pagination?.pageSizeMode === 'fit' && pagination.fitIdentity !== previous?.fitIdentity) {\n this.fitMeasurementPending = true;\n }\n }\n\n @Watch('loadingMore')\n handleLoadingMoreChange(loading: boolean): void {\n if (loading) this.incrementalWindowActive = true;\n if (!this.grouped) this.loadController.loadingChanged(loading);\n }\n\n @Watch('loadMoreError')\n handleLoadMoreErrorChange(error: string | undefined): void {\n if (error?.trim()) this.incrementalWindowActive = true;\n if (!this.grouped) this.loadController.errorChanged(error);\n }\n\n @Watch('hasMore')\n handleHasMoreChange(hasMore: boolean, hadMore: boolean): void {\n if (hasMore) this.incrementalWindowActive = true;\n if (!this.grouped) this.loadController.hasMoreChanged(hasMore, hadMore);\n }\n\n @Watch('columnCustomizer')\n @Watch('captionVisibility')\n handleColumnCustomizerAvailability(): void {\n if (!this.showsColumnCustomizer) {\n this.closeColumnCustomizer();\n this.columnCustomizerSurfaceOpen = false;\n }\n }\n\n @Watch('dataModeSwitcher')\n @Watch('captionVisibility')\n handleDataModeSwitcherAvailability(): void {\n if (!this.showsDataModeSwitcher) {\n this.closeDataModeSwitcher();\n this.dataModeSwitcherSurfaceOpen = false;\n }\n }\n\n @Watch('chromeLoading')\n handleChromeLoadingChange(loading: boolean): void {\n if (!loading) return;\n this.closeColumnCustomizer();\n this.closeDataModeSwitcher();\n this.columnCustomizerSurfaceOpen = false;\n this.dataModeSwitcherSurfaceOpen = false;\n }\n\n private get grouped(): boolean {\n return this.grouping !== null;\n }\n\n private get hasIncrementalState(): boolean {\n return (\n this.dataMode === 'infinite' &&\n (this.hasMore || this.loadingMore || !!this.loadMoreError?.trim())\n );\n }\n\n private get selectable(): boolean {\n return this.selectionMode === 'multiple';\n }\n\n private get visibleColumns(): TableColumn[] {\n const cached = this.visibleColumnsCache;\n if (\n cached &&\n cached.columns === this.columns &&\n cached.columnCustomizer === this.columnCustomizer &&\n cached.hiddenFieldIds === this.hiddenFieldIds &&\n cached.fieldOrder === this.fieldOrder\n ) {\n return cached.value;\n }\n const value = resolveTableVisibleColumns(this.columns, {\n columnCustomizer: this.columnCustomizer,\n hiddenFieldIds: this.hiddenFieldIds,\n fieldOrder: this.fieldOrder,\n });\n this.visibleColumnsCache = {\n columns: this.columns,\n columnCustomizer: this.columnCustomizer,\n hiddenFieldIds: this.hiddenFieldIds,\n fieldOrder: this.fieldOrder,\n value,\n };\n return value;\n }\n\n private get showsColumnCustomizer(): boolean {\n return this.columnCustomizer && this.captionVisibility === 'visible';\n }\n\n private get showsDataModeSwitcher(): boolean {\n return this.dataModeSwitcher && this.captionVisibility === 'visible';\n }\n\n private get showsCaptionTrailing(): boolean {\n return (\n this.showsDataModeSwitcher ||\n (this.showsColumnCustomizer && !this.hideColumnCustomizerTrigger)\n );\n }\n\n private get documentStickyHeader(): boolean {\n return this.stickyHeader && !this.containedScroll;\n }\n\n private get fixedHeight(): boolean {\n return !this.fitViewport && this.resolvedHeight !== undefined;\n }\n\n private get boundedComposition(): boolean {\n return this.fitViewport || this.fixedHeight;\n }\n\n private get containedScroll(): boolean {\n return this.boundedComposition || this.resolvedMaxHeight !== undefined;\n }\n\n private connectFitObserver(): void {\n if (typeof ResizeObserver === 'undefined') return;\n if (this.fitObservedViewport === this.viewportEl && this.fitObservedTable === this.tableEl) {\n return;\n }\n this.disconnectFitObserver();\n this.fitObservedViewport = this.viewportEl;\n this.fitObservedTable = this.tableEl;\n this.fitResizeObserver = new ResizeObserver(this.syncFitPageSize);\n if (this.fitObservedViewport) this.fitResizeObserver.observe(this.fitObservedViewport);\n if (this.fitObservedTable) this.fitResizeObserver.observe(this.fitObservedTable);\n }\n\n private disconnectFitObserver(): void {\n this.fitResizeObserver?.disconnect();\n this.fitResizeObserver = null;\n this.fitObservedViewport = null;\n this.fitObservedTable = null;\n }\n\n private connectCaptionCompactObserver(): void {\n if (this.captionCompactDisconnect) return;\n this.captionCompactDisconnect = observeTableCaptionCompact(this.el, compact => {\n if (this.captionCompact !== compact) this.captionCompact = compact;\n });\n }\n\n private disconnectCaptionCompactObserver(): void {\n this.captionCompactDisconnect?.();\n this.captionCompactDisconnect = undefined;\n }\n\n private readonly syncFitPageSize = (): void => {\n const pagination = this.dataMode === 'pagination' ? this.pagination : null;\n if (\n !pagination?.fitToPage ||\n pagination.fitToPageInactive ||\n !this.containedScroll ||\n !this.viewportEl ||\n !this.tableEl\n ) {\n if (this.fitPageSize !== undefined) this.fitPageSize = undefined;\n this.fitMeasurementPending = false;\n return;\n }\n const header = this.tableEl.querySelector<HTMLElement>('.ds-table__head .ds-table__header-row');\n const item = this.tableEl.querySelector<HTMLElement>(\n this.grouped ? '.ds-table__group-row' : '.ds-table__body .ds-table__row'\n );\n const itemBlockSize =\n item?.getBoundingClientRect().height ||\n resolveCssLengthPx(\n 'var(--ds-table-row-min-block-size, var(--dimension-size-500))',\n 0,\n this.rootEl ?? this.el\n );\n const next = resolveTableFitPageSize({\n viewportBlockSize: this.viewportEl.clientHeight,\n headerBlockSize: header?.getBoundingClientRect().height ?? 0,\n itemBlockSize,\n });\n if (next !== this.fitPageSize) this.fitPageSize = next;\n if (!this.fitMeasurementPending || pagination.pageSizeMode !== 'fit' || next === undefined) {\n return;\n }\n this.fitMeasurementPending = false;\n if (next === pagination.pageSize && pagination.pageIndex === 0) return;\n this.dsPaginationChange.emit({\n pageIndex: 0,\n pageSize: next,\n pageSizeMode: 'fit',\n totalItems: pagination.totalItems,\n pageSizeOptions: pagination.pageSizeOptions ?? [25, 50, 100, 200],\n fitToPage: true,\n fitToPageInactive: false,\n fitPageSize: next,\n fitPageSizeLabel: pagination.fitPageSizeLabel ?? 'Fit to page',\n fitPageSizeTriggerLabel: pagination.fitPageSizeTriggerLabel ?? 'Fit',\n itemLabel: pagination.itemLabel ?? 'items',\n pageSizeLabel: pagination.pageSizeLabel ?? 'Items',\n ariaLabel: pagination.ariaLabel ?? `${this.caption} pagination`,\n showFirstLastButtons: pagination.showFirstLastButtons ?? false,\n previousPageIndex: pagination.pageIndex,\n previousPageSize: pagination.pageSize,\n previousPageSizeMode: 'fit',\n reason: 'fit',\n });\n };\n\n @Listen('keydown', { target: 'window' })\n handleWindowPaginationKeyDown(event: KeyboardEvent): void {\n this.handlePaginationKeyDown(event, true);\n }\n\n private handlePaginationKeyDown(event: KeyboardEvent, fromWindow = false): void {\n const pagination = this.dataMode === 'pagination' ? this.pagination : null;\n if (\n !pagination ||\n this.loading ||\n event.defaultPrevented ||\n event.altKey ||\n event.ctrlKey ||\n event.metaKey ||\n event.shiftKey ||\n (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')\n ) {\n return;\n }\n\n if (paginationShortcutBlockedByPath(event.composedPath())) return;\n\n if (fromWindow) {\n const origin = event.composedPath()[0];\n const originNode = origin instanceof Text ? origin.parentNode : origin;\n if (\n !shouldHandleContainingPagePaginationShortcut({\n origin: originNode,\n table: this.el,\n eventPath: event.composedPath(),\n })\n ) {\n return;\n }\n }\n\n const state = resolvePaginationState(pagination);\n const pageIndex = event.key === 'ArrowLeft' ? state.pageIndex - 1 : state.pageIndex + 1;\n if (pageIndex < 0 || pageIndex >= state.totalPages) return;\n\n event.preventDefault();\n this.dsPaginationChange.emit({\n pageIndex,\n pageSize: state.pageSize,\n pageSizeMode: state.pageSizeMode,\n totalItems: state.totalItems,\n pageSizeOptions: state.pageSizeOptions,\n fitToPage: pagination.fitToPage ?? false,\n fitToPageInactive: pagination.fitToPageInactive ?? false,\n fitPageSize: this.fitPageSize ?? pagination.fitPageSize,\n fitPageSizeLabel: pagination.fitPageSizeLabel ?? 'Fit to page',\n fitPageSizeTriggerLabel: pagination.fitPageSizeTriggerLabel ?? 'Fit',\n itemLabel: pagination.itemLabel ?? 'items',\n pageSizeLabel: pagination.pageSizeLabel ?? 'Items',\n ariaLabel: pagination.ariaLabel ?? `${this.caption} pagination`,\n showFirstLastButtons: pagination.showFirstLastButtons ?? false,\n previousPageIndex: state.pageIndex,\n previousPageSize: state.pageSize,\n previousPageSizeMode: state.pageSizeMode,\n reason: 'page',\n });\n }\n\n private currentLoadedRows(): TableRow[] {\n return tableRows(this.rows, this.groups, this.grouped);\n }\n\n private currentLoadedRowCount(): number {\n return this.grouped\n ? this.groups.reduce((count, group) => count + group.rows.length, 0)\n : this.rows.length;\n }\n\n private createRenderModel(): TableRenderModel {\n const columns = this.visibleColumns;\n const grouped = this.grouped;\n const virtualCounts = this.dataMode === 'virtual';\n const cached = this.renderModelCache;\n if (\n cached &&\n cached.columns === columns &&\n cached.rows === this.rows &&\n cached.groups === this.groups &&\n cached.grouped === grouped &&\n cached.selectionMode === this.selectionMode &&\n cached.selectedRowIds === this.selectedRowIds &&\n cached.collapsedGroupIds === this.collapsedGroupIds &&\n cached.virtualCounts === virtualCounts\n ) {\n return cached.value;\n }\n const value = createTableRenderModel({\n columns,\n rows: this.rows,\n groups: this.groups,\n grouped,\n selectionMode: this.selectionMode,\n selectedRowIds: this.selectedRowIds,\n collapsedGroupIds: this.collapsedGroupIds,\n groupCountPresentation: virtualCounts ? 'total' : 'loaded-progress',\n });\n this.renderModelCache = {\n columns,\n rows: this.rows,\n groups: this.groups,\n grouped,\n selectionMode: this.selectionMode,\n selectedRowIds: this.selectedRowIds,\n collapsedGroupIds: this.collapsedGroupIds,\n virtualCounts,\n value,\n };\n return value;\n }\n\n private get highlightMatcher(): TableHighlightMatcher {\n if (this.highlightMatcherCache?.terms === this.highlightTerms) {\n return this.highlightMatcherCache.value;\n }\n const value = createTableHighlightMatcher(this.highlightTerms);\n this.highlightMatcherCache = { terms: this.highlightTerms, value };\n return value;\n }\n\n private get rowWindowingEnabled(): boolean {\n if (!this.hasVirtualViewportBounds) return false;\n if (this.dataMode === 'virtual') return true;\n return !this.grouped && this.currentLoadedRowCount() > TABLE_NON_VIRTUAL_WINDOW_THRESHOLD;\n }\n\n private get virtualViewportMissing(): boolean {\n return this.dataMode === 'virtual' && !this.hasVirtualViewportBounds;\n }\n\n private get hasVirtualViewportBounds(): boolean {\n return this.containedScroll && this.estimateVirtualViewportSize() > 0;\n }\n\n private estimateVirtualViewportSize(): number {\n const measured =\n this.virtualController.currentViewportSize() || this.viewportEl?.clientHeight || 0;\n const chrome =\n (this.captionVisibility === 'visible' ? 48 : 0) + (this.hasResultFooter ? 48 : 0);\n if (this.fixedHeight && this.resolvedHeight) {\n if (measured > 0) return measured;\n return Math.max(\n 0,\n resolveCssLengthPx(this.resolvedHeight, 0, this.rootEl ?? this.el) - chrome\n );\n }\n if (this.fitViewport) return Math.max(0, measured);\n if (this.resolvedMaxHeight) {\n if (this.resolvedMaxHeight.trim().toLowerCase() === 'none') return 0;\n return resolveCssLengthPx(this.resolvedMaxHeight, 0, this.rootEl ?? this.el);\n }\n return 0;\n }\n\n private virtualPinnedRowIds(): Set<string> {\n const pinned = new Set<string>();\n if (this.focusedRowId) pinned.add(this.focusedRowId);\n if (this.actionMenu?.rowId) pinned.add(this.actionMenu.rowId);\n return pinned;\n }\n\n private findRenderedRow(rowId: string): HTMLElement | null {\n return (\n [...this.el.querySelectorAll<HTMLElement>('[data-row-id]')].find(\n candidate => candidate.dataset.rowId === rowId\n ) ?? null\n );\n }\n\n private revealRenderedRow(row: HTMLElement): void {\n if (!this.containedScroll || !this.viewportEl) {\n row.scrollIntoView({ block: 'nearest', inline: 'nearest' });\n return;\n }\n\n const viewportRect = this.viewportEl.getBoundingClientRect();\n const rowRect = row.getBoundingClientRect();\n let visibleStart = viewportRect.top;\n if (this.stickyHeader) {\n visibleStart = Math.max(\n visibleStart,\n this.tableEl?.tHead?.getBoundingClientRect().bottom ?? visibleStart\n );\n }\n const groupRow = row.closest('tbody')?.querySelector<HTMLElement>('.ds-table__group-row');\n if (groupRow && getComputedStyle(groupRow).position === 'sticky') {\n visibleStart = Math.max(visibleStart, groupRow.getBoundingClientRect().bottom);\n }\n\n if (rowRect.top < visibleStart) {\n this.viewportEl.scrollTop += rowRect.top - visibleStart;\n } else if (rowRect.bottom > viewportRect.bottom) {\n this.viewportEl.scrollTop += rowRect.bottom - viewportRect.bottom;\n }\n }\n\n private nextAnimationFrame(): Promise<void> {\n return new Promise(resolve => requestAnimationFrame(() => resolve()));\n }\n\n private syncVirtualItems(model: TableRenderModel): void {\n if (!this.rowWindowingEnabled) {\n this.virtualItems = [];\n this.virtualItemsCache = null;\n this.bodyRenderer.resetVirtualState();\n return;\n }\n const columns = this.visibleColumns;\n const cached = this.virtualItemsCache;\n if (\n cached &&\n cached.columns === columns &&\n cached.rows === this.rows &&\n cached.groups === this.groups &&\n cached.grouped === model.grouped &&\n cached.collapsedGroupIds === this.collapsedGroupIds\n ) {\n return;\n }\n this.virtualItems = flattenTableVirtualItems({\n grouped: model.grouped,\n rows: this.rows,\n groups: this.groups,\n collapsedGroupIds: this.collapsedGroupIds,\n columns,\n });\n this.virtualItemsCache = {\n columns,\n rows: this.rows,\n groups: this.groups,\n grouped: model.grouped,\n collapsedGroupIds: this.collapsedGroupIds,\n };\n }\n\n private shouldWindowRows(model: TableRenderModel): boolean {\n return (\n this.rowWindowingEnabled && (model.hasData || (model.grouped && model.groups.length > 0))\n );\n }\n\n private get resolvedMaxHeight(): string | undefined {\n if (this.maxHeight == null || this.maxHeight === '') return undefined;\n return typeof this.maxHeight === 'number' ? `${Math.max(0, this.maxHeight)}px` : this.maxHeight;\n }\n\n private get resolvedHeight(): string | undefined {\n if (this.height == null || this.height === '') return undefined;\n return typeof this.height === 'number' ? `${Math.max(0, this.height)}px` : this.height;\n }\n\n private warnModelIssues(): void {\n const issues = tableModelIssues(this.columns, this.rows, this.groups, this.grouped);\n const visibleColumns = this.visibleColumns;\n if (!this.caption?.trim()) issues.unshift('A non-empty caption is required.');\n if (this.grouping) {\n const groupingColumn = this.columns.find(column => column.id === this.grouping!.fieldId);\n if (!groupingColumn) {\n issues.push(`Grouping references unknown column id: ${this.grouping.fieldId}`);\n } else if (!visibleColumns.some(column => column.id === groupingColumn.id)) {\n issues.push(`Grouping references hidden column id: ${this.grouping.fieldId}`);\n }\n }\n if (this.sort) {\n const sortColumn = this.columns.find(\n column =>\n column.id === this.sort!.fieldId ||\n column.segments?.some(segment => segment.sortKey === this.sort!.fieldId)\n );\n if (!sortColumn) {\n issues.push(`Sorting references unknown column id: ${this.sort.fieldId}`);\n } else if (!visibleColumns.some(column => column.id === sortColumn.id)) {\n issues.push(`Sorting references hidden column id: ${this.sort.fieldId}`);\n }\n }\n if (\n this.dataMode === 'virtual' &&\n Number.isFinite(this.totalCount) &&\n Math.max(0, Math.trunc(this.totalCount!)) !== this.currentLoadedRowCount()\n ) {\n issues.push('Virtual mode requires totalCount to match the complete supplied row count.');\n }\n const stickyStart = visibleColumns.filter(column => column.sticky === 'start');\n const stickyEnd = visibleColumns.filter(column => column.sticky === 'end');\n if (stickyStart.length > 1 || (this.selectable && stickyStart.length > 0)) {\n issues.push(\n 'Only one sticky start column is supported, and row selection already owns that lane.'\n );\n }\n if (stickyEnd.length > 1) issues.push('Only one sticky end column is supported.');\n for (const column of [...stickyStart, ...stickyEnd]) {\n if (!tableColumnSize(column))\n issues.push(`Sticky column ${column.id} requires an explicit size.`);\n }\n const message = issues.join(' ');\n if (!message) {\n this.previousModelWarning = '';\n return;\n }\n if (message === this.previousModelWarning) return;\n this.previousModelWarning = message;\n console.warn(`[ds-table] ${message}`);\n }\n\n private scheduleModelIssueWarning(): void {\n if (this.modelWarningQueued) return;\n this.modelWarningQueued = true;\n queueMicrotask(() => {\n this.modelWarningQueued = false;\n this.warnModelIssues();\n });\n }\n\n private scheduleInitialModelIssueWarning(): void {\n if (this.initialModelWarningFrame !== undefined) {\n cancelAnimationFrame(this.initialModelWarningFrame);\n }\n this.initialModelWarningFrame = requestAnimationFrame(() => {\n this.initialModelWarningFrame = undefined;\n this.warnModelIssues();\n });\n }\n\n private emitSort(column: TableColumn, sortKey = column.id): void {\n if (!column.sortable) return;\n this.dsSortChange.emit({ sort: nextDataSortState(this.sort, sortKey) });\n }\n\n private sortButtonLabel(column: TableColumn, sortKey = column.id, label = column.label): string {\n if (this.sort?.fieldId !== sortKey) return `Sort ${label} ascending`;\n if (this.sort.direction === 'asc') return `Sort ${label} descending. Currently ascending.`;\n return `Sort ${label} ascending. Currently descending.`;\n }\n\n private emitRowSelection(row: TableRow): void {\n const next = toggleTableRowSelection(this.selectedRowIds, row);\n const selected = next.includes(row.id);\n this.dsSelectionChange.emit({\n selectedRowIds: next,\n scope: 'row',\n changedRowId: row.id,\n selected,\n });\n }\n\n private emitAllSelection(): void {\n const loadedRows = this.currentLoadedRows();\n const selection = deriveTableSelectionState(loadedRows, this.selectedRowIds);\n this.dsSelectionChange.emit({\n selectedRowIds: toggleAllLoadedTableRows(this.selectedRowIds, loadedRows),\n scope: 'all-loaded',\n selected: !selection.allSelected,\n });\n }\n\n private emitGroupSelection(group: DataGroup): void {\n const state = deriveTableSelectionState(group.rows, this.selectedRowIds);\n this.dsSelectionChange.emit({\n selectedRowIds: toggleTableGroupSelection(this.selectedRowIds, group.rows),\n scope: 'group',\n groupId: group.id,\n selected: !state.allSelected,\n });\n }\n\n private rowEventOwnsActivation(event: Event): boolean {\n const currentTarget = event.currentTarget;\n return !event.composedPath().some(target => {\n if (!(target instanceof HTMLElement) || target === currentTarget) return false;\n return target.matches(\n 'button, a, input, select, textarea, [role=\"button\"], [role=\"checkbox\"], [popover], ds-button-unfilled, ds-menu'\n );\n });\n }\n\n private groupRowEventOwnsCollapse(event: Event): boolean {\n const currentTarget = event.currentTarget;\n return !event.composedPath().some(target => {\n if (!(target instanceof HTMLElement) || target === currentTarget) return false;\n return target.matches(\n 'input, select, textarea, [role=\"checkbox\"], .ds-table__selection-control, .ds-table__group-selection, ds-tooltip'\n );\n });\n }\n\n private handleGroupRowClick(group: DataGroup, event: Event): void {\n if (!this.groupRowEventOwnsCollapse(event)) return;\n this.emitGroupCollapse(group);\n }\n\n private emitRowActivation(row: TableRow, event: Event): void {\n if (row.disabled || !this.rowEventOwnsActivation(event)) return;\n if (this.selectionMode === 'multiple' && this.selectedRowIds.length > 0) {\n if (row.selectable !== false) this.emitRowSelection(row);\n return;\n }\n if (row.interactive) this.dsRowActivate.emit({ rowId: row.id });\n }\n\n private syncActionMenu(): void {\n if (!this.actionMenu) return;\n const row = tableRows(this.rows, this.groups, this.grouped).find(\n candidate => candidate.id === this.actionMenu?.rowId\n );\n const value = row && !row.disabled ? row.cells[this.actionMenu.columnId] : undefined;\n if (!isTableCellAction(value) || !isRenderableTableActionMenu(value)) {\n this.actionMenu = null;\n }\n }\n\n private closeActionMenu(): void {\n this.actionMenu = null;\n }\n\n private handleActionMenuSelect(item: MenuItemData): void {\n const open = this.actionMenu;\n const actionId = item.value;\n if (!open || !actionId || item.isInactive) return;\n this.dsCellAction.emit({\n actionId,\n rowId: open.rowId,\n columnId: open.columnId,\n });\n const triggerId = tableActionTriggerId(this.actionMenuElementId, open.rowId, open.columnId);\n this.closeActionMenu();\n requestAnimationFrame(() => {\n const trigger = this.el.querySelector<HTMLElement & { setFocus?: () => void }>(\n `#${CSS.escape(triggerId)}`\n );\n trigger?.setFocus?.();\n });\n }\n\n private toggleActionMenu(row: TableRow, column: TableColumn, event: MouseEvent): void {\n if (row.disabled) return;\n const open = this.actionMenu?.rowId === row.id && this.actionMenu?.columnId === column.id;\n this.actionMenuInitialFocusVisible = event.detail === 0;\n this.actionMenu = open ? null : { rowId: row.id, columnId: column.id };\n if (this.actionMenu) this.actionMenuSurface = this.actionMenu;\n }\n\n private renderOverflowActionMenu() {\n const open = this.actionMenu ?? this.actionMenuSurface;\n const row = open\n ? tableRows(this.rows, this.groups, this.grouped).find(\n candidate => candidate.id === open.rowId\n )\n : undefined;\n const value = row && open ? row.cells[open.columnId] : undefined;\n const menu = isTableCellAction(value) && isRenderableTableActionMenu(value) ? value : undefined;\n const sections = menu ? tableActionMenuSections(menu.items) : [];\n const triggerId = open\n ? tableActionTriggerId(this.actionMenuElementId, open.rowId, open.columnId)\n : undefined;\n\n return (\n <ds-menu\n id={this.actionMenuElementId}\n open={Boolean(this.actionMenu && sections.length)}\n anchorId={triggerId}\n align=\"end\"\n side=\"bottom\"\n menuLabel={menu?.ariaLabel ?? 'More actions'}\n initialFocusVisible={this.actionMenuInitialFocusVisible}\n sections={sections}\n onDsClose={() => this.closeActionMenu()}\n onDsAfterClose={() => {\n if (!this.actionMenu) this.actionMenuSurface = null;\n }}\n onDsSelect={event => this.handleActionMenuSelect(event.detail)}\n />\n );\n }\n\n private connectTruncateTooltip(): void {\n if (this.truncateTooltipBound) return;\n this.el.addEventListener('pointerover', this.onTruncatePointerOver);\n this.el.addEventListener('pointerout', this.onTruncatePointerOut);\n this.el.addEventListener('focusin', this.onTruncateFocusIn);\n this.el.addEventListener('focusout', this.onTruncateFocusOut);\n this.truncateTooltipBound = true;\n }\n\n private disconnectTruncateTooltip(): void {\n if (!this.truncateTooltipBound) return;\n this.el.removeEventListener('pointerover', this.onTruncatePointerOver);\n this.el.removeEventListener('pointerout', this.onTruncatePointerOut);\n this.el.removeEventListener('focusin', this.onTruncateFocusIn);\n this.el.removeEventListener('focusout', this.onTruncateFocusOut);\n this.truncateTooltipBound = false;\n this.dismissTruncateTooltip();\n }\n\n private onTruncatePointerOver = (event: PointerEvent) => {\n if (event.pointerType === 'touch') return;\n const track = resolveTableTruncateTrack(event.target);\n if (!track) return;\n this.presentOrDismissTruncateTrack(track);\n };\n\n private onTruncatePointerOut = (event: PointerEvent) => {\n if (event.pointerType === 'touch') return;\n if (resolveTableTruncateTrack(event.relatedTarget)) return;\n const next = event.relatedTarget;\n const cell = this.truncateAnchor?.closest('.ds-table__cell');\n if (cell && next instanceof Node && cell.contains(next)) return;\n this.dismissTruncateTooltip();\n };\n\n private onTruncateFocusIn = (event: FocusEvent) => {\n const track = resolveTableTruncateTrack(event.target);\n if (!track) {\n this.dismissTruncateTooltip();\n return;\n }\n this.presentOrDismissTruncateTrack(track);\n };\n\n private onTruncateFocusOut = (event: FocusEvent) => {\n if (resolveTableTruncateTrack(event.relatedTarget)) return;\n this.dismissTruncateTooltip();\n };\n\n private presentOrDismissTruncateTrack(track: HTMLElement): void {\n if (!isElementTruncated(track)) {\n this.dismissTruncateTooltip();\n return;\n }\n const label = tableTruncateLabel(track);\n if (!label) {\n this.dismissTruncateTooltip();\n return;\n }\n this.truncateAnchor = track;\n this.truncateTooltipLabel = label;\n void this.truncateTooltipEl?.presentFrom(track, label);\n }\n\n private dismissTruncateTooltip(): void {\n this.truncateAnchor = null;\n if (!this.truncateTooltipLabel && !this.truncateTooltipEl) return;\n this.truncateTooltipLabel = '';\n void this.truncateTooltipEl?.dismiss();\n }\n\n private renderTruncateTooltip() {\n return (\n <ds-tooltip\n ref={element => {\n this.truncateTooltipEl = element;\n }}\n label={this.truncateTooltipLabel}\n describedBy={false}\n wrapLabel={true}\n size=\"sm\"\n delay={0}\n side=\"top\"\n />\n );\n }\n\n private handleRowKeydown(row: TableRow, event: KeyboardEvent): void {\n if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' '))\n return;\n event.preventDefault();\n this.emitRowActivation(row, event);\n }\n\n private onVirtualFocusIn = (event: FocusEvent): void => {\n const target = event.target;\n if (!(target instanceof HTMLElement)) return;\n const row = target.closest('[data-row-id]');\n const id = row?.getAttribute('data-row-id') ?? null;\n if (id === this.focusedRowId) return;\n this.focusedRowId = id;\n if (this.rowWindowingEnabled) this.virtualController.schedule();\n };\n\n private renderSelectionControl(\n label: string,\n checked: boolean,\n indeterminate: boolean,\n disabled: boolean,\n onActivate: () => void\n ) {\n return (\n <button\n class=\"ds-table__selection-control ds-focus-ring ds-interaction-fill__content\"\n type=\"button\"\n role=\"checkbox\"\n aria-label={label}\n aria-checked={indeterminate ? 'mixed' : String(checked)}\n disabled={disabled}\n onClick={onActivate}\n >\n <ds-checkbox\n label=\"\"\n size=\"md\"\n checked={checked}\n indeterminate={indeterminate}\n presentation={true}\n />\n </button>\n );\n }\n\n private renderStickyEdge(sticky: TableColumn['sticky']) {\n if (!sticky) return null;\n return (\n <span class={`ds-table__sticky-edge ds-table__sticky-edge--${sticky}`} aria-hidden=\"true\" />\n );\n }\n\n private renderCollapseAllButton() {\n return (\n <ds-button-unfilled\n class=\"ds-table__collapse-all\"\n variant=\"icon\"\n icon=\"ChevronDownUp\"\n size=\"xs\"\n style={{ width: 'var(--dimension-size-300)' }}\n aria-label=\"Collapse all groups\"\n hasBorder={false}\n activeFill={false}\n pressScale={false}\n onDsClick={event => {\n event.stopPropagation();\n this.emitAllGroupsCollapse();\n }}\n />\n );\n }\n\n private renderFloatingCollapseAll(model: TableRenderModel) {\n if (model.collapseAllHost?.mode !== 'floating') return null;\n return (\n <span\n class=\"ds-table__collapse-all-overlay\"\n ref={element => {\n this.collapseAllOverlayEl = element ?? null;\n }}\n >\n <span class=\"ds-table__collapse-all-surface ds-control-elevation ds-control-elevation--md\">\n {this.renderCollapseAllButton()}\n </span>\n </span>\n );\n }\n\n private renderColumnHeader(\n column: TableColumn,\n model: TableRenderModel,\n interactive = true,\n presentational = false\n ) {\n const groupedColumn = this.grouping?.fieldId === column.id;\n const segments = column.segments?.length\n ? column.segments\n : [{ label: column.label, sortKey: column.id }];\n const activeMemberSegment = segments.find(segment => segment.sortKey === this.sort?.fieldId);\n const activeMemberSort = !!activeMemberSegment;\n const activeSort = activeMemberSort;\n const align = column.align ?? 'start';\n const direction = activeMemberSort ? this.sort!.direction : undefined;\n const memberAriaSort = activeMemberSort\n ? this.sort!.direction === 'asc'\n ? 'ascending'\n : 'descending'\n : undefined;\n\n const help = column.help?.trim();\n const labels = (\n <span\n class=\"ds-table__header-labels\"\n tabIndex={interactive && help && !column.sortable ? 0 : undefined}\n data-header-help={help ? '' : undefined}\n >\n {segments.map((segment, index) => {\n const segmentActive = activeMemberSegment?.sortKey === segment.sortKey;\n const segmentInteractive = interactive && !!column.sortable;\n const label = (\n <ds-text\n class=\"ds-table__header-label-box ds-control-label-box\"\n as=\"span\"\n variant=\"text-caption\"\n emphasis={segmentActive}\n color=\"inherit\"\n decoration={help ? 'dotted-underline' : undefined}\n lineTruncation={1}\n >\n {segment.label}\n </ds-text>\n );\n const control = segmentInteractive ? (\n <button\n class=\"ds-table__header-label ds-table__header-label--interactive ds-focus-ring\"\n type=\"button\"\n aria-label={this.sortButtonLabel(\n column,\n segment.sortKey,\n segment.dataLabel?.trim() || segment.label\n )}\n data-sort-control=\"label\"\n data-sort-key={segment.sortKey}\n data-sort-active={segmentActive ? 'true' : undefined}\n onClick={() => this.emitSort(column, segment.sortKey)}\n >\n {label}\n </button>\n ) : (\n <span class=\"ds-table__header-label ds-table__header-static\">{label}</span>\n );\n\n return (\n <span class=\"ds-table__header-segment\" key={segment.sortKey}>\n {control}\n {index < segments.length - 1 && (\n <ds-text\n class=\"ds-table__header-separator\"\n as=\"span\"\n variant=\"text-caption\"\n color=\"tertiary\"\n aria-hidden=\"true\"\n >\n {segment.separator ?? '/'}\n </ds-text>\n )}\n </span>\n );\n })}\n </span>\n );\n const labelControl =\n interactive && help ? (\n <ds-tooltip label={help} side=\"top\" size=\"sm\" wrapLabel={true}>\n {labels}\n </ds-tooltip>\n ) : (\n labels\n );\n const sortControl = interactive ? (\n <span class=\"ds-table__sort-slot\">\n {activeSort && (\n <ds-button-unfilled\n class=\"ds-table__sort-direction\"\n variant=\"icon\"\n icon={direction === 'asc' ? 'ArrowUp' : 'ArrowDown'}\n size=\"xs\"\n aria-label={this.sortButtonLabel(\n column,\n activeMemberSegment?.sortKey ?? column.id,\n activeMemberSegment?.label ?? column.label\n )}\n hasBorder={false}\n activeFill={false}\n isActive={true}\n pressScale={false}\n data-sort-control=\"direction\"\n onDsClick={() => this.emitSort(column, activeMemberSegment?.sortKey ?? column.id)}\n />\n )}\n </span>\n ) : null;\n const collapseHost = model.collapseAllHost;\n const actionCollapseHost =\n collapseHost?.mode === 'action' && collapseHost.columnId === column.id;\n const blankActionCollapseHost =\n actionCollapseHost && !column.label.trim() && !column.segments?.length;\n const collapseControl =\n interactive && actionCollapseHost ? (\n <span class=\"ds-table__collapse-slot\">{this.renderCollapseAllButton()}</span>\n ) : null;\n\n return (\n <th\n key={column.id}\n class={{\n 'ds-table__header-cell': true,\n [`ds-table__cell--align-${align}`]: true,\n 'ds-table__cell--sticky-start': column.sticky === 'start',\n 'ds-table__cell--sticky-end': column.sticky === 'end',\n 'ds-table__header-cell--collapse-all': actionCollapseHost,\n }}\n scope={presentational ? undefined : 'col'}\n aria-sort={presentational ? undefined : memberAriaSort}\n data-column-id={column.id}\n data-grouped={groupedColumn ? 'true' : undefined}\n data-sort-active={activeSort ? 'true' : undefined}\n >\n {!column.label.trim() && column.accessibleLabel?.trim() && (\n <span class=\"ds-visually-hidden\">{column.accessibleLabel}</span>\n )}\n {blankActionCollapseHost ? (\n <span class=\"ds-table__header-content ds-table__header-content--collapse-all\">\n {collapseControl}\n </span>\n ) : (\n <span class=\"ds-table__header-content\">\n {align === 'end' && sortControl}\n {align === 'center' && (\n <span\n class=\"ds-table__sort-slot ds-table__sort-slot--balance\"\n data-sort-balance=\"true\"\n aria-hidden=\"true\"\n />\n )}\n {labelControl}\n {align !== 'end' && sortControl}\n {collapseControl}\n </span>\n )}\n {this.renderStickyEdge(column.sticky)}\n </th>\n );\n }\n\n private renderColgroup(model: TableRenderModel) {\n const beforeSpacer =\n model.elasticSpacerIndex == null\n ? this.visibleColumns\n : this.visibleColumns.slice(0, model.elasticSpacerIndex);\n const afterSpacer =\n model.elasticSpacerIndex == null ? [] : this.visibleColumns.slice(model.elasticSpacerIndex);\n const renderColumn = (column: TableColumn) => {\n const width = tableColumnSize(column);\n return (\n <col\n key={column.id}\n class={{ 'ds-table__action-column': column.kind === 'action' }}\n style={width ? { width } : undefined}\n />\n );\n };\n return (\n <colgroup>\n {model.selectable && <col class=\"ds-table__selection-column\" />}\n {beforeSpacer.map(renderColumn)}\n {model.elasticSpacerIndex != null && <col class=\"ds-table__elastic-spacer-column\" />}\n {afterSpacer.map(renderColumn)}\n </colgroup>\n );\n }\n\n private renderHeader(\n model: TableRenderModel,\n interactive = true,\n presentational = false,\n ariaRowIndex?: number\n ) {\n const selection = model.selection;\n const beforeSpacer =\n model.elasticSpacerIndex == null\n ? this.visibleColumns\n : this.visibleColumns.slice(0, model.elasticSpacerIndex);\n const afterSpacer =\n model.elasticSpacerIndex == null ? [] : this.visibleColumns.slice(model.elasticSpacerIndex);\n return (\n <thead\n class={{\n 'ds-table__head': true,\n 'ds-table__head--semantic-copy': !interactive,\n }}\n ref={element => {\n if (interactive) this.interactiveHeadEl = element ?? null;\n }}\n >\n <tr class=\"ds-table__header-row\" aria-rowindex={ariaRowIndex}>\n {model.selectable && (\n <th\n class=\"ds-table__header-cell ds-table__selection-cell ds-table__cell--sticky-start\"\n scope={presentational ? undefined : 'col'}\n >\n {interactive ? (\n this.renderSelectionControl(\n selection.allSelected ? 'Deselect all loaded rows' : 'Select all loaded rows',\n selection.allSelected,\n selection.indeterminate,\n selection.selectableRowIds.length === 0,\n () => this.emitAllSelection()\n )\n ) : (\n <span class=\"ds-visually-hidden\">Select rows</span>\n )}\n {this.renderStickyEdge('start')}\n </th>\n )}\n {beforeSpacer.map(column =>\n this.renderColumnHeader(column, model, interactive, presentational)\n )}\n {model.elasticSpacerIndex != null && (\n <th\n class=\"ds-table__header-cell ds-table__elastic-spacer-cell\"\n aria-hidden=\"true\"\n role=\"presentation\"\n data-elastic-spacer=\"true\"\n />\n )}\n {afterSpacer.map(column =>\n this.renderColumnHeader(column, model, interactive, presentational)\n )}\n </tr>\n </thead>\n );\n }\n\n private renderRow(\n row: TableRow,\n model: TableRenderModel,\n ariaRowIndex?: number,\n variableVirtualSize = false,\n rowKey = row.id\n ) {\n return renderTableRowView({\n row,\n model,\n visibleColumns: this.visibleColumns,\n emptyCellLabel: this.emptyCellLabel,\n actionMenuElementId: this.actionMenuElementId,\n actionMenu: this.actionMenu,\n actionMenuSurface: this.actionMenuSurface,\n surfaceOpenRowId: this.surfaceOpenRowId,\n highlightMatcher: this.highlightMatcher,\n highlightFieldIds: this.highlightFieldIds,\n ariaRowIndex,\n variableVirtualSize,\n intrinsicBlockSize:\n this.dataMode !== 'virtual'\n ? estimateTableRowBlockSize(row, this.visibleColumns)\n : undefined,\n rowKey,\n renderSelectionControl: (label, checked, indeterminate, disabled, onActivate) =>\n this.renderSelectionControl(label, checked, indeterminate, disabled, onActivate),\n renderStickyEdge: sticky => this.renderStickyEdge(sticky),\n onRowActivate: (targetRow, event) => this.emitRowActivation(targetRow, event),\n onRowKeyDown: (targetRow, event) => this.handleRowKeydown(targetRow, event),\n onRowSelection: targetRow => this.emitRowSelection(targetRow),\n onCellAction: detail => this.dsCellAction.emit(detail),\n onActionMenuToggle: (targetRow, column, event) =>\n this.toggleActionMenu(targetRow, column, event),\n });\n }\n\n private emitGroupCollapse(group: DataGroup) {\n const collapsedGroupIds = toggleTableGroupCollapsed(this.collapsedGroupIds, group.id);\n this.dsGroupCollapseChange.emit({\n scope: 'group',\n groupId: group.id,\n collapsed: collapsedGroupIds.includes(group.id),\n collapsedGroupIds,\n });\n }\n\n private emitAllGroupsCollapse() {\n const groupIds = this.groups.map(group => group.id);\n const collapsedGroupIds = nextTableGroupsCollapsed(this.collapsedGroupIds, groupIds);\n this.dsGroupCollapseChange.emit({\n scope: 'all',\n collapsed: collapsedGroupIds.length > 0,\n collapsedGroupIds,\n });\n }\n\n private renderGroupAccessory(\n item: TableRenderModel['groups'][number]['accessories'][number],\n index: number\n ) {\n const text = (\n <ds-text\n key={`acc-${index}`}\n class=\"ds-table__group-accessory\"\n as=\"span\"\n variant=\"text-body-small\"\n color=\"secondary\"\n decoration={item.help ? 'dotted-underline' : undefined}\n lineTruncation={1}\n >\n {item.text}\n </ds-text>\n );\n if (!item.help) return text;\n return (\n <ds-tooltip\n key={`acc-${index}`}\n label={item.help}\n side=\"bottom\"\n align=\"start\"\n size=\"sm\"\n wrapLabel={true}\n >\n <span class=\"ds-table__group-accessory-tip\" tabIndex={0}>\n {text}\n </span>\n </ds-tooltip>\n );\n }\n\n private renderGroupContent(groupModel: TableRenderModel['groups'][number]) {\n const {\n group,\n countLabel,\n collapsed: isCollapsed,\n labelColor,\n selection: groupSelection,\n accessories,\n hero,\n } = groupModel;\n const heroTracks = hero?.kind === 'score' && hero.tracks === 2 ? 2 : 1;\n const heroPresentation = TABLE_GROUP_HERO_SCORE_PRESENTATION[heroTracks];\n return (\n <span\n class={{\n 'ds-table__group-content': true,\n 'ds-table__group-content--multi': accessories.length > 0 || heroTracks === 2,\n 'ds-table__group-content--hero': Boolean(hero),\n 'ds-table__group-content--hero-two-track': Boolean(hero) && heroTracks === 2,\n }}\n onClick={event => this.handleGroupRowClick(group, event)}\n >\n {groupSelection && (\n <span class=\"ds-table__group-selection\">\n {this.renderSelectionControl(\n groupSelection.allSelected\n ? `Deselect loaded rows in ${group.label} group`\n : `Select loaded rows in ${group.label} group`,\n groupSelection.allSelected,\n groupSelection.indeterminate,\n groupSelection.selectableRowIds.length === 0,\n () => this.emitGroupSelection(group)\n )}\n </span>\n )}\n {hero?.kind === 'score' && (\n <span class=\"ds-table__group-hero\">\n <ds-score\n size={heroPresentation.size}\n variant={heroPresentation.variant}\n value={hero.value}\n level={hero.level}\n label={hero.label ?? 'Safety score'}\n isLoading={hero.isLoading === true}\n />\n </span>\n )}\n <span class=\"ds-table__group-copy\">\n <span class=\"ds-table__group-primary\">\n <ds-text\n class=\"ds-table__group-label\"\n as=\"span\"\n variant=\"text-body-medium\"\n emphasis={true}\n color={labelColor}\n >\n {group.label}\n </ds-text>\n <span class=\"ds-visually-hidden\">{countLabel}</span>\n </span>\n {accessories.length > 0 && (\n <span class=\"ds-table__group-accessories\">\n {accessories.flatMap((item, index) => [\n ...(index > 0\n ? [\n <ds-text\n key={`sep-${index}`}\n class=\"ds-table__group-accessory-separator\"\n as=\"span\"\n variant=\"text-body-small\"\n color=\"secondary\"\n aria-hidden=\"true\"\n >\n ·\n </ds-text>,\n ]\n : []),\n this.renderGroupAccessory(item, index),\n ])}\n </span>\n )}\n </span>\n <span class=\"ds-table__group-trailing\">\n <ds-text\n class=\"ds-table__group-count\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n aria-hidden=\"true\"\n >\n {groupModel.visibleCountText}\n </ds-text>\n <span class=\"ds-table__group-action\">\n <ds-button-unfilled\n class=\"ds-table__group-toggle\"\n variant=\"icon\"\n size=\"md\"\n isInset={true}\n insetDepth=\"double\"\n icon={isCollapsed ? 'ChevronDown' : 'ChevronUp'}\n expanded={!isCollapsed}\n aria-label={\n isCollapsed ? `Expand ${group.label} group` : `Collapse ${group.label} group`\n }\n hasBorder={false}\n pressScale={false}\n />\n </span>\n </span>\n </span>\n );\n }\n\n private formatGroupLoadLabel(template: string, group: DataGroup): string {\n return template.split('{group}').join(group.label);\n }\n\n private renderGroupLoadRow(group: DataGroup, totalColumns: number) {\n const error = group.loadMoreError?.trim();\n if (!error && !group.loadingMore && !group.hasMore) return null;\n const manualFallback =\n this.loadMoreMode === 'manual' || !this.groupLoadController.intersectionSupported;\n\n return (\n <tr\n class=\"ds-table__load-row ds-table__group-load-row\"\n data-group-id={group.id}\n ref={element => {\n if (element) this.groupSentinelEls.set(group.id, element);\n else this.groupSentinelEls.delete(group.id);\n }}\n >\n <td class=\"ds-table__load-cell\" colSpan={totalColumns}>\n <div class=\"ds-table__viewport-band ds-table__load-band\">\n {renderTableLoadContent({\n error,\n loading: !!group.loadingMore,\n hasMore: !!group.hasMore,\n manualFallback,\n retryLabel: this.formatGroupLoadLabel(this.groupRetryLabel, group),\n loadingLabel: this.formatGroupLoadLabel(this.groupLoadingMoreLabel, group),\n loadMoreLabel: this.groupLoadMoreLabel,\n loadMoreAriaLabel: this.formatGroupLoadLabel(this.groupLoadMoreAriaLabel, group),\n onRetry: () => this.groupLoadController.request(group.id, 'retry'),\n onLoadMore: () => this.groupLoadController.request(group.id, 'manual'),\n })}\n </div>\n </td>\n </tr>\n );\n }\n\n private renderStickyGroup(model: TableRenderModel) {\n if (!this.documentStickyHeader || !this.activeStickyGroupId) return null;\n const groupModel = model.groups.find(item => item.group.id === this.activeStickyGroupId);\n if (!groupModel) return null;\n return (\n <div\n class={{\n 'ds-table__sticky-group': true,\n 'ds-table__sticky-group--selectable': model.selectable,\n [groupModel.intentClass ?? '']: !!groupModel.intentClass,\n }}\n data-group-id={groupModel.group.id}\n data-group-intent={groupModel.intent}\n >\n {this.renderGroupContent(groupModel)}\n </div>\n );\n }\n\n private renderDataBodies(model: TableRenderModel, plan: TableVirtualPlan | null) {\n return this.bodyRenderer.render({\n model,\n plan,\n rows: this.rows,\n stickyHeader: this.stickyHeader,\n documentStickyHeader: this.documentStickyHeader,\n activeStickyGroupId: this.activeStickyGroupId,\n renderRow: (row, renderModel, ariaRowIndex, variableVirtualSize, rowKey) =>\n this.renderRow(row, renderModel, ariaRowIndex, variableVirtualSize, rowKey),\n renderGroupContent: groupModel => this.renderGroupContent(groupModel),\n renderGroupLoadRow: (group, totalColumns) => this.renderGroupLoadRow(group, totalColumns),\n });\n }\n\n private renderSkeletonBody(model: TableRenderModel) {\n return renderTableSkeletonBody({\n model,\n visibleColumns: this.visibleColumns,\n skeletonRows: this.skeletonRows,\n renderStickyEdge: sticky => this.renderStickyEdge(sticky),\n });\n }\n\n private renderStateBody(kind: 'empty' | 'error' | 'virtual-viewport', totalColumns: number) {\n const error = kind !== 'empty';\n const heading =\n kind === 'virtual-viewport'\n ? TABLE_VIRTUAL_VIEWPORT_REQUIRED_HEADING\n : error\n ? this.errorHeading\n : this.emptyHeading;\n const body =\n kind === 'virtual-viewport'\n ? TABLE_VIRTUAL_VIEWPORT_REQUIRED_BODY\n : error\n ? this.errorBody\n : this.emptyBody;\n return (\n <tbody class=\"ds-table__body ds-table__state-body\">\n <tr class=\"ds-table__state-row\">\n <td class=\"ds-table__state-cell\" colSpan={totalColumns}>\n <div class=\"ds-table__viewport-band ds-table__state-band\">\n <ds-empty-state\n icon={error ? 'ExclamationTriangle' : 'Inbox'}\n heading={heading}\n body={body}\n />\n </div>\n </td>\n </tr>\n </tbody>\n );\n }\n\n private renderLazyBody(model: TableRenderModel) {\n if (\n this.dataMode !== 'infinite' ||\n !this.incrementalWindowActive ||\n !model.hasData ||\n model.grouped\n )\n return null;\n const error = this.loadMoreError?.trim();\n const manualFallback =\n this.loadMoreMode === 'manual' || !this.loadController.intersectionSupported;\n\n return (\n <tbody class=\"ds-table__body ds-table__load-body\">\n <tr\n class=\"ds-table__load-row\"\n ref={element => {\n this.sentinelEl = element ?? null;\n }}\n >\n <td class=\"ds-table__load-cell\" colSpan={model.totalColumns}>\n <div class=\"ds-table__viewport-band ds-table__load-band\">\n {renderTableLoadContent({\n error,\n loading: this.loadingMore,\n hasMore: this.hasMore,\n manualFallback,\n retryLabel: this.retryLabel,\n loadingLabel: this.loadingMoreLabel,\n loadMoreLabel: this.loadMoreLabel,\n endOfResultsLabel: this.endOfResultsLabel,\n onRetry: () => this.loadController.request('retry'),\n onLoadMore: () => this.loadController.request('manual'),\n })}\n </div>\n </td>\n </tr>\n </tbody>\n );\n }\n\n private renderDocumentStickyHeader(model: TableRenderModel) {\n if (!this.documentStickyHeader) return null;\n return (\n <div class=\"ds-table__document-sticky-header\">\n <table\n class={{\n 'ds-table__table': true,\n 'ds-table__sticky-header-table': true,\n 'ds-table__table--selectable': model.selectable,\n 'ds-table__table--grouped': model.grouped,\n }}\n style={model.tableStyle}\n role=\"presentation\"\n ref={element => {\n this.stickyHeaderTableEl = element ?? null;\n }}\n >\n {this.renderColgroup(model)}\n {this.renderHeader(model, true, true)}\n </table>\n {this.renderFloatingCollapseAll(model)}\n </div>\n );\n }\n\n private get resultSummary(): string | null {\n if (this.dataMode === 'pagination') return null;\n if (this.dataMode === 'virtual') {\n return formatTableTotalSummary(this.currentLoadedRowCount(), this.resultTotalSummaryLabel);\n }\n return formatTableResultSummary(this.displayedCount, this.totalCount, this.resultSummaryLabel);\n }\n\n private get hasResultFooter(): boolean {\n return (\n (this.dataMode === 'pagination' && !!this.pagination) ||\n !!this.resultSummary ||\n this.footerSlotPresence !== 0\n );\n }\n\n private renderResultFooter() {\n const summary = this.resultSummary;\n const hasLeading = (this.footerSlotPresence & TABLE_FOOTER_SLOT_LEADING) !== 0;\n const hasCopy = (this.footerSlotPresence & TABLE_FOOTER_SLOT_COPY) !== 0;\n const hasTrailing = (this.footerSlotPresence & TABLE_FOOTER_SLOT_TRAILING) !== 0;\n const pagination = this.dataMode === 'pagination' ? this.pagination : null;\n if (!this.hasResultFooter) return null;\n return (\n <div class=\"ds-table__footer ds-table__bar ds-chrome-header ds-control--md\">\n <div class=\"ds-table__bar-copy ds-chrome-header__copy ds-control--md\">\n {hasLeading && (\n <div class=\"ds-table__bar-status ds-chrome-header__heading\">\n <slot name=\"footer-leading\" onSlotchange={this.syncFooterSlotPresence} />\n </div>\n )}\n {hasCopy ? (\n <slot name=\"footer\" onSlotchange={this.syncFooterSlotPresence} />\n ) : summary ? (\n <ds-text\n class=\"ds-table__footer-summary ds-table__bar-text\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n lineTruncation={1}\n >\n {summary}\n </ds-text>\n ) : null}\n </div>\n {(pagination || hasTrailing) && (\n <div class=\"ds-table__bar-trailing ds-chrome-header__trailing\">\n {pagination && (\n <ds-pagination\n pageIndex={pagination.pageIndex}\n pageSize={pagination.pageSize}\n pageSizeMode={pagination.pageSizeMode ?? 'fixed'}\n totalItems={pagination.totalItems}\n pageSizeOptions={pagination.pageSizeOptions ?? [25, 50, 100, 200]}\n fitToPage={pagination.fitToPage ?? false}\n {...{ fitToPageInactive: pagination.fitToPageInactive ?? false }}\n fitPageSize={this.fitPageSize ?? pagination.fitPageSize}\n fitPageSizeLabel={pagination.fitPageSizeLabel ?? 'Fit to page'}\n fitPageSizeTriggerLabel={pagination.fitPageSizeTriggerLabel ?? 'Fit'}\n itemLabel={pagination.itemLabel ?? 'items'}\n pageSizeLabel={pagination.pageSizeLabel ?? 'Items'}\n label={pagination.ariaLabel ?? `${this.caption} pagination`}\n showFirstLastButtons={pagination.showFirstLastButtons ?? false}\n loading={this.loading}\n onDsChange={(event: CustomEvent<PaginationChangeDetail>) =>\n this.dsPaginationChange.emit(event.detail)\n }\n />\n )}\n <slot name=\"footer-trailing\" onSlotchange={this.syncFooterSlotPresence} />\n </div>\n )}\n </div>\n );\n }\n\n private renderCaptionBar() {\n if (this.captionVisibility !== 'visible') return null;\n return (\n <div class=\"ds-table__caption-bar ds-table__bar ds-control--md\">\n <div\n class={{\n 'ds-table__caption-content': true,\n 'ds-table__caption-content--trailing': this.showsCaptionTrailing,\n }}\n >\n <div\n class={{\n 'ds-table__caption-leading': true,\n 'ds-table__caption-leading--toolbar': this.headerUsesToolbar,\n }}\n >\n <slot name=\"header\" onSlotchange={this.syncHeaderSlotPresence} />\n {!this.headerPresent ? (\n <ds-text\n class=\"ds-table__caption-title ds-table__bar-text\"\n as=\"div\"\n variant=\"text-title-small\"\n emphasis={true}\n color=\"primary\"\n aria-hidden=\"true\"\n >\n {this.caption}\n </ds-text>\n ) : null}\n </div>\n {this.renderCaptionTrailing()}\n </div>\n <slot name=\"header-after\" />\n </div>\n );\n }\n\n private renderCaptionTrailing() {\n if (!this.showsCaptionTrailing) return null;\n return (\n <div class=\"ds-table__caption-trailing\">\n {this.renderColumnCustomizerTrigger()}\n {this.showsColumnCustomizer &&\n !this.hideColumnCustomizerTrigger &&\n this.showsDataModeSwitcher ? (\n <ds-divider orientation=\"vertical\" length=\"32px\" />\n ) : null}\n {this.renderDataModeSwitcherTrigger()}\n </div>\n );\n }\n\n private renderDataModeSwitcherTrigger() {\n if (!this.showsDataModeSwitcher) return null;\n if (this.chromeLoading) {\n return <ds-skeleton variant=\"control\" controlSize=\"md\" width=\"var(--dimension-size-400)\" />;\n }\n return (\n <span class=\"ds-table__caption-mode-switcher\">\n <ds-tooltip label={this.dataModeSwitcherLabel} side=\"top\" size=\"sm\">\n <ds-button-unfilled\n hasBorder={!this.captionControlsBorderless}\n id={`${this.dataModeSwitcherElementId}-trigger`}\n variant=\"icon\"\n size=\"md\"\n icon=\"Ellipses\"\n aria-label={this.dataModeSwitcherLabel}\n hasMenu={true}\n expanded={this.dataModeSwitcherOpen}\n surfaceOpen={this.dataModeSwitcherSurfaceOpen}\n controls={this.dataModeSwitcherElementId}\n activeFill={false}\n pressScale={false}\n onDsClick={(event: CustomEvent<MouseEvent>) => {\n this.toggleDataModeSwitcher(event.detail.detail === 0);\n }}\n />\n </ds-tooltip>\n </span>\n );\n }\n\n private renderDataModeSwitcherMenu() {\n if (!this.showsDataModeSwitcher || this.chromeLoading) return null;\n return (\n <ds-menu\n id={this.dataModeSwitcherElementId}\n open={this.dataModeSwitcherOpen}\n anchorId={`${this.dataModeSwitcherElementId}-trigger`}\n align=\"end\"\n side=\"bottom\"\n menuLabel={this.dataModeMenuLabel}\n initialFocusVisible={this.dataModeSwitcherInitialFocusVisible}\n items={tableDataModeMenuItems(this.dataMode, {\n infinite: this.infiniteModeLabel,\n pagination: this.paginationModeLabel,\n virtual: this.virtualModeLabel,\n })}\n onDsClose={() => this.closeDataModeSwitcher()}\n onDsAfterClose={() => {\n if (!this.dataModeSwitcherOpen) this.dataModeSwitcherSurfaceOpen = false;\n }}\n onDsSelect={event => this.handleDataModeSwitcherSelect(event.detail)}\n />\n );\n }\n\n private toggleDataModeSwitcher(fromKeyboard = false): void {\n if (this.dataModeSwitcherOpen) this.closeDataModeSwitcher();\n else this.openDataModeSwitcher(fromKeyboard);\n }\n\n private openDataModeSwitcher(fromKeyboard = false): void {\n if (!this.showsDataModeSwitcher || this.dataModeSwitcherOpen) return;\n this.closeColumnCustomizer();\n this.dataModeSwitcherInitialFocusVisible = fromKeyboard;\n this.dataModeSwitcherOpen = true;\n this.dataModeSwitcherSurfaceOpen = true;\n }\n\n private closeDataModeSwitcher(): void {\n this.dataModeSwitcherOpen = false;\n }\n\n private handleDataModeSwitcherSelect(item: MenuItemData): void {\n const dataMode = tableDataModeFromMenuItem(item);\n if (!dataMode) return;\n this.closeDataModeSwitcher();\n if (dataMode !== this.dataMode) this.dsDataModeChange.emit({ dataMode });\n requestAnimationFrame(() => {\n this.el\n .querySelector<\n HTMLElement & { setFocus?: () => void }\n >(`#${CSS.escape(`${this.dataModeSwitcherElementId}-trigger`)}`)\n ?.setFocus?.();\n });\n }\n\n private renderColumnCustomizerTrigger() {\n if (this.hideColumnCustomizerTrigger) return null;\n if (!this.showsColumnCustomizer) return null;\n return (\n <div\n class={{\n 'ds-table__caption-customizer': true,\n 'ds-table__caption-customizer--loading': this.chromeLoading,\n }}\n aria-hidden={this.chromeLoading ? 'true' : undefined}\n >\n <ds-tooltip label={this.captionCompact ? 'Customize' : ''} side=\"top\" size=\"sm\">\n <ds-button-unfilled\n hasBorder={!this.captionControlsBorderless}\n id={`${this.columnCustomizerElementId}-trigger`}\n variant={this.captionCompact ? 'icon' : 'icon-label'}\n size=\"md\"\n icon=\"Preferences\"\n label=\"Customize\"\n labelEmphasis={false}\n pressScale={false}\n aria-label=\"Customize table\"\n haspopup=\"menu\"\n expanded={this.columnCustomizerOpen}\n surfaceOpen={this.columnCustomizerSurfaceOpen}\n controls={this.columnCustomizerElementId}\n onDsClick={(event: CustomEvent<MouseEvent>) => {\n if (this.chromeLoading) return;\n this.toggleColumnCustomizer(event.detail.detail === 0);\n }}\n />\n </ds-tooltip>\n {this.chromeLoading ? (\n <ds-skeleton variant=\"control\" controlSize=\"md\" width=\"100%\" />\n ) : null}\n </div>\n );\n }\n\n private renderColumnCustomizerMenu() {\n if (!this.showsColumnCustomizer || this.chromeLoading) return null;\n return (\n <ds-menu\n id={this.columnCustomizerElementId}\n open={this.columnCustomizerOpen}\n anchorId={`${this.columnCustomizerElementId}-trigger`}\n align=\"end\"\n side=\"bottom\"\n menuLabel=\"Customize table\"\n initialFocusVisible={this.columnCustomizerInitialFocusVisible}\n sections={[\n {\n header: 'Columns',\n items: tableColumnCustomizerMenuItems(\n this.columns,\n this.hiddenFieldIds,\n this.fieldOrder\n ),\n },\n ...(this.customizeOptions.length\n ? [{ header: 'Options', items: this.customizeOptions }]\n : []),\n ]}\n onDsClose={() => this.closeColumnCustomizer()}\n onDsAfterClose={() => {\n if (!this.columnCustomizerOpen) this.columnCustomizerSurfaceOpen = false;\n }}\n onDsSelect={event => {\n if (this.customizeOptions.some(option => option.value === event.detail.value)) {\n this.dsCustomizeOptionChange.emit(event.detail.value!);\n } else this.handleColumnCustomizerSelect(event.detail);\n }}\n onDsReorder={event => this.handleColumnCustomizerReorder(event.detail)}\n />\n );\n }\n\n private toggleColumnCustomizer(fromKeyboard = false): void {\n if (this.columnCustomizerOpen) this.closeColumnCustomizer();\n else this.openColumnCustomizer(fromKeyboard);\n }\n\n private openColumnCustomizer(fromKeyboard = false): void {\n if (!this.showsColumnCustomizer || this.columnCustomizerOpen) return;\n this.closeDataModeSwitcher();\n this.columnCustomizerInitialFocusVisible = fromKeyboard;\n this.columnCustomizerOpen = true;\n this.columnCustomizerSurfaceOpen = true;\n }\n\n private closeColumnCustomizer(): void {\n this.columnCustomizerOpen = false;\n }\n\n private emitColumnsConfigChange(hiddenFieldIds: string[], fieldOrder: string[]): void {\n this.dsFieldsConfigChange.emit({\n hiddenFieldIds: resolveTableHiddenColumnIds(this.columns, hiddenFieldIds),\n fieldOrder: resolveTableColumnOrder(this.columns, fieldOrder),\n });\n }\n\n private handleColumnCustomizerReorder(detail: MenuReorderDetail): void {\n const order = detail.items\n .filter(item => item.reorderable)\n .map(item => item.value)\n .filter((id): id is string => !!id);\n this.emitColumnsConfigChange(this.hiddenFieldIds, order);\n }\n\n private handleColumnCustomizerSelect(item: MenuItemData): void {\n const fieldId = item.value;\n if (!fieldId || item.isInactive) return;\n this.emitColumnsConfigChange(\n toggleTableColumnHidden(this.columns, this.hiddenFieldIds, fieldId),\n resolveTableColumnOrder(this.columns, this.fieldOrder)\n );\n }\n\n render() {\n this.groupSentinelEls.clear();\n const model = this.createRenderModel();\n this.syncVirtualItems(model);\n this.renderedModel = model;\n const regionLabel = this.scrollLabel?.trim() || `${this.caption} scroll area`;\n const initialLoading = this.loading && !model.hasData;\n const initialError = !model.hasData && this.error;\n const hasGroupedStructure = model.grouped && model.groups.length > 0;\n const windowRows = this.shouldWindowRows(model);\n const virtualViewportMissing =\n this.virtualViewportMissing && !initialLoading && (model.hasData || hasGroupedStructure);\n const virtualPlan = windowRows\n ? (this.virtualWindow ??\n resolveTableVirtualPlan({\n items: this.virtualItems,\n sizes: this.virtualItems.map(item => this.virtualController.sizeFor(item)),\n scrollOffset: this.viewportEl?.scrollTop ?? 0,\n viewportSize: this.estimateVirtualViewportSize(),\n pinnedRowIds: this.virtualPinnedRowIds(),\n }))\n : null;\n const groupLoadingMore = hasGroupedStructure && this.groups.some(group => group.loadingMore);\n const viewportStyle =\n !this.boundedComposition && this.resolvedMaxHeight\n ? { '--ds-table-max-block-size': this.resolvedMaxHeight }\n : undefined;\n const hostStyle = this.fixedHeight\n ? { '--_table-fixed-block-size': this.resolvedHeight }\n : undefined;\n\n return (\n <Host\n class={{\n 'table-host': true,\n 'table-host--bounded': this.boundedComposition,\n 'table-host--fixed-height': this.fixedHeight,\n 'table-host--viewport-fit': this.fitViewport,\n }}\n style={hostStyle}\n onKeyDown={(event: KeyboardEvent) => this.handlePaginationKeyDown(event)}\n onFocusin={this.onVirtualFocusIn}\n >\n <div\n class={{\n 'ds-table': true,\n 'ds-table--bounded': this.boundedComposition,\n 'ds-table--contained-scroll': this.containedScroll,\n 'ds-table--fixed-height': this.fixedHeight,\n 'ds-table--viewport-fit': this.fitViewport,\n 'ds-table--viewport-fit-pending': this.fitViewport && !this.viewportFitSettled,\n 'ds-table--viewport-fit-settled': this.fitViewport && this.viewportFitSettled,\n 'ds-table--sticky-header': this.stickyHeader,\n 'ds-table--document-sticky-header': this.documentStickyHeader,\n 'ds-table--contained-sticky-header': this.stickyHeader && !this.documentStickyHeader,\n 'ds-table--caption-visible': this.captionVisibility === 'visible',\n 'ds-table--footer-visible': this.hasResultFooter,\n 'ds-table--state-fill':\n !initialLoading &&\n (initialError || virtualViewportMissing || !(model.hasData || hasGroupedStructure)),\n }}\n ref={element => {\n this.rootEl = element ?? null;\n }}\n >\n {this.renderCaptionBar()}\n <div\n class={{\n 'ds-table__frame': true,\n 'ds-table__frame--overflow-start': this.overflowStart,\n 'ds-table__frame--overflow-end': this.overflowEnd,\n }}\n ref={element => {\n this.frameEl = element ?? null;\n }}\n >\n {this.renderDocumentStickyHeader(model)}\n {this.renderStickyGroup(model)}\n {!this.documentStickyHeader && this.renderFloatingCollapseAll(model)}\n <div\n class={{\n 'ds-table__viewport': true,\n 'ds-focus-ring': this.scrollable,\n }}\n style={viewportStyle}\n ref={element => {\n this.viewportEl = element ?? null;\n }}\n role={this.scrollable ? 'region' : undefined}\n aria-label={this.scrollable ? regionLabel : undefined}\n tabIndex={this.scrollable ? 0 : undefined}\n >\n <table\n class={{\n 'ds-table__table': true,\n 'ds-table__table--selectable': model.selectable,\n 'ds-table__table--grouped': model.grouped,\n 'ds-table__table--deferred-rows': this.dataMode !== 'virtual',\n 'ds-table__table--windowed': windowRows,\n 'ds-table__table--virtual': this.dataMode === 'virtual' && windowRows,\n 'ds-table__table--native-group-sticky':\n (model.grouped && this.stickyHeader && !this.documentStickyHeader) ||\n windowRows,\n }}\n style={model.tableStyle}\n aria-rowcount={windowRows ? 1 + this.virtualItems.length : undefined}\n aria-busy={\n initialLoading ||\n (this.dataMode === 'infinite' && (this.loadingMore || groupLoadingMore))\n ? 'true'\n : undefined\n }\n ref={element => {\n this.tableEl = element ?? null;\n }}\n >\n <caption class=\"ds-table__caption ds-visually-hidden\">\n <ds-text as=\"span\" variant=\"text-title-small\" emphasis={true} color=\"primary\">\n {this.caption}\n </ds-text>\n </caption>\n {this.renderColgroup(model)}\n {this.renderHeader(\n model,\n !this.documentStickyHeader,\n false,\n windowRows ? 1 : undefined\n )}\n {initialLoading\n ? this.renderSkeletonBody(model)\n : initialError\n ? this.renderStateBody('error', model.totalColumns)\n : virtualViewportMissing\n ? this.renderStateBody('virtual-viewport', model.totalColumns)\n : model.hasData || hasGroupedStructure\n ? this.renderDataBodies(model, virtualPlan)\n : this.renderStateBody('empty', model.totalColumns)}\n {!initialLoading &&\n !initialError &&\n this.dataMode !== 'virtual' &&\n this.renderLazyBody(model)}\n </table>\n </div>\n </div>\n {this.renderResultFooter()}\n {this.renderOverflowActionMenu()}\n {this.renderDataModeSwitcherMenu()}\n {this.renderColumnCustomizerMenu()}\n {this.renderTruncateTooltip()}\n <div class=\"ds-visually-hidden\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n {this.announcement}\n </div>\n </div>\n </Host>\n );\n }\n}\n",
|
|
1641
|
+
"content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Listen,\n Method,\n Prop,\n State,\n Watch,\n} from '@stencil/core';\nimport {\n deriveTableSelectionState,\n formatTableResultSummary,\n formatTableTotalSummary,\n hasOwnedTableFooterSlot,\n isTableCellAction,\n nextDataSortState,\n tableColumnSize,\n tableModelIssues,\n TABLE_GROUP_HERO_SCORE_PRESENTATION,\n tableRows,\n toggleAllLoadedTableRows,\n toggleTableGroupCollapsed,\n toggleTableGroupSelection,\n nextTableGroupsCollapsed,\n toggleTableRowSelection,\n} from './table-model';\nimport { createTableRenderModel, type TableRenderModel } from './table-render-model';\nimport {\n isRenderableTableActionMenu,\n nextTableActionMenuElementId,\n tableActionMenuSections,\n tableActionTriggerId,\n} from './table-action-menu';\nimport {\n nextTableColumnCustomizerElementId,\n resolveTableColumnOrder,\n resolveTableHiddenColumnIds,\n resolveTableVisibleColumns,\n tableColumnCustomizerMenuItems,\n toggleTableColumnHidden,\n} from './table-column-customizer';\nimport {\n nextTableDataModeSwitcherElementId,\n tableDataModeFromMenuItem,\n tableDataModeMenuItems,\n} from './table-data-mode-switcher';\nimport { resolveTableTruncateTrack, tableTruncateLabel } from './table-truncate';\nimport { renderTableRow as renderTableRowView } from './table-row-view';\nimport { TableBodyRenderer } from './table-body-renderer';\nimport { tableHeaderBands, tablePinnedColumns, tableSpanModel } from './table-structure';\nimport {\n tableGridModel,\n tableRangeBounds,\n tableEditable,\n tableEditValue,\n tablePasteChanges,\n tableNextEditable,\n} from './table-grid-model';\nimport { renderTableCellEditor } from './table-cell-editor';\nimport type {\n TableCellSpan,\n TableCellRange,\n TableCellAddress,\n TableCellsChangeDetail,\n} from './table-types';\nimport { renderTableSkeletonBody } from './table-skeleton-view';\nimport { renderTableLoadContent } from './table-load-view';\nimport { TableLayoutController } from './table-layout-controller';\nimport { TableLoadController } from './table-load-controller';\nimport { TableGroupLoadController } from './table-group-load-controller';\nimport {\n estimateTableRowBlockSize,\n flattenTableVirtualItems,\n resolveTableVirtualPlan,\n TABLE_VIRTUAL_TOTAL_SUMMARY_LABEL,\n TABLE_VIRTUAL_VIEWPORT_REQUIRED_BODY,\n TABLE_VIRTUAL_VIEWPORT_REQUIRED_HEADING,\n type TableVirtualItem,\n type TableVirtualPlan,\n} from './table-virtual-model';\nimport { TableVirtualController } from './table-virtual-controller';\nimport type { PaginationChangeDetail } from '../Pagination/pagination-types';\nimport type { MenuItemData, MenuReorderDetail } from '../Menu/menu-types';\nimport { resolvePaginationState } from '../Pagination/pagination-model';\nimport { resolveCssLengthPx } from '../../utils/resolve-css-length-px';\nimport { isElementTruncated } from '../../utils/is-element-truncated';\nimport { observeTableCaptionCompact } from '../../utils/table-caption-compact';\nimport { resolveTableFitPageSize } from './table-pagination-fit';\nimport { createTableHighlightMatcher, type TableHighlightMatcher } from './table-highlight';\nimport {\n paginationShortcutBlockedByPath,\n shouldHandleContainingPagePaginationShortcut,\n} from './table-pagination-shortcut';\nimport {\n TableViewportFitController,\n type TableViewportFitMetrics,\n} from './table-viewport-fit-controller';\nimport type {\n TableCaptionVisibility,\n TableCellActionDetail,\n TableColumn,\n DataFieldsConfigChangeDetail,\n TableDataMode,\n TableDataModeChangeDetail,\n DataGroup,\n TableGroupCollapseChangeDetail,\n TableGroupLoadMoreDetail,\n DataGroupingState,\n TableLoadMoreDetail,\n TableLoadMoreMode,\n TablePaginationState,\n TableRow,\n TableRowActivateDetail,\n TableSelectionChangeDetail,\n TableSelectionMode,\n DataSortChangeDetail,\n DataSortState,\n} from './table-types';\n\nconst TABLE_FOOTER_SLOT_LEADING = 1;\nconst TABLE_FOOTER_SLOT_COPY = 2;\nconst TABLE_FOOTER_SLOT_TRAILING = 4;\nconst TABLE_NON_VIRTUAL_WINDOW_THRESHOLD = 50;\n\n@Component({\n tag: 'ds-table',\n styleUrls: ['../../utils/focus-ring.css', '../../utils/interaction-fill.css', 'Table.css'],\n scoped: true,\n})\nexport class Table {\n @Element() el!: HTMLElement;\n /** Stable column definitions. Assign through JavaScript. */\n @Prop() columns: TableColumn[] = [];\n /** Opt-in native card presentation below the 768px table container breakpoint. */\n @Prop() responsiveLayout: 'scroll' | 'cards' = 'scroll';\n /** Explicit report merges; invalid combinations leave the original cells visible. */\n @Prop() cellSpans: TableCellSpan[] = [];\n /** Table is read-only; edit uses per-cell pencil buttons; grid adds cell selection and keyboard navigation. Grouped tables remain read-only. */\n @Prop() interactionMode: 'table' | 'edit' | 'grid' = 'table';\n /** Controlled rectangular cell selection, independent of row checkbox selection. */\n @Prop() cellRange: TableCellRange | null = null;\n @Event() dsCellRangeChange!: EventEmitter<TableCellRange>;\n @Event() dsCellsChange!: EventEmitter<TableCellsChangeDetail>;\n /** Additional toggle options in the column customizer. */\n @Prop() customizeOptions: MenuItemData[] = [];\n @Event() dsCustomizeOptionChange!: EventEmitter<string>;\n /** Ungrouped row data. Ignored while grouping is active. Assign through JavaScript. */\n @Prop() rows: TableRow[] = [];\n /** One level of application-owned grouped data. Assign through JavaScript. */\n @Prop() groups: DataGroup[] = [];\n /** Controlled grouping column. Applications supply groups in their final fixed order. */\n @Prop() grouping: DataGroupingState | null = null;\n /** Controlled member-row sort state. */\n @Prop() sort: DataSortState | null = null;\n /** Controlled collapsed group identities. Groups not listed remain expanded. */\n @Prop() collapsedGroupIds: string[] = [];\n /** Literal terms to highlight in table-owned text cells. Applications still own filtering. */\n @Prop() highlightTerms: string[] = [];\n /** Optional DataSearch field identities that restrict which data-point tracks are highlighted. */\n @Prop() highlightFieldIds: string[] = [];\n\n /** Required accessible table name, retained as a native caption. */\n @Prop() caption!: string;\n /** Shows a matching presentational title bar above the native table frame. */\n @Prop() captionVisibility: TableCaptionVisibility = 'hidden';\n /**\n * Opt in to the table-owned column customizer. The `columns` prop remains the\n * catalog; hidden and ordered columns are controlled separately. The trigger\n * opens the shared Menu of live show/hide switch rows.\n */\n @Prop() columnCustomizer: boolean = false;\n /** Hide only the built-in Customize trigger when an external preferences surface owns it. */\n /** Remove borders from table-owned caption controls. */\n @Prop() captionControlsBorderless: boolean = false;\n @Prop() hideColumnCustomizerTrigger: boolean = false;\n /** Controlled hidden data-column identities. Action ids are ignored. */\n @Prop() hiddenFieldIds: string[] = [];\n /** Controlled data-column identities in display order. Omitted ids append in catalog order. */\n @Prop() fieldOrder: string[] = [];\n /**\n * Optional result summary footer. When both `displayedCount` and `totalCount`\n * are finite numbers, infinite mode shows “Displaying {displayed} of {total}”.\n * Virtual mode ignores `displayedCount` and derives its total from the\n * complete supplied rows; a mismatched `totalCount` emits a warning.\n */\n @Prop() displayedCount: number | undefined;\n @Prop() totalCount: number | undefined;\n /** Supports {displayed} and {total} placeholders. */\n @Prop() resultSummaryLabel: string = 'Displaying {displayed} of {total}';\n /** Supports the {total} placeholder. Used when dataMode is virtual. */\n @Prop() resultTotalSummaryLabel: string = TABLE_VIRTUAL_TOTAL_SUMMARY_LABEL;\n @Prop() stickyHeader: boolean = false;\n /** Maximum scroll-region height. Numbers resolve to CSS pixels. */\n @Prop() maxHeight: string | number | undefined;\n /** Fixed height for the complete header, table frame, and footer composition. */\n @Prop() height: string | number | undefined;\n /** Fit the complete table composition to its nearest vertical scrollport. */\n @Prop() fitViewport: boolean = false;\n /** Reserved space above a viewport-fitted table once surrounding chrome is compact. */\n @Prop() viewportInsetBlockStart: string | number = 0;\n /** Reserved space below a viewport-fitted table. */\n @Prop() viewportInsetBlockEnd: string | number = 0;\n /** Optional explicit label for the horizontal/vertical scroll region. */\n @Prop() scrollLabel: string | undefined;\n\n @Prop() selectionMode: TableSelectionMode = 'none';\n /** Controlled selected row identities. IDs outside the loaded rows are preserved. */\n @Prop() selectedRowIds: string[] = [];\n\n /**\n * Identity of the interactive row whose associated surface is visible.\n * Holds its pressed wash independently of checkbox selection. Keep the ID\n * through exit motion and clear it after close; the application owns toggling\n * and switching the surface. Off-window identities survive virtualization.\n */\n @Prop() surfaceOpenRowId: string | undefined;\n\n /** Initial loading state. Existing rows stay visible; incremental loading uses loadingMore. */\n @Prop() loading: boolean = false;\n /** Replace opted-in table-owned caption controls with same-size visual skeletons. */\n @Prop() chromeLoading: boolean = false;\n /** Initial-loading rows. Defaults to ten so bounded tables retain a useful filled viewport. */\n @Prop() skeletonRows: number = 10;\n @Prop() emptyHeading: string = 'No results';\n @Prop() emptyBody: string = 'No data is available.';\n /** Initial error state. Existing rows stay visible; incremental failures use loadMoreError. */\n @Prop() error: boolean = false;\n @Prop() errorHeading: string = 'Unable to load data';\n @Prop() errorBody: string = 'The data could not be loaded.';\n @Prop() emptyCellLabel: string = 'Not available';\n\n /** Top-level data-window strategy. Virtual mode recycles row DOM only. */\n @Prop() dataMode: TableDataMode = 'infinite';\n /** Opt in to the table-owned control for choosing between supported data modes. */\n @Prop() dataModeSwitcher: boolean = false;\n @Prop() dataModeSwitcherLabel: string = 'Change table variation';\n @Prop() dataModeMenuLabel: string = 'Table variation';\n @Prop() infiniteModeLabel: string = 'Infinite scroll';\n @Prop() paginationModeLabel: string = 'Pagination + Infinite groups';\n @Prop() virtualModeLabel: string = 'Virtual scroll';\n /** Controlled top-level pagination state. Required when dataMode is pagination. */\n @Prop() pagination: TablePaginationState | null = null;\n @Prop() loadMoreMode: TableLoadMoreMode = 'auto';\n @Prop() hasMore: boolean = false;\n @Prop() loadingMore: boolean = false;\n @Prop() loadMoreError: string | undefined;\n /** Reset key for a new query/group/sort dataset. */\n @Prop() loadIdentity: string | number = 'default';\n /** IntersectionObserver root margin in CSS pixels. */\n @Prop() loadMoreThreshold: number = 0;\n @Prop() loadMoreLabel: string = 'Load more';\n @Prop() retryLabel: string = 'Retry';\n @Prop() loadingMoreLabel: string = 'Loading more items';\n @Prop() endOfResultsLabel: string = 'All results loaded';\n /** Supports {count} and {total} placeholders. */\n @Prop() rowsLoadedLabel: string = '{count} more rows loaded. {total} rows loaded.';\n @Prop() groupLoadMoreLabel: string = 'Load more';\n /** Supports the {group} placeholder. */\n @Prop() groupLoadMoreAriaLabel: string = 'Load more {group} results';\n /** Supports the {group} placeholder. */\n @Prop() groupRetryLabel: string = 'Retry loading {group} results';\n /** Supports the {group} placeholder. */\n @Prop() groupLoadingMoreLabel: string = 'Loading more items';\n /** Supports the {group} placeholder. */\n @Prop() groupEndOfResultsLabel: string = 'All {group} results loaded';\n /** Supports {group}, {count}, {loaded}, and {total} placeholders. */\n @Prop() groupRowsLoadedLabel: string =\n '{count} more rows loaded in {group}. {loaded} of {total} rows loaded.';\n\n @Event() dsSortChange!: EventEmitter<DataSortChangeDetail>;\n @Event() dsGroupCollapseChange!: EventEmitter<TableGroupCollapseChangeDetail>;\n @Event() dsSelectionChange!: EventEmitter<TableSelectionChangeDetail>;\n @Event() dsLoadMore!: EventEmitter<TableLoadMoreDetail>;\n @Event() dsGroupLoadMore!: EventEmitter<TableGroupLoadMoreDetail>;\n @Event() dsPaginationChange!: EventEmitter<PaginationChangeDetail>;\n @Event() dsCellAction!: EventEmitter<TableCellActionDetail>;\n @Event() dsRowActivate!: EventEmitter<TableRowActivateDetail>;\n @Event() dsFieldsConfigChange!: EventEmitter<DataFieldsConfigChangeDetail>;\n @Event() dsDataModeChange!: EventEmitter<TableDataModeChangeDetail>;\n\n @State() private overflowStart = false;\n @State() private overflowEnd = false;\n @State() private scrollable = false;\n @State() private announcement = '';\n @State() private activeStickyGroupId: string | null = null;\n @State() private viewportFitSettled = false;\n @State() private headerPresent = false;\n @State() private headerUsesToolbar = false;\n @State() private footerSlotPresence = 0;\n @State() private fitPageSize: number | undefined;\n @State() private actionMenu: { rowId: string; columnId: string } | null = null;\n @State() private actionMenuInitialFocusVisible = false;\n @State() private actionMenuSurface: { rowId: string; columnId: string } | null = null;\n @State() private truncateTooltipLabel = '';\n @State() private captionCompact = false;\n @State() private narrowViewport = false;\n @State() private columnCustomizerOpen = false;\n @State() private columnCustomizerSurfaceOpen = false;\n @State() private columnCustomizerInitialFocusVisible = false;\n @State() private dataModeSwitcherOpen = false;\n @State() private dataModeSwitcherSurfaceOpen = false;\n @State() private dataModeSwitcherInitialFocusVisible = false;\n @State() private virtualWindow: TableVirtualPlan | null = null;\n @State() private activeCell: TableCellAddress | null = null;\n @State() private editingCell: TableCellAddress | null = null;\n private editDraft = '';\n private gridModelCache?: ReturnType<typeof tableGridModel>;\n private get gridEnabled(): boolean {\n return this.interactionMode === 'grid' && !this.grouped;\n }\n private get editingEnabled(): boolean {\n return this.interactionMode !== 'table' && !this.grouped;\n }\n private get gridModel() {\n if (\n !this.gridModelCache ||\n this.gridModelCache.rows !== this.rows ||\n this.gridModelCache.columns !== this.visibleColumns\n ) {\n this.gridModelCache = tableGridModel(this.rows, this.visibleColumns);\n }\n return this.gridModelCache;\n }\n private gridAddress(event: Event): TableCellAddress | null {\n if (\n !this.gridEnabled ||\n !(event.target instanceof HTMLElement) ||\n !event.target.matches('td[role=\"gridcell\"][data-cell-editable=\"true\"]')\n )\n return null;\n const rowId = event.target.closest<HTMLElement>('[data-row-id]')?.dataset.rowId;\n const columnId = event.target.dataset.columnId;\n return rowId && columnId ? { rowId, columnId } : null;\n }\n private selectCell(address: TableCellAddress, extend = false): void {\n if (!this.isEditableAddress(address)) return;\n this.activeCell = address;\n this.dsCellRangeChange.emit({\n anchor: extend ? (this.cellRange?.anchor ?? address) : address,\n focus: address,\n });\n }\n private async focusCell(address: TableCellAddress): Promise<void> {\n if (!this.isEditableAddress(address)) return;\n this.activeCell = address;\n this.focusedRowId = address.rowId;\n await this.scrollRowIntoView(address.rowId);\n await this.nextAnimationFrame();\n const cell = this.findRenderedRow(address.rowId)?.querySelector<HTMLElement>(\n `[data-column-id=\"${CSS.escape(address.columnId)}\"]`\n );\n if (this.gridEnabled) cell?.focus();\n else\n await cell\n ?.querySelector<HTMLDsButtonUnfilledElement>('.ds-table__edit-trigger ds-button-unfilled')\n ?.setFocus();\n }\n private isEditableAddress(address: TableCellAddress): boolean {\n const model = this.gridModel;\n const row = model.rows[model.rowIndices.get(address.rowId) ?? -1];\n const column = model.columns[model.columnIndices.get(address.columnId) ?? -1];\n return this.editingEnabled && !this.loading && !!row && !!column && tableEditable(row, column);\n }\n private beginCellEdit(address: TableCellAddress, initial?: string): void {\n const model = this.gridModel;\n const row = model.rows[model.rowIndices.get(address.rowId) ?? -1];\n const column = model.columns[model.columnIndices.get(address.columnId) ?? -1];\n if (!this.editingEnabled || this.loading || !row || !column || !tableEditable(row, column))\n return;\n this.activeCell = address;\n this.focusedRowId = address.rowId;\n this.editDraft = initial ?? String(row.cells[column.id] ?? '');\n this.editingCell = address;\n requestAnimationFrame(async () => {\n const control = this.el.querySelector<HTMLDsInputElement>('.ds-table__cell-editor > *');\n await control?.componentOnReady?.();\n if (this.editingCell !== address || !control?.isConnected) return;\n await control.setFocus();\n const input = control.querySelector<HTMLInputElement | HTMLTextAreaElement>('input,textarea');\n if (initial == null && input?.type !== 'number') input?.select();\n });\n }\n private handleGridKeyDown(event: KeyboardEvent): void {\n const address = this.gridAddress(event);\n if (!address || event.altKey || event.metaKey || event.ctrlKey || event.isComposing) return;\n if (event.key === 'Enter' || event.key === 'F2' || event.key.length === 1) {\n event.preventDefault();\n this.beginCellEdit(address, event.key.length === 1 ? event.key : undefined);\n return;\n }\n if (!['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(event.key))\n return;\n event.preventDefault();\n const next = tableNextEditable(this.gridModel, address, event.key);\n if (!next) return;\n this.selectCell(next, event.shiftKey);\n void this.focusCell(next);\n }\n private handleGridClipboard(event: ClipboardEvent): void {\n const address = this.gridAddress(event);\n if (!address || !event.clipboardData) return;\n if (event.type === 'paste') {\n event.preventDefault();\n if (this.loading) return;\n const changes = tablePasteChanges(\n this.gridModel,\n address,\n event.clipboardData.getData('text/plain')\n );\n if (!changes) {\n this.announcement =\n 'Paste rejected. Use a rectangular set of valid values in editable cells.';\n return;\n }\n this.dsCellsChange.emit({ changes, reason: 'paste' });\n this.announcement = `${changes.length} cell changes proposed.`;\n } else {\n const model = this.gridModel;\n const bounds = tableRangeBounds(model, this.cellRange ?? { anchor: address, focus: address });\n if (\n !bounds ||\n (bounds.lastRow - bounds.firstRow + 1) * (bounds.lastColumn - bounds.firstColumn + 1) >\n 10000\n )\n return;\n const text = model.rows\n .slice(bounds.firstRow, bounds.lastRow + 1)\n .map(row =>\n model.columns\n .slice(bounds.firstColumn, bounds.lastColumn + 1)\n .map(column => {\n if (!tableEditable(row, column)) return '';\n const value = row.cells[column.id];\n return typeof value === 'object' ? '' : String(value ?? '').replace(/[\\t\\r\\n]/g, ' ');\n })\n .join('\\t')\n )\n .join('\\n');\n event.preventDefault();\n event.clipboardData.setData('text/plain', text);\n }\n }\n private renderCellEditor(row: TableRow, column: TableColumn) {\n if (\n this.editingCell?.rowId !== row.id ||\n this.editingCell.columnId !== column.id ||\n !tableEditable(row, column) ||\n this.loading\n )\n return null;\n const address = this.editingCell;\n const editor = column.editor!;\n const cell = this.findRenderedRow(row.id)?.querySelector<HTMLElement>(\n `[data-column-id=\"${CSS.escape(column.id)}\"]`\n );\n const commit = (text: string, next = address) => {\n if (this.editingCell !== address || !this.isEditableAddress(address)) return false;\n const value = tableEditValue(text, column);\n if (value === undefined) {\n this.announcement = `Enter a valid value for ${column.label}.`;\n return false;\n }\n this.editingCell = null;\n this.dsCellsChange.emit({ changes: [{ ...address, value }], reason: 'edit' });\n void this.focusCell(next);\n return true;\n };\n return (\n <div\n class=\"ds-table__cell-editor\"\n key={`${row.id}:${column.id}`}\n onClick={event => event.stopPropagation()}\n onFocusout={event => {\n const wrapper = event.currentTarget as HTMLElement;\n requestAnimationFrame(() => {\n const root = wrapper.getRootNode() as Document | ShadowRoot;\n if (this.editingCell === address && !wrapper.contains(root.activeElement))\n this.editingCell = null;\n });\n }}\n onKeyDown={event => {\n event.stopPropagation();\n if (event.isComposing || event.defaultPrevented) return;\n const wrapper = event.currentTarget as HTMLElement;\n if (event.key === 'Escape') {\n event.preventDefault();\n this.editingCell = null;\n void this.focusCell(address);\n return;\n }\n if (event.key !== 'Enter' && event.key !== 'Tab') return;\n // Picker keyboard interactions belong to the picker, including its internal Tab stops.\n if ((event.target as HTMLElement).closest('[popover]')) return;\n if (\n event.key === 'Enter' &&\n editor.type === 'textarea' &&\n !event.ctrlKey &&\n !event.metaKey\n )\n return;\n event.preventDefault();\n const input =\n editor.type === 'select'\n ? null\n : wrapper.querySelector<HTMLInputElement | HTMLTextAreaElement>('input,textarea');\n // Unconstrained numeric columns retain decimal support; explicit steps use the\n // shared control's native step validity as well as the paste validator.\n if (\n input &&\n !input.checkValidity() &&\n !(editor.type === 'number' && editor.step == null && input.validity.stepMismatch)\n ) {\n input.reportValidity();\n return;\n }\n const next =\n event.key === 'Tab'\n ? (tableNextEditable(this.gridModel, address, 'Tab', event.shiftKey) ?? address)\n : address;\n commit(input?.value ?? this.editDraft, next);\n }}\n >\n {renderTableCellEditor(\n editor,\n this.editDraft,\n `Edit ${column.label} for ${row.selectionLabel ?? row.id}`,\n cell ?? undefined,\n value => {\n this.editDraft = value;\n // A single-select choice is a complete edit; let its selection handler finish first.\n if (editor.type === 'select') queueMicrotask(() => commit(value));\n }\n )}\n </div>\n );\n }\n\n private renderCellEditTrigger(row: TableRow, column: TableColumn) {\n if (\n this.interactionMode !== 'edit' ||\n !tableEditable(row, column) ||\n this.loading ||\n (this.editingCell?.rowId === row.id && this.editingCell.columnId === column.id)\n )\n return null;\n return (\n <span class=\"ds-table__edit-trigger\">\n <ds-button-unfilled\n variant=\"icon\"\n icon=\"Pencil\"\n size=\"sm\"\n aria-label={`Edit ${column.label} for ${row.selectionLabel ?? row.id}`}\n pressScale={false}\n onDsClick={event => {\n event.stopPropagation();\n this.beginCellEdit({ rowId: row.id, columnId: column.id });\n }}\n />\n </span>\n );\n }\n\n private gridCellAttributes(row: TableRow, column: TableColumn): Record<string, unknown> {\n const model = this.gridModel;\n const bounds = tableRangeBounds(model, this.cellRange);\n const ri = model.rowIndices.get(row.id)!,\n ci = model.columnIndices.get(column.id)!;\n const editable = tableEditable(row, column) && !this.loading;\n const editing =\n editable && this.editingCell?.rowId === row.id && this.editingCell.columnId === column.id;\n const active =\n this.activeCell && this.isEditableAddress(this.activeCell)\n ? this.activeCell\n : model.firstEditable;\n if (!this.gridEnabled)\n return {\n 'data-cell-editable': String(editable),\n 'data-cell-editing': String(editing),\n 'aria-disabled': editable ? undefined : 'true',\n };\n return {\n role: 'gridcell',\n tabIndex: editable\n ? active?.rowId === row.id && active.columnId === column.id\n ? 0\n : -1\n : undefined,\n 'data-cell-editable': String(editable),\n 'data-cell-editing': String(editing),\n 'aria-readonly': String(!editable),\n 'aria-disabled': editable ? undefined : 'true',\n 'aria-selected': String(\n editable &&\n !!bounds &&\n ri >= bounds.firstRow &&\n ri <= bounds.lastRow &&\n ci >= bounds.firstColumn &&\n ci <= bounds.lastColumn\n ),\n onClick: (event: MouseEvent) => {\n if (\n event.target !== event.currentTarget &&\n (event.target as HTMLElement).closest(\n 'button,a,input,textarea,ds-button-unfilled,.ds-table__cell-editor'\n )\n )\n return;\n event.stopPropagation();\n if (!editable) return;\n this.selectCell({ rowId: row.id, columnId: column.id }, event.shiftKey);\n (event.currentTarget as HTMLElement).focus();\n if (!event.shiftKey) this.beginCellEdit({ rowId: row.id, columnId: column.id });\n },\n };\n }\n\n private readonly actionMenuElementId = nextTableActionMenuElementId();\n private structureCache?: {\n columns: TableColumn[];\n rows: TableRow[];\n spans: TableCellSpan[];\n selectable: boolean;\n grouped: boolean;\n windowed: boolean;\n grid: boolean;\n pins: ReturnType<typeof tablePinnedColumns>;\n bands: ReturnType<typeof tableHeaderBands>;\n merges: ReturnType<typeof tableSpanModel>;\n };\n\n private get structure() {\n const columns = this.visibleColumns;\n const model = this.createRenderModel();\n const cached = this.structureCache;\n const windowed = this.rowWindowingEnabled;\n const grid = this.interactionMode !== 'table';\n if (\n cached &&\n cached.columns === columns &&\n cached.rows === this.rows &&\n cached.spans === this.cellSpans &&\n cached.selectable === model.selectable &&\n cached.grouped === model.grouped &&\n cached.windowed === windowed &&\n cached.grid === grid\n )\n return cached;\n return (this.structureCache = {\n columns,\n rows: this.rows,\n spans: this.cellSpans,\n selectable: model.selectable,\n grouped: model.grouped,\n windowed,\n grid,\n pins: tablePinnedColumns(columns, model.selectable),\n bands: tableHeaderBands(columns, model.selectable, model.elasticSpacerIndex),\n merges: tableSpanModel(this.cellSpans, this.rows, columns, {\n windowed,\n grouped: model.grouped,\n grid,\n selectable: model.selectable,\n spacer: model.elasticSpacerIndex,\n }),\n });\n }\n\n private headerId(columnId: string): string {\n return `${this.actionMenuElementId}-header-${encodeURIComponent(columnId)}`;\n }\n\n private columnBandId(columnId: string): string | undefined {\n const model = this.createRenderModel();\n const index = this.visibleColumns.findIndex(column => column.id === columnId);\n const lane =\n index +\n 1 +\n Number(model.selectable) +\n Number(model.elasticSpacerIndex != null && index >= model.elasticSpacerIndex);\n const band = this.structure.bands.find(\n band => band.label && lane >= band.start && lane < band.start + band.span\n );\n return band ? `${this.actionMenuElementId}-${band.key}` : undefined;\n }\n private readonly columnCustomizerElementId = nextTableColumnCustomizerElementId();\n private readonly dataModeSwitcherElementId = nextTableDataModeSwitcherElementId();\n private truncateTooltipEl?: HTMLDsTooltipElement;\n private truncateAnchor: HTMLElement | null = null;\n private truncateTooltipBound = false;\n private focusedRowId: string | null = null;\n private virtualRenderFocus: HTMLElement | null = null;\n private virtualItems: TableVirtualItem[] = [];\n private visibleColumnsCache: {\n columns: TableColumn[];\n columnCustomizer: boolean;\n hiddenFieldIds: string[];\n fieldOrder: string[];\n value: TableColumn[];\n } | null = null;\n private renderModelCache: {\n columns: TableColumn[];\n rows: TableRow[];\n groups: DataGroup[];\n grouped: boolean;\n selectionMode: TableSelectionMode;\n selectedRowIds: string[];\n collapsedGroupIds: string[];\n virtualCounts: boolean;\n value: TableRenderModel;\n } | null = null;\n private highlightMatcherCache: {\n terms: string[];\n value: TableHighlightMatcher;\n } | null = null;\n private virtualItemsCache: {\n columns: TableColumn[];\n rows: TableRow[];\n groups: DataGroup[];\n grouped: boolean;\n collapsedGroupIds: string[];\n } | null = null;\n private rootEl: HTMLElement | null = null;\n private viewportEl: HTMLElement | null = null;\n private frameEl: HTMLElement | null = null;\n private interactiveHeadEl: HTMLTableSectionElement | null = null;\n private collapseAllOverlayEl: HTMLElement | null = null;\n private stickyHeaderTableEl: HTMLTableElement | null = null;\n private tableEl: HTMLTableElement | null = null;\n private sentinelEl: HTMLElement | null = null;\n private incrementalWindowActive = false;\n private readonly groupSentinelEls = new Map<string, HTMLElement>();\n private previousModelWarning = '';\n private modelWarningQueued = false;\n private initialModelWarningFrame: number | undefined;\n private hasLoaded = false;\n private renderedModel: TableRenderModel | null = null;\n private stickyGroupConnected = false;\n private headerSlotObserver: MutationObserver | null = null;\n private footerSlotObserver: MutationObserver | null = null;\n private captionCompactDisconnect: (() => void) | undefined;\n private fitResizeObserver: ResizeObserver | null = null;\n private fitObservedViewport: HTMLElement | null = null;\n private fitObservedTable: HTMLTableElement | null = null;\n private fitMeasurementPending = false;\n private readonly bodyRenderer = new TableBodyRenderer();\n private readonly layoutController = new TableLayoutController({\n elements: () => ({\n viewport: this.viewportEl,\n contentTable: this.tableEl,\n stickyHeaderTable: this.stickyHeaderTableEl,\n collapseAllOverlay: this.collapseAllOverlayEl,\n frame: this.frameEl,\n interactiveHead: this.interactiveHeadEl,\n }),\n mode: () => ({\n documentStickyHeader: this.documentStickyHeader,\n floatingCollapseAll: this.renderedModel?.collapseAllHost?.mode === 'floating',\n clampVerticalOverscroll: this.fitViewport && this.viewportFitSettled,\n }),\n verticalEdgeWheel: deltaY => this.viewportFitController.scrollOuterBy(deltaY),\n narrowChanged: narrow => {\n if (this.narrowViewport !== narrow) this.narrowViewport = narrow;\n },\n overflowChanged: state => {\n if (state.start !== this.overflowStart) this.overflowStart = state.start;\n if (state.end !== this.overflowEnd) this.overflowEnd = state.end;\n if (state.scrollable !== this.scrollable) this.scrollable = state.scrollable;\n },\n });\n private readonly loadController = new TableLoadController({\n state: () => ({\n lazyLoading: this.dataMode === 'infinite' && !this.grouped,\n loadMoreMode: this.loadMoreMode,\n hasMore: this.hasMore,\n loadingMore: this.loadingMore,\n loadMoreError: this.loadMoreError,\n loadIdentity: this.loadIdentity,\n loadMoreThreshold: this.loadMoreThreshold,\n containedScroll: this.containedScroll,\n loadingMoreLabel: this.loadingMoreLabel,\n endOfResultsLabel: this.endOfResultsLabel,\n rowsLoadedLabel: this.rowsLoadedLabel,\n loadedRowCount: this.currentLoadedRowCount(),\n viewport: this.viewportEl,\n sentinel: this.sentinelEl,\n }),\n announce: message => {\n this.announcement = message;\n },\n request: detail => {\n this.dsLoadMore.emit(detail);\n },\n });\n private readonly groupLoadController = new TableGroupLoadController({\n state: () => ({\n enabled: this.grouped && this.dataMode !== 'virtual',\n loadMoreMode: this.loadMoreMode,\n loadMoreThreshold: this.loadMoreThreshold,\n containedScroll: this.containedScroll,\n groups: this.groups,\n viewport: this.viewportEl,\n sentinels: this.groupSentinelEls,\n loadingMoreLabel: this.groupLoadingMoreLabel,\n endOfResultsLabel: this.groupEndOfResultsLabel,\n rowsLoadedLabel: this.groupRowsLoadedLabel,\n }),\n announce: message => {\n this.announcement = message;\n },\n request: detail => {\n this.dsGroupLoadMore.emit(detail);\n },\n });\n private readonly virtualController = new TableVirtualController({\n state: () => ({\n enabled: this.rowWindowingEnabled,\n items: this.virtualItems,\n pinnedRowIds: this.virtualPinnedRowIds(),\n viewport: this.viewportEl,\n viewportSize: this.estimateVirtualViewportSize(),\n }),\n windowChanged: plan => {\n this.virtualWindow = plan;\n },\n });\n private readonly viewportFitController = new TableViewportFitController({\n enabled: () => this.fitViewport,\n elements: () => ({ host: this.el, surface: this.rootEl }),\n insets: () => ({\n blockStart: this.viewportInsetBlockStart,\n blockEnd: this.viewportInsetBlockEnd,\n }),\n fitChanged: (metrics: TableViewportFitMetrics | null) => {\n const settled = metrics?.settled ?? false;\n if (settled !== this.viewportFitSettled) this.viewportFitSettled = settled;\n },\n });\n\n componentWillLoad(): void {\n // Reserve the final viewport-fit height before the first paint. The fitted\n // surface is connected after render, but the host can already resolve its\n // owning scrollport and prevent an initial auto-height frame.\n this.viewportFitController.connect();\n this.incrementalWindowActive = this.hasIncrementalState;\n this.syncHeaderSlotPresence();\n this.syncFooterSlotPresence();\n this.loadController.initialize();\n this.groupLoadController.initialize();\n }\n\n componentDidLoad(): void {\n this.hasLoaded = true;\n this.layoutController.connect();\n this.loadController.connect();\n this.groupLoadController.connect();\n this.virtualController.connect();\n this.viewportFitController.connect();\n this.syncStickyGroupConnection();\n this.connectHeaderSlotObserver();\n this.connectFooterSlotObserver();\n this.connectFitObserver();\n this.connectCaptionCompactObserver();\n this.syncFitPageSize();\n this.connectTruncateTooltip();\n this.scheduleInitialModelIssueWarning();\n }\n\n componentWillRender(): void {\n const root = this.el.getRootNode() as Document | ShadowRoot;\n const active = root.activeElement;\n this.virtualRenderFocus =\n this.rowWindowingEnabled &&\n active instanceof HTMLElement &&\n this.tableEl?.contains(active) &&\n active.closest('[data-row-id]')\n ? active\n : null;\n }\n\n componentDidRender(): void {\n // Moving a retained pooled row can clear native focus in Firefox. Restore\n // only the same connected node, without scrolling or stealing external focus.\n const retainedFocus = this.virtualRenderFocus;\n this.virtualRenderFocus = null;\n if (retainedFocus?.isConnected) {\n const root = retainedFocus.getRootNode() as Document | ShadowRoot;\n if (!root.activeElement || root.activeElement === this.el.ownerDocument.body) {\n // Resolve the moved rows' scroll geometry before restoring native focus.\n // WebKit otherwise may reveal the caret using the pre-patch layout even\n // with preventScroll. Preserve both axes within the resulting bounds.\n const viewport = this.viewportEl;\n const top = viewport?.scrollTop ?? 0;\n const left = viewport?.scrollLeft ?? 0;\n retainedFocus.focus({ preventScroll: true });\n if (viewport) {\n if (viewport.scrollTop !== top) viewport.scrollTop = top;\n if (viewport.scrollLeft !== left) viewport.scrollLeft = left;\n }\n }\n }\n this.layoutController.refresh(false);\n this.loadController.refresh();\n this.groupLoadController.refresh();\n this.virtualController.refresh();\n this.virtualController.collectMeasurements(this.tableEl);\n if (\n this.rowWindowingEnabled &&\n this.truncateAnchor &&\n this.tableEl &&\n !this.tableEl.contains(this.truncateAnchor)\n ) {\n this.dismissTruncateTooltip();\n }\n this.viewportFitController.refresh(false);\n this.syncStickyGroupConnection();\n this.connectFitObserver();\n this.connectCaptionCompactObserver();\n this.syncFitPageSize();\n if (this.stickyGroupConnected) this.updateStickyGroup();\n }\n\n /**\n * Scroll the nearest vertical owner only enough to reveal a supplied row.\n * Resolves false when the row is absent from the supplied, expanded table model.\n * This method never moves focus.\n */\n @Method()\n async scrollRowIntoView(rowId: string): Promise<boolean> {\n if (!rowId) return false;\n\n if (this.rowWindowingEnabled) {\n const headerSize = this.tableEl?.tHead?.getBoundingClientRect().height ?? 0;\n const revealed = this.virtualController.scrollRowIntoView(\n rowId,\n headerSize,\n this.stickyHeader ? headerSize : 0\n );\n if (!revealed) return false;\n await this.nextAnimationFrame();\n }\n\n const row = this.findRenderedRow(rowId);\n if (!row) return false;\n this.revealRenderedRow(row);\n return true;\n }\n\n connectedCallback(): void {\n if (!this.hasLoaded) return;\n this.layoutController.connect();\n this.loadController.connect();\n this.groupLoadController.connect();\n this.virtualController.connect();\n this.viewportFitController.connect();\n this.syncStickyGroupConnection();\n this.connectHeaderSlotObserver();\n this.connectFooterSlotObserver();\n this.connectFitObserver();\n this.connectCaptionCompactObserver();\n this.connectTruncateTooltip();\n }\n\n disconnectedCallback(): void {\n this.editingCell = null;\n this.layoutController.disconnect();\n this.loadController.disconnect();\n this.groupLoadController.disconnect();\n this.virtualController.disconnect();\n this.viewportFitController.disconnect();\n this.disconnectStickyGroup();\n this.headerSlotObserver?.disconnect();\n this.headerSlotObserver = null;\n this.footerSlotObserver?.disconnect();\n this.footerSlotObserver = null;\n this.disconnectFitObserver();\n this.disconnectCaptionCompactObserver();\n this.disconnectTruncateTooltip();\n this.closeColumnCustomizer();\n if (this.initialModelWarningFrame !== undefined) {\n cancelAnimationFrame(this.initialModelWarningFrame);\n this.initialModelWarningFrame = undefined;\n }\n }\n\n private syncHeaderSlotPresence = () => {\n const header = this.el.querySelector<HTMLElement>('[slot=\"header\"]');\n this.headerPresent = !!header;\n this.headerUsesToolbar = header?.tagName === 'DS-DATA-TOOLBAR';\n };\n\n private connectHeaderSlotObserver(): void {\n if (this.headerSlotObserver || typeof MutationObserver === 'undefined') return;\n this.headerSlotObserver = new MutationObserver(this.syncHeaderSlotPresence);\n this.headerSlotObserver.observe(this.el, { childList: true });\n this.syncHeaderSlotPresence();\n }\n\n private syncFooterSlotPresence = () => {\n let presence = 0;\n if (hasOwnedTableFooterSlot(this.el, 'footer-leading')) presence |= TABLE_FOOTER_SLOT_LEADING;\n if (hasOwnedTableFooterSlot(this.el, 'footer')) presence |= TABLE_FOOTER_SLOT_COPY;\n if (hasOwnedTableFooterSlot(this.el, 'footer-trailing')) presence |= TABLE_FOOTER_SLOT_TRAILING;\n if (presence !== this.footerSlotPresence) this.footerSlotPresence = presence;\n };\n\n private connectFooterSlotObserver(): void {\n if (this.footerSlotObserver || typeof MutationObserver === 'undefined') return;\n this.footerSlotObserver = new MutationObserver(this.syncFooterSlotPresence);\n this.footerSlotObserver.observe(this.el, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: ['slot'],\n });\n this.syncFooterSlotPresence();\n }\n\n private connectStickyGroup(): void {\n if (this.stickyGroupConnected || typeof window === 'undefined') return;\n this.stickyGroupConnected = true;\n document.addEventListener('scroll', this.updateStickyGroup, {\n capture: true,\n passive: true,\n });\n window.addEventListener('resize', this.updateStickyGroup, { passive: true });\n this.updateStickyGroup();\n }\n\n private syncStickyGroupConnection(): void {\n if (this.documentStickyHeader && this.grouped) {\n this.connectStickyGroup();\n return;\n }\n\n this.disconnectStickyGroup();\n this.frameEl?.style.removeProperty('--_table-sticky-group-top');\n if (this.activeStickyGroupId !== null) {\n this.activeStickyGroupId = null;\n }\n }\n\n private disconnectStickyGroup(): void {\n if (!this.stickyGroupConnected || typeof window === 'undefined') return;\n this.stickyGroupConnected = false;\n document.removeEventListener('scroll', this.updateStickyGroup, true);\n window.removeEventListener('resize', this.updateStickyGroup);\n }\n\n private readonly updateStickyGroup = (): void => {\n if (!this.documentStickyHeader || !this.grouped || !this.frameEl || !this.tableEl) {\n if (this.activeStickyGroupId !== null) this.activeStickyGroupId = null;\n return;\n }\n\n const stickyHeader = this.frameEl.querySelector<HTMLElement>(\n '.ds-table__document-sticky-header'\n );\n if (!stickyHeader) return;\n const threshold = stickyHeader.getBoundingClientRect().bottom;\n let activeGroupId: string | null = null;\n let activeGroupTop = threshold;\n for (const body of this.tableEl.querySelectorAll<HTMLElement>('tbody[data-group-id]')) {\n const source = body.querySelector<HTMLElement>('.ds-table__group-cell');\n if (!source) continue;\n const sourceRect = source.getBoundingClientRect();\n const bodyRect = body.getBoundingClientRect();\n if (sourceRect.top <= threshold && bodyRect.bottom > threshold) {\n activeGroupId = body.dataset.groupId ?? null;\n // A sticky section is bounded by its own row group. As that boundary\n // approaches the sticky lane, move the outgoing section bar with it so\n // the incoming source bar pushes it away instead of rendering beneath it.\n activeGroupTop = Math.min(threshold, bodyRect.bottom - sourceRect.height);\n }\n }\n\n const frameRect = this.frameEl.getBoundingClientRect();\n this.frameEl.style.setProperty(\n '--_table-sticky-group-top',\n `${activeGroupTop - frameRect.top}px`\n );\n\n if (activeGroupId !== this.activeStickyGroupId) {\n this.activeStickyGroupId = activeGroupId;\n return;\n }\n };\n\n @Watch('columns')\n @Watch('grouping')\n @Watch('hiddenFieldIds')\n @Watch('fieldOrder')\n @Watch('columnCustomizer')\n @Watch('cellSpans')\n @Watch('interactionMode')\n @Watch('responsiveLayout')\n handleStructureChange(): void {\n this.virtualItemsCache = null;\n this.scheduleModelIssueWarning();\n this.loadController.structureChanged();\n this.groupLoadController.structureChanged();\n this.virtualController.invalidateMeasures();\n }\n\n @Watch('rows')\n @Watch('groups')\n handleDataChange(): void {\n if (this.grouped) this.groupLoadController.dataChanged();\n else this.loadController.dataChanged();\n this.scheduleModelIssueWarning();\n this.syncActionMenu();\n this.virtualController.invalidateMeasures();\n }\n\n @Watch('totalCount')\n handleTotalCountChange(): void {\n this.scheduleModelIssueWarning();\n }\n\n @Watch('caption')\n handleCaptionChange(): void {\n this.scheduleModelIssueWarning();\n }\n\n @Watch('loadIdentity')\n handleLoadIdentityChange(): void {\n this.incrementalWindowActive = this.hasIncrementalState;\n this.loadController.identityChanged();\n this.virtualController.resetToTop();\n }\n\n @Watch('sort')\n @Watch('grouping')\n handleVirtualIndexReset(): void {\n this.virtualController.resetToTop();\n }\n\n @Watch('loadMoreMode')\n @Watch('loadMoreThreshold')\n handleLazyConfigurationChange(): void {\n this.loadController.configurationChanged();\n this.groupLoadController.configurationChanged();\n }\n\n @Watch('dataMode')\n handleDataModeChange(): void {\n this.incrementalWindowActive = this.hasIncrementalState;\n this.scheduleModelIssueWarning();\n this.handleLazyConfigurationChange();\n this.virtualController.resetToTop();\n }\n\n @Watch('pagination')\n handlePaginationChange(\n pagination: TablePaginationState | null,\n previous: TablePaginationState | null\n ): void {\n if (pagination?.pageSizeMode === 'fit' && pagination.fitIdentity !== previous?.fitIdentity) {\n this.fitMeasurementPending = true;\n }\n }\n\n @Watch('loadingMore')\n handleLoadingMoreChange(loading: boolean): void {\n if (loading) this.incrementalWindowActive = true;\n if (!this.grouped) this.loadController.loadingChanged(loading);\n }\n\n @Watch('loadMoreError')\n handleLoadMoreErrorChange(error: string | undefined): void {\n if (error?.trim()) this.incrementalWindowActive = true;\n if (!this.grouped) this.loadController.errorChanged(error);\n }\n\n @Watch('hasMore')\n handleHasMoreChange(hasMore: boolean, hadMore: boolean): void {\n if (hasMore) this.incrementalWindowActive = true;\n if (!this.grouped) this.loadController.hasMoreChanged(hasMore, hadMore);\n }\n\n @Watch('columnCustomizer')\n @Watch('captionVisibility')\n handleColumnCustomizerAvailability(): void {\n if (!this.showsColumnCustomizer) {\n this.closeColumnCustomizer();\n this.columnCustomizerSurfaceOpen = false;\n }\n }\n\n @Watch('dataModeSwitcher')\n @Watch('captionVisibility')\n handleDataModeSwitcherAvailability(): void {\n if (!this.showsDataModeSwitcher) {\n this.closeDataModeSwitcher();\n this.dataModeSwitcherSurfaceOpen = false;\n }\n }\n\n @Watch('chromeLoading')\n handleChromeLoadingChange(loading: boolean): void {\n if (!loading) return;\n this.closeColumnCustomizer();\n this.closeDataModeSwitcher();\n this.columnCustomizerSurfaceOpen = false;\n this.dataModeSwitcherSurfaceOpen = false;\n }\n\n private get grouped(): boolean {\n return this.grouping !== null;\n }\n\n private get hasIncrementalState(): boolean {\n return (\n this.dataMode === 'infinite' &&\n (this.hasMore || this.loadingMore || !!this.loadMoreError?.trim())\n );\n }\n\n private get selectable(): boolean {\n return this.selectionMode === 'multiple';\n }\n\n private get visibleColumns(): TableColumn[] {\n const cached = this.visibleColumnsCache;\n if (\n cached &&\n cached.columns === this.columns &&\n cached.columnCustomizer === this.columnCustomizer &&\n cached.hiddenFieldIds === this.hiddenFieldIds &&\n cached.fieldOrder === this.fieldOrder\n ) {\n return cached.value;\n }\n const value = resolveTableVisibleColumns(this.columns, {\n columnCustomizer: this.columnCustomizer,\n hiddenFieldIds: this.hiddenFieldIds,\n fieldOrder: this.fieldOrder,\n });\n this.visibleColumnsCache = {\n columns: this.columns,\n columnCustomizer: this.columnCustomizer,\n hiddenFieldIds: this.hiddenFieldIds,\n fieldOrder: this.fieldOrder,\n value,\n };\n return value;\n }\n\n private get showsColumnCustomizer(): boolean {\n return this.columnCustomizer && this.captionVisibility === 'visible';\n }\n\n private get showsDataModeSwitcher(): boolean {\n return this.dataModeSwitcher && this.captionVisibility === 'visible';\n }\n\n private get showsCaptionTrailing(): boolean {\n return (\n this.showsDataModeSwitcher ||\n (this.showsColumnCustomizer && !this.hideColumnCustomizerTrigger)\n );\n }\n\n private get documentStickyHeader(): boolean {\n return this.stickyHeader && !this.containedScroll && this.responsiveLayout !== 'cards';\n }\n\n private get fixedHeight(): boolean {\n return !this.fitViewport && this.resolvedHeight !== undefined;\n }\n\n private get boundedComposition(): boolean {\n return this.fitViewport || this.fixedHeight;\n }\n\n private get containedScroll(): boolean {\n return this.boundedComposition || this.resolvedMaxHeight !== undefined;\n }\n\n private connectFitObserver(): void {\n if (typeof ResizeObserver === 'undefined') return;\n if (this.fitObservedViewport === this.viewportEl && this.fitObservedTable === this.tableEl) {\n return;\n }\n this.disconnectFitObserver();\n this.fitObservedViewport = this.viewportEl;\n this.fitObservedTable = this.tableEl;\n this.fitResizeObserver = new ResizeObserver(this.syncFitPageSize);\n if (this.fitObservedViewport) this.fitResizeObserver.observe(this.fitObservedViewport);\n if (this.fitObservedTable) this.fitResizeObserver.observe(this.fitObservedTable);\n }\n\n private disconnectFitObserver(): void {\n this.fitResizeObserver?.disconnect();\n this.fitResizeObserver = null;\n this.fitObservedViewport = null;\n this.fitObservedTable = null;\n }\n\n private connectCaptionCompactObserver(): void {\n if (this.captionCompactDisconnect) return;\n this.captionCompactDisconnect = observeTableCaptionCompact(this.el, compact => {\n if (this.captionCompact !== compact) this.captionCompact = compact;\n });\n }\n\n private disconnectCaptionCompactObserver(): void {\n this.captionCompactDisconnect?.();\n this.captionCompactDisconnect = undefined;\n }\n\n private readonly syncFitPageSize = (): void => {\n const pagination = this.dataMode === 'pagination' ? this.pagination : null;\n if (\n !pagination?.fitToPage ||\n pagination.fitToPageInactive ||\n !this.containedScroll ||\n !this.viewportEl ||\n !this.tableEl\n ) {\n if (this.fitPageSize !== undefined) this.fitPageSize = undefined;\n this.fitMeasurementPending = false;\n return;\n }\n const header = this.tableEl.querySelector<HTMLElement>('.ds-table__head');\n const item = this.tableEl.querySelector<HTMLElement>(\n this.grouped ? '.ds-table__group-row' : '.ds-table__body .ds-table__row'\n );\n const itemBlockSize =\n item?.getBoundingClientRect().height ||\n resolveCssLengthPx(\n 'var(--ds-table-row-min-block-size, var(--dimension-size-500))',\n 0,\n this.rootEl ?? this.el\n );\n const next = resolveTableFitPageSize({\n viewportBlockSize: this.viewportEl.clientHeight,\n headerBlockSize: header?.getBoundingClientRect().height ?? 0,\n itemBlockSize,\n });\n if (next !== this.fitPageSize) this.fitPageSize = next;\n if (!this.fitMeasurementPending || pagination.pageSizeMode !== 'fit' || next === undefined) {\n return;\n }\n this.fitMeasurementPending = false;\n if (next === pagination.pageSize && pagination.pageIndex === 0) return;\n this.dsPaginationChange.emit({\n pageIndex: 0,\n pageSize: next,\n pageSizeMode: 'fit',\n totalItems: pagination.totalItems,\n pageSizeOptions: pagination.pageSizeOptions ?? [25, 50, 100, 200],\n fitToPage: true,\n fitToPageInactive: false,\n fitPageSize: next,\n fitPageSizeLabel: pagination.fitPageSizeLabel ?? 'Fit to page',\n fitPageSizeTriggerLabel: pagination.fitPageSizeTriggerLabel ?? 'Fit',\n itemLabel: pagination.itemLabel ?? 'items',\n pageSizeLabel: pagination.pageSizeLabel ?? 'Items',\n ariaLabel: pagination.ariaLabel ?? `${this.caption} pagination`,\n showFirstLastButtons: pagination.showFirstLastButtons ?? false,\n previousPageIndex: pagination.pageIndex,\n previousPageSize: pagination.pageSize,\n previousPageSizeMode: 'fit',\n reason: 'fit',\n });\n };\n\n @Listen('keydown', { target: 'window' })\n handleWindowPaginationKeyDown(event: KeyboardEvent): void {\n this.handlePaginationKeyDown(event, true);\n }\n\n private handlePaginationKeyDown(event: KeyboardEvent, fromWindow = false): void {\n const pagination = this.dataMode === 'pagination' ? this.pagination : null;\n if (\n !pagination ||\n this.loading ||\n event.defaultPrevented ||\n event.altKey ||\n event.ctrlKey ||\n event.metaKey ||\n event.shiftKey ||\n (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')\n ) {\n return;\n }\n\n if (paginationShortcutBlockedByPath(event.composedPath())) return;\n\n if (fromWindow) {\n const origin = event.composedPath()[0];\n const originNode = origin instanceof Text ? origin.parentNode : origin;\n if (\n !shouldHandleContainingPagePaginationShortcut({\n origin: originNode,\n table: this.el,\n eventPath: event.composedPath(),\n })\n ) {\n return;\n }\n }\n\n const state = resolvePaginationState(pagination);\n const pageIndex = event.key === 'ArrowLeft' ? state.pageIndex - 1 : state.pageIndex + 1;\n if (pageIndex < 0 || pageIndex >= state.totalPages) return;\n\n event.preventDefault();\n this.dsPaginationChange.emit({\n pageIndex,\n pageSize: state.pageSize,\n pageSizeMode: state.pageSizeMode,\n totalItems: state.totalItems,\n pageSizeOptions: state.pageSizeOptions,\n fitToPage: pagination.fitToPage ?? false,\n fitToPageInactive: pagination.fitToPageInactive ?? false,\n fitPageSize: this.fitPageSize ?? pagination.fitPageSize,\n fitPageSizeLabel: pagination.fitPageSizeLabel ?? 'Fit to page',\n fitPageSizeTriggerLabel: pagination.fitPageSizeTriggerLabel ?? 'Fit',\n itemLabel: pagination.itemLabel ?? 'items',\n pageSizeLabel: pagination.pageSizeLabel ?? 'Items',\n ariaLabel: pagination.ariaLabel ?? `${this.caption} pagination`,\n showFirstLastButtons: pagination.showFirstLastButtons ?? false,\n previousPageIndex: state.pageIndex,\n previousPageSize: state.pageSize,\n previousPageSizeMode: state.pageSizeMode,\n reason: 'page',\n });\n }\n\n private currentLoadedRows(): TableRow[] {\n return tableRows(this.rows, this.groups, this.grouped);\n }\n\n private currentLoadedRowCount(): number {\n return this.grouped\n ? this.groups.reduce((count, group) => count + group.rows.length, 0)\n : this.rows.length;\n }\n\n private createRenderModel(): TableRenderModel {\n const columns = this.visibleColumns;\n const grouped = this.grouped;\n const virtualCounts = this.dataMode === 'virtual';\n const cached = this.renderModelCache;\n if (\n cached &&\n cached.columns === columns &&\n cached.rows === this.rows &&\n cached.groups === this.groups &&\n cached.grouped === grouped &&\n cached.selectionMode === this.selectionMode &&\n cached.selectedRowIds === this.selectedRowIds &&\n cached.collapsedGroupIds === this.collapsedGroupIds &&\n cached.virtualCounts === virtualCounts\n ) {\n return cached.value;\n }\n const value = createTableRenderModel({\n columns,\n rows: this.rows,\n groups: this.groups,\n grouped,\n selectionMode: this.selectionMode,\n selectedRowIds: this.selectedRowIds,\n collapsedGroupIds: this.collapsedGroupIds,\n groupCountPresentation: virtualCounts ? 'total' : 'loaded-progress',\n });\n this.renderModelCache = {\n columns,\n rows: this.rows,\n groups: this.groups,\n grouped,\n selectionMode: this.selectionMode,\n selectedRowIds: this.selectedRowIds,\n collapsedGroupIds: this.collapsedGroupIds,\n virtualCounts,\n value,\n };\n return value;\n }\n\n private get highlightMatcher(): TableHighlightMatcher {\n if (this.highlightMatcherCache?.terms === this.highlightTerms) {\n return this.highlightMatcherCache.value;\n }\n const value = createTableHighlightMatcher(this.highlightTerms);\n this.highlightMatcherCache = { terms: this.highlightTerms, value };\n return value;\n }\n\n private get rowWindowingEnabled(): boolean {\n if (!this.hasVirtualViewportBounds) return false;\n if (this.dataMode === 'virtual') return true;\n return !this.grouped && this.currentLoadedRowCount() > TABLE_NON_VIRTUAL_WINDOW_THRESHOLD;\n }\n\n private get virtualViewportMissing(): boolean {\n return this.dataMode === 'virtual' && !this.hasVirtualViewportBounds;\n }\n\n private get hasVirtualViewportBounds(): boolean {\n return this.containedScroll && this.estimateVirtualViewportSize() > 0;\n }\n\n private estimateVirtualViewportSize(): number {\n const measured =\n this.virtualController.currentViewportSize() || this.viewportEl?.clientHeight || 0;\n const chrome =\n (this.captionVisibility === 'visible' ? 48 : 0) + (this.hasResultFooter ? 48 : 0);\n if (this.fixedHeight && this.resolvedHeight) {\n if (measured > 0) return measured;\n return Math.max(\n 0,\n resolveCssLengthPx(this.resolvedHeight, 0, this.rootEl ?? this.el) - chrome\n );\n }\n if (this.fitViewport) return Math.max(0, measured);\n if (this.resolvedMaxHeight) {\n if (this.resolvedMaxHeight.trim().toLowerCase() === 'none') return 0;\n return resolveCssLengthPx(this.resolvedMaxHeight, 0, this.rootEl ?? this.el);\n }\n return 0;\n }\n\n private virtualPinnedRowIds(): Set<string> {\n const pinned = new Set<string>();\n if (this.focusedRowId) pinned.add(this.focusedRowId);\n if (this.actionMenu?.rowId) pinned.add(this.actionMenu.rowId);\n return pinned;\n }\n\n private findRenderedRow(rowId: string): HTMLElement | null {\n return (\n [...this.el.querySelectorAll<HTMLElement>('[data-row-id]')].find(\n candidate => candidate.dataset.rowId === rowId\n ) ?? null\n );\n }\n\n private revealRenderedRow(row: HTMLElement): void {\n if (!this.containedScroll || !this.viewportEl) {\n row.scrollIntoView({ block: 'nearest', inline: 'nearest' });\n return;\n }\n\n const viewportRect = this.viewportEl.getBoundingClientRect();\n const rowRect = row.getBoundingClientRect();\n let visibleStart = viewportRect.top;\n if (this.stickyHeader) {\n visibleStart = Math.max(\n visibleStart,\n this.tableEl?.tHead?.getBoundingClientRect().bottom ?? visibleStart\n );\n }\n const groupRow = row.closest('tbody')?.querySelector<HTMLElement>('.ds-table__group-row');\n if (groupRow && getComputedStyle(groupRow).position === 'sticky') {\n visibleStart = Math.max(visibleStart, groupRow.getBoundingClientRect().bottom);\n }\n\n if (rowRect.top < visibleStart) {\n this.viewportEl.scrollTop += rowRect.top - visibleStart;\n } else if (rowRect.bottom > viewportRect.bottom) {\n this.viewportEl.scrollTop += rowRect.bottom - viewportRect.bottom;\n }\n }\n\n private nextAnimationFrame(): Promise<void> {\n return new Promise(resolve => requestAnimationFrame(() => resolve()));\n }\n\n private syncVirtualItems(model: TableRenderModel): void {\n if (!this.rowWindowingEnabled) {\n this.virtualItems = [];\n this.virtualItemsCache = null;\n this.bodyRenderer.resetVirtualState();\n return;\n }\n const columns = this.visibleColumns;\n const cached = this.virtualItemsCache;\n if (\n cached &&\n cached.columns === columns &&\n cached.rows === this.rows &&\n cached.groups === this.groups &&\n cached.grouped === model.grouped &&\n cached.collapsedGroupIds === this.collapsedGroupIds\n ) {\n return;\n }\n this.virtualItems = flattenTableVirtualItems({\n grouped: model.grouped,\n rows: this.rows,\n groups: this.groups,\n collapsedGroupIds: this.collapsedGroupIds,\n columns,\n });\n if (this.responsiveLayout === 'cards') {\n this.virtualItems = this.virtualItems.map(item => ({ ...item, variableSize: true }));\n }\n this.virtualItemsCache = {\n columns,\n rows: this.rows,\n groups: this.groups,\n grouped: model.grouped,\n collapsedGroupIds: this.collapsedGroupIds,\n };\n }\n\n private shouldWindowRows(model: TableRenderModel): boolean {\n return (\n this.rowWindowingEnabled && (model.hasData || (model.grouped && model.groups.length > 0))\n );\n }\n\n private get resolvedMaxHeight(): string | undefined {\n if (this.maxHeight == null || this.maxHeight === '') return undefined;\n return typeof this.maxHeight === 'number' ? `${Math.max(0, this.maxHeight)}px` : this.maxHeight;\n }\n\n private get resolvedHeight(): string | undefined {\n if (this.height == null || this.height === '') return undefined;\n return typeof this.height === 'number' ? `${Math.max(0, this.height)}px` : this.height;\n }\n\n private warnModelIssues(): void {\n const issues = tableModelIssues(this.columns, this.rows, this.groups, this.grouped);\n const visibleColumns = this.visibleColumns;\n if (!this.caption?.trim()) issues.unshift('A non-empty caption is required.');\n if (this.grouping) {\n const groupingColumn = this.columns.find(column => column.id === this.grouping!.fieldId);\n if (!groupingColumn) {\n issues.push(`Grouping references unknown column id: ${this.grouping.fieldId}`);\n } else if (!visibleColumns.some(column => column.id === groupingColumn.id)) {\n issues.push(`Grouping references hidden column id: ${this.grouping.fieldId}`);\n }\n }\n if (this.sort) {\n const sortColumn = this.columns.find(\n column =>\n column.id === this.sort!.fieldId ||\n column.segments?.some(segment => segment.sortKey === this.sort!.fieldId)\n );\n if (!sortColumn) {\n issues.push(`Sorting references unknown column id: ${this.sort.fieldId}`);\n } else if (!visibleColumns.some(column => column.id === sortColumn.id)) {\n issues.push(`Sorting references hidden column id: ${this.sort.fieldId}`);\n }\n }\n if (\n this.dataMode === 'virtual' &&\n Number.isFinite(this.totalCount) &&\n Math.max(0, Math.trunc(this.totalCount!)) !== this.currentLoadedRowCount()\n ) {\n issues.push('Virtual mode requires totalCount to match the complete supplied row count.');\n }\n const stickyStart = visibleColumns.filter(column => column.sticky === 'start');\n const stickyEnd = visibleColumns.filter(column => column.sticky === 'end');\n issues.push(...this.structure.merges.issues);\n if (this.interactionMode === 'grid' && this.grouped)\n issues.push('Grid interaction requires ungrouped rows.');\n for (const column of [...stickyStart, ...stickyEnd]) {\n if (!tableColumnSize(column))\n issues.push(`Sticky column ${column.id} requires an explicit size.`);\n }\n const message = issues.join(' ');\n if (!message) {\n this.previousModelWarning = '';\n return;\n }\n if (message === this.previousModelWarning) return;\n this.previousModelWarning = message;\n console.warn(`[ds-table] ${message}`);\n }\n\n private scheduleModelIssueWarning(): void {\n if (this.modelWarningQueued) return;\n this.modelWarningQueued = true;\n queueMicrotask(() => {\n this.modelWarningQueued = false;\n this.warnModelIssues();\n });\n }\n\n private scheduleInitialModelIssueWarning(): void {\n if (this.initialModelWarningFrame !== undefined) {\n cancelAnimationFrame(this.initialModelWarningFrame);\n }\n this.initialModelWarningFrame = requestAnimationFrame(() => {\n this.initialModelWarningFrame = undefined;\n this.warnModelIssues();\n });\n }\n\n private emitSort(column: TableColumn, sortKey = column.id): void {\n if (!column.sortable) return;\n this.dsSortChange.emit({ sort: nextDataSortState(this.sort, sortKey) });\n }\n\n private sortButtonLabel(column: TableColumn, sortKey = column.id, label = column.label): string {\n if (this.sort?.fieldId !== sortKey) return `Sort ${label} ascending`;\n if (this.sort.direction === 'asc') return `Sort ${label} descending. Currently ascending.`;\n return `Sort ${label} ascending. Currently descending.`;\n }\n\n private emitRowSelection(row: TableRow): void {\n const next = toggleTableRowSelection(this.selectedRowIds, row);\n const selected = next.includes(row.id);\n this.dsSelectionChange.emit({\n selectedRowIds: next,\n scope: 'row',\n changedRowId: row.id,\n selected,\n });\n }\n\n private emitAllSelection(): void {\n const loadedRows = this.currentLoadedRows();\n const selection = deriveTableSelectionState(loadedRows, this.selectedRowIds);\n this.dsSelectionChange.emit({\n selectedRowIds: toggleAllLoadedTableRows(this.selectedRowIds, loadedRows),\n scope: 'all-loaded',\n selected: !selection.allSelected,\n });\n }\n\n private emitGroupSelection(group: DataGroup): void {\n const state = deriveTableSelectionState(group.rows, this.selectedRowIds);\n this.dsSelectionChange.emit({\n selectedRowIds: toggleTableGroupSelection(this.selectedRowIds, group.rows),\n scope: 'group',\n groupId: group.id,\n selected: !state.allSelected,\n });\n }\n\n private rowEventOwnsActivation(event: Event): boolean {\n const currentTarget = event.currentTarget;\n return !event.composedPath().some(target => {\n if (!(target instanceof HTMLElement) || target === currentTarget) return false;\n return target.matches(\n 'button, a, input, select, textarea, [role=\"button\"], [role=\"checkbox\"], [popover], ds-button-unfilled, ds-menu'\n );\n });\n }\n\n private groupRowEventOwnsCollapse(event: Event): boolean {\n const currentTarget = event.currentTarget;\n return !event.composedPath().some(target => {\n if (!(target instanceof HTMLElement) || target === currentTarget) return false;\n return target.matches(\n 'input, select, textarea, [role=\"checkbox\"], .ds-table__selection-control, .ds-table__group-selection, ds-tooltip'\n );\n });\n }\n\n private handleGroupRowClick(group: DataGroup, event: Event): void {\n if (!this.groupRowEventOwnsCollapse(event)) return;\n this.emitGroupCollapse(group);\n }\n\n private emitRowActivation(row: TableRow, event: Event): void {\n if (this.gridEnabled || row.disabled || !this.rowEventOwnsActivation(event)) return;\n if (this.selectionMode === 'multiple' && this.selectedRowIds.length > 0) {\n if (row.selectable !== false) this.emitRowSelection(row);\n return;\n }\n if (row.interactive) this.dsRowActivate.emit({ rowId: row.id });\n }\n\n private syncActionMenu(): void {\n if (!this.actionMenu) return;\n const row = tableRows(this.rows, this.groups, this.grouped).find(\n candidate => candidate.id === this.actionMenu?.rowId\n );\n const value = row && !row.disabled ? row.cells[this.actionMenu.columnId] : undefined;\n if (!isTableCellAction(value) || !isRenderableTableActionMenu(value)) {\n this.actionMenu = null;\n }\n }\n\n private closeActionMenu(): void {\n this.actionMenu = null;\n }\n\n private handleActionMenuSelect(item: MenuItemData): void {\n const open = this.actionMenu;\n const actionId = item.value;\n if (!open || !actionId || item.isInactive) return;\n this.dsCellAction.emit({\n actionId,\n rowId: open.rowId,\n columnId: open.columnId,\n });\n const triggerId = tableActionTriggerId(this.actionMenuElementId, open.rowId, open.columnId);\n this.closeActionMenu();\n requestAnimationFrame(() => {\n const trigger = this.el.querySelector<HTMLElement & { setFocus?: () => void }>(\n `#${CSS.escape(triggerId)}`\n );\n trigger?.setFocus?.();\n });\n }\n\n private toggleActionMenu(row: TableRow, column: TableColumn, event: MouseEvent): void {\n if (row.disabled) return;\n const open = this.actionMenu?.rowId === row.id && this.actionMenu?.columnId === column.id;\n this.actionMenuInitialFocusVisible = event.detail === 0;\n this.actionMenu = open ? null : { rowId: row.id, columnId: column.id };\n if (this.actionMenu) this.actionMenuSurface = this.actionMenu;\n }\n\n private renderOverflowActionMenu() {\n const open = this.actionMenu ?? this.actionMenuSurface;\n const row = open\n ? tableRows(this.rows, this.groups, this.grouped).find(\n candidate => candidate.id === open.rowId\n )\n : undefined;\n const value = row && open ? row.cells[open.columnId] : undefined;\n const menu = isTableCellAction(value) && isRenderableTableActionMenu(value) ? value : undefined;\n const sections = menu ? tableActionMenuSections(menu.items) : [];\n const triggerId = open\n ? tableActionTriggerId(this.actionMenuElementId, open.rowId, open.columnId)\n : undefined;\n\n return (\n <ds-menu\n id={this.actionMenuElementId}\n open={Boolean(this.actionMenu && sections.length)}\n anchorId={triggerId}\n align=\"end\"\n side=\"bottom\"\n menuLabel={menu?.ariaLabel ?? 'More actions'}\n initialFocusVisible={this.actionMenuInitialFocusVisible}\n sections={sections}\n onDsClose={() => this.closeActionMenu()}\n onDsAfterClose={() => {\n if (!this.actionMenu) this.actionMenuSurface = null;\n }}\n onDsSelect={event => this.handleActionMenuSelect(event.detail)}\n />\n );\n }\n\n private connectTruncateTooltip(): void {\n if (this.truncateTooltipBound) return;\n this.el.addEventListener('pointerover', this.onTruncatePointerOver);\n this.el.addEventListener('pointerout', this.onTruncatePointerOut);\n this.el.addEventListener('focusin', this.onTruncateFocusIn);\n this.el.addEventListener('focusout', this.onTruncateFocusOut);\n this.truncateTooltipBound = true;\n }\n\n private disconnectTruncateTooltip(): void {\n if (!this.truncateTooltipBound) return;\n this.el.removeEventListener('pointerover', this.onTruncatePointerOver);\n this.el.removeEventListener('pointerout', this.onTruncatePointerOut);\n this.el.removeEventListener('focusin', this.onTruncateFocusIn);\n this.el.removeEventListener('focusout', this.onTruncateFocusOut);\n this.truncateTooltipBound = false;\n this.dismissTruncateTooltip();\n }\n\n private onTruncatePointerOver = (event: PointerEvent) => {\n if (event.pointerType === 'touch') return;\n const track = resolveTableTruncateTrack(event.target);\n if (!track) return;\n this.presentOrDismissTruncateTrack(track);\n };\n\n private onTruncatePointerOut = (event: PointerEvent) => {\n if (event.pointerType === 'touch') return;\n if (resolveTableTruncateTrack(event.relatedTarget)) return;\n const next = event.relatedTarget;\n const cell = this.truncateAnchor?.closest('.ds-table__cell');\n if (cell && next instanceof Node && cell.contains(next)) return;\n this.dismissTruncateTooltip();\n };\n\n private onTruncateFocusIn = (event: FocusEvent) => {\n const track = resolveTableTruncateTrack(event.target);\n if (!track) {\n this.dismissTruncateTooltip();\n return;\n }\n this.presentOrDismissTruncateTrack(track);\n };\n\n private onTruncateFocusOut = (event: FocusEvent) => {\n if (resolveTableTruncateTrack(event.relatedTarget)) return;\n this.dismissTruncateTooltip();\n };\n\n private presentOrDismissTruncateTrack(track: HTMLElement): void {\n if (!isElementTruncated(track)) {\n this.dismissTruncateTooltip();\n return;\n }\n const label = tableTruncateLabel(track);\n if (!label) {\n this.dismissTruncateTooltip();\n return;\n }\n this.truncateAnchor = track;\n this.truncateTooltipLabel = label;\n void this.truncateTooltipEl?.presentFrom(track, label);\n }\n\n private dismissTruncateTooltip(): void {\n this.truncateAnchor = null;\n if (!this.truncateTooltipLabel && !this.truncateTooltipEl) return;\n this.truncateTooltipLabel = '';\n void this.truncateTooltipEl?.dismiss();\n }\n\n private renderTruncateTooltip() {\n return (\n <ds-tooltip\n ref={element => {\n this.truncateTooltipEl = element;\n }}\n label={this.truncateTooltipLabel}\n describedBy={false}\n wrapLabel={true}\n size=\"sm\"\n delay={0}\n side=\"top\"\n />\n );\n }\n\n private handleRowKeydown(row: TableRow, event: KeyboardEvent): void {\n if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' '))\n return;\n event.preventDefault();\n this.emitRowActivation(row, event);\n }\n\n private onVirtualFocusIn = (event: FocusEvent): void => {\n const target = event.target;\n if (!(target instanceof HTMLElement)) return;\n const row = target.closest('[data-row-id]');\n const id = row?.getAttribute('data-row-id') ?? null;\n if (id === this.focusedRowId) return;\n this.focusedRowId = id;\n if (this.rowWindowingEnabled) this.virtualController.schedule();\n };\n\n private renderSelectionControl(\n label: string,\n checked: boolean,\n indeterminate: boolean,\n disabled: boolean,\n onActivate: () => void\n ) {\n return (\n <button\n class=\"ds-table__selection-control ds-focus-ring ds-interaction-fill__content\"\n type=\"button\"\n role=\"checkbox\"\n aria-label={label}\n aria-checked={indeterminate ? 'mixed' : String(checked)}\n disabled={disabled}\n onClick={onActivate}\n >\n <ds-checkbox\n label=\"\"\n size=\"md\"\n checked={checked}\n indeterminate={indeterminate}\n presentation={true}\n />\n </button>\n );\n }\n\n private renderStickyEdge(sticky: TableColumn['sticky'], shadow = true) {\n if (!sticky) return null;\n return (\n <span\n class={{\n 'ds-table__sticky-edge': true,\n [`ds-table__sticky-edge--${sticky}`]: true,\n 'ds-table__sticky-edge--internal': !shadow,\n }}\n aria-hidden=\"true\"\n />\n );\n }\n\n private renderCollapseAllButton() {\n return (\n <ds-button-unfilled\n class=\"ds-table__collapse-all\"\n variant=\"icon\"\n icon=\"ChevronDownUp\"\n size=\"xs\"\n style={{ width: 'var(--dimension-size-300)' }}\n aria-label=\"Collapse all groups\"\n hasBorder={false}\n activeFill={false}\n pressScale={false}\n onDsClick={event => {\n event.stopPropagation();\n this.emitAllGroupsCollapse();\n }}\n />\n );\n }\n\n private renderFloatingCollapseAll(model: TableRenderModel) {\n if (model.collapseAllHost?.mode !== 'floating') return null;\n return (\n <span\n class=\"ds-table__collapse-all-overlay\"\n ref={element => {\n this.collapseAllOverlayEl = element ?? null;\n }}\n >\n <span class=\"ds-table__collapse-all-surface ds-control-elevation ds-control-elevation--md\">\n {this.renderCollapseAllButton()}\n </span>\n </span>\n );\n }\n\n private renderColumnHeader(\n column: TableColumn,\n model: TableRenderModel,\n interactive = true,\n presentational = false,\n headerLane?: { start: number; spanning: boolean }\n ) {\n const groupedColumn = this.grouping?.fieldId === column.id;\n const previousColumn = headerLane\n ? this.visibleColumns[this.visibleColumns.indexOf(column) - 1]\n : undefined;\n const segments = column.segments?.length\n ? column.segments\n : [{ label: column.label, sortKey: column.id }];\n const activeMemberSegment = segments.find(segment => segment.sortKey === this.sort?.fieldId);\n const activeMemberSort = !!activeMemberSegment;\n const activeSort = activeMemberSort;\n const align = column.align ?? 'start';\n const direction = activeMemberSort ? this.sort!.direction : undefined;\n const memberAriaSort = activeMemberSort\n ? this.sort!.direction === 'asc'\n ? 'ascending'\n : 'descending'\n : undefined;\n\n const help = column.help?.trim();\n const labels = (\n <span\n class=\"ds-table__header-labels\"\n tabIndex={interactive && help && !column.sortable ? 0 : undefined}\n data-header-help={help ? '' : undefined}\n >\n {segments.map((segment, index) => {\n const segmentActive = activeMemberSegment?.sortKey === segment.sortKey;\n const segmentInteractive = interactive && !!column.sortable;\n const label = (\n <ds-text\n class=\"ds-table__header-label-box ds-control-label-box\"\n as=\"span\"\n variant=\"text-caption\"\n emphasis={segmentActive}\n color=\"inherit\"\n decoration={help ? 'dotted-underline' : undefined}\n lineTruncation={1}\n >\n {segment.label}\n </ds-text>\n );\n const control = segmentInteractive ? (\n <button\n class=\"ds-table__header-label ds-table__header-label--interactive ds-focus-ring\"\n type=\"button\"\n aria-label={this.sortButtonLabel(\n column,\n segment.sortKey,\n segment.dataLabel?.trim() || segment.label\n )}\n data-sort-control=\"label\"\n data-sort-key={segment.sortKey}\n data-sort-active={segmentActive ? 'true' : undefined}\n onClick={() => this.emitSort(column, segment.sortKey)}\n >\n {label}\n </button>\n ) : (\n <span class=\"ds-table__header-label ds-table__header-static\">{label}</span>\n );\n\n return (\n <span class=\"ds-table__header-segment\" key={segment.sortKey}>\n {control}\n {index < segments.length - 1 && (\n <ds-text\n class=\"ds-table__header-separator\"\n as=\"span\"\n variant=\"text-caption\"\n color=\"tertiary\"\n aria-hidden=\"true\"\n >\n {segment.separator ?? '/'}\n </ds-text>\n )}\n </span>\n );\n })}\n </span>\n );\n const labelControl =\n interactive && help ? (\n <ds-tooltip label={help} side=\"top\" size=\"sm\" wrapLabel={true}>\n {labels}\n </ds-tooltip>\n ) : (\n labels\n );\n const sortControl = interactive ? (\n <span class=\"ds-table__sort-slot\">\n {activeSort && (\n <ds-button-unfilled\n class=\"ds-table__sort-direction\"\n variant=\"icon\"\n icon={direction === 'asc' ? 'ArrowUp' : 'ArrowDown'}\n size=\"xs\"\n aria-label={this.sortButtonLabel(\n column,\n activeMemberSegment?.sortKey ?? column.id,\n activeMemberSegment?.label ?? column.label\n )}\n hasBorder={false}\n activeFill={false}\n isActive={true}\n pressScale={false}\n data-sort-control=\"direction\"\n onDsClick={() => this.emitSort(column, activeMemberSegment?.sortKey ?? column.id)}\n />\n )}\n </span>\n ) : null;\n const collapseHost = model.collapseAllHost;\n const actionCollapseHost =\n collapseHost?.mode === 'action' && collapseHost.columnId === column.id;\n const blankActionCollapseHost =\n actionCollapseHost && !column.label.trim() && !column.segments?.length;\n const collapseControl =\n interactive && actionCollapseHost ? (\n <span class=\"ds-table__collapse-slot\">{this.renderCollapseAllButton()}</span>\n ) : null;\n\n return (\n <th\n key={column.id}\n id={presentational ? undefined : this.headerId(column.id)}\n headers={presentational ? undefined : this.columnBandId(column.id)}\n rowSpan={headerLane?.spanning ? 2 : undefined}\n style={{\n ...this.structure.pins.get(column.id)?.style,\n ...(headerLane\n ? {\n gridColumn: String(headerLane.start),\n gridRow: headerLane.spanning ? '1 / span 2' : '2',\n }\n : {}),\n }}\n class={{\n 'ds-table__header-cell': true,\n 'ds-table__header-cell--rowspan': !!headerLane?.spanning,\n 'ds-table__header-cell--after-rowspan':\n !!headerLane &&\n !headerLane.spanning &&\n !!previousColumn &&\n !previousColumn.group &&\n !this.structure.pins.has(previousColumn.id),\n [`ds-table__cell--align-${align}`]: true,\n 'ds-table__cell--sticky-start':\n column.sticky === 'start' && this.structure.pins.has(column.id),\n 'ds-table__cell--sticky-end':\n column.sticky === 'end' && this.structure.pins.has(column.id),\n 'ds-table__header-cell--collapse-all': actionCollapseHost,\n }}\n scope={presentational ? undefined : 'col'}\n aria-sort={presentational ? undefined : memberAriaSort}\n data-column-id={column.id}\n data-grouped={groupedColumn ? 'true' : undefined}\n data-sort-active={activeSort ? 'true' : undefined}\n >\n {!column.label.trim() && column.accessibleLabel?.trim() && (\n <span class=\"ds-visually-hidden\">{column.accessibleLabel}</span>\n )}\n {blankActionCollapseHost ? (\n <span class=\"ds-table__header-content ds-table__header-content--collapse-all\">\n {collapseControl}\n </span>\n ) : (\n <span class=\"ds-table__header-content\">\n {align === 'end' && sortControl}\n {align === 'center' && (\n <span\n class=\"ds-table__sort-slot ds-table__sort-slot--balance\"\n data-sort-balance=\"true\"\n aria-hidden=\"true\"\n />\n )}\n {labelControl}\n {align !== 'end' && sortControl}\n {collapseControl}\n </span>\n )}\n {this.renderStickyEdge(\n this.structure.pins.has(column.id) ? column.sticky : undefined,\n !!this.structure.pins.get(column.id)?.edge\n )}\n </th>\n );\n }\n\n private renderColgroup(model: TableRenderModel) {\n const beforeSpacer =\n model.elasticSpacerIndex == null\n ? this.visibleColumns\n : this.visibleColumns.slice(0, model.elasticSpacerIndex);\n const afterSpacer =\n model.elasticSpacerIndex == null ? [] : this.visibleColumns.slice(model.elasticSpacerIndex);\n const renderColumn = (column: TableColumn) => {\n const width = tableColumnSize(column);\n return (\n <col\n key={column.id}\n class={{ 'ds-table__action-column': column.kind === 'action' }}\n style={width ? { width } : undefined}\n />\n );\n };\n return (\n <colgroup>\n {model.selectable && <col class=\"ds-table__selection-column\" />}\n {beforeSpacer.map(renderColumn)}\n {model.elasticSpacerIndex != null && <col class=\"ds-table__elastic-spacer-column\" />}\n {afterSpacer.map(renderColumn)}\n </colgroup>\n );\n }\n\n private renderHeader(\n model: TableRenderModel,\n interactive = true,\n presentational = false,\n ariaRowIndex?: number\n ) {\n const selection = model.selection;\n const bands = this.structure.bands;\n const multirow = bands.length > 0;\n const controls =\n interactive &&\n !(\n this.responsiveLayout === 'cards' &&\n this.narrowViewport &&\n this.interactionMode !== 'grid' &&\n !this.cellSpans.length\n );\n const selectionHeader = model.selectable && (\n <th\n class={{\n 'ds-table__header-cell': true,\n 'ds-table__selection-cell': true,\n 'ds-table__cell--sticky-start': true,\n 'ds-table__header-cell--rowspan': multirow,\n }}\n rowSpan={multirow ? 2 : undefined}\n style={multirow ? { gridColumn: '1', gridRow: '1 / span 2' } : undefined}\n scope={presentational ? undefined : 'col'}\n >\n {controls ? (\n this.renderSelectionControl(\n selection.allSelected ? 'Deselect all loaded rows' : 'Select all loaded rows',\n selection.allSelected,\n selection.indeterminate,\n selection.selectableRowIds.length === 0,\n () => this.emitAllSelection()\n )\n ) : (\n <span class=\"ds-visually-hidden\">Select rows</span>\n )}\n {this.renderStickyEdge(\n 'start',\n ![...this.structure.pins.values()].some(pin => pin.edge === 'start')\n )}\n </th>\n );\n const lanes: (TableColumn | undefined)[] = [...this.visibleColumns];\n if (model.elasticSpacerIndex != null) lanes.splice(model.elasticSpacerIndex, 0, undefined);\n const renderLane = (column: TableColumn | undefined, index: number) => {\n const start = index + 1 + Number(model.selectable);\n if (column)\n return this.renderColumnHeader(\n column,\n model,\n controls,\n presentational,\n multirow ? { start, spanning: !column.group } : undefined\n );\n return (\n <th\n class=\"ds-table__header-cell ds-table__elastic-spacer-cell\"\n rowSpan={multirow ? 2 : undefined}\n style={multirow ? { gridColumn: String(start), gridRow: '1 / span 2' } : undefined}\n aria-hidden=\"true\"\n role=\"presentation\"\n data-elastic-spacer=\"true\"\n />\n );\n };\n return (\n <thead\n class={{\n 'ds-table__head': true,\n 'ds-table__head--multirow': multirow,\n 'ds-table__head--semantic-copy': !interactive,\n }}\n ref={element => {\n if (interactive) this.interactiveHeadEl = element ?? null;\n }}\n >\n {multirow && (\n <tr class=\"ds-table__header-row ds-table__super-header-row\" aria-rowindex={ariaRowIndex}>\n {selectionHeader}\n {bands.map(band =>\n !band.label ? (\n lanes.map((column, index) => {\n const start = index + 1 + Number(model.selectable);\n return start >= band.start && start < band.start + band.span\n ? renderLane(column, index)\n : null;\n })\n ) : (\n <th\n key={band.key}\n id={presentational ? undefined : `${this.actionMenuElementId}-${band.key}`}\n class={{\n 'ds-table__header-cell': true,\n 'ds-table__super-header-cell': true,\n 'ds-table__cell--sticky-start': band.sticky === 'start',\n 'ds-table__cell--sticky-end': band.sticky === 'end',\n }}\n scope={presentational || !band.label ? undefined : 'colgroup'}\n colSpan={band.span}\n style={{\n gridColumn: `${band.start} / span ${band.span}`,\n gridRow: '1',\n ...this.structure.pins.get(\n (band.sticky === 'end' ? band.lastColumnId : band.firstColumnId) ?? ''\n )?.style,\n }}\n aria-hidden={!band.label ? 'true' : undefined}\n >\n <span class=\"ds-table__header-content\">\n <span class=\"ds-table__header-labels\">\n <span class=\"ds-table__header-segment\">\n {/* eslint-disable-next-line compomo/prefer-direct-ds-text -- Match the normal header's structural label canvas and inner text inset. */}\n <span class=\"ds-table__header-label ds-table__header-static\">\n <ds-text\n class=\"ds-table__header-label-box ds-control-label-box\"\n as=\"span\"\n variant=\"text-caption\"\n color=\"inherit\"\n lineTruncation={1}\n >\n {band.label}\n </ds-text>\n </span>\n </span>\n </span>\n </span>\n {this.renderStickyEdge(\n band.sticky,\n !!this.structure.pins.get(\n (band.sticky === 'end' ? band.firstColumnId : band.lastColumnId) ?? ''\n )?.edge\n )}\n </th>\n )\n )}\n </tr>\n )}\n <tr\n class=\"ds-table__header-row\"\n aria-rowindex={\n ariaRowIndex == null\n ? undefined\n : ariaRowIndex + Number(this.structure.bands.length > 0)\n }\n >\n {!multirow && selectionHeader}\n {lanes.map((column, index) => (!multirow || column?.group) && renderLane(column, index))}\n </tr>\n </thead>\n );\n }\n\n private renderRow(\n row: TableRow,\n model: TableRenderModel,\n ariaRowIndex?: number,\n variableVirtualSize = false,\n rowKey = row.id\n ) {\n return renderTableRowView({\n grid: this.gridEnabled,\n cellAttributes: this.editingEnabled\n ? (targetRow, column) => this.gridCellAttributes(targetRow, column)\n : undefined,\n renderEditor: this.editingEnabled\n ? (targetRow, column) => this.renderCellEditor(targetRow, column)\n : undefined,\n renderEditTrigger: this.editingEnabled\n ? (targetRow, column) => this.renderCellEditTrigger(targetRow, column)\n : undefined,\n pins: this.structure.pins,\n spans: this.structure.merges.cells.get(row.id),\n headerId: id => this.headerId(id),\n responsiveCards: this.responsiveLayout === 'cards',\n row,\n model,\n visibleColumns: this.visibleColumns,\n emptyCellLabel: this.emptyCellLabel,\n actionMenuElementId: this.actionMenuElementId,\n actionMenu: this.actionMenu,\n actionMenuSurface: this.actionMenuSurface,\n surfaceOpenRowId: this.surfaceOpenRowId,\n highlightMatcher: this.highlightMatcher,\n highlightFieldIds: this.highlightFieldIds,\n ariaRowIndex:\n ariaRowIndex == null ? undefined : ariaRowIndex + Number(this.structure.bands.length > 0),\n variableVirtualSize,\n intrinsicBlockSize:\n this.dataMode !== 'virtual'\n ? estimateTableRowBlockSize(row, this.visibleColumns)\n : undefined,\n rowKey,\n renderSelectionControl: (label, checked, indeterminate, disabled, onActivate) =>\n this.renderSelectionControl(label, checked, indeterminate, disabled, onActivate),\n renderStickyEdge: (sticky, shadow) => this.renderStickyEdge(sticky, shadow),\n onRowActivate: (targetRow, event) => this.emitRowActivation(targetRow, event),\n onRowKeyDown: (targetRow, event) => this.handleRowKeydown(targetRow, event),\n onRowSelection: targetRow => this.emitRowSelection(targetRow),\n onCellAction: detail => this.dsCellAction.emit(detail),\n onActionMenuToggle: (targetRow, column, event) =>\n this.toggleActionMenu(targetRow, column, event),\n });\n }\n\n private emitGroupCollapse(group: DataGroup) {\n const collapsedGroupIds = toggleTableGroupCollapsed(this.collapsedGroupIds, group.id);\n this.dsGroupCollapseChange.emit({\n scope: 'group',\n groupId: group.id,\n collapsed: collapsedGroupIds.includes(group.id),\n collapsedGroupIds,\n });\n }\n\n private emitAllGroupsCollapse() {\n const groupIds = this.groups.map(group => group.id);\n const collapsedGroupIds = nextTableGroupsCollapsed(this.collapsedGroupIds, groupIds);\n this.dsGroupCollapseChange.emit({\n scope: 'all',\n collapsed: collapsedGroupIds.length > 0,\n collapsedGroupIds,\n });\n }\n\n private renderGroupAccessory(\n item: TableRenderModel['groups'][number]['accessories'][number],\n index: number\n ) {\n const text = (\n <ds-text\n key={`acc-${index}`}\n class=\"ds-table__group-accessory\"\n as=\"span\"\n variant=\"text-body-small\"\n color=\"secondary\"\n decoration={item.help ? 'dotted-underline' : undefined}\n lineTruncation={1}\n >\n {item.text}\n </ds-text>\n );\n if (!item.help) return text;\n return (\n <ds-tooltip\n key={`acc-${index}`}\n label={item.help}\n side=\"bottom\"\n align=\"start\"\n size=\"sm\"\n wrapLabel={true}\n >\n <span class=\"ds-table__group-accessory-tip\" tabIndex={0}>\n {text}\n </span>\n </ds-tooltip>\n );\n }\n\n private renderGroupContent(groupModel: TableRenderModel['groups'][number]) {\n const {\n group,\n countLabel,\n collapsed: isCollapsed,\n labelColor,\n selection: groupSelection,\n accessories,\n hero,\n } = groupModel;\n const heroTracks = hero?.kind === 'score' && hero.tracks === 2 ? 2 : 1;\n const heroPresentation = TABLE_GROUP_HERO_SCORE_PRESENTATION[heroTracks];\n return (\n <span\n class={{\n 'ds-table__group-content': true,\n 'ds-table__group-content--multi': accessories.length > 0 || heroTracks === 2,\n 'ds-table__group-content--hero': Boolean(hero),\n 'ds-table__group-content--hero-two-track': Boolean(hero) && heroTracks === 2,\n }}\n onClick={event => this.handleGroupRowClick(group, event)}\n >\n {groupSelection && (\n <span class=\"ds-table__group-selection\">\n {this.renderSelectionControl(\n groupSelection.allSelected\n ? `Deselect loaded rows in ${group.label} group`\n : `Select loaded rows in ${group.label} group`,\n groupSelection.allSelected,\n groupSelection.indeterminate,\n groupSelection.selectableRowIds.length === 0,\n () => this.emitGroupSelection(group)\n )}\n </span>\n )}\n {hero?.kind === 'score' && (\n <span class=\"ds-table__group-hero\">\n <ds-score\n size={heroPresentation.size}\n variant={heroPresentation.variant}\n value={hero.value}\n level={hero.level}\n label={hero.label ?? 'Safety score'}\n isLoading={hero.isLoading === true}\n />\n </span>\n )}\n <span class=\"ds-table__group-copy\">\n <span class=\"ds-table__group-primary\">\n <ds-text\n class=\"ds-table__group-label\"\n as=\"span\"\n variant=\"text-body-medium\"\n emphasis={true}\n color={labelColor}\n >\n {group.label}\n </ds-text>\n <span class=\"ds-visually-hidden\">{countLabel}</span>\n </span>\n {accessories.length > 0 && (\n <span class=\"ds-table__group-accessories\">\n {accessories.flatMap((item, index) => [\n ...(index > 0\n ? [\n <ds-text\n key={`sep-${index}`}\n class=\"ds-table__group-accessory-separator\"\n as=\"span\"\n variant=\"text-body-small\"\n color=\"secondary\"\n aria-hidden=\"true\"\n >\n ·\n </ds-text>,\n ]\n : []),\n this.renderGroupAccessory(item, index),\n ])}\n </span>\n )}\n </span>\n <span class=\"ds-table__group-trailing\">\n <ds-text\n class=\"ds-table__group-count\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n aria-hidden=\"true\"\n >\n {groupModel.visibleCountText}\n </ds-text>\n <span class=\"ds-table__group-action\">\n <ds-button-unfilled\n class=\"ds-table__group-toggle\"\n variant=\"icon\"\n size=\"md\"\n isInset={true}\n insetDepth=\"double\"\n icon={isCollapsed ? 'ChevronDown' : 'ChevronUp'}\n expanded={!isCollapsed}\n aria-label={\n isCollapsed ? `Expand ${group.label} group` : `Collapse ${group.label} group`\n }\n hasBorder={false}\n pressScale={false}\n />\n </span>\n </span>\n </span>\n );\n }\n\n private formatGroupLoadLabel(template: string, group: DataGroup): string {\n return template.split('{group}').join(group.label);\n }\n\n private renderGroupLoadRow(group: DataGroup, totalColumns: number) {\n const error = group.loadMoreError?.trim();\n if (!error && !group.loadingMore && !group.hasMore) return null;\n const manualFallback =\n this.loadMoreMode === 'manual' || !this.groupLoadController.intersectionSupported;\n\n return (\n <tr\n class=\"ds-table__load-row ds-table__group-load-row\"\n data-group-id={group.id}\n ref={element => {\n if (element) this.groupSentinelEls.set(group.id, element);\n else this.groupSentinelEls.delete(group.id);\n }}\n >\n <td class=\"ds-table__load-cell\" colSpan={totalColumns}>\n <div class=\"ds-table__viewport-band ds-table__load-band\">\n {renderTableLoadContent({\n error,\n loading: !!group.loadingMore,\n hasMore: !!group.hasMore,\n manualFallback,\n retryLabel: this.formatGroupLoadLabel(this.groupRetryLabel, group),\n loadingLabel: this.formatGroupLoadLabel(this.groupLoadingMoreLabel, group),\n loadMoreLabel: this.groupLoadMoreLabel,\n loadMoreAriaLabel: this.formatGroupLoadLabel(this.groupLoadMoreAriaLabel, group),\n onRetry: () => this.groupLoadController.request(group.id, 'retry'),\n onLoadMore: () => this.groupLoadController.request(group.id, 'manual'),\n })}\n </div>\n </td>\n </tr>\n );\n }\n\n private renderStickyGroup(model: TableRenderModel) {\n if (!this.documentStickyHeader || !this.activeStickyGroupId) return null;\n const groupModel = model.groups.find(item => item.group.id === this.activeStickyGroupId);\n if (!groupModel) return null;\n return (\n <div\n class={{\n 'ds-table__sticky-group': true,\n 'ds-table__sticky-group--selectable': model.selectable,\n [groupModel.intentClass ?? '']: !!groupModel.intentClass,\n }}\n data-group-id={groupModel.group.id}\n data-group-intent={groupModel.intent}\n >\n {this.renderGroupContent(groupModel)}\n </div>\n );\n }\n\n private renderDataBodies(model: TableRenderModel, plan: TableVirtualPlan | null) {\n return this.bodyRenderer.render({\n headerRowCount: 1 + Number(this.structure.bands.length > 0),\n model,\n plan,\n rows: this.rows,\n stickyHeader: this.stickyHeader,\n documentStickyHeader: this.documentStickyHeader,\n activeStickyGroupId: this.activeStickyGroupId,\n renderRow: (row, renderModel, ariaRowIndex, variableVirtualSize, rowKey) =>\n this.renderRow(row, renderModel, ariaRowIndex, variableVirtualSize, rowKey),\n renderGroupContent: groupModel => this.renderGroupContent(groupModel),\n renderGroupLoadRow: (group, totalColumns) => this.renderGroupLoadRow(group, totalColumns),\n });\n }\n\n private renderSkeletonBody(model: TableRenderModel) {\n return renderTableSkeletonBody({\n pins: this.structure.pins,\n model,\n visibleColumns: this.visibleColumns,\n skeletonRows: this.skeletonRows,\n renderStickyEdge: (sticky, shadow) => this.renderStickyEdge(sticky, shadow),\n });\n }\n\n private renderStateBody(kind: 'empty' | 'error' | 'virtual-viewport', totalColumns: number) {\n const error = kind !== 'empty';\n const heading =\n kind === 'virtual-viewport'\n ? TABLE_VIRTUAL_VIEWPORT_REQUIRED_HEADING\n : error\n ? this.errorHeading\n : this.emptyHeading;\n const body =\n kind === 'virtual-viewport'\n ? TABLE_VIRTUAL_VIEWPORT_REQUIRED_BODY\n : error\n ? this.errorBody\n : this.emptyBody;\n return (\n <tbody class=\"ds-table__body ds-table__state-body\">\n <tr class=\"ds-table__state-row\">\n <td class=\"ds-table__state-cell\" colSpan={totalColumns}>\n <div class=\"ds-table__viewport-band ds-table__state-band\">\n <ds-empty-state\n icon={error ? 'ExclamationTriangle' : 'Inbox'}\n heading={heading}\n body={body}\n />\n </div>\n </td>\n </tr>\n </tbody>\n );\n }\n\n private renderLazyBody(model: TableRenderModel) {\n if (\n this.dataMode !== 'infinite' ||\n !this.incrementalWindowActive ||\n !model.hasData ||\n model.grouped\n )\n return null;\n const error = this.loadMoreError?.trim();\n const manualFallback =\n this.loadMoreMode === 'manual' || !this.loadController.intersectionSupported;\n\n return (\n <tbody class=\"ds-table__body ds-table__load-body\">\n <tr\n class=\"ds-table__load-row\"\n ref={element => {\n this.sentinelEl = element ?? null;\n }}\n >\n <td class=\"ds-table__load-cell\" colSpan={model.totalColumns}>\n <div class=\"ds-table__viewport-band ds-table__load-band\">\n {renderTableLoadContent({\n error,\n loading: this.loadingMore,\n hasMore: this.hasMore,\n manualFallback,\n retryLabel: this.retryLabel,\n loadingLabel: this.loadingMoreLabel,\n loadMoreLabel: this.loadMoreLabel,\n endOfResultsLabel: this.endOfResultsLabel,\n onRetry: () => this.loadController.request('retry'),\n onLoadMore: () => this.loadController.request('manual'),\n })}\n </div>\n </td>\n </tr>\n </tbody>\n );\n }\n\n private renderDocumentStickyHeader(model: TableRenderModel) {\n if (!this.documentStickyHeader) return null;\n return (\n <div class=\"ds-table__document-sticky-header\">\n <table\n class={{\n 'ds-table__table': true,\n 'ds-table__sticky-header-table': true,\n 'ds-table__table--selectable': model.selectable,\n 'ds-table__table--grouped': model.grouped,\n }}\n style={model.tableStyle}\n role=\"presentation\"\n ref={element => {\n this.stickyHeaderTableEl = element ?? null;\n }}\n >\n {this.renderColgroup(model)}\n {this.renderHeader(model, true, true)}\n </table>\n {this.renderFloatingCollapseAll(model)}\n </div>\n );\n }\n\n private get resultSummary(): string | null {\n if (this.dataMode === 'pagination') return null;\n if (this.dataMode === 'virtual') {\n return formatTableTotalSummary(this.currentLoadedRowCount(), this.resultTotalSummaryLabel);\n }\n return formatTableResultSummary(this.displayedCount, this.totalCount, this.resultSummaryLabel);\n }\n\n private get hasResultFooter(): boolean {\n return (\n (this.dataMode === 'pagination' && !!this.pagination) ||\n !!this.resultSummary ||\n this.footerSlotPresence !== 0\n );\n }\n\n private renderResultFooter() {\n const summary = this.resultSummary;\n const hasLeading = (this.footerSlotPresence & TABLE_FOOTER_SLOT_LEADING) !== 0;\n const hasCopy = (this.footerSlotPresence & TABLE_FOOTER_SLOT_COPY) !== 0;\n const hasTrailing = (this.footerSlotPresence & TABLE_FOOTER_SLOT_TRAILING) !== 0;\n const pagination = this.dataMode === 'pagination' ? this.pagination : null;\n if (!this.hasResultFooter) return null;\n return (\n <div class=\"ds-table__footer ds-table__bar ds-chrome-header ds-control--md\">\n <div class=\"ds-table__bar-copy ds-chrome-header__copy ds-control--md\">\n {hasLeading && (\n <div class=\"ds-table__bar-status ds-chrome-header__heading\">\n <slot name=\"footer-leading\" onSlotchange={this.syncFooterSlotPresence} />\n </div>\n )}\n {hasCopy ? (\n <slot name=\"footer\" onSlotchange={this.syncFooterSlotPresence} />\n ) : summary ? (\n <ds-text\n class=\"ds-table__footer-summary ds-table__bar-text\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n lineTruncation={1}\n >\n {summary}\n </ds-text>\n ) : null}\n </div>\n {(pagination || hasTrailing) && (\n <div class=\"ds-table__bar-trailing ds-chrome-header__trailing\">\n {pagination && (\n <ds-pagination\n pageIndex={pagination.pageIndex}\n pageSize={pagination.pageSize}\n pageSizeMode={pagination.pageSizeMode ?? 'fixed'}\n totalItems={pagination.totalItems}\n pageSizeOptions={pagination.pageSizeOptions ?? [25, 50, 100, 200]}\n fitToPage={pagination.fitToPage ?? false}\n {...{ fitToPageInactive: pagination.fitToPageInactive ?? false }}\n fitPageSize={this.fitPageSize ?? pagination.fitPageSize}\n fitPageSizeLabel={pagination.fitPageSizeLabel ?? 'Fit to page'}\n fitPageSizeTriggerLabel={pagination.fitPageSizeTriggerLabel ?? 'Fit'}\n itemLabel={pagination.itemLabel ?? 'items'}\n pageSizeLabel={pagination.pageSizeLabel ?? 'Items'}\n label={pagination.ariaLabel ?? `${this.caption} pagination`}\n showFirstLastButtons={pagination.showFirstLastButtons ?? false}\n loading={this.loading}\n onDsChange={(event: CustomEvent<PaginationChangeDetail>) =>\n this.dsPaginationChange.emit(event.detail)\n }\n />\n )}\n <slot name=\"footer-trailing\" onSlotchange={this.syncFooterSlotPresence} />\n </div>\n )}\n </div>\n );\n }\n\n private renderCaptionBar() {\n if (this.captionVisibility !== 'visible') return null;\n return (\n <div class=\"ds-table__caption-bar ds-table__bar ds-control--md\">\n <div\n class={{\n 'ds-table__caption-content': true,\n 'ds-table__caption-content--trailing': this.showsCaptionTrailing,\n }}\n >\n <div\n class={{\n 'ds-table__caption-leading': true,\n 'ds-table__caption-leading--toolbar': this.headerUsesToolbar,\n }}\n >\n <slot name=\"header\" onSlotchange={this.syncHeaderSlotPresence} />\n {!this.headerPresent ? (\n <ds-text\n class=\"ds-table__caption-title ds-table__bar-text\"\n as=\"div\"\n variant=\"text-title-small\"\n emphasis={true}\n color=\"primary\"\n aria-hidden=\"true\"\n >\n {this.caption}\n </ds-text>\n ) : null}\n </div>\n {this.renderCaptionTrailing()}\n </div>\n <slot name=\"header-after\" />\n </div>\n );\n }\n\n private renderCaptionTrailing() {\n if (!this.showsCaptionTrailing) return null;\n return (\n <div class=\"ds-table__caption-trailing\">\n {this.renderColumnCustomizerTrigger()}\n {this.showsColumnCustomizer &&\n !this.hideColumnCustomizerTrigger &&\n this.showsDataModeSwitcher ? (\n <ds-divider orientation=\"vertical\" length=\"32px\" />\n ) : null}\n {this.renderDataModeSwitcherTrigger()}\n </div>\n );\n }\n\n private renderDataModeSwitcherTrigger() {\n if (!this.showsDataModeSwitcher) return null;\n if (this.chromeLoading) {\n return <ds-skeleton variant=\"control\" controlSize=\"md\" width=\"var(--dimension-size-400)\" />;\n }\n return (\n <span class=\"ds-table__caption-mode-switcher\">\n <ds-tooltip label={this.dataModeSwitcherLabel} side=\"top\" size=\"sm\">\n <ds-button-unfilled\n hasBorder={!this.captionControlsBorderless}\n id={`${this.dataModeSwitcherElementId}-trigger`}\n variant=\"icon\"\n size=\"md\"\n icon=\"Ellipses\"\n aria-label={this.dataModeSwitcherLabel}\n hasMenu={true}\n expanded={this.dataModeSwitcherOpen}\n surfaceOpen={this.dataModeSwitcherSurfaceOpen}\n controls={this.dataModeSwitcherElementId}\n activeFill={false}\n pressScale={false}\n onDsClick={(event: CustomEvent<MouseEvent>) => {\n this.toggleDataModeSwitcher(event.detail.detail === 0);\n }}\n />\n </ds-tooltip>\n </span>\n );\n }\n\n private renderDataModeSwitcherMenu() {\n if (!this.showsDataModeSwitcher || this.chromeLoading) return null;\n return (\n <ds-menu\n id={this.dataModeSwitcherElementId}\n open={this.dataModeSwitcherOpen}\n anchorId={`${this.dataModeSwitcherElementId}-trigger`}\n align=\"end\"\n side=\"bottom\"\n menuLabel={this.dataModeMenuLabel}\n initialFocusVisible={this.dataModeSwitcherInitialFocusVisible}\n items={tableDataModeMenuItems(this.dataMode, {\n infinite: this.infiniteModeLabel,\n pagination: this.paginationModeLabel,\n virtual: this.virtualModeLabel,\n })}\n onDsClose={() => this.closeDataModeSwitcher()}\n onDsAfterClose={() => {\n if (!this.dataModeSwitcherOpen) this.dataModeSwitcherSurfaceOpen = false;\n }}\n onDsSelect={event => this.handleDataModeSwitcherSelect(event.detail)}\n />\n );\n }\n\n private toggleDataModeSwitcher(fromKeyboard = false): void {\n if (this.dataModeSwitcherOpen) this.closeDataModeSwitcher();\n else this.openDataModeSwitcher(fromKeyboard);\n }\n\n private openDataModeSwitcher(fromKeyboard = false): void {\n if (!this.showsDataModeSwitcher || this.dataModeSwitcherOpen) return;\n this.closeColumnCustomizer();\n this.dataModeSwitcherInitialFocusVisible = fromKeyboard;\n this.dataModeSwitcherOpen = true;\n this.dataModeSwitcherSurfaceOpen = true;\n }\n\n private closeDataModeSwitcher(): void {\n this.dataModeSwitcherOpen = false;\n }\n\n private handleDataModeSwitcherSelect(item: MenuItemData): void {\n const dataMode = tableDataModeFromMenuItem(item);\n if (!dataMode) return;\n this.closeDataModeSwitcher();\n if (dataMode !== this.dataMode) this.dsDataModeChange.emit({ dataMode });\n requestAnimationFrame(() => {\n this.el\n .querySelector<\n HTMLElement & { setFocus?: () => void }\n >(`#${CSS.escape(`${this.dataModeSwitcherElementId}-trigger`)}`)\n ?.setFocus?.();\n });\n }\n\n private renderColumnCustomizerTrigger() {\n if (this.hideColumnCustomizerTrigger) return null;\n if (!this.showsColumnCustomizer) return null;\n return (\n <div\n class={{\n 'ds-table__caption-customizer': true,\n 'ds-table__caption-customizer--loading': this.chromeLoading,\n }}\n aria-hidden={this.chromeLoading ? 'true' : undefined}\n >\n <ds-tooltip label={this.captionCompact ? 'Customize' : ''} side=\"top\" size=\"sm\">\n <ds-button-unfilled\n hasBorder={!this.captionControlsBorderless}\n id={`${this.columnCustomizerElementId}-trigger`}\n variant={this.captionCompact ? 'icon' : 'icon-label'}\n size=\"md\"\n icon=\"Preferences\"\n label=\"Customize\"\n labelEmphasis={false}\n pressScale={false}\n aria-label=\"Customize table\"\n haspopup=\"menu\"\n expanded={this.columnCustomizerOpen}\n surfaceOpen={this.columnCustomizerSurfaceOpen}\n controls={this.columnCustomizerElementId}\n onDsClick={(event: CustomEvent<MouseEvent>) => {\n if (this.chromeLoading) return;\n this.toggleColumnCustomizer(event.detail.detail === 0);\n }}\n />\n </ds-tooltip>\n {this.chromeLoading ? (\n <ds-skeleton variant=\"control\" controlSize=\"md\" width=\"100%\" />\n ) : null}\n </div>\n );\n }\n\n private renderColumnCustomizerMenu() {\n if (!this.showsColumnCustomizer || this.chromeLoading) return null;\n return (\n <ds-menu\n id={this.columnCustomizerElementId}\n open={this.columnCustomizerOpen}\n anchorId={`${this.columnCustomizerElementId}-trigger`}\n align=\"end\"\n side=\"bottom\"\n menuLabel=\"Customize table\"\n initialFocusVisible={this.columnCustomizerInitialFocusVisible}\n sections={[\n {\n header: 'Columns',\n items: tableColumnCustomizerMenuItems(\n this.columns,\n this.hiddenFieldIds,\n this.fieldOrder\n ),\n },\n ...(this.customizeOptions.length\n ? [{ header: 'Options', items: this.customizeOptions }]\n : []),\n ]}\n onDsClose={() => this.closeColumnCustomizer()}\n onDsAfterClose={() => {\n if (!this.columnCustomizerOpen) this.columnCustomizerSurfaceOpen = false;\n }}\n onDsSelect={event => {\n if (this.customizeOptions.some(option => option.value === event.detail.value)) {\n this.dsCustomizeOptionChange.emit(event.detail.value!);\n } else this.handleColumnCustomizerSelect(event.detail);\n }}\n onDsReorder={event => this.handleColumnCustomizerReorder(event.detail)}\n />\n );\n }\n\n private toggleColumnCustomizer(fromKeyboard = false): void {\n if (this.columnCustomizerOpen) this.closeColumnCustomizer();\n else this.openColumnCustomizer(fromKeyboard);\n }\n\n private openColumnCustomizer(fromKeyboard = false): void {\n if (!this.showsColumnCustomizer || this.columnCustomizerOpen) return;\n this.closeDataModeSwitcher();\n this.columnCustomizerInitialFocusVisible = fromKeyboard;\n this.columnCustomizerOpen = true;\n this.columnCustomizerSurfaceOpen = true;\n }\n\n private closeColumnCustomizer(): void {\n this.columnCustomizerOpen = false;\n }\n\n private emitColumnsConfigChange(hiddenFieldIds: string[], fieldOrder: string[]): void {\n this.dsFieldsConfigChange.emit({\n hiddenFieldIds: resolveTableHiddenColumnIds(this.columns, hiddenFieldIds),\n fieldOrder: resolveTableColumnOrder(this.columns, fieldOrder),\n });\n }\n\n private handleColumnCustomizerReorder(detail: MenuReorderDetail): void {\n const order = detail.items\n .filter(item => item.reorderable)\n .map(item => item.value)\n .filter((id): id is string => !!id);\n this.emitColumnsConfigChange(this.hiddenFieldIds, order);\n }\n\n private handleColumnCustomizerSelect(item: MenuItemData): void {\n const fieldId = item.value;\n if (!fieldId || item.isInactive) return;\n this.emitColumnsConfigChange(\n toggleTableColumnHidden(this.columns, this.hiddenFieldIds, fieldId),\n resolveTableColumnOrder(this.columns, this.fieldOrder)\n );\n }\n\n render() {\n this.groupSentinelEls.clear();\n const model = this.createRenderModel();\n this.syncVirtualItems(model);\n this.renderedModel = model;\n const regionLabel = this.scrollLabel?.trim() || `${this.caption} scroll area`;\n const initialLoading = this.loading && !model.hasData;\n const initialError = !model.hasData && this.error;\n const hasGroupedStructure = model.grouped && model.groups.length > 0;\n const windowRows = this.shouldWindowRows(model);\n const virtualViewportMissing =\n this.virtualViewportMissing && !initialLoading && (model.hasData || hasGroupedStructure);\n const virtualPlan = windowRows\n ? (this.virtualWindow ??\n resolveTableVirtualPlan({\n items: this.virtualItems,\n sizes: this.virtualItems.map(item => this.virtualController.sizeFor(item)),\n scrollOffset: this.viewportEl?.scrollTop ?? 0,\n viewportSize: this.estimateVirtualViewportSize(),\n pinnedRowIds: this.virtualPinnedRowIds(),\n }))\n : null;\n const groupLoadingMore = hasGroupedStructure && this.groups.some(group => group.loadingMore);\n const viewportStyle =\n !this.boundedComposition && this.resolvedMaxHeight\n ? { '--ds-table-max-block-size': this.resolvedMaxHeight }\n : undefined;\n const hostStyle = this.fixedHeight\n ? { '--_table-fixed-block-size': this.resolvedHeight }\n : undefined;\n\n return (\n <Host\n class={{\n 'table-host': true,\n 'table-host--bounded': this.boundedComposition,\n 'table-host--fixed-height': this.fixedHeight,\n 'table-host--viewport-fit': this.fitViewport,\n }}\n style={hostStyle}\n onKeyDown={(event: KeyboardEvent) => {\n this.handleGridKeyDown(event);\n this.handlePaginationKeyDown(event);\n }}\n onCopy={(event: ClipboardEvent) => this.handleGridClipboard(event)}\n onPaste={(event: ClipboardEvent) => this.handleGridClipboard(event)}\n onFocusin={this.onVirtualFocusIn}\n >\n <div\n class={{\n 'ds-table': true,\n 'ds-table--bounded': this.boundedComposition,\n 'ds-table--contained-scroll': this.containedScroll,\n 'ds-table--fixed-height': this.fixedHeight,\n 'ds-table--viewport-fit': this.fitViewport,\n 'ds-table--viewport-fit-pending': this.fitViewport && !this.viewportFitSettled,\n 'ds-table--viewport-fit-settled': this.fitViewport && this.viewportFitSettled,\n 'ds-table--sticky-header': this.stickyHeader,\n 'ds-table--document-sticky-header': this.documentStickyHeader,\n 'ds-table--contained-sticky-header': this.stickyHeader && !this.documentStickyHeader,\n 'ds-table--caption-visible': this.captionVisibility === 'visible',\n 'ds-table--super-headers': this.structure.bands.length > 0,\n 'ds-table--responsive-cards':\n this.responsiveLayout === 'cards' &&\n this.interactionMode !== 'grid' &&\n this.cellSpans.length === 0,\n 'ds-table--cards-active':\n this.narrowViewport &&\n this.responsiveLayout === 'cards' &&\n this.interactionMode !== 'grid' &&\n this.cellSpans.length === 0,\n 'ds-table--footer-visible': this.hasResultFooter,\n 'ds-table--state-fill':\n !initialLoading &&\n (initialError || virtualViewportMissing || !(model.hasData || hasGroupedStructure)),\n }}\n ref={element => {\n this.rootEl = element ?? null;\n }}\n >\n {this.renderCaptionBar()}\n <div\n class={{\n 'ds-table__frame': true,\n 'ds-table__frame--overflow-start': this.overflowStart,\n 'ds-table__frame--overflow-end': this.overflowEnd,\n }}\n ref={element => {\n this.frameEl = element ?? null;\n }}\n >\n {this.renderDocumentStickyHeader(model)}\n {this.renderStickyGroup(model)}\n {!this.documentStickyHeader && this.renderFloatingCollapseAll(model)}\n <div\n class={{\n 'ds-table__viewport': true,\n 'ds-focus-ring': this.scrollable,\n }}\n style={viewportStyle}\n ref={element => {\n this.viewportEl = element ?? null;\n }}\n role={this.scrollable ? 'region' : undefined}\n aria-label={this.scrollable ? regionLabel : undefined}\n tabIndex={this.scrollable ? 0 : undefined}\n >\n <table\n class={{\n 'ds-table__table': true,\n 'ds-table__table--selectable': model.selectable,\n 'ds-table__table--grouped': model.grouped,\n 'ds-table__table--deferred-rows':\n this.dataMode !== 'virtual' && !this.structure.merges.hasRowSpans,\n 'ds-table__table--windowed': windowRows,\n 'ds-table__table--virtual': this.dataMode === 'virtual' && windowRows,\n 'ds-table__table--native-group-sticky':\n (model.grouped && this.stickyHeader && !this.documentStickyHeader) ||\n windowRows,\n }}\n style={model.tableStyle}\n role={this.gridEnabled ? 'grid' : 'table'}\n aria-multiselectable={this.gridEnabled ? 'true' : undefined}\n aria-rowcount={\n windowRows\n ? 1 + Number(this.structure.bands.length > 0) + this.virtualItems.length\n : undefined\n }\n aria-busy={\n initialLoading ||\n (this.dataMode === 'infinite' && (this.loadingMore || groupLoadingMore))\n ? 'true'\n : undefined\n }\n ref={element => {\n this.tableEl = element ?? null;\n }}\n >\n <caption class=\"ds-table__caption ds-visually-hidden\">\n <ds-text as=\"span\" variant=\"text-title-small\" emphasis={true} color=\"primary\">\n {this.caption}\n </ds-text>\n </caption>\n {this.renderColgroup(model)}\n {this.renderHeader(\n model,\n !this.documentStickyHeader,\n false,\n windowRows ? 1 : undefined\n )}\n {initialLoading\n ? this.renderSkeletonBody(model)\n : initialError\n ? this.renderStateBody('error', model.totalColumns)\n : virtualViewportMissing\n ? this.renderStateBody('virtual-viewport', model.totalColumns)\n : model.hasData || hasGroupedStructure\n ? this.renderDataBodies(model, virtualPlan)\n : this.renderStateBody('empty', model.totalColumns)}\n {!initialLoading &&\n !initialError &&\n this.dataMode !== 'virtual' &&\n this.renderLazyBody(model)}\n </table>\n </div>\n </div>\n {this.renderResultFooter()}\n {this.renderOverflowActionMenu()}\n {this.renderDataModeSwitcherMenu()}\n {this.renderColumnCustomizerMenu()}\n {this.renderTruncateTooltip()}\n <div class=\"ds-visually-hidden\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n {this.announcement}\n </div>\n </div>\n </Host>\n );\n }\n}\n",
|
|
1536
1642
|
"type": "registry:ui"
|
|
1537
1643
|
},
|
|
1538
1644
|
{
|
|
@@ -1542,7 +1648,12 @@
|
|
|
1542
1648
|
},
|
|
1543
1649
|
{
|
|
1544
1650
|
"path": "src/wc/components/Table/table-body-renderer.tsx",
|
|
1545
|
-
"content": "import { h } from '@stencil/core';\nimport type { TableGroupRenderModel, TableRenderModel } from './table-render-model';\nimport type { DataGroup, TableRow } from './table-types';\nimport type { TableVirtualNode, TableVirtualPlan } from './table-virtual-model';\n\nexport interface TableVirtualRowPoolState {\n slotsByRowId: Map<string, number>;\n nextSlot: number;\n}\n\nexport interface TableBodyRenderOptions {\n model: TableRenderModel;\n plan: TableVirtualPlan | null;\n rows: TableRow[];\n stickyHeader: boolean;\n documentStickyHeader: boolean;\n activeStickyGroupId: string | null;\n renderRow: (\n row: TableRow,\n model: TableRenderModel,\n ariaRowIndex?: number,\n variableVirtualSize?: boolean,\n rowKey?: string\n ) => unknown;\n renderGroupContent: (group: TableGroupRenderModel) => unknown;\n renderGroupLoadRow: (group: DataGroup, totalColumns: number) => unknown;\n}\n\n/**\n * Assign stable, bounded DOM keys to the rows currently occupying one virtual scope.\n * Departed rows release their slots so long scrolls recycle DOM instead of growing it.\n */\nexport function assignTableVirtualRowPoolKeys(\n nodes: readonly TableVirtualNode[],\n state: TableVirtualRowPoolState\n): Map<number, string> {\n const rows = nodes.filter(\n (node): node is Extract<TableVirtualNode, { kind: 'row' }> => node.kind === 'row'\n );\n const desiredIds = new Set(rows.map(node => node.item.rowId ?? node.item.id));\n const freeSlots: number[] = [];\n for (const [rowId, slot] of state.slotsByRowId) {\n if (desiredIds.has(rowId)) continue;\n state.slotsByRowId.delete(rowId);\n freeSlots.push(slot);\n }\n freeSlots.sort((left, right) => left - right);\n\n const keys = new Map<number, string>();\n for (const node of rows) {\n const rowId = node.item.rowId ?? node.item.id;\n let slot = state.slotsByRowId.get(rowId);\n if (slot == null) {\n slot = freeSlots.shift() ?? state.nextSlot++;\n state.slotsByRowId.set(rowId, slot);\n }\n keys.set(node.index, `virtual-row-slot-${slot}`);\n }\n return keys;\n}\n\n/** Owns grouped and virtual tbody composition while Table remains the behavior orchestrator. */\nexport class TableBodyRenderer {\n private virtualLookupCache: {\n rows: TableRow[];\n groups: TableGroupRenderModel[];\n rowsById: Map<string, TableRow>;\n groupsById: Map<string, TableGroupRenderModel>;\n } | null = null;\n\n private readonly virtualRowPoolStates = new Map<string, TableVirtualRowPoolState>();\n\n resetVirtualState(): void {\n this.virtualLookupCache = null;\n this.virtualRowPoolStates.clear();\n }\n\n render(options: TableBodyRenderOptions) {\n if (options.plan) return this.renderVirtualBodies(options);\n\n const { model } = options;\n if (!model.grouped) {\n return (\n <tbody class=\"ds-table__body\">\n {options.rows.map(row => options.renderRow(row, model))}\n </tbody>\n );\n }\n\n return model.groups.map(groupModel => {\n const { group, collapsed: isCollapsed, intent, intentClass } = groupModel;\n const stickySourceHidden =\n options.documentStickyHeader && options.activeStickyGroupId === group.id;\n return (\n <tbody\n class=\"ds-table__body ds-table__group\"\n role=\"rowgroup\"\n data-group-id={group.id}\n data-group-intent={intent}\n data-collapsed={isCollapsed ? 'true' : undefined}\n aria-busy={!isCollapsed && group.loadingMore ? 'true' : undefined}\n key={group.id}\n >\n <tr\n role=\"row\"\n aria-hidden={stickySourceHidden ? 'true' : undefined}\n class={{\n 'ds-table__group-row': true,\n 'ds-table__group-row--native-sticky':\n options.stickyHeader && !options.documentStickyHeader,\n }}\n >\n <th\n class={{\n 'ds-table__group-cell': true,\n 'ds-table__group-cell--sticky-source-hidden': stickySourceHidden,\n [intentClass ?? '']: !!intentClass,\n }}\n role=\"rowheader\"\n scope=\"rowgroup\"\n colSpan={model.totalColumns}\n >\n {options.renderGroupContent(groupModel)}\n </th>\n </tr>\n {!isCollapsed && group.rows.map(row => options.renderRow(row, model))}\n {!isCollapsed && options.renderGroupLoadRow(group, model.totalColumns)}\n </tbody>\n );\n });\n }\n\n private renderVirtualSpacer(\n node: Extract<TableVirtualNode, { kind: 'spacer' }>,\n totalColumns: number\n ) {\n if (node.size <= 0) return null;\n return (\n <tr class=\"ds-table__virtual-spacer-row\" key={node.key} aria-hidden=\"true\">\n <td\n class=\"ds-table__virtual-spacer-cell\"\n colSpan={totalColumns}\n style={\n { '--_table-virtual-spacer-block-size': `${node.size}px` } as Record<string, string>\n }\n />\n </tr>\n );\n }\n\n private renderVirtualRow(\n node: Extract<TableVirtualNode, { kind: 'row' }>,\n options: TableBodyRenderOptions,\n rowsById: Map<string, TableRow>,\n rowKey: string\n ) {\n const row = node.item.rowId ? rowsById.get(node.item.rowId) : undefined;\n if (!row) return null;\n return options.renderRow(row, options.model, node.index + 2, node.item.variableSize, rowKey);\n }\n\n private virtualRowPoolKeys(nodes: readonly TableVirtualNode[], scope: string) {\n const state = this.virtualRowPoolStates.get(scope) ?? {\n slotsByRowId: new Map<string, number>(),\n nextSlot: 0,\n };\n this.virtualRowPoolStates.set(scope, state);\n return assignTableVirtualRowPoolKeys(nodes, state);\n }\n\n private renderVirtualGroup(\n node: Extract<TableVirtualNode, { kind: 'group' }>,\n options: TableBodyRenderOptions,\n rowsById: Map<string, TableRow>,\n groupsById: Map<string, TableGroupRenderModel>\n ) {\n const groupModel = groupsById.get(node.groupId);\n if (!groupModel) return null;\n const { group, collapsed: isCollapsed, intent, intentClass } = groupModel;\n const rowKeys = this.virtualRowPoolKeys(node.nodes, `group:${group.id}`);\n return (\n <tbody\n class=\"ds-table__body ds-table__group\"\n role=\"rowgroup\"\n data-group-id={group.id}\n data-group-intent={intent}\n data-collapsed={isCollapsed ? 'true' : undefined}\n key={group.id}\n >\n <tr\n role=\"row\"\n data-virtual-id={`group:${group.id}`}\n aria-rowindex={node.headerIndex +
|
|
1651
|
+
"content": "import { h } from '@stencil/core';\nimport type { TableGroupRenderModel, TableRenderModel } from './table-render-model';\nimport type { DataGroup, TableRow } from './table-types';\nimport type { TableVirtualNode, TableVirtualPlan } from './table-virtual-model';\n\nexport interface TableVirtualRowPoolState {\n slotsByRowId: Map<string, number>;\n nextSlot: number;\n}\n\nexport interface TableBodyRenderOptions {\n headerRowCount?: number;\n model: TableRenderModel;\n plan: TableVirtualPlan | null;\n rows: TableRow[];\n stickyHeader: boolean;\n documentStickyHeader: boolean;\n activeStickyGroupId: string | null;\n renderRow: (\n row: TableRow,\n model: TableRenderModel,\n ariaRowIndex?: number,\n variableVirtualSize?: boolean,\n rowKey?: string\n ) => unknown;\n renderGroupContent: (group: TableGroupRenderModel) => unknown;\n renderGroupLoadRow: (group: DataGroup, totalColumns: number) => unknown;\n}\n\n/**\n * Assign stable, bounded DOM keys to the rows currently occupying one virtual scope.\n * Departed rows release their slots so long scrolls recycle DOM instead of growing it.\n */\nexport function assignTableVirtualRowPoolKeys(\n nodes: readonly TableVirtualNode[],\n state: TableVirtualRowPoolState\n): Map<number, string> {\n const rows = nodes.filter(\n (node): node is Extract<TableVirtualNode, { kind: 'row' }> => node.kind === 'row'\n );\n const desiredIds = new Set(rows.map(node => node.item.rowId ?? node.item.id));\n const freeSlots: number[] = [];\n for (const [rowId, slot] of state.slotsByRowId) {\n if (desiredIds.has(rowId)) continue;\n state.slotsByRowId.delete(rowId);\n freeSlots.push(slot);\n }\n freeSlots.sort((left, right) => left - right);\n\n const keys = new Map<number, string>();\n for (const node of rows) {\n const rowId = node.item.rowId ?? node.item.id;\n let slot = state.slotsByRowId.get(rowId);\n if (slot == null) {\n slot = freeSlots.shift() ?? state.nextSlot++;\n state.slotsByRowId.set(rowId, slot);\n }\n keys.set(node.index, `virtual-row-slot-${slot}`);\n }\n return keys;\n}\n\n/** Owns grouped and virtual tbody composition while Table remains the behavior orchestrator. */\nexport class TableBodyRenderer {\n private virtualLookupCache: {\n rows: TableRow[];\n groups: TableGroupRenderModel[];\n rowsById: Map<string, TableRow>;\n groupsById: Map<string, TableGroupRenderModel>;\n } | null = null;\n\n private readonly virtualRowPoolStates = new Map<string, TableVirtualRowPoolState>();\n\n resetVirtualState(): void {\n this.virtualLookupCache = null;\n this.virtualRowPoolStates.clear();\n }\n\n render(options: TableBodyRenderOptions) {\n if (options.plan) return this.renderVirtualBodies(options);\n\n const { model } = options;\n if (!model.grouped) {\n return (\n <tbody class=\"ds-table__body\">\n {options.rows.map(row => options.renderRow(row, model))}\n </tbody>\n );\n }\n\n return model.groups.map(groupModel => {\n const { group, collapsed: isCollapsed, intent, intentClass } = groupModel;\n const stickySourceHidden =\n options.documentStickyHeader && options.activeStickyGroupId === group.id;\n return (\n <tbody\n class=\"ds-table__body ds-table__group\"\n role=\"rowgroup\"\n data-group-id={group.id}\n data-group-intent={intent}\n data-collapsed={isCollapsed ? 'true' : undefined}\n aria-busy={!isCollapsed && group.loadingMore ? 'true' : undefined}\n key={group.id}\n >\n <tr\n role=\"row\"\n aria-hidden={stickySourceHidden ? 'true' : undefined}\n class={{\n 'ds-table__group-row': true,\n 'ds-table__group-row--native-sticky':\n options.stickyHeader && !options.documentStickyHeader,\n }}\n >\n <th\n class={{\n 'ds-table__group-cell': true,\n 'ds-table__group-cell--sticky-source-hidden': stickySourceHidden,\n [intentClass ?? '']: !!intentClass,\n }}\n role=\"rowheader\"\n scope=\"rowgroup\"\n colSpan={model.totalColumns}\n >\n {options.renderGroupContent(groupModel)}\n </th>\n </tr>\n {!isCollapsed && group.rows.map(row => options.renderRow(row, model))}\n {!isCollapsed && options.renderGroupLoadRow(group, model.totalColumns)}\n </tbody>\n );\n });\n }\n\n private renderVirtualSpacer(\n node: Extract<TableVirtualNode, { kind: 'spacer' }>,\n totalColumns: number\n ) {\n if (node.size <= 0) return null;\n return (\n <tr class=\"ds-table__virtual-spacer-row\" key={node.key} aria-hidden=\"true\">\n <td\n class=\"ds-table__virtual-spacer-cell\"\n colSpan={totalColumns}\n style={\n { '--_table-virtual-spacer-block-size': `${node.size}px` } as Record<string, string>\n }\n />\n </tr>\n );\n }\n\n private renderVirtualRow(\n node: Extract<TableVirtualNode, { kind: 'row' }>,\n options: TableBodyRenderOptions,\n rowsById: Map<string, TableRow>,\n rowKey: string\n ) {\n const row = node.item.rowId ? rowsById.get(node.item.rowId) : undefined;\n if (!row) return null;\n return options.renderRow(row, options.model, node.index + 2, node.item.variableSize, rowKey);\n }\n\n private virtualRowPoolKeys(nodes: readonly TableVirtualNode[], scope: string) {\n const state = this.virtualRowPoolStates.get(scope) ?? {\n slotsByRowId: new Map<string, number>(),\n nextSlot: 0,\n };\n this.virtualRowPoolStates.set(scope, state);\n return assignTableVirtualRowPoolKeys(nodes, state);\n }\n\n private renderVirtualGroup(\n node: Extract<TableVirtualNode, { kind: 'group' }>,\n options: TableBodyRenderOptions,\n rowsById: Map<string, TableRow>,\n groupsById: Map<string, TableGroupRenderModel>\n ) {\n const groupModel = groupsById.get(node.groupId);\n if (!groupModel) return null;\n const { group, collapsed: isCollapsed, intent, intentClass } = groupModel;\n const rowKeys = this.virtualRowPoolKeys(node.nodes, `group:${group.id}`);\n return (\n <tbody\n class=\"ds-table__body ds-table__group\"\n role=\"rowgroup\"\n data-group-id={group.id}\n data-group-intent={intent}\n data-collapsed={isCollapsed ? 'true' : undefined}\n key={group.id}\n >\n <tr\n role=\"row\"\n data-virtual-id={`group:${group.id}`}\n aria-rowindex={node.headerIndex + 1 + (options.headerRowCount ?? 1)}\n class={{\n 'ds-table__group-row': true,\n 'ds-table__group-row--native-sticky':\n options.stickyHeader && !options.documentStickyHeader,\n }}\n >\n <th\n class={{\n 'ds-table__group-cell': true,\n [intentClass ?? '']: !!intentClass,\n }}\n role=\"rowheader\"\n scope=\"rowgroup\"\n colSpan={options.model.totalColumns}\n >\n {options.renderGroupContent(groupModel)}\n </th>\n </tr>\n {node.nodes.map(child =>\n child.kind === 'spacer'\n ? this.renderVirtualSpacer(child, options.model.totalColumns)\n : this.renderVirtualRow(\n child,\n options,\n rowsById,\n rowKeys.get(child.index) ?? `virtual-row-${child.index}`\n )\n )}\n </tbody>\n );\n }\n\n private renderVirtualBodies(options: TableBodyRenderOptions) {\n const { model, plan } = options;\n if (!plan) return null;\n let lookup = this.virtualLookupCache;\n if (!lookup || lookup.rows !== model.loadedRows || lookup.groups !== model.groups) {\n lookup = {\n rows: model.loadedRows,\n groups: model.groups,\n rowsById: new Map(model.loadedRows.map(row => [row.id, row])),\n groupsById: new Map(model.groups.map(groupModel => [groupModel.group.id, groupModel])),\n };\n this.virtualLookupCache = lookup;\n }\n const { rowsById, groupsById } = lookup;\n if (!model.grouped) {\n for (const scope of this.virtualRowPoolStates.keys()) {\n if (scope !== 'flat') this.virtualRowPoolStates.delete(scope);\n }\n const rowKeys = this.virtualRowPoolKeys(plan.nodes, 'flat');\n return (\n <tbody class=\"ds-table__body\">\n {plan.nodes.map(node =>\n node.kind === 'spacer'\n ? this.renderVirtualSpacer(node, model.totalColumns)\n : node.kind === 'row'\n ? this.renderVirtualRow(\n node,\n options,\n rowsById,\n rowKeys.get(node.index) ?? `virtual-row-${node.index}`\n )\n : null\n )}\n </tbody>\n );\n }\n\n const activeGroupScopes = new Set(\n plan.nodes\n .filter(\n (node): node is Extract<TableVirtualNode, { kind: 'group' }> => node.kind === 'group'\n )\n .map(node => `group:${node.groupId}`)\n );\n for (const scope of this.virtualRowPoolStates.keys()) {\n if (!activeGroupScopes.has(scope)) this.virtualRowPoolStates.delete(scope);\n }\n return plan.nodes.map(node => {\n if (node.kind === 'spacer') {\n return (\n <tbody class=\"ds-table__body ds-table__virtual-pad\" aria-hidden=\"true\" key={node.key}>\n {this.renderVirtualSpacer(node, model.totalColumns)}\n </tbody>\n );\n }\n if (node.kind === 'group') {\n return this.renderVirtualGroup(node, options, rowsById, groupsById);\n }\n return null;\n });\n }\n}\n",
|
|
1652
|
+
"type": "registry:ui"
|
|
1653
|
+
},
|
|
1654
|
+
{
|
|
1655
|
+
"path": "src/wc/components/Table/table-cell-editor.tsx",
|
|
1656
|
+
"content": "import { h } from '@stencil/core';\nimport type { TableCellEditor } from './table-types';\n\n/** Shared controls own field behavior; Table owns the draft, cell chrome and commit boundary. */\nexport function renderTableCellEditor(\n editor: TableCellEditor,\n value: string,\n ariaLabel: string,\n anchor: HTMLElement | undefined,\n change: (value: string) => void\n) {\n const common = {\n value,\n ariaLabel,\n required: editor.required,\n size: 'md' as const,\n width: 'fill' as const,\n hasBorder: false,\n hasInteractionFill: false,\n onDsChange: (event: CustomEvent<string | string[]>) => {\n event.stopPropagation();\n if (typeof event.detail === 'string') change(event.detail);\n },\n };\n switch (editor.type) {\n case 'select':\n return (\n <ds-select\n {...common}\n options={editor.options}\n searchable={editor.searchable}\n anchor={anchor}\n hasFocusRing={false}\n />\n );\n case 'date':\n return <ds-input-date {...common} min={editor.min} max={editor.max} />;\n case 'time':\n return (\n <ds-input-time\n {...common}\n min={editor.min}\n max={editor.max}\n step={editor.step ?? 60}\n hourFormat={editor.hourFormat ?? '24'}\n />\n );\n case 'textarea':\n return (\n <ds-textarea\n {...common}\n rows={1}\n resize=\"none\"\n minLength={editor.minLength}\n maxLength={editor.maxLength}\n />\n );\n case 'number':\n return (\n <ds-input\n {...common}\n type=\"number\"\n min={editor.min}\n max={editor.max}\n step={editor.step}\n showStepper={editor.showStepper ?? true}\n />\n );\n default:\n return (\n <ds-input\n {...common}\n type=\"text\"\n minLength={editor.minLength}\n maxLength={editor.maxLength}\n />\n );\n }\n}\n",
|
|
1546
1657
|
"type": "registry:ui"
|
|
1547
1658
|
},
|
|
1548
1659
|
{
|
|
@@ -1560,6 +1671,11 @@
|
|
|
1560
1671
|
"content": "import type { MenuItemData } from '../Menu/menu-types';\nimport type { TableDataMode } from './table-types';\n\nlet tableDataModeSwitcherSequence = 0;\n\nexport function nextTableDataModeSwitcherElementId(): string {\n tableDataModeSwitcherSequence += 1;\n return `ds-table-data-mode-${tableDataModeSwitcherSequence}`;\n}\n\n/** Menu of supported data-mode choices. */\nexport function tableDataModeMenuItems(\n dataMode: TableDataMode,\n labels: { infinite: string; pagination: string; virtual: string }\n): MenuItemData[] {\n return [\n {\n label: labels.infinite,\n value: 'infinite',\n isSelected: dataMode === 'infinite',\n },\n {\n label: labels.pagination,\n value: 'pagination',\n isSelected: dataMode === 'pagination',\n },\n {\n label: labels.virtual,\n value: 'virtual',\n isSelected: dataMode === 'virtual',\n },\n ];\n}\n\nexport function tableDataModeFromMenuItem(item: MenuItemData): TableDataMode | null {\n return item.value === 'infinite' || item.value === 'pagination' || item.value === 'virtual'\n ? item.value\n : null;\n}\n",
|
|
1561
1672
|
"type": "registry:ui"
|
|
1562
1673
|
},
|
|
1674
|
+
{
|
|
1675
|
+
"path": "src/wc/components/Table/table-grid-model.ts",
|
|
1676
|
+
"content": "import type {\n TableCellAddress,\n TableCellRange,\n TableCellsChangeDetail,\n TableColumn,\n TableRow,\n} from './table-types';\nimport { parseLooseCalendarDate } from '../../utils/date-filter-value';\nimport { parseLooseClockTime, isClockTimeStepAligned } from '../../utils/clock-time';\n\n/** Snapshot-local index; arrow navigation and visible-cell range checks are constant time. */\nexport function tableGridModel(rows: TableRow[], columns: TableColumn[]) {\n let firstEditable: TableCellAddress | null = null;\n for (const row of rows) {\n const column = columns.find(column => tableEditable(row, column));\n if (column) {\n firstEditable = { rowId: row.id, columnId: column.id };\n break;\n }\n }\n return {\n firstEditable,\n rows,\n columns,\n rowIndices: new Map(rows.map((row, index) => [row.id, index])),\n columnIndices: new Map(columns.map((column, index) => [column.id, index])),\n };\n}\nexport type TableGridModel = ReturnType<typeof tableGridModel>;\n\n/** Scan only on navigation, not render/scroll; read-only cells are never focus stops. */\nexport function tableNextEditable(\n model: TableGridModel,\n address: TableCellAddress,\n key: string,\n backwards = false\n): TableCellAddress | null {\n let ri = model.rowIndices.get(address.rowId),\n ci = model.columnIndices.get(address.columnId);\n if (ri == null || ci == null) return null;\n let dr = 0,\n dc = 0;\n if (key === 'ArrowDown') dr = 1;\n else if (key === 'ArrowUp') dr = -1;\n else if (key === 'ArrowRight') dc = 1;\n else if (key === 'ArrowLeft') dc = -1;\n else if (key === 'Home') {\n ci = -1;\n dc = 1;\n } else if (key === 'End') {\n ci = model.columns.length;\n dc = -1;\n } else if (key === 'Tab') dc = backwards ? -1 : 1;\n else return null;\n while (true) {\n ri += dr;\n ci += dc;\n if (key === 'Tab') {\n if (ci < 0) {\n ri--;\n ci = model.columns.length - 1;\n } else if (ci >= model.columns.length) {\n ri++;\n ci = 0;\n }\n }\n const row = model.rows[ri],\n column = model.columns[ci];\n if (!row || !column) return null;\n if (tableEditable(row, column)) return { rowId: row.id, columnId: column.id };\n }\n}\n\nexport function tableRangeBounds(model: TableGridModel, range: TableCellRange | null) {\n if (!range) return null;\n const r1 = model.rowIndices.get(range.anchor.rowId),\n r2 = model.rowIndices.get(range.focus.rowId);\n const c1 = model.columnIndices.get(range.anchor.columnId),\n c2 = model.columnIndices.get(range.focus.columnId);\n if (r1 == null || r2 == null || c1 == null || c2 == null) return null;\n return {\n firstRow: Math.min(r1, r2),\n lastRow: Math.max(r1, r2),\n firstColumn: Math.min(c1, c2),\n lastColumn: Math.max(c1, c2),\n };\n}\n\nexport function tableEditable(row: TableRow, column: TableColumn): boolean {\n const value = row.cells[column.id];\n return (\n !!column.editor &&\n column.kind !== 'action' &&\n !row.disabled &&\n (value == null || typeof value === 'string' || typeof value === 'number')\n );\n}\n\nexport function tableEditValue(\n text: string,\n column: TableColumn\n): string | number | null | undefined {\n const editor = column.editor;\n if (!editor || (editor.required && !text.trim())) return undefined;\n if (editor.type === 'text' || editor.type === 'textarea')\n return (editor.minLength == null || !text || text.length >= editor.minLength) &&\n (editor.maxLength == null || text.length <= editor.maxLength)\n ? text\n : undefined;\n if (editor.type === 'select')\n return !text || editor.options.some(option => option.value === text && !option.isInactive)\n ? text\n : undefined;\n if (editor.type === 'date' || editor.type === 'time') {\n if (!text.trim()) return '';\n const value = editor.type === 'date' ? parseLooseCalendarDate(text) : parseLooseClockTime(text);\n return value &&\n (!editor.min || value >= editor.min) &&\n (!editor.max || value <= editor.max) &&\n (editor.type !== 'time' || isClockTimeStepAligned(value, editor.step ?? 60, editor.min))\n ? value\n : undefined;\n }\n if (editor.type !== 'number') return undefined;\n if (!text.trim()) return null;\n const value = Number(text);\n return Number.isFinite(value) &&\n (editor.min == null || value >= editor.min) &&\n (editor.max == null || value <= editor.max) &&\n (editor.step == null ||\n editor.step <= 0 ||\n Math.abs(\n (value - (editor.min ?? 0)) / editor.step -\n Math.round((value - (editor.min ?? 0)) / editor.step)\n ) < 1e-8)\n ? value\n : undefined;\n}\n\n/** Plain TSV only, bounded and atomic: never partially apply invalid or read-only rectangles. */\nexport function tablePasteChanges(\n model: TableGridModel,\n address: TableCellAddress,\n text: string\n): TableCellsChangeDetail['changes'] | null {\n if (text.length > 1_000_000) return null;\n const row = model.rowIndices.get(address.rowId),\n column = model.columnIndices.get(address.columnId);\n if (row == null || column == null) return null;\n const records = text\n .replace(/\\r\\n?/g, '\\n')\n .replace(/\\n$/, '')\n .split('\\n')\n .map(line => line.split('\\t'));\n const width = records[0].length;\n if (records.length * width > 10000 || records.some(record => record.length !== width))\n return null;\n const changes: TableCellsChangeDetail['changes'] = [];\n for (const [ri, record] of records.entries()) {\n for (const [ci, textValue] of record.entries()) {\n const targetRow = model.rows[row + ri],\n targetColumn = model.columns[column + ci];\n if (!targetRow || !targetColumn || !tableEditable(targetRow, targetColumn)) return null;\n const value = tableEditValue(textValue, targetColumn);\n if (value === undefined) return null;\n changes.push({ rowId: targetRow.id, columnId: targetColumn.id, value });\n }\n }\n return changes;\n}\n",
|
|
1677
|
+
"type": "registry:ui"
|
|
1678
|
+
},
|
|
1563
1679
|
{
|
|
1564
1680
|
"path": "src/wc/components/Table/table-group-load-controller.ts",
|
|
1565
1681
|
"content": "import type {\n DataGroup,\n TableGroupLoadMoreDetail,\n TableLoadMoreMode,\n TableLoadMoreReason,\n} from './table-types';\n\nexport interface TableGroupLoadControllerState {\n enabled: boolean;\n loadMoreMode: TableLoadMoreMode;\n loadMoreThreshold: number;\n containedScroll: boolean;\n groups: DataGroup[];\n viewport: HTMLElement | null;\n sentinels: ReadonlyMap<string, HTMLElement>;\n loadingMoreLabel: string;\n endOfResultsLabel: string;\n rowsLoadedLabel: string;\n}\n\nexport interface TableGroupLoadControllerOptions {\n state: () => TableGroupLoadControllerState;\n announce: (message: string) => void;\n request: (detail: TableGroupLoadMoreDetail) => void;\n}\n\ninterface GroupSnapshot {\n count: number;\n hasMore: boolean;\n loading: boolean;\n error: string;\n identity: string | number;\n}\n\nfunction groupIdentity(group: DataGroup): string | number {\n return group.loadIdentity ?? group.id;\n}\n\nfunction formatGroupLabel(template: string, group: DataGroup, count?: number): string {\n return template\n .split('{group}')\n .join(group.label)\n .split('{count}')\n .join(String(count ?? group.rows.length))\n .split('{loaded}')\n .join(String(group.rows.length))\n .split('{total}')\n .join(String(group.totalCount ?? group.rows.length));\n}\n\n/** Owns request guards, announcements, and observation for independently loaded groups. */\nexport class TableGroupLoadController {\n readonly intersectionSupported = typeof IntersectionObserver !== 'undefined';\n\n private connected = false;\n private intersectionObserver: IntersectionObserver | null = null;\n private snapshots = new Map<string, GroupSnapshot>();\n private pending = new Map<string, { identity: string | number; count: number }>();\n\n constructor(private readonly options: TableGroupLoadControllerOptions) {}\n\n initialize(): void {\n this.snapshots = this.createSnapshots();\n }\n\n connect(): void {\n this.connected = true;\n this.refresh();\n }\n\n disconnect(): void {\n this.connected = false;\n this.disconnectObserver();\n }\n\n refresh(): void {\n if (!this.connected) return;\n const state = this.options.state();\n if (\n !state.enabled ||\n state.loadMoreMode !== 'auto' ||\n !this.intersectionSupported ||\n !state.viewport\n ) {\n this.disconnectObserver();\n return;\n }\n\n this.disconnectObserver();\n const threshold = Number.isFinite(state.loadMoreThreshold)\n ? Math.max(0, state.loadMoreThreshold)\n : 0;\n this.intersectionObserver = new IntersectionObserver(\n entries => {\n for (const entry of entries) {\n if (!entry.isIntersecting) continue;\n const groupId = (entry.target as HTMLElement).dataset.groupId;\n if (groupId) this.request(groupId, 'auto');\n }\n },\n {\n root: state.containedScroll ? state.viewport : null,\n rootMargin: `0px 0px ${threshold}px 0px`,\n }\n );\n\n for (const [groupId, sentinel] of state.sentinels) {\n const group = state.groups.find(item => item.id === groupId);\n if (!group?.hasMore || group.loadingMore || group.loadMoreError?.trim()) continue;\n this.intersectionObserver.observe(sentinel);\n }\n }\n\n structureChanged(): void {\n this.pending.clear();\n this.snapshots = this.createSnapshots();\n this.disconnectObserver();\n }\n\n dataChanged(): void {\n const state = this.options.state();\n const nextSnapshots = new Map<string, GroupSnapshot>();\n\n for (const group of state.groups) {\n const next = this.snapshot(group);\n const previous = this.snapshots.get(group.id);\n const pending = this.pending.get(group.id);\n\n if (!previous || previous.identity !== next.identity) {\n this.pending.delete(group.id);\n } else {\n if (next.count > previous.count) {\n this.options.announce(\n formatGroupLabel(state.rowsLoadedLabel, group, next.count - previous.count)\n );\n }\n if (!previous.error && next.error) {\n this.pending.delete(group.id);\n this.options.announce(next.error);\n }\n if (previous.hasMore && !next.hasMore) {\n this.pending.delete(group.id);\n this.options.announce(formatGroupLabel(state.endOfResultsLabel, group));\n }\n if (pending && next.count > pending.count) this.pending.delete(group.id);\n if (pending && previous.loading && !next.loading && next.count === pending.count) {\n this.pending.delete(group.id);\n }\n }\n\n nextSnapshots.set(group.id, next);\n }\n\n this.snapshots = nextSnapshots;\n for (const groupId of this.pending.keys()) {\n if (!nextSnapshots.has(groupId)) this.pending.delete(groupId);\n }\n this.disconnectObserver();\n }\n\n configurationChanged(): void {\n this.disconnectObserver();\n }\n\n request(groupId: string, reason: TableLoadMoreReason): void {\n const state = this.options.state();\n const group = state.groups.find(item => item.id === groupId);\n if (\n !state.enabled ||\n !group?.hasMore ||\n group.loadingMore ||\n (reason !== 'retry' && !!group.loadMoreError?.trim()) ||\n this.pending.has(groupId)\n )\n return;\n\n const identity = groupIdentity(group);\n this.pending.set(groupId, { identity, count: group.rows.length });\n this.options.announce(formatGroupLabel(state.loadingMoreLabel, group));\n this.options.request({\n groupId,\n reason,\n loadIdentity: identity,\n loadedRowCount: group.rows.length,\n });\n }\n\n private createSnapshots(): Map<string, GroupSnapshot> {\n return new Map(this.options.state().groups.map(group => [group.id, this.snapshot(group)]));\n }\n\n private snapshot(group: DataGroup): GroupSnapshot {\n return {\n count: group.rows.length,\n hasMore: !!group.hasMore,\n loading: !!group.loadingMore,\n error: group.loadMoreError?.trim() ?? '',\n identity: groupIdentity(group),\n };\n }\n\n private disconnectObserver(): void {\n this.intersectionObserver?.disconnect();\n this.intersectionObserver = null;\n }\n}\n",
|
|
@@ -1572,7 +1688,7 @@
|
|
|
1572
1688
|
},
|
|
1573
1689
|
{
|
|
1574
1690
|
"path": "src/wc/components/Table/table-layout-controller.ts",
|
|
1575
|
-
"content": "export interface TableLayoutElements {\n viewport: HTMLElement | null;\n contentTable: HTMLTableElement | null;\n stickyHeaderTable: HTMLTableElement | null;\n collapseAllOverlay: HTMLElement | null;\n frame: HTMLElement | null;\n interactiveHead: HTMLTableSectionElement | null;\n}\n\nexport interface TableLayoutMode {\n documentStickyHeader: boolean;\n floatingCollapseAll: boolean;\n clampVerticalOverscroll: boolean;\n}\n\nexport interface TableOverflowState {\n start: boolean;\n end: boolean;\n scrollable: boolean;\n}\n\nexport interface TableLayoutControllerOptions {\n elements: () => TableLayoutElements;\n mode: () => TableLayoutMode;\n overflowChanged: (state: TableOverflowState) => void;\n verticalEdgeWheel?: (deltaY: number) => boolean;\n}\n\nexport interface TableLayoutMetricInput {\n viewportInlineSize: number;\n viewportBlockSize: number;\n scrollInlineSize: number;\n scrollBlockSize: number;\n scrollInlineOffset: number;\n collapseHeadBlockStart?: number;\n collapseFrameBlockStart?: number;\n}\n\nexport interface TableLayoutMetrics {\n visibleInlineSize: number;\n overflow: TableOverflowState;\n inlineOffset: number;\n maxInlineOffset: number;\n collapseBlockOffset: number | null;\n}\n\n/** Pure geometry resolution shared by the controller and focused tests. */\nexport function resolveTableLayoutMetrics(input: TableLayoutMetricInput): TableLayoutMetrics {\n const horizontalOverflow = input.scrollInlineSize - input.viewportInlineSize > 1;\n return {\n visibleInlineSize: input.viewportInlineSize,\n overflow: {\n start: horizontalOverflow && input.scrollInlineOffset > 1,\n end:\n horizontalOverflow &&\n input.scrollInlineOffset + input.viewportInlineSize < input.scrollInlineSize - 1,\n scrollable: horizontalOverflow || input.scrollBlockSize - input.viewportBlockSize > 1,\n },\n inlineOffset: input.scrollInlineOffset,\n maxInlineOffset: Math.max(0, input.scrollInlineSize - input.viewportInlineSize),\n collapseBlockOffset:\n input.collapseHeadBlockStart == null || input.collapseFrameBlockStart == null\n ? null\n : Math.max(0, input.collapseHeadBlockStart - input.collapseFrameBlockStart),\n };\n}\n\n/**\n * Owns table viewport observation. It resolves newly rendered geometry\n * immediately so the first paint has the correct viewport-width chrome, then\n * batches observer-driven scroll and resize reads/writes into one animation frame.\n * The component remains responsible only for refs and reactive overflow state.\n */\nexport class TableLayoutController {\n private connected = false;\n private connectedViewport: HTMLElement | null = null;\n private connectedContentTable: HTMLTableElement | null = null;\n private resizeObserver: ResizeObserver | null = null;\n private frame: number | null = null;\n private overflow: TableOverflowState | null = null;\n private inlineOffset = 0;\n private maxInlineOffset = 0;\n private maxBlockOffset = 0;\n private lastMode: TableLayoutMode | null = null;\n\n constructor(private readonly options: TableLayoutControllerOptions) {}\n\n connect(): void {\n this.connected = true;\n this.refresh();\n }\n\n disconnect(): void {\n this.connected = false;\n if (this.frame !== null) cancelAnimationFrame(this.frame);\n this.frame = null;\n this.connectedViewport?.removeEventListener('scroll', this.handleScroll);\n this.connectedViewport?.removeEventListener('wheel', this.handleWheel);\n this.connectedViewport = null;\n this.connectedContentTable = null;\n this.resizeObserver?.disconnect();\n this.resizeObserver = null;\n }\n\n /** Reconcile newly rendered refs, observers, and pending geometry. */\n refresh(forceGeometry = true): void {\n if (!this.connected) return;\n const { viewport, contentTable } = this.options.elements();\n const elementsChanged =\n viewport !== this.connectedViewport || contentTable !== this.connectedContentTable;\n if (elementsChanged) {\n this.connectedViewport?.removeEventListener('scroll', this.handleScroll);\n this.connectedViewport?.removeEventListener('wheel', this.handleWheel);\n this.resizeObserver?.disconnect();\n this.connectedViewport = viewport;\n this.connectedContentTable = contentTable;\n viewport?.addEventListener('scroll', this.handleScroll, { passive: true });\n viewport?.addEventListener('wheel', this.handleWheel, { passive: false });\n\n if (typeof ResizeObserver !== 'undefined' && viewport) {\n this.resizeObserver ??= new ResizeObserver(this.schedule);\n this.resizeObserver.observe(viewport);\n if (contentTable) this.resizeObserver.observe(contentTable);\n }\n }\n const mode = this.options.mode();\n const modeChanged =\n !this.lastMode ||\n mode.documentStickyHeader !== this.lastMode.documentStickyHeader ||\n mode.floatingCollapseAll !== this.lastMode.floatingCollapseAll ||\n mode.clampVerticalOverscroll !== this.lastMode.clampVerticalOverscroll;\n this.lastMode = mode;\n // New refs and explicit refreshes resolve before paint. A recycled virtual\n // window keeps the same scroll geometry, so ResizeObserver owns later size\n // changes without forcing layout after every row patch.\n if (forceGeometry || elementsChanged || modeChanged) this.sync();\n }\n\n private schedule = (): void => {\n if (!this.connected || this.frame !== null) return;\n this.frame = requestAnimationFrame(() => {\n this.frame = null;\n this.sync();\n });\n };\n\n private readonly handleScroll = (): void => {\n const viewport = this.connectedViewport;\n if (!viewport) return;\n const mode = this.options.mode();\n this.clampInlineOffset();\n if (mode.clampVerticalOverscroll) this.clampBlockOffset();\n if (\n viewport.scrollLeft !== this.inlineOffset ||\n (mode.floatingCollapseAll && !mode.documentStickyHeader)\n ) {\n this.schedule();\n }\n };\n\n private readonly handleWheel = (event: WheelEvent): void => {\n const viewport = this.connectedViewport;\n if (!viewport) return;\n\n if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) {\n const atStart = viewport.scrollLeft <= 0 && event.deltaX < 0;\n const atEnd = viewport.scrollLeft >= this.maxInlineOffset && event.deltaX > 0;\n if (atStart || atEnd) event.preventDefault();\n return;\n }\n\n if (!this.options.mode().clampVerticalOverscroll || event.deltaY === 0) return;\n const atStart = viewport.scrollTop <= 0 && event.deltaY < 0;\n const atEnd = viewport.scrollTop >= this.maxBlockOffset && event.deltaY > 0;\n if (!atStart && !atEnd) return;\n\n this.options.verticalEdgeWheel?.(event.deltaY);\n event.preventDefault();\n };\n\n private clampInlineOffset(): void {\n const viewport = this.connectedViewport;\n if (!viewport) return;\n const clamped = Math.min(this.maxInlineOffset, Math.max(0, viewport.scrollLeft));\n if (viewport.scrollLeft !== clamped) viewport.scrollLeft = clamped;\n }\n\n private clampBlockOffset(): void {\n const viewport = this.connectedViewport;\n if (!viewport) return;\n const clamped = Math.min(this.maxBlockOffset, Math.max(0, viewport.scrollTop));\n if (viewport.scrollTop !== clamped) viewport.scrollTop = clamped;\n }\n\n private sync(): void {\n const elements = this.options.elements();\n const viewport = elements.viewport;\n if (!viewport) return;\n\n // Read every layout value before writing styles or reactive state.\n const mode = this.options.mode();\n const measureCollapse =\n mode.floatingCollapseAll &&\n !mode.documentStickyHeader &&\n elements.collapseAllOverlay &&\n elements.frame &&\n elements.interactiveHead;\n const metrics = resolveTableLayoutMetrics({\n viewportInlineSize: viewport.clientWidth,\n viewportBlockSize: viewport.clientHeight,\n scrollInlineSize: viewport.scrollWidth,\n scrollBlockSize: viewport.scrollHeight,\n scrollInlineOffset: viewport.scrollLeft,\n collapseHeadBlockStart: measureCollapse\n ? elements.interactiveHead!.getBoundingClientRect().top\n : undefined,\n collapseFrameBlockStart: measureCollapse\n ? elements.frame!.getBoundingClientRect().top\n : undefined,\n });\n this.inlineOffset = metrics.inlineOffset;\n this.maxInlineOffset = metrics.maxInlineOffset;\n this.maxBlockOffset = Math.max(0, viewport.scrollHeight - viewport.clientHeight);\n\n this.setProperty(viewport, '--ds-table-visible-inline-size', `${metrics.visibleInlineSize}px`);\n if (elements.stickyHeaderTable) {\n this.setProperty(\n elements.stickyHeaderTable,\n '--ds-table-inline-scroll-offset',\n `${metrics.inlineOffset}px`\n );\n this.setProperty(\n elements.stickyHeaderTable,\n '--ds-table-inline-scroll-max-offset',\n `${metrics.maxInlineOffset}px`\n );\n }\n if (metrics.collapseBlockOffset !== null && elements.collapseAllOverlay) {\n this.setProperty(\n elements.collapseAllOverlay,\n '--ds-table-collapse-all-block-offset',\n `${metrics.collapseBlockOffset}px`\n );\n }\n\n if (\n !this.overflow ||\n this.overflow.start !== metrics.overflow.start ||\n this.overflow.end !== metrics.overflow.end ||\n this.overflow.scrollable !== metrics.overflow.scrollable\n ) {\n this.overflow = metrics.overflow;\n this.options.overflowChanged(metrics.overflow);\n }\n }\n\n private setProperty(element: HTMLElement, property: string, value: string): void {\n if (element.style.getPropertyValue(property) !== value) {\n element.style.setProperty(property, value);\n }\n }\n}\n",
|
|
1691
|
+
"content": "export interface TableLayoutElements {\n viewport: HTMLElement | null;\n contentTable: HTMLTableElement | null;\n stickyHeaderTable: HTMLTableElement | null;\n collapseAllOverlay: HTMLElement | null;\n frame: HTMLElement | null;\n interactiveHead: HTMLTableSectionElement | null;\n}\n\nexport interface TableLayoutMode {\n documentStickyHeader: boolean;\n floatingCollapseAll: boolean;\n clampVerticalOverscroll: boolean;\n}\n\nexport interface TableOverflowState {\n start: boolean;\n end: boolean;\n scrollable: boolean;\n}\n\nexport interface TableLayoutControllerOptions {\n elements: () => TableLayoutElements;\n mode: () => TableLayoutMode;\n overflowChanged: (state: TableOverflowState) => void;\n narrowChanged?: (narrow: boolean) => void;\n verticalEdgeWheel?: (deltaY: number) => boolean;\n}\n\nexport interface TableLayoutMetricInput {\n viewportInlineSize: number;\n viewportBlockSize: number;\n scrollInlineSize: number;\n scrollBlockSize: number;\n scrollInlineOffset: number;\n collapseHeadBlockStart?: number;\n collapseFrameBlockStart?: number;\n}\n\nexport interface TableLayoutMetrics {\n visibleInlineSize: number;\n overflow: TableOverflowState;\n inlineOffset: number;\n maxInlineOffset: number;\n collapseBlockOffset: number | null;\n}\n\n/** Pure geometry resolution shared by the controller and focused tests. */\nexport function resolveTableLayoutMetrics(input: TableLayoutMetricInput): TableLayoutMetrics {\n const horizontalOverflow = input.scrollInlineSize - input.viewportInlineSize > 1;\n return {\n visibleInlineSize: input.viewportInlineSize,\n overflow: {\n start: horizontalOverflow && input.scrollInlineOffset > 1,\n end:\n horizontalOverflow &&\n input.scrollInlineOffset + input.viewportInlineSize < input.scrollInlineSize - 1,\n scrollable: horizontalOverflow || input.scrollBlockSize - input.viewportBlockSize > 1,\n },\n inlineOffset: input.scrollInlineOffset,\n maxInlineOffset: Math.max(0, input.scrollInlineSize - input.viewportInlineSize),\n collapseBlockOffset:\n input.collapseHeadBlockStart == null || input.collapseFrameBlockStart == null\n ? null\n : Math.max(0, input.collapseHeadBlockStart - input.collapseFrameBlockStart),\n };\n}\n\n/**\n * Owns table viewport observation. It resolves newly rendered geometry\n * immediately so the first paint has the correct viewport-width chrome, then\n * batches observer-driven scroll and resize reads/writes into one animation frame.\n * The component remains responsible only for refs and reactive overflow state.\n */\nexport class TableLayoutController {\n private connected = false;\n private connectedViewport: HTMLElement | null = null;\n private connectedContentTable: HTMLTableElement | null = null;\n private resizeObserver: ResizeObserver | null = null;\n private frame: number | null = null;\n private overflow: TableOverflowState | null = null;\n private inlineOffset = 0;\n private maxInlineOffset = 0;\n private maxBlockOffset = 0;\n private lastMode: TableLayoutMode | null = null;\n\n constructor(private readonly options: TableLayoutControllerOptions) {}\n\n connect(): void {\n this.connected = true;\n this.refresh();\n }\n\n disconnect(): void {\n this.connected = false;\n if (this.frame !== null) cancelAnimationFrame(this.frame);\n this.frame = null;\n this.connectedViewport?.removeEventListener('scroll', this.handleScroll);\n this.connectedViewport?.removeEventListener('wheel', this.handleWheel);\n this.connectedViewport = null;\n this.connectedContentTable = null;\n this.resizeObserver?.disconnect();\n this.resizeObserver = null;\n }\n\n /** Reconcile newly rendered refs, observers, and pending geometry. */\n refresh(forceGeometry = true): void {\n if (!this.connected) return;\n const { viewport, contentTable } = this.options.elements();\n const elementsChanged =\n viewport !== this.connectedViewport || contentTable !== this.connectedContentTable;\n if (elementsChanged) {\n this.connectedViewport?.removeEventListener('scroll', this.handleScroll);\n this.connectedViewport?.removeEventListener('wheel', this.handleWheel);\n this.resizeObserver?.disconnect();\n this.connectedViewport = viewport;\n this.connectedContentTable = contentTable;\n viewport?.addEventListener('scroll', this.handleScroll, { passive: true });\n viewport?.addEventListener('wheel', this.handleWheel, { passive: false });\n\n if (typeof ResizeObserver !== 'undefined' && viewport) {\n this.resizeObserver ??= new ResizeObserver(this.schedule);\n this.resizeObserver.observe(viewport);\n if (contentTable) this.resizeObserver.observe(contentTable);\n }\n }\n const mode = this.options.mode();\n const modeChanged =\n !this.lastMode ||\n mode.documentStickyHeader !== this.lastMode.documentStickyHeader ||\n mode.floatingCollapseAll !== this.lastMode.floatingCollapseAll ||\n mode.clampVerticalOverscroll !== this.lastMode.clampVerticalOverscroll;\n this.lastMode = mode;\n // New refs and explicit refreshes resolve before paint. A recycled virtual\n // window keeps the same scroll geometry, so ResizeObserver owns later size\n // changes without forcing layout after every row patch.\n if (forceGeometry || elementsChanged || modeChanged) this.sync();\n }\n\n private schedule = (): void => {\n if (!this.connected || this.frame !== null) return;\n this.frame = requestAnimationFrame(() => {\n this.frame = null;\n this.sync();\n });\n };\n\n private readonly handleScroll = (): void => {\n const viewport = this.connectedViewport;\n if (!viewport) return;\n const mode = this.options.mode();\n this.clampInlineOffset();\n if (mode.clampVerticalOverscroll) this.clampBlockOffset();\n if (\n viewport.scrollLeft !== this.inlineOffset ||\n (mode.floatingCollapseAll && !mode.documentStickyHeader)\n ) {\n this.schedule();\n }\n };\n\n private readonly handleWheel = (event: WheelEvent): void => {\n const viewport = this.connectedViewport;\n if (!viewport) return;\n\n if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) {\n const atStart = viewport.scrollLeft <= 0 && event.deltaX < 0;\n const atEnd = viewport.scrollLeft >= this.maxInlineOffset && event.deltaX > 0;\n if (atStart || atEnd) event.preventDefault();\n return;\n }\n\n if (!this.options.mode().clampVerticalOverscroll || event.deltaY === 0) return;\n const atStart = viewport.scrollTop <= 0 && event.deltaY < 0;\n const atEnd = viewport.scrollTop >= this.maxBlockOffset && event.deltaY > 0;\n if (!atStart && !atEnd) return;\n\n this.options.verticalEdgeWheel?.(event.deltaY);\n event.preventDefault();\n };\n\n private clampInlineOffset(): void {\n const viewport = this.connectedViewport;\n if (!viewport) return;\n const clamped = Math.min(this.maxInlineOffset, Math.max(0, viewport.scrollLeft));\n if (viewport.scrollLeft !== clamped) viewport.scrollLeft = clamped;\n }\n\n private clampBlockOffset(): void {\n const viewport = this.connectedViewport;\n if (!viewport) return;\n const clamped = Math.min(this.maxBlockOffset, Math.max(0, viewport.scrollTop));\n if (viewport.scrollTop !== clamped) viewport.scrollTop = clamped;\n }\n\n private sync(): void {\n const elements = this.options.elements();\n const viewport = elements.viewport;\n if (!viewport) return;\n\n // Read every layout value before writing styles or reactive state.\n const mode = this.options.mode();\n const measureCollapse =\n mode.floatingCollapseAll &&\n !mode.documentStickyHeader &&\n elements.collapseAllOverlay &&\n elements.frame &&\n elements.interactiveHead;\n const metrics = resolveTableLayoutMetrics({\n viewportInlineSize: viewport.clientWidth,\n viewportBlockSize: viewport.clientHeight,\n scrollInlineSize: viewport.scrollWidth,\n scrollBlockSize: viewport.scrollHeight,\n scrollInlineOffset: viewport.scrollLeft,\n collapseHeadBlockStart: measureCollapse\n ? elements.interactiveHead!.getBoundingClientRect().top\n : undefined,\n collapseFrameBlockStart: measureCollapse\n ? elements.frame!.getBoundingClientRect().top\n : undefined,\n });\n this.inlineOffset = metrics.inlineOffset;\n this.maxInlineOffset = metrics.maxInlineOffset;\n this.maxBlockOffset = Math.max(0, viewport.scrollHeight - viewport.clientHeight);\n // Header-only geometry; independent of the loaded/recycled row count.\n const pinnedCells = elements.interactiveHead?.querySelectorAll?.<HTMLElement>(\n '.ds-table__header-cell:not(.ds-table__super-header-cell).ds-table__cell--sticky-start, .ds-table__header-cell:not(.ds-table__super-header-cell).ds-table__cell--sticky-end'\n );\n const pinnedSize = [...(pinnedCells ?? [])].reduce(\n (size, cell) => size + cell.getBoundingClientRect().width,\n 0\n );\n const crowded = pinnedSize > 0 && pinnedSize >= metrics.visibleInlineSize;\n if (elements.frame?.getAttribute('data-pins-crowded') !== String(crowded))\n elements.frame?.setAttribute('data-pins-crowded', String(crowded));\n // Reuse viewport observation rather than size-contain the recycled body:\n // WebKit resets native scrolling when contained virtual rows are replaced.\n // Responsive mode belongs to the frame, not the scrollable content width:\n // classic scrollbar gutters must not move the card breakpoint.\n const narrow = (elements.frame?.clientWidth ?? metrics.visibleInlineSize) < 768;\n this.options.narrowChanged?.(narrow);\n if (elements.frame?.getAttribute('data-narrow') !== String(narrow))\n elements.frame?.setAttribute('data-narrow', String(narrow));\n\n this.setProperty(viewport, '--ds-table-visible-inline-size', `${metrics.visibleInlineSize}px`);\n if (elements.stickyHeaderTable) {\n this.setProperty(\n elements.stickyHeaderTable,\n '--ds-table-inline-scroll-offset',\n `${metrics.inlineOffset}px`\n );\n this.setProperty(\n elements.stickyHeaderTable,\n '--ds-table-inline-scroll-max-offset',\n `${metrics.maxInlineOffset}px`\n );\n }\n if (metrics.collapseBlockOffset !== null && elements.collapseAllOverlay) {\n this.setProperty(\n elements.collapseAllOverlay,\n '--ds-table-collapse-all-block-offset',\n `${metrics.collapseBlockOffset}px`\n );\n }\n\n if (\n !this.overflow ||\n this.overflow.start !== metrics.overflow.start ||\n this.overflow.end !== metrics.overflow.end ||\n this.overflow.scrollable !== metrics.overflow.scrollable\n ) {\n this.overflow = metrics.overflow;\n this.options.overflowChanged(metrics.overflow);\n }\n }\n\n private setProperty(element: HTMLElement, property: string, value: string): void {\n if (element.style.getPropertyValue(property) !== value) {\n element.style.setProperty(property, value);\n }\n }\n}\n",
|
|
1576
1692
|
"type": "registry:ui"
|
|
1577
1693
|
},
|
|
1578
1694
|
{
|
|
@@ -1605,14 +1721,24 @@
|
|
|
1605
1721
|
"content": "import {\n deriveTableSelectionState,\n isTableGroupIntent,\n resolvedTableGroupCount,\n tableCollapseAllHost,\n tableColumnSize,\n tableElasticSpacerIndex,\n tableExplicitMinWidth,\n tableGroupAccessories,\n tableGroupHero,\n tableGroupIntentClass,\n tableGroupLabelColor,\n tableRows,\n type TableCollapseAllHost,\n type TableSelectionState,\n} from './table-model';\nimport type {\n TableColumn,\n DataGroup,\n TableGroupAccessory,\n TableGroupHero,\n TableGroupIntent,\n TableRow,\n TableSelectionMode,\n} from './table-types';\n\nexport type TableGroupCountPresentation = 'loaded-progress' | 'total';\n\nexport interface TableGroupRenderModel {\n group: DataGroup;\n count: number;\n loadedCount: number;\n visibleCountText: string;\n countLabel: string;\n intent: TableGroupIntent | undefined;\n intentClass: string | undefined;\n labelColor: ReturnType<typeof tableGroupLabelColor>;\n collapsed: boolean;\n accessories: TableGroupAccessory[];\n hero: TableGroupHero | undefined;\n selection: TableSelectionState | null;\n}\n\nexport interface TableRenderModel {\n grouped: boolean;\n selectable: boolean;\n loadedRows: TableRow[];\n hasData: boolean;\n selectedRowIds: ReadonlySet<string>;\n selection: TableSelectionState;\n collapsedGroupIds: ReadonlySet<string>;\n groups: TableGroupRenderModel[];\n totalColumns: number;\n allGroupsCollapsed: boolean;\n showCollapseAll: boolean;\n collapseAllHost: TableCollapseAllHost | undefined;\n elasticSpacerIndex: number | undefined;\n tableStyle: Record<string, string> | undefined;\n}\n\nexport interface TableRenderModelInput {\n columns: TableColumn[];\n rows: TableRow[];\n groups: DataGroup[];\n grouped: boolean;\n selectionMode: TableSelectionMode;\n selectedRowIds: string[];\n collapsedGroupIds: string[];\n groupCountPresentation?: TableGroupCountPresentation;\n}\n\n/** Create one immutable snapshot of all derived state consumed during a render. */\nexport function createTableRenderModel(input: TableRenderModelInput): TableRenderModel {\n const selectable = input.selectionMode === 'multiple';\n const loadedRows = tableRows(input.rows, input.groups, input.grouped);\n const selectedRowIds = new Set(input.selectedRowIds);\n const collapsedGroupIds = new Set(input.collapsedGroupIds);\n const allGroupsCollapsed =\n input.grouped &&\n input.groups.length > 0 &&\n input.groups.every(group => collapsedGroupIds.has(group.id));\n const showCollapseAll = input.grouped && input.groups.length > 0 && !allGroupsCollapsed;\n const explicitMinWidth = tableExplicitMinWidth(input.columns);\n const elasticSpacerIndex = tableElasticSpacerIndex(input.columns);\n const columnTracks = input.columns.map(column => tableColumnSize(column) ?? 'minmax(0, 1fr)');\n if (elasticSpacerIndex != null) columnTracks.splice(elasticSpacerIndex, 0, 'minmax(0, 1fr)');\n const gridTracks = [\n ...(selectable ? ['var(--_table-selection-column-inline-size)'] : []),\n ...columnTracks,\n ].join(' ');\n const tableStyle: Record<string, string> = {\n '--_table-grid-template-columns': gridTracks || 'minmax(0, 1fr)',\n };\n if (explicitMinWidth != null) {\n tableStyle['--ds-table-explicit-min-inline-size'] = explicitMinWidth;\n }\n\n return {\n grouped: input.grouped,\n selectable,\n loadedRows,\n hasData: loadedRows.length > 0,\n selectedRowIds,\n selection: deriveTableSelectionState(loadedRows, selectedRowIds),\n collapsedGroupIds,\n groups: input.groups.map(group => {\n const loadedCount = group.rows.length;\n const collapsed = collapsedGroupIds.has(group.id);\n const count = resolvedTableGroupCount(group);\n const showTotalOnly =\n input.groupCountPresentation === 'total' || (collapsed && loadedCount === 0);\n const totalLabel = group.countLabel ?? `${count} ${count === 1 ? 'item' : 'items'}`;\n const accessories = tableGroupAccessories(group);\n const hero = tableGroupHero(group);\n const intent = hero || !isTableGroupIntent(group.intent) ? undefined : group.intent;\n return {\n group,\n count,\n loadedCount,\n visibleCountText: showTotalOnly ? String(count) : `${loadedCount} of ${count}`,\n countLabel: showTotalOnly ? totalLabel : `${loadedCount} of ${totalLabel} loaded`,\n intent,\n intentClass: tableGroupIntentClass(intent),\n labelColor: tableGroupLabelColor(intent),\n collapsed,\n accessories,\n hero,\n selection: selectable ? deriveTableSelectionState(group.rows, selectedRowIds) : null,\n };\n }),\n totalColumns:\n input.columns.length + (selectable ? 1 : 0) + (elasticSpacerIndex == null ? 0 : 1),\n allGroupsCollapsed,\n showCollapseAll,\n collapseAllHost: showCollapseAll ? tableCollapseAllHost(input.columns) : undefined,\n elasticSpacerIndex,\n tableStyle,\n };\n}\n",
|
|
1606
1722
|
"type": "registry:ui"
|
|
1607
1723
|
},
|
|
1724
|
+
{
|
|
1725
|
+
"path": "src/wc/components/Table/table-review-fixtures.ts",
|
|
1726
|
+
"content": "import type { TableColumn, TableRow } from './table-types';\n\nexport const REVIEW_COLUMNS: TableColumn[] = [\n {\n id: 'driver',\n label: 'Driver',\n size: 'sm',\n sticky: 'start',\n sortable: true,\n group: { id: 'identity', label: 'Identity' },\n },\n {\n id: 'vehicle',\n label: 'Vehicle',\n size: 'xs',\n sticky: 'start',\n group: { id: 'identity', label: 'Identity' },\n },\n {\n id: 'event',\n label: 'Event / severity',\n size: 'sm',\n sortable: true,\n segments: [\n { label: 'Event', sortKey: 'event' },\n { label: 'Severity', sortKey: 'severity' },\n ],\n group: { id: 'trip', label: 'Trip details' },\n },\n { id: 'location', label: 'Location', size: 'sm' },\n {\n id: 'status',\n label: 'Status',\n size: 'xs',\n sticky: 'end',\n group: { id: 'review', label: 'Review' },\n },\n {\n id: 'action',\n label: '',\n accessibleLabel: 'Actions',\n kind: 'action',\n size: 40,\n sticky: 'end',\n group: { id: 'review', label: 'Review' },\n },\n];\n\nexport function tableReviewRows(count = 8): TableRow[] {\n return Array.from({ length: count }, (_, index) => ({\n id: `review-${index}`,\n selectionLabel: `Driver ${index + 1}`,\n interactive: true,\n cells: {\n driver: {\n primary: `Driver ${String(index + 1).padStart(5, '0')}`,\n secondary: `DRV-${index + 1}`,\n tertiary: 'West coast fleet',\n },\n vehicle: {\n kind: 'icon-text',\n icon: 'VehicleTruck',\n primary: `V-${2048 + index}`,\n secondary: 'Class 8',\n },\n event: {\n primary: index % 2 ? 'Close following' : 'Harsh braking',\n secondary: index % 2 ? 'High' : 'Medium',\n tertiary: 'Recorded today',\n },\n severity: index % 2 ? 'High' : 'Medium',\n location: { primary: 'Burnaby, BC', secondary: 'Highway 1', tertiary: 'Northbound' },\n status: { kind: 'tag', label: 'Pending', intent: 'caution' },\n action: {\n kind: 'action',\n ariaLabel: `Actions for Driver ${index + 1}`,\n items: [{ actionId: 'review', label: 'Review event' }],\n },\n },\n cellPresentation:\n index % 3 === 0\n ? {\n event: { highlight: { intent: 'negative', label: 'Needs review' } },\n location: { flag: { label: 'Location needs confirmation', icon: 'DocumentInverted' } },\n }\n : undefined,\n }));\n}\n\nexport const EDIT_COLUMNS: TableColumn[] = [\n {\n id: 'name',\n label: 'Driver',\n size: 'sm',\n sticky: 'start',\n editor: { type: 'text', required: true },\n },\n {\n id: 'limit',\n label: 'Limit (0–100)',\n size: 'xs',\n editor: { type: 'number', min: 0, max: 100, required: true },\n },\n { id: 'note', label: 'Dispatch note', editor: { type: 'text' } },\n { id: 'recordId', label: 'Record ID', size: 'xs' },\n];\nexport const EDIT_ROWS: TableRow[] = Array.from({ length: 12 }, (_, i) => ({\n id: `edit-${i}`,\n disabled: i === 3,\n cells: {\n name: `Driver ${i + 1}`,\n limit: 50 + i,\n note: i === 3 ? 'Read-only record' : 'Ready',\n recordId: `DRV-${i + 1}`,\n },\n}));\n\nexport const FIELD_COLUMNS: TableColumn[] = [\n EDIT_COLUMNS[0],\n {\n id: 'limit',\n label: 'Limit',\n size: 'xs',\n editor: { type: 'number', min: 0, max: 100, step: 5 },\n },\n {\n id: 'status',\n label: 'Status',\n size: 'xs',\n editor: {\n type: 'select',\n options: [\n { value: 'Ready', label: 'Ready' },\n { value: 'In transit', label: 'In transit' },\n { value: 'Unavailable', label: 'Unavailable', isInactive: true },\n ],\n },\n },\n { id: 'date', label: 'Dispatch date', size: 'sm', editor: { type: 'date' } },\n { id: 'time', label: 'Departure', size: 'xs', editor: { type: 'time', step: 900 } },\n { id: 'note', label: 'Notes', size: 'sm', editor: { type: 'textarea', maxLength: 200 } },\n EDIT_COLUMNS[3],\n];\nexport const FIELD_ROWS: TableRow[] = EDIT_ROWS.slice(0, 6).map(row => ({\n ...row,\n cells: { ...row.cells, limit: 50, status: 'Ready', date: '2026-09-20', time: '09:00' },\n}));\n\nexport const MERGE_COLUMNS: TableColumn[] = [\n { id: 'fleet', label: 'Fleet', size: 'sm' },\n { id: 'monday', label: 'Monday', group: { id: 'week', label: 'Weekly coverage' } },\n { id: 'tuesday', label: 'Tuesday', group: { id: 'week', label: 'Weekly coverage' } },\n { id: 'wednesday', label: 'Wednesday', group: { id: 'week', label: 'Weekly coverage' } },\n];\nexport const MERGE_ROWS: TableRow[] = [\n {\n id: 'west-am',\n cells: {\n fleet: { primary: 'West fleet', secondary: 'Two shifts' },\n monday: 'Training · Monday and Tuesday',\n tuesday: '',\n wednesday: 'Morning coverage',\n },\n },\n {\n id: 'west-pm',\n cells: { fleet: '', monday: 'Dispatch', tuesday: 'Dispatch', wednesday: 'Afternoon coverage' },\n },\n {\n id: 'east',\n cells: { fleet: 'East fleet', monday: 'All-week maintenance', tuesday: '', wednesday: '' },\n },\n];\n",
|
|
1727
|
+
"type": "registry:ui"
|
|
1728
|
+
},
|
|
1608
1729
|
{
|
|
1609
1730
|
"path": "src/wc/components/Table/table-row-view.tsx",
|
|
1610
|
-
"content": "import { h } from '@stencil/core';\nimport {\n resolveTableCellPresentation,\n tableCellTrackStackBlockSize,\n tableCellTextOverflowProps,\n type TableCellPresentation,\n} from './table-cell-model';\nimport {\n isRenderableTableActionMenu,\n isTableCellActionMenu,\n tableActionTriggerId,\n} from './table-action-menu';\nimport { tableRowSelectionLabel } from './table-model';\nimport type { TableRenderModel } from './table-render-model';\nimport { TABLE_NO_HIGHLIGHT_MATCHER, type TableHighlightMatcher } from './table-highlight';\nimport type { TableCellActionDetail, TableCellTextRun, TableColumn, TableRow } from './table-types';\nimport { resolveSafeUrl } from '../../utils/safe-url';\n\nexport interface TableRowViewOptions {\n row: TableRow;\n model: TableRenderModel;\n visibleColumns: TableColumn[];\n emptyCellLabel: string;\n actionMenuElementId: string;\n actionMenu: { rowId: string; columnId: string } | null;\n actionMenuSurface?: { rowId: string; columnId: string } | null;\n highlightMatcher: TableHighlightMatcher;\n highlightFieldIds: string[];\n surfaceOpenRowId?: string;\n ariaRowIndex?: number;\n variableVirtualSize?: boolean;\n intrinsicBlockSize?: number;\n rowKey?: string;\n renderSelectionControl: (\n label: string,\n checked: boolean,\n indeterminate: boolean,\n disabled: boolean,\n onActivate: () => void\n ) => unknown;\n renderStickyEdge: (sticky: TableColumn['sticky']) => unknown;\n onRowActivate: (row: TableRow, event: Event) => void;\n onRowKeyDown: (row: TableRow, event: KeyboardEvent) => void;\n onRowSelection: (row: TableRow) => void;\n onCellAction: (detail: TableCellActionDetail) => void;\n onActionMenuToggle: (row: TableRow, column: TableColumn, event: MouseEvent) => void;\n}\n\n/** Render one semantic body row and the complete declarative table-cell vocabulary. */\nexport function renderTableRow({\n row,\n model,\n visibleColumns,\n emptyCellLabel,\n actionMenuElementId,\n actionMenu,\n actionMenuSurface,\n highlightMatcher,\n highlightFieldIds,\n surfaceOpenRowId,\n ariaRowIndex,\n variableVirtualSize = false,\n intrinsicBlockSize,\n rowKey = row.id,\n renderSelectionControl,\n renderStickyEdge,\n onRowActivate,\n onRowKeyDown,\n onRowSelection,\n onCellAction,\n onActionMenuToggle,\n}: TableRowViewOptions) {\n const selected = model.selectedRowIds.has(row.id);\n const surfaceOpen = row.id === surfaceOpenRowId && !!row.interactive && !row.disabled;\n const rowSelectable = row.selectable !== false && !row.disabled;\n const rowInteractive =\n !row.disabled &&\n (model.selectable && model.selectedRowIds.size > 0 ? rowSelectable : !!row.interactive);\n const beforeSpacer =\n model.elasticSpacerIndex == null\n ? visibleColumns\n : visibleColumns.slice(0, model.elasticSpacerIndex);\n const afterSpacer =\n model.elasticSpacerIndex == null ? [] : visibleColumns.slice(model.elasticSpacerIndex);\n const renderColumn = (column: TableColumn) =>\n renderTableCell({\n row,\n column,\n selected,\n surfaceOpen,\n emptyCellLabel,\n actionMenuElementId,\n actionMenu,\n actionMenuSurface,\n highlightMatcher,\n highlightFieldIds,\n renderStickyEdge,\n onCellAction,\n onActionMenuToggle,\n });\n\n return (\n <tr\n key={rowKey}\n class={{\n 'ds-table__row': true,\n 'ds-table__row--selected': selected,\n 'ds-table__row--disabled': !!row.disabled,\n 'ds-table__row--interactive': rowInteractive,\n 'ds-focus-ring': rowInteractive,\n }}\n data-row-id={row.id}\n data-virtual-id={ariaRowIndex != null ? `row:${row.id}` : undefined}\n data-virtual-pool-key={ariaRowIndex != null ? rowKey : undefined}\n data-virtual-measure={ariaRowIndex != null && variableVirtualSize ? 'true' : undefined}\n data-selected={selected ? 'true' : undefined}\n data-surface-open={surfaceOpen ? 'true' : undefined}\n style={\n intrinsicBlockSize != null\n ? { '--_table-row-intrinsic-block-size': `${intrinsicBlockSize}px` }\n : undefined\n }\n aria-rowindex={ariaRowIndex}\n aria-disabled={row.disabled ? 'true' : undefined}\n tabIndex={rowInteractive ? 0 : undefined}\n onClick={event => onRowActivate(row, event)}\n onKeyDown={event => onRowKeyDown(row, event)}\n >\n {model.selectable && (\n <td\n class={{\n 'ds-table__cell': true,\n 'ds-table__selection-cell': true,\n 'ds-table__cell--sticky-start': true,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--grouped': true,\n 'ds-interaction-fill--selected': selected,\n 'ds-interaction-fill--surface-open': surfaceOpen,\n }}\n >\n {renderSelectionControl(\n `${selected ? 'Deselect' : 'Select'} ${tableRowSelectionLabel(row, visibleColumns)}`,\n selected,\n false,\n !rowSelectable,\n () => onRowSelection(row)\n )}\n {renderStickyEdge('start')}\n </td>\n )}\n {beforeSpacer.map(renderColumn)}\n {model.elasticSpacerIndex != null && (\n <td\n class={{\n 'ds-table__cell': true,\n 'ds-table__elastic-spacer-cell': true,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--grouped': true,\n 'ds-interaction-fill--selected': selected,\n 'ds-interaction-fill--surface-open': surfaceOpen,\n }}\n aria-hidden=\"true\"\n role=\"presentation\"\n data-elastic-spacer=\"true\"\n />\n )}\n {afterSpacer.map(renderColumn)}\n </tr>\n );\n}\n\ninterface TableCellViewOptions {\n row: TableRow;\n column: TableColumn;\n selected: boolean;\n surfaceOpen: boolean;\n emptyCellLabel: string;\n actionMenuElementId: string;\n actionMenu: { rowId: string; columnId: string } | null;\n actionMenuSurface?: { rowId: string; columnId: string } | null;\n highlightMatcher: TableHighlightMatcher;\n highlightFieldIds: string[];\n renderStickyEdge: (sticky: TableColumn['sticky']) => unknown;\n onCellAction: (detail: TableCellActionDetail) => void;\n onActionMenuToggle: (row: TableRow, column: TableColumn, event: MouseEvent) => void;\n}\n\nfunction renderTableCell(options: TableCellViewOptions) {\n const { row, column, selected, surfaceOpen, renderStickyEdge } = options;\n const align = column.align ?? 'start';\n const cell = resolveTableCellPresentation(row.cells[column.id], column);\n const tagCell = cell.kind === 'tag';\n const tagsCell = cell.kind === 'tags';\n const iconCell = cell.kind === 'icon';\n const iconTextCell = cell.kind === 'icon-text';\n const scoreTextCell = cell.kind === 'score-text';\n const imageCell = cell.kind === 'image';\n const actionCell = cell.kind === 'action';\n const actionMenuCell = actionCell && isRenderableTableActionMenu(cell.value);\n const textCell = cell.kind === 'text';\n const primaryTextCell = textCell && cell.primaryText;\n const singleTextCell = textCell && cell.singleLine;\n const emptyCell = cell.kind === 'empty';\n const blankCell = cell.kind === 'blank';\n const tagVariant = tagCell ? cell.variant : undefined;\n const tagsVariant = tagsCell ? cell.variant : undefined;\n const textVariant = textCell ? cell.variant : undefined;\n const imageVariant = imageCell ? cell.variant : undefined;\n const iconTextVariant = iconTextCell ? cell.variant : undefined;\n const scoreTextVariant = scoreTextCell ? cell.variant : undefined;\n const wraps = (textCell || iconTextCell || scoreTextCell) && cell.wraps;\n\n return (\n <td\n key={`${row.id}:${column.id}`}\n class={{\n 'ds-table__cell': true,\n [`ds-table__cell--align-${align}`]: true,\n 'ds-table__cell--tag': tagCell,\n [`ds-table__cell--tag-${tagVariant}`]: tagCell,\n 'ds-table__cell--tags': tagsCell,\n [`ds-table__cell--tags-${tagsVariant}`]: tagsCell,\n 'ds-table__cell--icon': iconCell,\n 'ds-table__cell--icon-text': iconTextCell,\n [`ds-table__cell--icon-text-${iconTextVariant}`]: iconTextCell,\n 'ds-table__cell--icon-text-wrap': iconTextCell && wraps,\n 'ds-table__cell--score-text': scoreTextCell,\n [`ds-table__cell--score-text-${scoreTextVariant}`]: scoreTextCell,\n 'ds-table__cell--score-text-wrap': scoreTextCell && wraps,\n 'ds-table__cell--image': imageCell,\n [`ds-table__cell--image-${imageVariant}`]: imageCell,\n 'ds-table__cell--action': actionCell,\n 'ds-table__cell--action-menu': actionMenuCell,\n 'ds-table__cell--primary-text': primaryTextCell,\n 'ds-table__cell--text-single': singleTextCell,\n 'ds-table__cell--text-multi': textCell && !singleTextCell && textVariant !== 'triple',\n 'ds-table__cell--text-triple': textVariant === 'triple',\n 'ds-table__cell--text-wrap': textCell && wraps,\n 'ds-table__cell--empty': emptyCell,\n 'ds-table__cell--blank': blankCell,\n 'ds-table__cell--sticky-start': column.sticky === 'start',\n 'ds-table__cell--sticky-end': column.sticky === 'end',\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--grouped': true,\n 'ds-interaction-fill--selected': selected,\n 'ds-interaction-fill--surface-open': surfaceOpen,\n }}\n data-column-id={column.id}\n data-cell-type={cell.cellType}\n data-cell-variant={\n tagVariant ??\n tagsVariant ??\n textVariant ??\n imageVariant ??\n iconTextVariant ??\n scoreTextVariant\n }\n data-cell-tracks={tagsCell ? cell.tracks : undefined}\n >\n <span class=\"ds-table__cell-content ds-interaction-fill__content\">\n {renderTableCellValue(cell, options)}\n </span>\n {renderStickyEdge(column.sticky)}\n </td>\n );\n}\n\nfunction renderTableCellValue(cell: TableCellPresentation, options: TableCellViewOptions) {\n const {\n row,\n column,\n emptyCellLabel,\n actionMenuElementId,\n actionMenu,\n actionMenuSurface,\n onCellAction,\n onActionMenuToggle,\n } = options;\n if (cell.kind === 'blank') return null;\n\n if (cell.kind === 'empty') {\n return (\n <ds-text\n class=\"ds-table__cell-track ds-table__cell-track--text\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"tertiary\"\n >\n <span aria-hidden=\"true\">—</span>\n <span class=\"ds-visually-hidden\">{emptyCellLabel}</span>\n </ds-text>\n );\n }\n\n if (cell.kind === 'icon') {\n const value = cell.value;\n return (\n <ds-icon name={value.icon} size=\"md\" color={value.color ?? 'secondary'} label={value.label} />\n );\n }\n\n if (cell.kind === 'icon-text') {\n return (\n <span class=\"ds-table__cell-icon-text\">\n <span class=\"ds-table__cell-icon-text-icon\">\n <ds-icon\n name={cell.icon}\n size=\"md\"\n color={cell.iconColor ?? 'secondary'}\n label={cell.iconLabel}\n />\n </span>\n {renderTextCopy(cell, options)}\n </span>\n );\n }\n\n if (cell.kind === 'score-text') {\n return (\n <span class=\"ds-table__cell-score-text\">\n {renderTextCopy(cell, options)}\n <span class=\"ds-table__cell-score-text-score\">\n <ds-score\n size=\"sm\"\n value={cell.score}\n level={cell.scoreLevel}\n label={cell.scoreLabel}\n isLoading={cell.scoreLoading}\n />\n </span>\n </span>\n );\n }\n\n if (cell.kind === 'image') {\n const value = cell.value;\n return (\n <span class=\"ds-table__cell-image\">\n {value.src ? (\n <img\n class=\"ds-table__cell-image-content\"\n src={value.src}\n alt={value.alt}\n loading=\"lazy\"\n />\n ) : (\n <span class=\"ds-table__cell-image-placeholder\" role=\"img\" aria-label={value.alt} />\n )}\n </span>\n );\n }\n\n if (cell.kind === 'action') {\n const value = cell.value;\n const menu = isRenderableTableActionMenu(value);\n const triggerId = tableActionTriggerId(actionMenuElementId, row.id, column.id);\n const expanded = menu && actionMenu?.rowId === row.id && actionMenu?.columnId === column.id;\n return (\n <ds-button-unfilled\n id={menu ? triggerId : undefined}\n variant={menu ? 'icon' : (value.variant ?? 'label')}\n size=\"md\"\n isInset={true}\n insetDepth=\"double\"\n label={value.label ?? ''}\n icon={value.icon ?? (menu ? 'Ellipses' : '')}\n aria-label={value.ariaLabel ?? null}\n hasBorder={value.hasBorder ?? false}\n isInactive={value.isInactive ?? false}\n isLoading={value.isLoading ?? false}\n hasMenu={menu}\n expanded={menu ? expanded : undefined}\n surfaceOpen={\n menu && actionMenuSurface?.rowId === row.id && actionMenuSurface?.columnId === column.id\n }\n controls={menu ? actionMenuElementId : undefined}\n onDsClick={event => {\n event.stopPropagation();\n if (menu) {\n onActionMenuToggle(row, column, event.detail);\n return;\n }\n if (isTableCellActionMenu(value)) return;\n onCellAction({ actionId: value.actionId, rowId: row.id, columnId: column.id });\n }}\n />\n );\n }\n\n if (cell.kind === 'tag') {\n const value = cell.value;\n const variant = cell.variant;\n const tag = (\n <ds-tag\n label={value.label}\n intent={value.intent ?? 'neutral'}\n contrast={value.contrast ?? 'faint'}\n size={variant === 'text-with-tag' ? 'sm' : 'md'}\n icon={value.icon ?? ''}\n rounded={value.rounded ?? false}\n isInset\n insetDepth={variant === 'text-with-tag' ? 'single' : 'double'}\n />\n );\n if (variant === 'tag-only') return tag;\n return (\n <span class={`ds-table__cell-tag-stack ds-table__cell-tag-stack--${variant}`}>\n {variant === 'tag-with-text' && tag}\n <ds-text\n class=\"ds-table__cell-tag-text ds-table__cell-track\"\n as=\"span\"\n variant={variant === 'tag-with-text' ? 'text-body-small' : 'text-body-medium'}\n color=\"secondary\"\n lineTruncation={1}\n data-table-truncate=\"\"\n >\n {renderHighlightedText(\n value.text,\n tableCellFieldMatcher(options, variant === 'tag-with-text' ? 1 : 0)\n )}\n </ds-text>\n {variant === 'text-with-tag' && <span class=\"ds-table__cell-tag-control-track\">{tag}</span>}\n </span>\n );\n }\n\n if (cell.kind === 'tags') {\n if (cell.value.variant === 'inline') {\n return (\n <span class=\"ds-table__cell-tag-stack ds-table__cell-tag-stack--tag-with-text ds-table__cell-inline-tags-stack\">\n <span class=\"ds-table__cell-inline-tags\">\n {cell.value.items.map((item, index) => [\n index > 0 && (\n <ds-text\n class=\"ds-table__cell-tag-separator\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"quaternary\"\n aria-hidden=\"true\"\n >\n /\n </ds-text>\n ),\n <ds-tag\n key={`${item.label}-${index}`}\n label={item.label}\n intent={item.intent ?? 'neutral'}\n contrast={item.contrast ?? 'faint'}\n size=\"md\"\n icon={item.icon ?? ''}\n rounded={item.rounded ?? false}\n isInset\n insetDepth=\"double\"\n />,\n ])}\n </span>\n {cell.tracks === 2 && (\n <ds-text\n class=\"ds-table__cell-tag-text ds-table__cell-track\"\n as=\"span\"\n variant=\"text-body-small\"\n color=\"secondary\"\n lineTruncation={1}\n data-table-truncate=\"\"\n >\n {renderHighlightedText(cell.value.text, tableCellFieldMatcher(options, 1))}\n </ds-text>\n )}\n </span>\n );\n }\n return (\n <span\n class=\"ds-table__cell-tags\"\n style={{ '--_table-cell-tags-min-block-size': tableCellTrackStackBlockSize(cell.tracks) }}\n >\n {cell.value.items.map((item, index) => (\n <ds-tag\n key={`${item.label}-${index}`}\n label={item.label}\n intent={item.intent ?? 'neutral'}\n contrast={item.contrast ?? 'faint'}\n size=\"sm\"\n icon={item.icon ?? ''}\n rounded={item.rounded ?? false}\n isInset\n insetDepth=\"single\"\n />\n ))}\n </span>\n );\n }\n\n if (cell.kind !== 'text') return null;\n return renderTextCopy(cell, options);\n}\n\nfunction renderTextCopy(\n cell: Extract<TableCellPresentation, { kind: 'text' | 'icon-text' | 'score-text' }>,\n options: TableCellViewOptions\n) {\n const text = cell.value;\n const overflow = tableCellTextOverflowProps(cell.lineClamp);\n const primaryText = cell.kind === 'text' && cell.primaryText;\n const href = resolveSafeUrl(text.href);\n const primary = (\n <ds-text\n class=\"ds-table__cell-primary ds-table__cell-track ds-table__cell-track--text\"\n as=\"span\"\n variant=\"text-body-medium\"\n color={href ? 'inherit' : 'primary'}\n lineTruncation={overflow.lineTruncation}\n wrap={overflow.wrap}\n fontFeature={text.fontFeature ?? 'normal'}\n data-table-truncate={truncateAttr(cell.lineClamp)}\n >\n {renderHighlightedText(text.primary, tableCellFieldMatcher(options, 0))}\n </ds-text>\n );\n\n return (\n <span class={{ 'ds-table__cell-copy': true, 'ds-table__cell-copy--wrap': cell.wraps }}>\n {href ? (\n <a\n class=\"ds-table__cell-link ds-text-action ds-focus-ring\"\n href={href}\n target={text.target === '_blank' ? '_blank' : undefined}\n rel={text.target === '_blank' ? 'noopener noreferrer' : undefined}\n >\n {primary}\n </a>\n ) : (\n primary\n )}\n {renderTextTrack(text.secondary, {\n track: 'secondary',\n variant: primaryText ? 'text-body-medium' : 'text-body-small',\n defaultColor: primaryText ? 'primary' : 'secondary',\n wholeColor: text.secondaryColor,\n lineClamp: cell.lineClamp,\n highlightMatcherForRun: runIndex => tableCellFieldMatcher(options, 1 + runIndex),\n })}\n {renderTextTrack(text.tertiary, {\n track: 'tertiary',\n variant: 'text-body-small',\n defaultColor: 'secondary',\n wholeColor: text.tertiaryColor,\n lineClamp: cell.lineClamp,\n highlightMatcherForRun: runIndex =>\n tableCellFieldMatcher(options, 1 + (text.secondary?.length ?? 0) + runIndex),\n })}\n </span>\n );\n}\n\nfunction renderTextTrack(\n runs: TableCellTextRun[] | undefined,\n options: {\n track: 'secondary' | 'tertiary';\n variant: 'text-body-medium' | 'text-body-small';\n defaultColor: 'primary' | 'secondary';\n wholeColor?: TableCellTextRun['color'];\n lineClamp: Extract<\n TableCellPresentation,\n { kind: 'text' | 'icon-text' | 'score-text' }\n >['lineClamp'];\n highlightMatcherForRun: (runIndex: number) => TableHighlightMatcher;\n }\n) {\n if (!runs?.length) return null;\n const trackClass = `ds-table__cell-${options.track} ds-table__cell-track ds-table__cell-track--text`;\n const overflow = tableCellTextOverflowProps(options.lineClamp);\n const colorFor = (run: TableCellTextRun) =>\n run.color ?? options.wholeColor ?? options.defaultColor;\n const renderRun = (run: TableCellTextRun, index: number, className: string) => {\n const copy = (\n <ds-text\n class={className}\n as=\"span\"\n variant={options.variant}\n color={colorFor(run)}\n decoration={run.help ? 'dotted-underline' : undefined}\n lineTruncation={overflow.lineTruncation}\n wrap={overflow.wrap}\n data-table-truncate={truncateAttr(options.lineClamp)}\n >\n {renderHighlightedText(run.text, options.highlightMatcherForRun(index))}\n </ds-text>\n );\n return run.help ? (\n <ds-tooltip\n key={`${options.track}-run-${index}`}\n label={run.help}\n side=\"bottom\"\n align=\"start\"\n size=\"sm\"\n wrapLabel={true}\n >\n {copy}\n </ds-tooltip>\n ) : (\n copy\n );\n };\n if (runs.length === 1) {\n const run = runs[0];\n if (!run.help) return renderRun(run, 0, trackClass);\n return <span class={trackClass}>{renderRun(run, 0, 'ds-table__cell-run')}</span>;\n }\n\n return (\n <span class={`${trackClass} ds-table__cell-track--runs`}>\n {runs.map((run, index) => [\n index > 0 && (\n <ds-text\n key={`${options.track}-sep-${index}`}\n class=\"ds-table__cell-run-separator\"\n as=\"span\"\n variant={options.variant}\n color=\"secondary\"\n aria-hidden=\"true\"\n >\n ·\n </ds-text>\n ),\n renderRun(run, index, 'ds-table__cell-run'),\n ])}\n </span>\n );\n}\n\nfunction renderHighlightedText(value: string | number | undefined, matcher: TableHighlightMatcher) {\n if (value === undefined) return null;\n return matcher(value).map((segment, index) =>\n segment.match ? (\n <mark class=\"ds-table__match\" key={`match-${index}`}>\n {segment.text}\n </mark>\n ) : (\n <span key={`text-${index}`}>{segment.text}</span>\n )\n );\n}\n\nfunction tableCellFieldMatcher(\n options: TableCellViewOptions,\n fieldIndex: number\n): TableHighlightMatcher {\n const selected = options.highlightFieldIds;\n if (selected.length === 0 || selected.includes(options.column.id)) {\n return options.highlightMatcher;\n }\n const fieldId = options.column.segments?.[fieldIndex]?.sortKey;\n return fieldId && selected.includes(fieldId)\n ? options.highlightMatcher\n : TABLE_NO_HIGHLIGHT_MATCHER;\n}\n\nfunction truncateAttr(\n lineClamp: Extract<\n TableCellPresentation,\n { kind: 'text' | 'icon-text' | 'score-text' }\n >['lineClamp']\n) {\n return lineClamp === 'none' ? undefined : '';\n}\n",
|
|
1731
|
+
"content": "import { h } from '@stencil/core';\nimport {\n resolveTableCellPresentation,\n tableCellTrackStackBlockSize,\n tableCellTextOverflowProps,\n type TableCellPresentation,\n} from './table-cell-model';\nimport {\n isRenderableTableActionMenu,\n isTableCellActionMenu,\n tableActionTriggerId,\n} from './table-action-menu';\nimport { tableRowSelectionLabel } from './table-model';\nimport type { TableRenderModel } from './table-render-model';\nimport { TABLE_NO_HIGHLIGHT_MATCHER, type TableHighlightMatcher } from './table-highlight';\nimport type { TableCellActionDetail, TableCellTextRun, TableColumn, TableRow } from './table-types';\nimport { resolveSafeUrl } from '../../utils/safe-url';\nimport type { TableColumnLayout, TableSpanCell } from './table-structure';\n\nexport interface TableRowViewOptions {\n pins?: Map<string, TableColumnLayout>;\n spans?: Map<string, TableSpanCell>;\n headerId?: (id: string) => string;\n responsiveCards?: boolean;\n cellAttributes?: (row: TableRow, column: TableColumn) => Record<string, unknown>;\n renderEditor?: (row: TableRow, column: TableColumn) => unknown;\n renderEditTrigger?: (row: TableRow, column: TableColumn) => unknown;\n grid?: boolean;\n row: TableRow;\n model: TableRenderModel;\n visibleColumns: TableColumn[];\n emptyCellLabel: string;\n actionMenuElementId: string;\n actionMenu: { rowId: string; columnId: string } | null;\n actionMenuSurface?: { rowId: string; columnId: string } | null;\n highlightMatcher: TableHighlightMatcher;\n highlightFieldIds: string[];\n surfaceOpenRowId?: string;\n ariaRowIndex?: number;\n variableVirtualSize?: boolean;\n intrinsicBlockSize?: number;\n rowKey?: string;\n renderSelectionControl: (\n label: string,\n checked: boolean,\n indeterminate: boolean,\n disabled: boolean,\n onActivate: () => void\n ) => unknown;\n renderStickyEdge: (sticky: TableColumn['sticky'], shadow?: boolean) => unknown;\n onRowActivate: (row: TableRow, event: Event) => void;\n onRowKeyDown: (row: TableRow, event: KeyboardEvent) => void;\n onRowSelection: (row: TableRow) => void;\n onCellAction: (detail: TableCellActionDetail) => void;\n onActionMenuToggle: (row: TableRow, column: TableColumn, event: MouseEvent) => void;\n}\n\n/** Render one semantic body row and the complete declarative table-cell vocabulary. */\nexport function renderTableRow({\n pins,\n spans,\n headerId,\n responsiveCards,\n cellAttributes,\n renderEditor,\n renderEditTrigger,\n grid,\n row,\n model,\n visibleColumns,\n emptyCellLabel,\n actionMenuElementId,\n actionMenu,\n actionMenuSurface,\n highlightMatcher,\n highlightFieldIds,\n surfaceOpenRowId,\n ariaRowIndex,\n variableVirtualSize = false,\n intrinsicBlockSize,\n rowKey = row.id,\n renderSelectionControl,\n renderStickyEdge,\n onRowActivate,\n onRowKeyDown,\n onRowSelection,\n onCellAction,\n onActionMenuToggle,\n}: TableRowViewOptions) {\n const selected = model.selectedRowIds.has(row.id);\n const surfaceOpen = row.id === surfaceOpenRowId && !!row.interactive && !row.disabled;\n const rowSelectable = row.selectable !== false && !row.disabled;\n const rowInteractive =\n !grid &&\n !row.disabled &&\n (model.selectable && model.selectedRowIds.size > 0 ? rowSelectable : !!row.interactive);\n const beforeSpacer =\n model.elasticSpacerIndex == null\n ? visibleColumns\n : visibleColumns.slice(0, model.elasticSpacerIndex);\n const afterSpacer =\n model.elasticSpacerIndex == null ? [] : visibleColumns.slice(model.elasticSpacerIndex);\n const cardActions = responsiveCards\n ? visibleColumns.filter(column => column.kind === 'action')\n : [];\n const firstDataColumn = visibleColumns.find(column => column.kind !== 'action');\n const renderColumn = (column: TableColumn) => {\n const span = spans?.get(column.id);\n if (span === 'covered') return null;\n const lastPin = pins?.get(span?.columnIds[span.columnIds.length - 1] ?? column.id);\n const firstPin = pins?.get(column.id);\n const layout = firstPin\n ? {\n style: column.sticky === 'end' ? (lastPin?.style ?? firstPin.style) : firstPin.style,\n edge: column.sticky === 'end' ? firstPin.edge : lastPin?.edge,\n }\n : undefined;\n return renderTableCell({\n layout,\n afterCoveredColumn:\n !!spans && spans.get(visibleColumns[visibleColumns.indexOf(column) - 1]?.id) === 'covered',\n span: spans?.get(column.id),\n headerId,\n responsiveCards,\n cardActionIndex: cardActions.indexOf(column),\n cardActionDivider: model.selectable && column === cardActions[0],\n firstDataColumn: column === firstDataColumn,\n attributes: cellAttributes?.(row, column),\n editor: renderEditor?.(row, column),\n editTrigger: renderEditTrigger?.(row, column),\n row,\n column,\n selected,\n surfaceOpen,\n emptyCellLabel,\n actionMenuElementId,\n actionMenu,\n actionMenuSurface,\n highlightMatcher,\n highlightFieldIds,\n renderStickyEdge,\n onCellAction,\n onActionMenuToggle,\n });\n };\n\n return (\n <tr\n key={rowKey}\n class={{\n 'ds-table__row': true,\n 'ds-table__row--selected': selected,\n 'ds-table__row--disabled': !!row.disabled,\n 'ds-table__row--interactive': rowInteractive,\n 'ds-focus-ring': rowInteractive,\n }}\n data-row-id={row.id}\n data-responsive-card={responsiveCards ? 'true' : undefined}\n data-virtual-id={ariaRowIndex != null ? `row:${row.id}` : undefined}\n data-virtual-pool-key={ariaRowIndex != null ? rowKey : undefined}\n data-virtual-measure={ariaRowIndex != null && variableVirtualSize ? 'true' : undefined}\n data-selected={selected ? 'true' : undefined}\n data-surface-open={surfaceOpen ? 'true' : undefined}\n style={{\n ...(intrinsicBlockSize != null\n ? { '--_table-row-intrinsic-block-size': `${intrinsicBlockSize}px` }\n : {}),\n ...(responsiveCards\n ? {\n '--_table-card-action-count': String(cardActions.length),\n '--_table-card-control-count': String(cardActions.length + Number(model.selectable)),\n }\n : {}),\n }}\n aria-rowindex={ariaRowIndex}\n aria-disabled={row.disabled ? 'true' : undefined}\n tabIndex={rowInteractive ? 0 : undefined}\n onClick={event => onRowActivate(row, event)}\n onKeyDown={event => onRowKeyDown(row, event)}\n >\n {model.selectable && (\n <td\n class={{\n 'ds-table__cell': true,\n 'ds-table__selection-cell': true,\n 'ds-table__cell--sticky-start': true,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--grouped': true,\n 'ds-interaction-fill--selected': selected,\n 'ds-interaction-fill--surface-open': surfaceOpen,\n }}\n >\n {renderSelectionControl(\n `${selected ? 'Deselect' : 'Select'} ${tableRowSelectionLabel(row, visibleColumns)}`,\n selected,\n false,\n !rowSelectable,\n () => onRowSelection(row)\n )}\n {renderStickyEdge(\n 'start',\n !pins || ![...pins.values()].some(pin => pin.edge === 'start')\n )}\n </td>\n )}\n {beforeSpacer.map(renderColumn)}\n {model.elasticSpacerIndex != null && (\n <td\n class={{\n 'ds-table__cell': true,\n 'ds-table__elastic-spacer-cell': true,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--grouped': true,\n 'ds-interaction-fill--selected': selected,\n 'ds-interaction-fill--surface-open': surfaceOpen,\n }}\n aria-hidden=\"true\"\n role=\"presentation\"\n data-elastic-spacer=\"true\"\n />\n )}\n {afterSpacer.map(renderColumn)}\n </tr>\n );\n}\n\ninterface TableCellViewOptions {\n cardActionIndex?: number;\n cardActionDivider?: boolean;\n firstDataColumn?: boolean;\n afterCoveredColumn?: boolean;\n layout?: TableColumnLayout;\n span?: TableSpanCell;\n headerId?: (id: string) => string;\n responsiveCards?: boolean;\n attributes?: Record<string, unknown>;\n editor?: unknown;\n editTrigger?: unknown;\n row: TableRow;\n column: TableColumn;\n selected: boolean;\n surfaceOpen: boolean;\n emptyCellLabel: string;\n actionMenuElementId: string;\n actionMenu: { rowId: string; columnId: string } | null;\n actionMenuSurface?: { rowId: string; columnId: string } | null;\n highlightMatcher: TableHighlightMatcher;\n highlightFieldIds: string[];\n renderStickyEdge: (sticky: TableColumn['sticky'], shadow?: boolean) => unknown;\n onCellAction: (detail: TableCellActionDetail) => void;\n onActionMenuToggle: (row: TableRow, column: TableColumn, event: MouseEvent) => void;\n}\n\nfunction renderTableCell(options: TableCellViewOptions) {\n const { row, column, selected, surfaceOpen, renderStickyEdge } = options;\n const align = column.align ?? 'start';\n const decoration = row.cellPresentation?.[column.id];\n const span = options.span === 'covered' ? undefined : options.span;\n const cell = resolveTableCellPresentation(row.cells[column.id], column);\n const tagCell = cell.kind === 'tag';\n const tagsCell = cell.kind === 'tags';\n const iconCell = cell.kind === 'icon';\n const iconTextCell = cell.kind === 'icon-text';\n const scoreTextCell = cell.kind === 'score-text';\n const imageCell = cell.kind === 'image';\n const actionCell = cell.kind === 'action';\n const actionMenuCell = actionCell && isRenderableTableActionMenu(cell.value);\n const textCell = cell.kind === 'text';\n const primaryTextCell = textCell && cell.primaryText;\n const singleTextCell = textCell && cell.singleLine;\n const emptyCell = cell.kind === 'empty';\n const blankCell = cell.kind === 'blank';\n const tagVariant = tagCell ? cell.variant : undefined;\n const tagsVariant = tagsCell ? cell.variant : undefined;\n const textVariant = textCell ? cell.variant : undefined;\n const imageVariant = imageCell ? cell.variant : undefined;\n const iconTextVariant = iconTextCell ? cell.variant : undefined;\n const scoreTextVariant = scoreTextCell ? cell.variant : undefined;\n const wraps = (textCell || iconTextCell || scoreTextCell) && cell.wraps;\n\n return (\n <td\n {...options.attributes}\n colSpan={span?.colSpan}\n rowSpan={span?.rowSpan}\n headers={\n options.headerId\n ? (span?.columnIds ?? [column.id]).map(options.headerId).join(' ')\n : undefined\n }\n style={{\n ...options.layout?.style,\n ...(options.cardActionIndex != null && options.cardActionIndex >= 0\n ? { '--_table-card-action-index': String(options.cardActionIndex) }\n : {}),\n ...(span ? { gridColumn: `${span.columnStart} / span ${span.colSpan}` } : {}),\n }}\n data-vertical-align={decoration?.verticalAlign}\n data-cell-highlight={decoration?.highlight?.intent}\n data-cell-borders={decoration?.borders?.join(' ')}\n key={`${row.id}:${column.id}`}\n class={{\n 'ds-table__cell': true,\n 'ds-table__cell--card-action': column.kind === 'action',\n 'ds-table__cell--card-first': !!options.firstDataColumn,\n 'ds-table__cell--after-covered': !!options.afterCoveredColumn,\n [`ds-table__cell--align-${align}`]: true,\n 'ds-table__cell--tag': tagCell,\n [`ds-table__cell--tag-${tagVariant}`]: tagCell,\n 'ds-table__cell--tags': tagsCell,\n [`ds-table__cell--tags-${tagsVariant}`]: tagsCell,\n 'ds-table__cell--icon': iconCell,\n 'ds-table__cell--icon-text': iconTextCell,\n [`ds-table__cell--icon-text-${iconTextVariant}`]: iconTextCell,\n 'ds-table__cell--icon-text-wrap': iconTextCell && wraps,\n 'ds-table__cell--score-text': scoreTextCell,\n [`ds-table__cell--score-text-${scoreTextVariant}`]: scoreTextCell,\n 'ds-table__cell--score-text-wrap': scoreTextCell && wraps,\n 'ds-table__cell--image': imageCell,\n [`ds-table__cell--image-${imageVariant}`]: imageCell,\n 'ds-table__cell--action': actionCell,\n 'ds-table__cell--action-menu': actionMenuCell,\n 'ds-table__cell--primary-text': primaryTextCell,\n 'ds-table__cell--text-single': singleTextCell,\n 'ds-table__cell--text-multi': textCell && !singleTextCell && textVariant !== 'triple',\n 'ds-table__cell--text-triple': textVariant === 'triple',\n 'ds-table__cell--text-wrap': textCell && wraps,\n 'ds-table__cell--empty': emptyCell,\n 'ds-table__cell--blank': blankCell,\n 'ds-table__cell--sticky-start': !!options.layout && column.sticky === 'start',\n 'ds-table__cell--sticky-end': !!options.layout && column.sticky === 'end',\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--grouped': true,\n 'ds-interaction-fill--selected': selected,\n 'ds-interaction-fill--surface-open': surfaceOpen,\n }}\n data-column-id={column.id}\n data-cell-type={cell.cellType}\n data-cell-variant={\n tagVariant ??\n tagsVariant ??\n textVariant ??\n imageVariant ??\n iconTextVariant ??\n scoreTextVariant\n }\n data-cell-tracks={tagsCell ? cell.tracks : undefined}\n >\n {decoration?.highlight && <span class=\"ds-table__cell-highlight\" aria-hidden=\"true\" />}\n {options.responsiveCards && column.kind !== 'action' && !options.firstDataColumn && (\n <span class=\"ds-table__card-field-divider\" aria-hidden=\"true\" />\n )}\n {options.responsiveCards && options.cardActionDivider && (\n <span class=\"ds-table__card-action-divider\" aria-hidden=\"true\" />\n )}\n {options.responsiveCards && column.kind !== 'action' && (\n <ds-text\n class=\"ds-table__card-label ds-interaction-fill__content\"\n as=\"span\"\n variant=\"text-caption\"\n color=\"secondary\"\n aria-hidden=\"true\"\n >\n {column.label || column.accessibleLabel}\n </ds-text>\n )}\n <span class=\"ds-table__cell-content ds-interaction-fill__content\">\n {options.editor ?? renderTableCellValue(cell, options)}\n {!options.editor &&\n options.attributes?.role === 'gridcell' &&\n column.editor?.type === 'select' && (\n <ds-icon\n class=\"ds-table__select-indicator\"\n name=\"ChevronDown\"\n size=\"md\"\n color=\"secondary\"\n />\n )}\n {decoration?.flag && (\n <span class=\"ds-table__cell-flag\">\n <ds-icon\n name={decoration.flag.icon ?? 'Flag'}\n size=\"md\"\n color=\"secondary\"\n label={decoration.flag.label}\n />\n </span>\n )}\n {decoration?.highlight && (\n <span class=\"ds-visually-hidden\">{decoration.highlight.label}</span>\n )}\n </span>\n {options.editTrigger}\n {renderStickyEdge(options.layout ? column.sticky : undefined, !!options.layout?.edge)}\n </td>\n );\n}\n\nfunction renderTableCellValue(cell: TableCellPresentation, options: TableCellViewOptions) {\n const {\n row,\n column,\n emptyCellLabel,\n actionMenuElementId,\n actionMenu,\n actionMenuSurface,\n onCellAction,\n onActionMenuToggle,\n } = options;\n if (cell.kind === 'blank') return null;\n\n if (cell.kind === 'empty') {\n return (\n <ds-text\n class=\"ds-table__cell-track ds-table__cell-track--text\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"tertiary\"\n >\n <span aria-hidden=\"true\">—</span>\n <span class=\"ds-visually-hidden\">{emptyCellLabel}</span>\n </ds-text>\n );\n }\n\n if (cell.kind === 'icon') {\n const value = cell.value;\n return (\n <ds-icon name={value.icon} size=\"md\" color={value.color ?? 'secondary'} label={value.label} />\n );\n }\n\n if (cell.kind === 'icon-text') {\n return (\n <span class=\"ds-table__cell-icon-text\">\n <span class=\"ds-table__cell-icon-text-icon\">\n <ds-icon\n name={cell.icon}\n size=\"md\"\n color={cell.iconColor ?? 'secondary'}\n label={cell.iconLabel}\n />\n </span>\n {renderTextCopy(cell, options)}\n </span>\n );\n }\n\n if (cell.kind === 'score-text') {\n return (\n <span class=\"ds-table__cell-score-text\">\n {renderTextCopy(cell, options)}\n <span class=\"ds-table__cell-score-text-score\">\n <ds-score\n size=\"sm\"\n value={cell.score}\n level={cell.scoreLevel}\n label={cell.scoreLabel}\n isLoading={cell.scoreLoading}\n />\n </span>\n </span>\n );\n }\n\n if (cell.kind === 'image') {\n const value = cell.value;\n return (\n <span class=\"ds-table__cell-image\">\n {value.src ? (\n <img\n class=\"ds-table__cell-image-content\"\n src={value.src}\n alt={value.alt}\n loading=\"lazy\"\n />\n ) : (\n <span class=\"ds-table__cell-image-placeholder\" role=\"img\" aria-label={value.alt} />\n )}\n </span>\n );\n }\n\n if (cell.kind === 'action') {\n const value = cell.value;\n const menu = isRenderableTableActionMenu(value);\n const triggerId = tableActionTriggerId(actionMenuElementId, row.id, column.id);\n const expanded = menu && actionMenu?.rowId === row.id && actionMenu?.columnId === column.id;\n return (\n <ds-button-unfilled\n id={menu ? triggerId : undefined}\n variant={menu ? 'icon' : (value.variant ?? 'label')}\n size=\"md\"\n isInset={true}\n insetDepth=\"double\"\n label={value.label ?? ''}\n icon={value.icon ?? (menu ? 'Ellipses' : '')}\n aria-label={value.ariaLabel ?? null}\n hasBorder={value.hasBorder ?? false}\n isInactive={value.isInactive ?? false}\n isLoading={value.isLoading ?? false}\n hasMenu={menu}\n expanded={menu ? expanded : undefined}\n surfaceOpen={\n menu && actionMenuSurface?.rowId === row.id && actionMenuSurface?.columnId === column.id\n }\n controls={menu ? actionMenuElementId : undefined}\n onDsClick={event => {\n event.stopPropagation();\n if (menu) {\n onActionMenuToggle(row, column, event.detail);\n return;\n }\n if (isTableCellActionMenu(value)) return;\n onCellAction({ actionId: value.actionId, rowId: row.id, columnId: column.id });\n }}\n />\n );\n }\n\n if (cell.kind === 'tag') {\n const value = cell.value;\n const variant = cell.variant;\n const tag = (\n <ds-tag\n label={value.label}\n intent={value.intent ?? 'neutral'}\n contrast={value.contrast ?? 'faint'}\n size={variant === 'text-with-tag' ? 'sm' : 'md'}\n icon={value.icon ?? ''}\n rounded={value.rounded ?? false}\n isInset\n insetDepth={variant === 'text-with-tag' ? 'single' : 'double'}\n />\n );\n if (variant === 'tag-only') return tag;\n return (\n <span class={`ds-table__cell-tag-stack ds-table__cell-tag-stack--${variant}`}>\n {variant === 'tag-with-text' && tag}\n <ds-text\n class=\"ds-table__cell-tag-text ds-table__cell-track\"\n as=\"span\"\n variant={variant === 'tag-with-text' ? 'text-body-small' : 'text-body-medium'}\n color=\"secondary\"\n lineTruncation={1}\n data-table-truncate=\"\"\n >\n {renderHighlightedText(\n value.text,\n tableCellFieldMatcher(options, variant === 'tag-with-text' ? 1 : 0)\n )}\n </ds-text>\n {variant === 'text-with-tag' && <span class=\"ds-table__cell-tag-control-track\">{tag}</span>}\n </span>\n );\n }\n\n if (cell.kind === 'tags') {\n if (cell.value.variant === 'inline') {\n return (\n <span class=\"ds-table__cell-tag-stack ds-table__cell-tag-stack--tag-with-text ds-table__cell-inline-tags-stack\">\n <span class=\"ds-table__cell-inline-tags\">\n {cell.value.items.map((item, index) => [\n index > 0 && (\n <ds-text\n class=\"ds-table__cell-tag-separator\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"quaternary\"\n aria-hidden=\"true\"\n >\n /\n </ds-text>\n ),\n <ds-tag\n key={`${item.label}-${index}`}\n label={item.label}\n intent={item.intent ?? 'neutral'}\n contrast={item.contrast ?? 'faint'}\n size=\"md\"\n icon={item.icon ?? ''}\n rounded={item.rounded ?? false}\n isInset\n insetDepth=\"double\"\n />,\n ])}\n </span>\n {cell.tracks === 2 && (\n <ds-text\n class=\"ds-table__cell-tag-text ds-table__cell-track\"\n as=\"span\"\n variant=\"text-body-small\"\n color=\"secondary\"\n lineTruncation={1}\n data-table-truncate=\"\"\n >\n {renderHighlightedText(cell.value.text, tableCellFieldMatcher(options, 1))}\n </ds-text>\n )}\n </span>\n );\n }\n return (\n <span\n class=\"ds-table__cell-tags\"\n style={{ '--_table-cell-tags-min-block-size': tableCellTrackStackBlockSize(cell.tracks) }}\n >\n {cell.value.items.map((item, index) => (\n <ds-tag\n key={`${item.label}-${index}`}\n label={item.label}\n intent={item.intent ?? 'neutral'}\n contrast={item.contrast ?? 'faint'}\n size=\"sm\"\n icon={item.icon ?? ''}\n rounded={item.rounded ?? false}\n isInset\n insetDepth=\"single\"\n />\n ))}\n </span>\n );\n }\n\n if (cell.kind !== 'text') return null;\n return renderTextCopy(cell, options);\n}\n\nfunction renderTextCopy(\n cell: Extract<TableCellPresentation, { kind: 'text' | 'icon-text' | 'score-text' }>,\n options: TableCellViewOptions\n) {\n const text = cell.value;\n const overflow = tableCellTextOverflowProps(cell.lineClamp);\n const primaryText = cell.kind === 'text' && cell.primaryText;\n const href = resolveSafeUrl(text.href);\n const primary = (\n <ds-text\n class=\"ds-table__cell-primary ds-table__cell-track ds-table__cell-track--text\"\n as=\"span\"\n variant=\"text-body-medium\"\n color={href ? 'inherit' : 'primary'}\n lineTruncation={overflow.lineTruncation}\n wrap={overflow.wrap}\n fontFeature={text.fontFeature ?? 'normal'}\n data-table-truncate={truncateAttr(cell.lineClamp)}\n >\n {renderHighlightedText(text.primary, tableCellFieldMatcher(options, 0))}\n </ds-text>\n );\n\n return (\n <span class={{ 'ds-table__cell-copy': true, 'ds-table__cell-copy--wrap': cell.wraps }}>\n {href ? (\n <a\n class=\"ds-table__cell-link ds-text-action ds-focus-ring\"\n href={href}\n target={text.target === '_blank' ? '_blank' : undefined}\n rel={text.target === '_blank' ? 'noopener noreferrer' : undefined}\n >\n {primary}\n </a>\n ) : (\n primary\n )}\n {renderTextTrack(text.secondary, {\n track: 'secondary',\n variant: primaryText ? 'text-body-medium' : 'text-body-small',\n defaultColor: primaryText ? 'primary' : 'secondary',\n wholeColor: text.secondaryColor,\n lineClamp: cell.lineClamp,\n highlightMatcherForRun: runIndex => tableCellFieldMatcher(options, 1 + runIndex),\n })}\n {renderTextTrack(text.tertiary, {\n track: 'tertiary',\n variant: 'text-body-small',\n defaultColor: 'secondary',\n wholeColor: text.tertiaryColor,\n lineClamp: cell.lineClamp,\n highlightMatcherForRun: runIndex =>\n tableCellFieldMatcher(options, 1 + (text.secondary?.length ?? 0) + runIndex),\n })}\n </span>\n );\n}\n\nfunction renderTextTrack(\n runs: TableCellTextRun[] | undefined,\n options: {\n track: 'secondary' | 'tertiary';\n variant: 'text-body-medium' | 'text-body-small';\n defaultColor: 'primary' | 'secondary';\n wholeColor?: TableCellTextRun['color'];\n lineClamp: Extract<\n TableCellPresentation,\n { kind: 'text' | 'icon-text' | 'score-text' }\n >['lineClamp'];\n highlightMatcherForRun: (runIndex: number) => TableHighlightMatcher;\n }\n) {\n if (!runs?.length) return null;\n const trackClass = `ds-table__cell-${options.track} ds-table__cell-track ds-table__cell-track--text`;\n const overflow = tableCellTextOverflowProps(options.lineClamp);\n const colorFor = (run: TableCellTextRun) =>\n run.color ?? options.wholeColor ?? options.defaultColor;\n const renderRun = (run: TableCellTextRun, index: number, className: string) => {\n const copy = (\n <ds-text\n class={className}\n as=\"span\"\n variant={options.variant}\n color={colorFor(run)}\n decoration={run.help ? 'dotted-underline' : undefined}\n lineTruncation={overflow.lineTruncation}\n wrap={overflow.wrap}\n data-table-truncate={truncateAttr(options.lineClamp)}\n >\n {renderHighlightedText(run.text, options.highlightMatcherForRun(index))}\n </ds-text>\n );\n return run.help ? (\n <ds-tooltip\n key={`${options.track}-run-${index}`}\n label={run.help}\n side=\"bottom\"\n align=\"start\"\n size=\"sm\"\n wrapLabel={true}\n >\n {copy}\n </ds-tooltip>\n ) : (\n copy\n );\n };\n if (runs.length === 1) {\n const run = runs[0];\n if (!run.help) return renderRun(run, 0, trackClass);\n return <span class={trackClass}>{renderRun(run, 0, 'ds-table__cell-run')}</span>;\n }\n\n return (\n <span class={`${trackClass} ds-table__cell-track--runs`}>\n {runs.map((run, index) => [\n index > 0 && (\n <ds-text\n key={`${options.track}-sep-${index}`}\n class=\"ds-table__cell-run-separator\"\n as=\"span\"\n variant={options.variant}\n color=\"secondary\"\n aria-hidden=\"true\"\n >\n ·\n </ds-text>\n ),\n renderRun(run, index, 'ds-table__cell-run'),\n ])}\n </span>\n );\n}\n\nfunction renderHighlightedText(value: string | number | undefined, matcher: TableHighlightMatcher) {\n if (value === undefined) return null;\n return matcher(value).map((segment, index) =>\n segment.match ? (\n <mark class=\"ds-table__match\" key={`match-${index}`}>\n {segment.text}\n </mark>\n ) : (\n <span key={`text-${index}`}>{segment.text}</span>\n )\n );\n}\n\nfunction tableCellFieldMatcher(\n options: TableCellViewOptions,\n fieldIndex: number\n): TableHighlightMatcher {\n const selected = options.highlightFieldIds;\n if (selected.length === 0 || selected.includes(options.column.id)) {\n return options.highlightMatcher;\n }\n const fieldId = options.column.segments?.[fieldIndex]?.sortKey;\n return fieldId && selected.includes(fieldId)\n ? options.highlightMatcher\n : TABLE_NO_HIGHLIGHT_MATCHER;\n}\n\nfunction truncateAttr(\n lineClamp: Extract<\n TableCellPresentation,\n { kind: 'text' | 'icon-text' | 'score-text' }\n >['lineClamp']\n) {\n return lineClamp === 'none' ? undefined : '';\n}\n",
|
|
1611
1732
|
"type": "registry:ui"
|
|
1612
1733
|
},
|
|
1613
1734
|
{
|
|
1614
1735
|
"path": "src/wc/components/Table/table-skeleton-view.tsx",
|
|
1615
|
-
"content": "import { h } from '@stencil/core';\nimport { resolveTableCellImageTracks, tableCellImageVariant } from './table-cell-model';\nimport type { TableRenderModel } from './table-render-model';\nimport type { TableCellSkeleton, TableColumn } from './table-types';\n\ninterface TableSkeletonViewOptions {\n model: TableRenderModel;\n visibleColumns: TableColumn[];\n skeletonRows: number;\n renderStickyEdge: (sticky: TableColumn['sticky']) => unknown;\n}\n\n/** Render the loading body from the same cell recipes used by loaded rows. */\nexport function renderTableSkeletonBody(options: TableSkeletonViewOptions) {\n const { model, visibleColumns, renderStickyEdge } = options;\n const count = Math.min(20, Math.max(1, Math.round(options.skeletonRows) || 1));\n const beforeSpacer =\n model.elasticSpacerIndex == null\n ? visibleColumns\n : visibleColumns.slice(0, model.elasticSpacerIndex);\n const afterSpacer =\n model.elasticSpacerIndex == null ? [] : visibleColumns.slice(model.elasticSpacerIndex);\n return (\n <tbody class=\"ds-table__body ds-table__skeleton-body\">\n {Array.from({ length: count }, (_, index) => (\n <tr class=\"ds-table__row ds-table__skeleton-row\" key={`skeleton-${index}`}>\n {model.selectable && (\n <td\n class=\"ds-table__cell ds-table__selection-cell ds-table__cell--sticky-start ds-table__skeleton-cell ds-interaction-fill ds-interaction-fill--grouped\"\n data-skeleton-kind=\"checkbox\"\n >\n <span class=\"ds-table__skeleton-checkbox-canvas ds-interaction-fill__content\">\n <ds-skeleton\n class=\"ds-table__skeleton-checkbox\"\n variant=\"control\"\n controlSize=\"xs\"\n width=\"var(--dimension-iconography-sm)\"\n />\n </span>\n {renderStickyEdge('start')}\n </td>\n )}\n {beforeSpacer.map(column
|
|
1736
|
+
"content": "import { h } from '@stencil/core';\nimport { resolveTableCellImageTracks, tableCellImageVariant } from './table-cell-model';\nimport type { TableRenderModel } from './table-render-model';\nimport type { TableCellSkeleton, TableColumn } from './table-types';\nimport type { TableColumnLayout } from './table-structure';\n\ninterface TableSkeletonViewOptions {\n pins?: Map<string, TableColumnLayout>;\n model: TableRenderModel;\n visibleColumns: TableColumn[];\n skeletonRows: number;\n renderStickyEdge: (sticky: TableColumn['sticky'], shadow?: boolean) => unknown;\n}\n\n/** Render the loading body from the same cell recipes used by loaded rows. */\nexport function renderTableSkeletonBody(options: TableSkeletonViewOptions) {\n const { model, visibleColumns, renderStickyEdge } = options;\n const count = Math.min(20, Math.max(1, Math.round(options.skeletonRows) || 1));\n const beforeSpacer =\n model.elasticSpacerIndex == null\n ? visibleColumns\n : visibleColumns.slice(0, model.elasticSpacerIndex);\n const afterSpacer =\n model.elasticSpacerIndex == null ? [] : visibleColumns.slice(model.elasticSpacerIndex);\n return (\n <tbody class=\"ds-table__body ds-table__skeleton-body\">\n {Array.from({ length: count }, (_, index) => (\n <tr class=\"ds-table__row ds-table__skeleton-row\" key={`skeleton-${index}`}>\n {model.selectable && (\n <td\n class=\"ds-table__cell ds-table__selection-cell ds-table__cell--sticky-start ds-table__skeleton-cell ds-interaction-fill ds-interaction-fill--grouped\"\n data-skeleton-kind=\"checkbox\"\n >\n <span class=\"ds-table__skeleton-checkbox-canvas ds-interaction-fill__content\">\n <ds-skeleton\n class=\"ds-table__skeleton-checkbox\"\n variant=\"control\"\n controlSize=\"xs\"\n width=\"var(--dimension-iconography-sm)\"\n />\n </span>\n {renderStickyEdge(\n 'start',\n !options.pins || ![...options.pins.values()].some(pin => pin.edge === 'start')\n )}\n </td>\n )}\n {beforeSpacer.map(column =>\n renderSkeletonCell(column, index, renderStickyEdge, options.pins?.get(column.id))\n )}\n {model.elasticSpacerIndex != null && (\n <td\n class=\"ds-table__cell ds-table__elastic-spacer-cell ds-table__skeleton-cell ds-interaction-fill ds-interaction-fill--grouped\"\n aria-hidden=\"true\"\n role=\"presentation\"\n data-elastic-spacer=\"true\"\n />\n )}\n {afterSpacer.map(column =>\n renderSkeletonCell(column, index, renderStickyEdge, options.pins?.get(column.id))\n )}\n </tr>\n ))}\n </tbody>\n );\n}\n\nfunction renderSkeletonCell(\n column: TableColumn,\n rowIndex: number,\n renderStickyEdge: TableSkeletonViewOptions['renderStickyEdge'],\n layout?: TableColumnLayout\n) {\n const skeleton =\n column.skeleton ??\n ((column.kind === 'action'\n ? { kind: 'action', variant: 'icon' }\n : { kind: 'text', lines: 1 }) satisfies TableCellSkeleton);\n const align = column.align ?? 'start';\n const text = skeleton.kind === 'text';\n const iconText = skeleton.kind === 'icon-text';\n const scoreText = skeleton.kind === 'score-text';\n const lines = text || iconText || scoreText ? (skeleton.lines ?? 1) : 1;\n const tag = skeleton.kind === 'tag';\n const icon = skeleton.kind === 'icon';\n const image = skeleton.kind === 'image';\n const imageVariant = image\n ? tableCellImageVariant(resolveTableCellImageTracks(skeleton.tracks))\n : undefined;\n const iconTextVariant = iconText\n ? lines === 3\n ? 'triple'\n : lines === 2\n ? 'multi'\n : 'single'\n : undefined;\n const scoreTextVariant = scoreText\n ? lines === 3\n ? 'triple'\n : lines === 2\n ? 'multi'\n : 'single'\n : undefined;\n const action = skeleton.kind === 'action';\n const blank = skeleton.kind === 'blank';\n\n return (\n <td\n style={layout?.style}\n class={{\n 'ds-table__cell': true,\n [`ds-table__cell--align-${align}`]: true,\n 'ds-table__skeleton-cell': true,\n 'ds-table__cell--text-single': text && lines === 1,\n 'ds-table__cell--text-multi': text && lines === 2,\n 'ds-table__cell--text-triple': text && lines === 3,\n 'ds-table__cell--tag': tag,\n 'ds-table__cell--tag-tag-only': tag,\n 'ds-table__cell--icon': icon,\n 'ds-table__cell--icon-text': iconText,\n [`ds-table__cell--icon-text-${iconTextVariant}`]: iconText,\n 'ds-table__cell--score-text': scoreText,\n [`ds-table__cell--score-text-${scoreTextVariant}`]: scoreText,\n 'ds-table__cell--image': image,\n [`ds-table__cell--image-${imageVariant}`]: image,\n 'ds-table__cell--action': action,\n 'ds-table__cell--blank': blank,\n 'ds-table__cell--sticky-start': column.sticky === 'start',\n 'ds-table__cell--sticky-end': column.sticky === 'end',\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--grouped': true,\n }}\n data-column-id={column.id}\n data-skeleton-kind={skeleton.kind}\n data-cell-variant={imageVariant ?? iconTextVariant ?? scoreTextVariant}\n key={`skeleton-${rowIndex}:${column.id}`}\n >\n <span class=\"ds-table__cell-content ds-interaction-fill__content\">\n {renderSkeletonCellContent(skeleton)}\n </span>\n {renderStickyEdge(layout ? column.sticky : undefined, !!layout?.edge)}\n </td>\n );\n}\n\nfunction renderSkeletonCellContent(skeleton: TableCellSkeleton) {\n if (skeleton.kind === 'blank') return null;\n\n if (skeleton.kind === 'image') {\n return (\n <span class=\"ds-table__cell-image ds-table__skeleton-image\">\n <ds-skeleton\n class=\"ds-table__skeleton-image-shape\"\n variant=\"control\"\n controlSize=\"md\"\n width=\"100%\"\n />\n </span>\n );\n }\n\n if (skeleton.kind === 'icon') {\n return <ds-skeleton variant=\"icon\" iconSize=\"md\" rounded={skeleton.rounded ?? false} />;\n }\n\n if (skeleton.kind === 'tag') {\n return <ds-skeleton variant=\"control\" controlSize=\"sm\" width={skeleton.width ?? '64%'} />;\n }\n\n if (skeleton.kind === 'action') {\n const iconOnly = (skeleton.variant ?? 'icon') === 'icon';\n return (\n <ds-skeleton\n variant=\"control\"\n controlSize=\"sm\"\n width={skeleton.width ?? (iconOnly ? '24px' : '72%')}\n />\n );\n }\n\n const lines = skeleton.lines ?? 1;\n const copy = (\n <span class=\"ds-table__cell-copy\">\n <span class=\"ds-table__cell-primary ds-table__cell-track ds-table__cell-track--text\">\n <ds-skeleton\n variant=\"text\"\n textVariant=\"text-body-medium\"\n width={skeleton.primaryWidth ?? '100%'}\n />\n </span>\n {lines >= 2 && (\n <span class=\"ds-table__cell-secondary ds-table__cell-track ds-table__cell-track--text\">\n <ds-skeleton\n variant=\"text\"\n textVariant=\"text-body-small\"\n width={skeleton.secondaryWidth ?? '72%'}\n />\n </span>\n )}\n {lines === 3 && (\n <span class=\"ds-table__cell-tertiary ds-table__cell-track ds-table__cell-track--text\">\n <ds-skeleton\n variant=\"text\"\n textVariant=\"text-body-small\"\n width={skeleton.tertiaryWidth ?? '56%'}\n />\n </span>\n )}\n </span>\n );\n if (skeleton.kind === 'icon-text') {\n return (\n <span class=\"ds-table__cell-icon-text\">\n <span class=\"ds-table__cell-icon-text-icon\">\n <ds-skeleton variant=\"icon\" iconSize=\"md\" />\n </span>\n {copy}\n </span>\n );\n }\n if (skeleton.kind === 'score-text') {\n return (\n <span class=\"ds-table__cell-score-text\">\n {copy}\n <span class=\"ds-table__cell-score-text-score\">\n <ds-score size=\"sm\" isLoading={true} label=\"Safety score\" />\n </span>\n </span>\n );\n }\n return copy;\n}\n",
|
|
1737
|
+
"type": "registry:ui"
|
|
1738
|
+
},
|
|
1739
|
+
{
|
|
1740
|
+
"path": "src/wc/components/Table/table-structure.ts",
|
|
1741
|
+
"content": "import { tableColumnSize } from './table-model';\nimport type { TableCellSpan, TableColumn, TableRow } from './table-types';\n\nexport interface TableColumnLayout {\n style: Record<string, string>;\n edge?: 'start' | 'end';\n}\n\n/** Column-only work. Never measure cells or scan rows while scrolling. */\nexport function tablePinnedColumns(columns: TableColumn[], selectable: boolean) {\n const result = new Map<string, TableColumnLayout>();\n for (const edge of ['start', 'end'] as const) {\n const pinned = columns.filter(column => column.sticky === edge && tableColumnSize(column));\n const ordered = edge === 'start' ? pinned : [...pinned].reverse();\n const sizes =\n edge === 'start' && selectable ? ['var(--_table-selection-column-inline-size)'] : [];\n for (const [index, column] of ordered.entries()) {\n result.set(column.id, {\n style: { '--_table-pin-offset': sizes.length ? `calc(${sizes.join(' + ')})` : '0px' },\n edge: index === ordered.length - 1 ? edge : undefined,\n });\n sizes.push(tableColumnSize(column)!);\n }\n }\n return result;\n}\n\nexport interface TableHeaderBand {\n key: string;\n label?: string;\n start: number;\n span: number;\n firstColumnId?: string;\n lastColumnId?: string;\n sticky?: 'start' | 'end';\n}\n\n/** Split at visibility, reorder, selection, spacer and pin boundaries. Never span an unrelated column. */\nexport function tableHeaderBands(\n columns: TableColumn[],\n selectable: boolean,\n spacer?: number\n): TableHeaderBand[] {\n if (!columns.some(column => column.group)) return [];\n const lanes: Array<TableColumn | undefined> = [...columns];\n if (spacer != null) lanes.splice(spacer, 0, undefined);\n if (selectable) lanes.unshift(undefined);\n const bands: TableHeaderBand[] = [];\n let previous: TableColumn | undefined;\n for (const [index, column] of lanes.entries()) {\n const group = column?.group;\n const last = bands[bands.length - 1];\n if (group && previous?.group?.id === group.id && previous.sticky === column?.sticky && last) {\n last.span++;\n last.lastColumnId = column?.id;\n } else {\n bands.push({\n key: `band-${index}`,\n label: group?.label,\n start: index + 1,\n span: 1,\n firstColumnId: column?.id,\n lastColumnId: column?.id,\n sticky: column?.sticky,\n });\n }\n previous = column;\n }\n return bands;\n}\n\nexport type TableSpanCell =\n | { rowSpan: number; colSpan: number; columnStart: number; columnIds: string[] }\n | 'covered';\nexport interface TableSpanModel {\n cells: Map<string, Map<string, TableSpanCell>>;\n issues: string[];\n hasRowSpans: boolean;\n}\n\n/** Validate the complete rectangle before covering any cells. Invalid spans leave all data visible. */\nexport function tableSpanModel(\n spans: TableCellSpan[],\n rows: TableRow[],\n columns: TableColumn[],\n options: {\n windowed: boolean;\n grouped: boolean;\n grid: boolean;\n selectable: boolean;\n spacer?: number;\n }\n): TableSpanModel {\n const result: TableSpanModel = { cells: new Map(), issues: [], hasRowSpans: false };\n if (!spans.length) return result;\n const rowIndex = new Map(rows.map((row, index) => [row.id, index]));\n const columnIndex = new Map(columns.map((column, index) => [column.id, index]));\n const contiguous = (ids: string[], indices: Map<string, number>) =>\n ids.length > 0 &&\n ids.every((id, index) => indices.get(id) === (indices.get(ids[0]) ?? -100000) + index);\n for (const [index, span] of spans.entries()) {\n if (\n !span ||\n !Array.isArray(span.rowIds) ||\n !Array.isArray(span.columnIds) ||\n !span.rowIds.every(id => typeof id === 'string') ||\n !span.columnIds.every(id => typeof id === 'string')\n ) {\n result.issues.push(\n `Merge ${index + 1} ignored: rowIds and columnIds must be identity arrays.`\n );\n continue;\n }\n const start = columnIndex.get(span.columnIds[0]) ?? -1;\n const end = start + span.columnIds.length;\n const sticky = columns[start]?.sticky;\n const invalid =\n options.grouped ||\n options.grid ||\n span.rowIds.length * span.columnIds.length > 10000 ||\n (options.windowed && span.rowIds.length > 1) ||\n !contiguous(span.rowIds, rowIndex) ||\n !contiguous(span.columnIds, columnIndex) ||\n (options.spacer != null && start < options.spacer && end > options.spacer) ||\n span.columnIds.some(id => columns[columnIndex.get(id)!]?.sticky !== sticky) ||\n (!!sticky && span.columnIds.some(id => !tableColumnSize(columns[columnIndex.get(id)!]))) ||\n span.rowIds.some(rowId => span.columnIds.some(id => result.cells.get(rowId)?.has(id)));\n if (invalid) {\n result.issues.push(\n `Merge ${index + 1} ignored: requires a non-overlapping contiguous rectangle, no pin/spacer crossing, no grouping/grid, and no virtual row span.`\n );\n continue;\n }\n result.hasRowSpans ||= span.rowIds.length > 1;\n for (const [ri, rowId] of span.rowIds.entries()) {\n const cells = result.cells.get(rowId) ?? new Map<string, TableSpanCell>();\n for (const [ci, id] of span.columnIds.entries()) {\n cells.set(\n id,\n ri === 0 && ci === 0\n ? {\n rowSpan: span.rowIds.length,\n colSpan: span.columnIds.length,\n columnStart:\n start +\n 1 +\n Number(options.selectable) +\n Number(options.spacer != null && start >= options.spacer),\n columnIds: span.columnIds,\n }\n : 'covered'\n );\n }\n result.cells.set(rowId, cells);\n }\n }\n return result;\n}\n",
|
|
1616
1742
|
"type": "registry:ui"
|
|
1617
1743
|
},
|
|
1618
1744
|
{
|
|
@@ -1622,7 +1748,7 @@
|
|
|
1622
1748
|
},
|
|
1623
1749
|
{
|
|
1624
1750
|
"path": "src/wc/components/Table/table-types.ts",
|
|
1625
|
-
"content": "import type { DataField, DataFieldSegment } from '../../utils/data-field';\nimport type { TagContrast, TagIntent } from '../Tag/Tag';\nimport type { IconColor } from '../Icon/Icon';\nimport type { SafetyScoreLevel } from '../Score/score-types';\nimport type { TextColor } from '../Text/text-types';\nimport type { PaginationState } from '../Pagination/pagination-types';\n\nexport type DataSortDirection = 'asc' | 'desc';\nexport type TableSelectionMode = 'none' | 'multiple';\nexport type TableCellAlign = 'start' | 'center' | 'end';\nexport type TableCellLinkTarget = '_self' | '_blank';\nexport type TableColumnSticky = 'start' | 'end';\nexport type TableCaptionVisibility = 'visible' | 'hidden';\nexport type TableLoadMoreMode = 'auto' | 'manual';\nexport type TableDataMode = 'infinite' | 'pagination' | 'virtual';\nexport interface TableDataModeChangeDetail {\n dataMode: TableDataMode;\n}\nexport type TableLoadMoreReason = 'auto' | 'manual' | 'retry';\nexport type TableColumnWidth = 'xs' | 'sm' | 'md' | 'lg' | 'xl';\n/** Visible line budget before a text track ellipsizes. */\nexport type TableCellMaxLines = 1 | 2 | 3;\n/** Resolved clamp, including unlimited wrapping. */\nexport type TableCellLineClamp = TableCellMaxLines | 'none';\n/** Semantic color for a group section header (faint surface + bold title). */\nexport type TableGroupIntent =\n | 'brand'\n | 'neutral'\n | 'negative'\n | 'warning'\n | 'caution'\n | 'positive';\n\n/** Independently sortable label within a compound column header. */\nexport interface TableHeaderSegment extends DataFieldSegment {\n /** Visible separator rendered after this label when another segment follows. */\n separator?: string;\n}\n\n/** Controlled member-row sort state. Group order is controlled separately. */\nexport interface DataSortState {\n fieldId: string;\n direction: DataSortDirection;\n}\n\n/** One controlled grouping level. Applications supply groups in their final fixed order. */\nexport interface DataGroupingState {\n fieldId: string;\n direction: DataSortDirection;\n /** Optional application-owned data point used to order the group sections. */\n orderBy?: string;\n}\n\n/** One independently colored run inside a secondary or tertiary track. */\nexport interface TableCellTextRun {\n text: string;\n /** Defaults to the track’s standard color. */\n color?: TextColor;\n /** Optional supplementary copy shown from a dotted-underlined tooltip trigger. */\n help?: string;\n}\n\n/** A secondary or tertiary track: one string, or up to three colorable runs. */\nexport type TableCellTextTrack = string | TableCellTextRun[];\n\n/** Standard two- or three-line cell content owned by the table renderer. */\nexport interface TableCellText {\n primary: string | number;\n secondary?: TableCellTextTrack;\n /** Third subdued track; ignored on dual-primary cells. */\n tertiary?: TableCellTextTrack;\n /** Optional semantic foreground for a string secondary track. */\n secondaryColor?: TextColor;\n /** Optional semantic foreground for a string tertiary track. */\n tertiaryColor?: TextColor;\n /** Application-owned URL for the primary track only. Scalars stay unlinked. */\n href?: string;\n /** Native anchor target for a resolved primary link. */\n target?: TableCellLinkTarget;\n /** Allow this cell to wrap even when its column truncates by default. */\n wrap?: boolean;\n /** Clamp wrapping text after 1, 2, or 3 lines. Overrides column wrap. */\n maxLines?: TableCellMaxLines;\n fontFeature?: 'normal' | 'tabular-nums';\n}\n\n/** Two-line text content where both tracks use primary body text. */\nexport interface TableCellPrimaryText {\n kind: 'primary-text';\n primary: string | number;\n secondary: string | number;\n /** Application-owned URL for the primary track only. */\n href?: string;\n /** Native anchor target for a resolved primary link. */\n target?: TableCellLinkTarget;\n /** Allow this cell to wrap even when its column truncates by default. */\n wrap?: boolean;\n /** Clamp wrapping text after 1, 2, or 3 lines. Overrides column wrap. */\n maxLines?: TableCellMaxLines;\n fontFeature?: 'normal' | 'tabular-nums';\n}\n\n/** Applicable data point whose value is currently unavailable. */\nexport interface TableCellEmpty {\n kind: 'empty';\n}\n\n/** Data point that is intentionally not applicable to this row. */\nexport interface TableCellBlank {\n kind: 'blank';\n}\n\n/** Declarative icon-only content rendered by the table's standard cell primitive. */\nexport interface TableCellIcon {\n kind: 'icon';\n /** Exact canonical IcoMo export name. */\n icon: string;\n color?: IconColor;\n /** Accessible label for informative icons. Omit only when the icon is decorative. */\n label?: string;\n /** Application-owned value used when this icon column participates in sorting. */\n sortValue?: string | number | boolean;\n}\n\n/**\n * Prefix icon beside a text copy stack. Independent of icon-only and plain\n * text cells so those recipes stay unchanged.\n */\nexport interface TableCellIconText {\n kind: 'icon-text';\n /** Exact canonical IcoMo export name. */\n icon: string;\n iconColor?: IconColor;\n /** Accessible name. Omit when the glyph is decorative. */\n iconLabel?: string;\n primary: string | number;\n secondary?: TableCellTextTrack;\n tertiary?: TableCellTextTrack;\n secondaryColor?: TextColor;\n tertiaryColor?: TextColor;\n href?: string;\n target?: TableCellLinkTarget;\n wrap?: boolean;\n maxLines?: TableCellMaxLines;\n fontFeature?: 'normal' | 'tabular-nums';\n}\n\n/**\n * Compact safety-score fill at the top-right of a text copy stack. The score\n * occupies one 24px track while the copy keeps the standard text-cell anatomy.\n */\nexport interface TableCellScoreText {\n kind: 'score-text';\n /** Display-ready safety score. Numeric values infer their semantic level. */\n score: string | number;\n scoreLevel?: SafetyScoreLevel;\n /** Accessible name prefix. Defaults to “Safety score”. */\n scoreLabel?: string;\n /** Replace the score figure with its matching sm skeleton. */\n scoreLoading?: boolean;\n primary: string | number;\n secondary?: TableCellTextTrack;\n tertiary?: TableCellTextTrack;\n secondaryColor?: TextColor;\n tertiaryColor?: TextColor;\n href?: string;\n target?: TableCellLinkTarget;\n wrap?: boolean;\n maxLines?: TableCellMaxLines;\n fontFeature?: 'normal' | 'tabular-nums';\n}\n\n/** Body-row track stack an image preview occupies. */\nexport type TableCellImageTracks = 1 | 2 | 3;\n\n/** Declarative 16:9 image preview rendered by the table's standard cell primitive. */\nexport interface TableCellImage {\n kind: 'image';\n /** Optional image source. Omit it to render the standard bordered placeholder. */\n src?: string;\n /** Accessible description for either the image or its placeholder. */\n alt: string;\n /**\n * Which body-row contract the preview fills. The thumbnail height is that\n * cell height minus 8px padding; width follows 16:9. Defaults to 1.\n */\n tracks?: TableCellImageTracks;\n}\n\ninterface TableCellActionBase {\n kind: 'action';\n /** Add the ButtonUnfilled resting border. Action cells are unbordered by default. */\n hasBorder?: boolean;\n isInactive?: boolean;\n isLoading?: boolean;\n}\n\n/** One command inside an overflow action menu. */\nexport interface TableCellActionMenuItem {\n /** Stable application-owned action identity emitted with dsCellAction. */\n actionId: string;\n label: string;\n isInactive?: boolean;\n isDestructive?: boolean;\n}\n\n/** Visual separator between overflow action-menu commands. */\nexport interface TableCellActionDivider {\n kind: 'divider';\n}\n\nexport type TableCellActionMenuEntry = TableCellActionMenuItem | TableCellActionDivider;\n\n/** Declarative ButtonUnfilled content rendered by the table's standard action cell. */\nexport type TableCellAction = TableCellActionBase &\n (\n | {\n variant?: 'label';\n /** Stable application-owned action identity emitted with dsCellAction. */\n actionId: string;\n label: string;\n icon?: never;\n ariaLabel?: string;\n items?: never;\n }\n | {\n variant: 'icon-label';\n actionId: string;\n label: string;\n icon: string;\n ariaLabel?: string;\n items?: never;\n }\n | {\n variant: 'icon';\n actionId: string;\n label?: never;\n icon: string;\n ariaLabel: string;\n items?: never;\n }\n | {\n variant?: 'icon';\n /** Overflow commands. The Ellipses trigger opens ds-menu instead of emitting on click. */\n items: TableCellActionMenuEntry[];\n ariaLabel: string;\n icon?: string;\n actionId?: never;\n label?: never;\n }\n );\n\n/** Declarative Tag content rendered by the table's standard cell primitive. */\nexport type TableCellTagVariant = 'tag-only' | 'tag-with-text' | 'text-with-tag';\n\n/** One Tag inside a single- or multiple-Tag table cell. */\nexport interface TableCellTagItem {\n label: string;\n intent?: TagIntent;\n contrast?: TagContrast;\n icon?: string;\n rounded?: boolean;\n}\n\nexport type TableCellTag = TableCellTagItem & { kind: 'tag' } & (\n | {\n variant?: 'tag-only';\n text?: never;\n }\n | {\n variant: Exclude<TableCellTagVariant, 'tag-only'>;\n text: string;\n }\n );\n\n/** Multiple Tags in wrapping tracks or a compact slash-separated primary track. */\nexport type TableCellTags = { kind: 'tags' } & (\n | {\n variant?: 'wrap';\n items: TableCellTagItem[];\n /** Expected positive wrapped-line count used for row geometry and virtual estimates. */\n tracks: number;\n text?: never;\n }\n | {\n variant: 'inline';\n /** One to three tags. Runtime inputs beyond three are capped. */\n items:\n | [TableCellTagItem]\n | [TableCellTagItem, TableCellTagItem]\n | [TableCellTagItem, TableCellTagItem, TableCellTagItem];\n /** Optional secondary text beneath the non-wrapping tag track. */\n text?: string;\n tracks?: never;\n }\n);\n\nexport type TableCellValue =\n | string\n | number\n | null\n | undefined\n | TableCellText\n | TableCellPrimaryText\n | TableCellTag\n | TableCellTags\n | TableCellIcon\n | TableCellIconText\n | TableCellScoreText\n | TableCellImage\n | TableCellAction\n | TableCellEmpty\n | TableCellBlank;\n\nexport type TableSkeletonWidth = string | number;\n\n/** Representative loading geometry for one table column. */\nexport type TableCellSkeleton =\n | {\n kind: 'text';\n /** One track for scalar content, two for primary and secondary, three when tertiary is present. */\n lines?: 1 | 2 | 3;\n primaryWidth?: TableSkeletonWidth;\n secondaryWidth?: TableSkeletonWidth;\n tertiaryWidth?: TableSkeletonWidth;\n }\n | {\n kind: 'image';\n /** Match the loaded image cell's track stack. Defaults to 1. */\n tracks?: TableCellImageTracks;\n }\n | {\n kind: 'tag';\n width?: TableSkeletonWidth;\n }\n | {\n kind: 'icon';\n rounded?: boolean;\n }\n | {\n kind: 'icon-text';\n lines?: 1 | 2 | 3;\n primaryWidth?: TableSkeletonWidth;\n secondaryWidth?: TableSkeletonWidth;\n tertiaryWidth?: TableSkeletonWidth;\n }\n | {\n kind: 'score-text';\n lines?: 1 | 2 | 3;\n primaryWidth?: TableSkeletonWidth;\n secondaryWidth?: TableSkeletonWidth;\n tertiaryWidth?: TableSkeletonWidth;\n }\n | {\n kind: 'action';\n /** Icon actions use a square control canvas; labeled actions may provide a wider width. */\n variant?: 'icon' | 'label' | 'icon-label';\n width?: TableSkeletonWidth;\n }\n | {\n kind: 'blank';\n };\n\nexport interface TableColumn extends DataField {\n segments?: TableHeaderSegment[];\n /** Supplementary header help. Does not replace the visible or accessible column name. */\n help?: string;\n align?: TableCellAlign;\n /** Preferred TokoMo table-column width. Numbers remain available for exceptional custom pixel widths. */\n size?: TableColumnWidth | number;\n /** Derive a fixed image-column width from the matching 1, 2, or 3 track cell geometry. Ignored when size is set. */\n imageTracks?: TableCellImageTracks;\n /** Minimum accepted preferred width in CSS pixels. */\n minSize?: number;\n /** Maximum accepted preferred width in CSS pixels. */\n maxSize?: number;\n /** Wrap cell text instead of truncating it to one line. */\n wrap?: boolean;\n /** Clamp wrapping text after 1, 2, or 3 lines. Omit with wrap for unlimited wrap. */\n maxLines?: TableCellMaxLines;\n /** Pin one application column to either inline edge during horizontal scrolling. */\n sticky?: TableColumnSticky;\n /**\n * Declare a trailing application-action lane. Action columns keep fixed\n * geometry and may host grouped table-level controls in their header.\n */\n kind?: 'data' | 'action';\n /** Representative loading geometry for this column. Defaults to one text line. */\n skeleton?: TableCellSkeleton;\n}\n\nexport interface TableRow {\n /** Stable identity, unique across all groups. */\n id: string;\n cells: Record<string, TableCellValue>;\n /** Accessible row name used by its selection control. Falls back to the first cell. */\n selectionLabel?: string;\n /** Exclude this row from selection without muting its content. */\n selectable?: boolean;\n /** Keep the row visible while disabling its selection control and styling it as inactive. */\n disabled?: boolean;\n /** Make the complete row a keyboard- and pointer-activatable application target. */\n interactive?: boolean;\n}\n\n/** Supporting copy on a group section header's second track. */\nexport interface TableGroupAccessory {\n text: string;\n /** Optional tooltip. Uses the shared dotted underline when present. */\n help?: string;\n}\n\n/** Leading group-header accessory. Score is the first supported hero. */\nexport interface TableGroupHeroScore {\n kind: 'score';\n value: string | number;\n level?: SafetyScoreLevel;\n /** Span one label track by default, or center a larger score across two tracks. */\n tracks?: 1 | 2;\n /** Accessible name prefix, for example “Safety score”. */\n label?: string;\n isLoading?: boolean;\n}\n\nexport type TableGroupHero = TableGroupHeroScore;\n\nexport interface DataGroup {\n /** Stable group identity. */\n id: string;\n label: string;\n value?: string | number;\n /** Total server-side member count; may exceed the currently loaded row count. */\n totalCount?: number;\n /** Optional localized total-count phrase such as “166 events” for accessible loaded progress. */\n countLabel?: string;\n /**\n * Optional semantic intent for the group section header. Applies a faint\n * intent background and bold intent title color.\n */\n intent?: TableGroupIntent;\n /**\n * Optional accessory copy on a second 24px track under the label and count.\n * Middle-dot separated. At most four items are shown.\n */\n accessories?: TableGroupAccessory[];\n /**\n * Optional leading hero before the label and count. A score uses sm on both\n * single-track and two-track headers and sits at the start of the copy stack.\n */\n hero?: TableGroupHero;\n /** Whether this group has more member rows available to load. */\n hasMore?: boolean;\n /** Controlled incremental loading state for this group's member rows. */\n loadingMore?: boolean;\n /** Controlled incremental loading failure for this group's member rows. */\n loadMoreError?: string;\n /** Reset key for this group's query, sort, or filter state. Defaults to the group ID. */\n loadIdentity?: string | number;\n rows: TableRow[];\n}\n\nexport interface DataSortChangeDetail {\n sort: DataSortState | null;\n}\n\nexport interface TableSelectionChangeDetail {\n selectedRowIds: string[];\n scope: 'row' | 'all-loaded' | 'group';\n changedRowId?: string;\n /** Present when scope is `group`. */\n groupId?: string;\n selected?: boolean;\n}\n\nexport interface TableLoadMoreDetail {\n reason: TableLoadMoreReason;\n loadIdentity: string | number;\n loadedRowCount: number;\n}\n\nexport interface TableGroupLoadMoreDetail extends TableLoadMoreDetail {\n groupId: string;\n}\n\nexport interface TableCellActionDetail {\n actionId: string;\n rowId: string;\n columnId: string;\n}\n\nexport interface TableGroupCollapseChangeDetail {\n scope: 'group' | 'all';\n /** Present when a single group was toggled. */\n groupId?: string;\n collapsed: boolean;\n collapsedGroupIds: string[];\n}\n\nexport interface TableRowActivateDetail {\n rowId: string;\n}\n\n/** Controlled show/hide and data-column order for the table-owned customizer. */\nexport interface DataFieldsConfigChangeDetail {\n hiddenFieldIds: string[];\n fieldOrder: string[];\n}\n\n/** Controlled top-level pagination state. Rows or groups contain only the active page. */\nexport interface TablePaginationState extends PaginationState {\n /** Change to request one fresh fitted-capacity snapshot for the current query shape. */\n fitIdentity?: string | number;\n}\n",
|
|
1751
|
+
"content": "import type { DataField, DataFieldSegment } from '../../utils/data-field';\nimport type { TagContrast, TagIntent } from '../Tag/Tag';\nimport type { IconColor } from '../Icon/Icon';\nimport type { SafetyScoreLevel } from '../Score/score-types';\nimport type { TextColor } from '../Text/text-types';\nimport type { PaginationState } from '../Pagination/pagination-types';\nimport type { SelectOption } from '../Select/Select';\n\nexport type DataSortDirection = 'asc' | 'desc';\nexport type TableSelectionMode = 'none' | 'multiple';\nexport type TableCellAlign = 'start' | 'center' | 'end';\nexport type TableCellLinkTarget = '_self' | '_blank';\nexport type TableColumnSticky = 'start' | 'end';\nexport type TableCaptionVisibility = 'visible' | 'hidden';\nexport type TableLoadMoreMode = 'auto' | 'manual';\nexport type TableDataMode = 'infinite' | 'pagination' | 'virtual';\nexport interface TableDataModeChangeDetail {\n dataMode: TableDataMode;\n}\nexport type TableLoadMoreReason = 'auto' | 'manual' | 'retry';\nexport type TableColumnWidth = 'xs' | 'sm' | 'md' | 'lg' | 'xl';\n/** Visible line budget before a text track ellipsizes. */\nexport type TableCellMaxLines = 1 | 2 | 3;\n/** Resolved clamp, including unlimited wrapping. */\nexport type TableCellLineClamp = TableCellMaxLines | 'none';\n/** Semantic color for a group section header (faint surface + bold title). */\nexport type TableGroupIntent =\n | 'brand'\n | 'neutral'\n | 'negative'\n | 'warning'\n | 'caution'\n | 'positive';\n\n/** Independently sortable label within a compound column header. */\nexport interface TableHeaderSegment extends DataFieldSegment {\n /** Visible separator rendered after this label when another segment follows. */\n separator?: string;\n}\n\n/** Controlled member-row sort state. Group order is controlled separately. */\nexport interface DataSortState {\n fieldId: string;\n direction: DataSortDirection;\n}\n\n/** One controlled grouping level. Applications supply groups in their final fixed order. */\nexport interface DataGroupingState {\n fieldId: string;\n direction: DataSortDirection;\n /** Optional application-owned data point used to order the group sections. */\n orderBy?: string;\n}\n\n/** One independently colored run inside a secondary or tertiary track. */\nexport interface TableCellTextRun {\n text: string;\n /** Defaults to the track’s standard color. */\n color?: TextColor;\n /** Optional supplementary copy shown from a dotted-underlined tooltip trigger. */\n help?: string;\n}\n\n/** A secondary or tertiary track: one string, or up to three colorable runs. */\nexport type TableCellTextTrack = string | TableCellTextRun[];\n\n/** Standard two- or three-line cell content owned by the table renderer. */\nexport interface TableCellText {\n primary: string | number;\n secondary?: TableCellTextTrack;\n /** Third subdued track; ignored on dual-primary cells. */\n tertiary?: TableCellTextTrack;\n /** Optional semantic foreground for a string secondary track. */\n secondaryColor?: TextColor;\n /** Optional semantic foreground for a string tertiary track. */\n tertiaryColor?: TextColor;\n /** Application-owned URL for the primary track only. Scalars stay unlinked. */\n href?: string;\n /** Native anchor target for a resolved primary link. */\n target?: TableCellLinkTarget;\n /** Allow this cell to wrap even when its column truncates by default. */\n wrap?: boolean;\n /** Clamp wrapping text after 1, 2, or 3 lines. Overrides column wrap. */\n maxLines?: TableCellMaxLines;\n fontFeature?: 'normal' | 'tabular-nums';\n}\n\n/** Two-line text content where both tracks use primary body text. */\nexport interface TableCellPrimaryText {\n kind: 'primary-text';\n primary: string | number;\n secondary: string | number;\n /** Application-owned URL for the primary track only. */\n href?: string;\n /** Native anchor target for a resolved primary link. */\n target?: TableCellLinkTarget;\n /** Allow this cell to wrap even when its column truncates by default. */\n wrap?: boolean;\n /** Clamp wrapping text after 1, 2, or 3 lines. Overrides column wrap. */\n maxLines?: TableCellMaxLines;\n fontFeature?: 'normal' | 'tabular-nums';\n}\n\n/** Applicable data point whose value is currently unavailable. */\nexport interface TableCellEmpty {\n kind: 'empty';\n}\n\n/** Data point that is intentionally not applicable to this row. */\nexport interface TableCellBlank {\n kind: 'blank';\n}\n\n/** Declarative icon-only content rendered by the table's standard cell primitive. */\nexport interface TableCellIcon {\n kind: 'icon';\n /** Exact canonical IcoMo export name. */\n icon: string;\n color?: IconColor;\n /** Accessible label for informative icons. Omit only when the icon is decorative. */\n label?: string;\n /** Application-owned value used when this icon column participates in sorting. */\n sortValue?: string | number | boolean;\n}\n\n/**\n * Prefix icon beside a text copy stack. Independent of icon-only and plain\n * text cells so those recipes stay unchanged.\n */\nexport interface TableCellIconText {\n kind: 'icon-text';\n /** Exact canonical IcoMo export name. */\n icon: string;\n iconColor?: IconColor;\n /** Accessible name. Omit when the glyph is decorative. */\n iconLabel?: string;\n primary: string | number;\n secondary?: TableCellTextTrack;\n tertiary?: TableCellTextTrack;\n secondaryColor?: TextColor;\n tertiaryColor?: TextColor;\n href?: string;\n target?: TableCellLinkTarget;\n wrap?: boolean;\n maxLines?: TableCellMaxLines;\n fontFeature?: 'normal' | 'tabular-nums';\n}\n\n/**\n * Compact safety-score fill at the top-right of a text copy stack. The score\n * occupies one 24px track while the copy keeps the standard text-cell anatomy.\n */\nexport interface TableCellScoreText {\n kind: 'score-text';\n /** Display-ready safety score. Numeric values infer their semantic level. */\n score: string | number;\n scoreLevel?: SafetyScoreLevel;\n /** Accessible name prefix. Defaults to “Safety score”. */\n scoreLabel?: string;\n /** Replace the score figure with its matching sm skeleton. */\n scoreLoading?: boolean;\n primary: string | number;\n secondary?: TableCellTextTrack;\n tertiary?: TableCellTextTrack;\n secondaryColor?: TextColor;\n tertiaryColor?: TextColor;\n href?: string;\n target?: TableCellLinkTarget;\n wrap?: boolean;\n maxLines?: TableCellMaxLines;\n fontFeature?: 'normal' | 'tabular-nums';\n}\n\n/** Body-row track stack an image preview occupies. */\nexport type TableCellImageTracks = 1 | 2 | 3;\n\n/** Declarative 16:9 image preview rendered by the table's standard cell primitive. */\nexport interface TableCellImage {\n kind: 'image';\n /** Optional image source. Omit it to render the standard bordered placeholder. */\n src?: string;\n /** Accessible description for either the image or its placeholder. */\n alt: string;\n /**\n * Which body-row contract the preview fills. The thumbnail height is that\n * cell height minus 8px padding; width follows 16:9. Defaults to 1.\n */\n tracks?: TableCellImageTracks;\n}\n\ninterface TableCellActionBase {\n kind: 'action';\n /** Add the ButtonUnfilled resting border. Action cells are unbordered by default. */\n hasBorder?: boolean;\n isInactive?: boolean;\n isLoading?: boolean;\n}\n\n/** One command inside an overflow action menu. */\nexport interface TableCellActionMenuItem {\n /** Stable application-owned action identity emitted with dsCellAction. */\n actionId: string;\n label: string;\n isInactive?: boolean;\n isDestructive?: boolean;\n}\n\n/** Visual separator between overflow action-menu commands. */\nexport interface TableCellActionDivider {\n kind: 'divider';\n}\n\nexport type TableCellActionMenuEntry = TableCellActionMenuItem | TableCellActionDivider;\n\n/** Declarative ButtonUnfilled content rendered by the table's standard action cell. */\nexport type TableCellAction = TableCellActionBase &\n (\n | {\n variant?: 'label';\n /** Stable application-owned action identity emitted with dsCellAction. */\n actionId: string;\n label: string;\n icon?: never;\n ariaLabel?: string;\n items?: never;\n }\n | {\n variant: 'icon-label';\n actionId: string;\n label: string;\n icon: string;\n ariaLabel?: string;\n items?: never;\n }\n | {\n variant: 'icon';\n actionId: string;\n label?: never;\n icon: string;\n ariaLabel: string;\n items?: never;\n }\n | {\n variant?: 'icon';\n /** Overflow commands. The Ellipses trigger opens ds-menu instead of emitting on click. */\n items: TableCellActionMenuEntry[];\n ariaLabel: string;\n icon?: string;\n actionId?: never;\n label?: never;\n }\n );\n\n/** Declarative Tag content rendered by the table's standard cell primitive. */\nexport type TableCellTagVariant = 'tag-only' | 'tag-with-text' | 'text-with-tag';\n\n/** One Tag inside a single- or multiple-Tag table cell. */\nexport interface TableCellTagItem {\n label: string;\n intent?: TagIntent;\n contrast?: TagContrast;\n icon?: string;\n rounded?: boolean;\n}\n\nexport type TableCellTag = TableCellTagItem & { kind: 'tag' } & (\n | {\n variant?: 'tag-only';\n text?: never;\n }\n | {\n variant: Exclude<TableCellTagVariant, 'tag-only'>;\n text: string;\n }\n );\n\n/** Multiple Tags in wrapping tracks or a compact slash-separated primary track. */\nexport type TableCellTags = { kind: 'tags' } & (\n | {\n variant?: 'wrap';\n items: TableCellTagItem[];\n /** Expected positive wrapped-line count used for row geometry and virtual estimates. */\n tracks: number;\n text?: never;\n }\n | {\n variant: 'inline';\n /** One to three tags. Runtime inputs beyond three are capped. */\n items:\n | [TableCellTagItem]\n | [TableCellTagItem, TableCellTagItem]\n | [TableCellTagItem, TableCellTagItem, TableCellTagItem];\n /** Optional secondary text beneath the non-wrapping tag track. */\n text?: string;\n tracks?: never;\n }\n);\n\nexport type TableCellValue =\n | string\n | number\n | null\n | undefined\n | TableCellText\n | TableCellPrimaryText\n | TableCellTag\n | TableCellTags\n | TableCellIcon\n | TableCellIconText\n | TableCellScoreText\n | TableCellImage\n | TableCellAction\n | TableCellEmpty\n | TableCellBlank;\n\nexport type TableSkeletonWidth = string | number;\n\n/** Representative loading geometry for one table column. */\nexport type TableCellSkeleton =\n | {\n kind: 'text';\n /** One track for scalar content, two for primary and secondary, three when tertiary is present. */\n lines?: 1 | 2 | 3;\n primaryWidth?: TableSkeletonWidth;\n secondaryWidth?: TableSkeletonWidth;\n tertiaryWidth?: TableSkeletonWidth;\n }\n | {\n kind: 'image';\n /** Match the loaded image cell's track stack. Defaults to 1. */\n tracks?: TableCellImageTracks;\n }\n | {\n kind: 'tag';\n width?: TableSkeletonWidth;\n }\n | {\n kind: 'icon';\n rounded?: boolean;\n }\n | {\n kind: 'icon-text';\n lines?: 1 | 2 | 3;\n primaryWidth?: TableSkeletonWidth;\n secondaryWidth?: TableSkeletonWidth;\n tertiaryWidth?: TableSkeletonWidth;\n }\n | {\n kind: 'score-text';\n lines?: 1 | 2 | 3;\n primaryWidth?: TableSkeletonWidth;\n secondaryWidth?: TableSkeletonWidth;\n tertiaryWidth?: TableSkeletonWidth;\n }\n | {\n kind: 'action';\n /** Icon actions use a square control canvas; labeled actions may provide a wider width. */\n variant?: 'icon' | 'label' | 'icon-label';\n width?: TableSkeletonWidth;\n }\n | {\n kind: 'blank';\n };\n\n/** Scalar editors compose the shared borderless controls; the cell owns interaction chrome. */\nexport type TableCellEditor = { required?: boolean } & (\n | { type: 'text' | 'textarea'; minLength?: number; maxLength?: number }\n | { type: 'number'; min?: number; max?: number; step?: number; showStepper?: boolean }\n | { type: 'select'; options: SelectOption[]; searchable?: boolean }\n | { type: 'date'; min?: string; max?: string }\n | { type: 'time'; min?: string; max?: string; step?: number; hourFormat?: '12' | '24' }\n);\n\nexport interface TableColumn extends DataField {\n /** Optional super-header identity and label. Adjacent matching identities form one band. */\n group?: { id: string; label: string };\n /** Opt-in scalar editor used in edit (pencil) or grid (worksheet) interaction modes. */\n editor?: TableCellEditor;\n segments?: TableHeaderSegment[];\n /** Supplementary header help. Does not replace the visible or accessible column name. */\n help?: string;\n align?: TableCellAlign;\n /** Preferred TokoMo table-column width. Numbers remain available for exceptional custom pixel widths. */\n size?: TableColumnWidth | number;\n /** Derive a fixed image-column width from the matching 1, 2, or 3 track cell geometry. Ignored when size is set. */\n imageTracks?: TableCellImageTracks;\n /** Minimum accepted preferred width in CSS pixels. */\n minSize?: number;\n /** Maximum accepted preferred width in CSS pixels. */\n maxSize?: number;\n /** Wrap cell text instead of truncating it to one line. */\n wrap?: boolean;\n /** Clamp wrapping text after 1, 2, or 3 lines. Omit with wrap for unlimited wrap. */\n maxLines?: TableCellMaxLines;\n /** Pin an explicitly sized column. Visible pinned columns accumulate in display order. */\n sticky?: TableColumnSticky;\n /**\n * Declare a trailing application-action lane. Action columns keep fixed\n * geometry and may host grouped table-level controls in their header.\n */\n kind?: 'data' | 'action';\n /** Representative loading geometry for this column. Defaults to one text line. */\n skeleton?: TableCellSkeleton;\n}\n\nexport interface TableRow {\n /** Stable identity, unique across all groups. */\n id: string;\n cells: Record<string, TableCellValue>;\n /** Presentation is separate from cell values; omitted alignment preserves first-track alignment. */\n cellPresentation?: Record<string, TableCellDecoration>;\n /** Accessible row name used by its selection control. Falls back to the first cell. */\n selectionLabel?: string;\n /** Exclude this row from selection without muting its content. */\n selectable?: boolean;\n /** Keep the row visible while disabling its selection control and styling it as inactive. */\n disabled?: boolean;\n /** Make the complete row a keyboard- and pointer-activatable application target. */\n interactive?: boolean;\n}\n\nexport interface TableCellDecoration {\n verticalAlign?: 'top' | 'middle' | 'bottom';\n /** Left-edge intent outline with an accessible, non-color-only explanation. */\n highlight?: { intent: TableGroupIntent; label: string };\n borders?: Array<'start' | 'end' | 'top' | 'bottom'>;\n flag?: { label: string; icon?: string };\n}\n\n/** A rectangular merge anchored at the first row/column. Covered values remain in application data. */\nexport interface TableCellSpan {\n rowIds: string[];\n columnIds: string[];\n}\n\nexport interface TableCellAddress {\n rowId: string;\n columnId: string;\n}\n\nexport interface TableCellRange {\n anchor: TableCellAddress;\n focus: TableCellAddress;\n}\n\nexport interface TableCellsChangeDetail {\n changes: Array<TableCellAddress & { value: string | number | null }>;\n reason: 'edit' | 'paste';\n}\n\n/** Supporting copy on a group section header's second track. */\nexport interface TableGroupAccessory {\n text: string;\n /** Optional tooltip. Uses the shared dotted underline when present. */\n help?: string;\n}\n\n/** Leading group-header accessory. Score is the first supported hero. */\nexport interface TableGroupHeroScore {\n kind: 'score';\n value: string | number;\n level?: SafetyScoreLevel;\n /** Span one label track by default, or center a larger score across two tracks. */\n tracks?: 1 | 2;\n /** Accessible name prefix, for example “Safety score”. */\n label?: string;\n isLoading?: boolean;\n}\n\nexport type TableGroupHero = TableGroupHeroScore;\n\nexport interface DataGroup {\n /** Stable group identity. */\n id: string;\n label: string;\n value?: string | number;\n /** Total server-side member count; may exceed the currently loaded row count. */\n totalCount?: number;\n /** Optional localized total-count phrase such as “166 events” for accessible loaded progress. */\n countLabel?: string;\n /**\n * Optional semantic intent for the group section header. Applies a faint\n * intent background and bold intent title color.\n */\n intent?: TableGroupIntent;\n /**\n * Optional accessory copy on a second 24px track under the label and count.\n * Middle-dot separated. At most four items are shown.\n */\n accessories?: TableGroupAccessory[];\n /**\n * Optional leading hero before the label and count. A score uses sm on both\n * single-track and two-track headers and sits at the start of the copy stack.\n */\n hero?: TableGroupHero;\n /** Whether this group has more member rows available to load. */\n hasMore?: boolean;\n /** Controlled incremental loading state for this group's member rows. */\n loadingMore?: boolean;\n /** Controlled incremental loading failure for this group's member rows. */\n loadMoreError?: string;\n /** Reset key for this group's query, sort, or filter state. Defaults to the group ID. */\n loadIdentity?: string | number;\n rows: TableRow[];\n}\n\nexport interface DataSortChangeDetail {\n sort: DataSortState | null;\n}\n\nexport interface TableSelectionChangeDetail {\n selectedRowIds: string[];\n scope: 'row' | 'all-loaded' | 'group';\n changedRowId?: string;\n /** Present when scope is `group`. */\n groupId?: string;\n selected?: boolean;\n}\n\nexport interface TableLoadMoreDetail {\n reason: TableLoadMoreReason;\n loadIdentity: string | number;\n loadedRowCount: number;\n}\n\nexport interface TableGroupLoadMoreDetail extends TableLoadMoreDetail {\n groupId: string;\n}\n\nexport interface TableCellActionDetail {\n actionId: string;\n rowId: string;\n columnId: string;\n}\n\nexport interface TableGroupCollapseChangeDetail {\n scope: 'group' | 'all';\n /** Present when a single group was toggled. */\n groupId?: string;\n collapsed: boolean;\n collapsedGroupIds: string[];\n}\n\nexport interface TableRowActivateDetail {\n rowId: string;\n}\n\n/** Controlled show/hide and data-column order for the table-owned customizer. */\nexport interface DataFieldsConfigChangeDetail {\n hiddenFieldIds: string[];\n fieldOrder: string[];\n}\n\n/** Controlled top-level pagination state. Rows or groups contain only the active page. */\nexport interface TablePaginationState extends PaginationState {\n /** Change to request one fresh fitted-capacity snapshot for the current query shape. */\n fitIdentity?: string | number;\n}\n",
|
|
1626
1752
|
"type": "registry:ui"
|
|
1627
1753
|
},
|
|
1628
1754
|
{
|
|
@@ -1632,7 +1758,7 @@
|
|
|
1632
1758
|
},
|
|
1633
1759
|
{
|
|
1634
1760
|
"path": "src/wc/components/Table/table-virtual-controller.ts",
|
|
1635
|
-
"content": "import {\n createTableVirtualIndex,\n findTableVirtualIndexAtOffset,\n resolveTableVirtualPlanFromIndex,\n sameTableVirtualPlan,\n tableVirtualItemSizes,\n type TableVirtualItem,\n type TableVirtualIndex,\n type TableVirtualPlan,\n} from './table-virtual-model';\n\nexport interface TableVirtualControllerState {\n enabled: boolean;\n items: readonly TableVirtualItem[];\n pinnedRowIds: ReadonlySet<string>;\n viewport: HTMLElement | null;\n viewportSize: number;\n}\n\nexport interface TableVirtualControllerOptions {\n state: () => TableVirtualControllerState;\n windowChanged: (plan: TableVirtualPlan | null) => void;\n requestAnimationFrame?: (callback: FrameRequestCallback) => number;\n cancelAnimationFrame?: (handle: number) => void;\n}\n\nconst MEASURE_EPSILON_PX = 0.5;\n\n/** Binds viewport scroll/resize to a recycled row window. Scroll offset stays off render state. */\nexport class TableVirtualController {\n private connected = false;\n private connectedViewport: HTMLElement | null = null;\n private resizeObserver: ResizeObserver | null = null;\n private measurementObserver: ResizeObserver | null = null;\n private measurementTargets = new Set<HTMLElement>();\n private viewportWidth = 0;\n private viewportSize = 0;\n private frame: number | null = null;\n private measures = new Map<string, number>();\n private measuredIds = new Set<string>();\n private plan: TableVirtualPlan | null = null;\n private index: TableVirtualIndex | null = null;\n private indexedItems: readonly TableVirtualItem[] | null = null;\n private indexDirty = true;\n private scrollOffset = 0;\n private scrollDirection: 'backward' | 'forward' | 'none' = 'none';\n private pendingReset = false;\n private readonly raf: (callback: FrameRequestCallback) => number;\n private readonly caf: (handle: number) => void;\n\n constructor(private readonly options: TableVirtualControllerOptions) {\n this.raf =\n options.requestAnimationFrame ??\n (typeof requestAnimationFrame === 'function'\n ? callback => requestAnimationFrame(callback)\n : callback => setTimeout(callback, 16) as unknown as number);\n this.caf =\n options.cancelAnimationFrame ??\n (typeof cancelAnimationFrame === 'function'\n ? handle => cancelAnimationFrame(handle)\n : handle => clearTimeout(handle));\n }\n\n connect(): void {\n this.connected = true;\n this.refresh();\n }\n\n disconnect(): void {\n this.connected = false;\n this.cancelFrame();\n this.unbindViewport();\n }\n\n refresh(): void {\n if (!this.connected) return;\n const { viewport, enabled } = this.options.state();\n if (!enabled || !viewport) {\n this.unbindViewport();\n this.emit(null);\n return;\n }\n if (viewport !== this.connectedViewport) {\n this.unbindViewport();\n this.connectedViewport = viewport;\n this.viewportWidth = viewport.clientWidth;\n this.viewportSize = viewport.clientHeight;\n this.scrollOffset = viewport.scrollTop;\n viewport.addEventListener('scroll', this.handleScroll, { passive: true });\n if (typeof ResizeObserver !== 'undefined') {\n this.resizeObserver = new ResizeObserver(this.handleViewportResize);\n this.resizeObserver.observe(viewport);\n }\n }\n this.sync();\n }\n\n schedule = (): void => {\n if (!this.connected || this.frame !== null) return;\n this.frame = this.raf(() => {\n this.frame = null;\n this.sync();\n });\n };\n\n /** Drop cached heights after column, density, or wrap geometry changes. */\n invalidateMeasures(): void {\n this.measures.clear();\n this.measuredIds.clear();\n this.indexDirty = true;\n this.schedule();\n }\n\n /** Rebuild from a new index and pin the viewport to the start. */\n resetToTop(): void {\n this.pendingReset = true;\n this.measures.clear();\n this.measuredIds.clear();\n this.indexDirty = true;\n this.scrollOffset = 0;\n this.scrollDirection = 'none';\n const viewport = this.connectedViewport ?? this.options.state().viewport;\n if (viewport) viewport.scrollTop = 0;\n this.schedule();\n }\n\n sizeFor(item: TableVirtualItem): number {\n if (!item.variableSize) return item.estimatedSize;\n return this.measures.get(item.id) ?? item.estimatedSize;\n }\n\n currentPlan(): TableVirtualPlan | null {\n return this.plan;\n }\n\n currentViewportSize(): number {\n return this.viewportSize;\n }\n\n /** Reveal a supplied row without requiring its recycled DOM node to exist first. */\n scrollRowIntoView(rowId: string, contentOffset = 0, stickyInset = 0): boolean {\n const state = this.options.state();\n const viewport = this.connectedViewport ?? state.viewport;\n if (!state.enabled || !viewport) return false;\n\n const index = this.ensureIndex(state.items);\n const rowIndex = index.rowIndexById.get(rowId);\n if (rowIndex == null) return false;\n\n const item = state.items[rowIndex];\n const groupHeaderIndex = item?.groupId ? index.headerIndexByGroup.get(item.groupId) : undefined;\n const groupInset = groupHeaderIndex == null ? 0 : (index.sizes[groupHeaderIndex] ?? 0);\n const leadingInset = Math.max(0, stickyInset) + groupInset;\n const itemStart = Math.max(0, contentOffset) + (index.prefix[rowIndex] ?? 0);\n const itemEnd = Math.max(0, contentOffset) + (index.prefix[rowIndex + 1] ?? itemStart);\n const viewportSize = this.viewportSize || state.viewportSize || viewport.clientHeight || 0;\n const currentOffset = viewport.scrollTop;\n const visibleStart = currentOffset + leadingInset;\n const visibleEnd = currentOffset + viewportSize;\n let nextOffset: number;\n\n if (itemStart < visibleStart) nextOffset = itemStart - leadingInset;\n else if (itemEnd > visibleEnd) nextOffset = itemEnd - viewportSize;\n else return true;\n\n const maxOffset = Math.max(0, Math.max(0, contentOffset) + index.totalSize - viewportSize);\n nextOffset = Math.max(0, Math.min(nextOffset, maxOffset));\n this.scrollDirection =\n nextOffset > currentOffset ? 'forward' : nextOffset < currentOffset ? 'backward' : 'none';\n viewport.scrollTop = nextOffset;\n this.scrollOffset = viewport.scrollTop;\n this.sync();\n return true;\n }\n\n /** Measure painted rows and correct scroll when estimates were wrong. */\n collectMeasurements(root: ParentNode | null): void {\n if (!root || !this.connected || !this.options.state().enabled) return;\n const painted = root.querySelectorAll<HTMLElement>('[data-virtual-measure=\"true\"]');\n this.syncMeasurementTargets(painted);\n if (painted.length === 0) return;\n\n const state = this.options.state();\n const measurements: Array<{ id: string; height: number }> = [];\n painted.forEach(element => {\n const id = element.getAttribute('data-virtual-id');\n if (!id || this.measuredIds.has(id)) return;\n measurements.push({ id, height: element.getBoundingClientRect().height });\n });\n this.applyMeasurements(state.items, measurements);\n }\n\n private applyMeasurements(\n items: readonly TableVirtualItem[],\n measurements: readonly { id: string; height: number }[]\n ): void {\n if (measurements.length === 0) return;\n const currentIndex = this.ensureIndex(items);\n const prefixBefore = currentIndex.prefix;\n const viewport = this.connectedViewport;\n const scrollTop = viewport?.scrollTop ?? this.scrollOffset;\n const anchorIndex = findTableVirtualIndexAtOffset(prefixBefore, scrollTop);\n const anchorOffset = prefixBefore[anchorIndex] ?? 0;\n let changed = false;\n for (const { id, height } of measurements) {\n if (!Number.isFinite(height) || height <= 0) continue;\n const itemIndex = currentIndex.itemIndexById.get(id);\n const item = itemIndex == null ? undefined : items[itemIndex];\n if (!item?.variableSize) continue;\n this.measuredIds.add(id);\n const previous = this.measures.get(id);\n if (Math.abs(item.estimatedSize - height) < MEASURE_EPSILON_PX) {\n if (previous != null) {\n this.measures.delete(id);\n changed = true;\n }\n continue;\n }\n if (previous != null && Math.abs(previous - height) < MEASURE_EPSILON_PX) continue;\n this.measures.set(id, height);\n changed = true;\n }\n if (!changed) return;\n this.indexDirty = true;\n const prefixAfter = this.ensureIndex(items).prefix;\n const nextAnchor = prefixAfter[anchorIndex] ?? 0;\n const delta = nextAnchor - anchorOffset;\n if (viewport && Math.abs(delta) >= MEASURE_EPSILON_PX) {\n viewport.scrollTop = Math.max(0, viewport.scrollTop + delta);\n this.scrollOffset = viewport.scrollTop;\n }\n this.sync();\n }\n\n private syncMeasurementTargets(elements: NodeListOf<HTMLElement>): void {\n if (typeof ResizeObserver === 'undefined') return;\n if (!this.measurementObserver) {\n this.measurementObserver = new ResizeObserver(entries => {\n const state = this.options.state();\n const measurements = entries.map(entry => ({\n id: (entry.target as HTMLElement).getAttribute('data-virtual-id') ?? '',\n height: entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height,\n }));\n this.applyMeasurements(state.items, measurements);\n });\n }\n const next = new Set(elements);\n for (const element of this.measurementTargets) {\n if (!next.has(element)) this.measurementObserver.unobserve(element);\n }\n for (const element of next) {\n if (!this.measurementTargets.has(element)) this.measurementObserver.observe(element);\n }\n this.measurementTargets = next;\n }\n\n private readonly handleViewportResize = (entries: ResizeObserverEntry[]): void => {\n const entry = entries.find(candidate => candidate.target === this.connectedViewport);\n const width = entry?.contentRect.width ?? this.connectedViewport?.clientWidth ?? 0;\n const size = entry?.contentRect.height ?? this.connectedViewport?.clientHeight ?? 0;\n const sizeChanged = size !== this.viewportSize;\n this.viewportSize = size;\n if (width !== this.viewportWidth) {\n this.viewportWidth = width;\n const items = this.options.state().items;\n const hasVariableSize =\n this.indexedItems === items && this.index\n ? this.index.hasVariableSize\n : items.some(item => item.variableSize);\n if (hasVariableSize) this.invalidateMeasures();\n else if (sizeChanged) this.schedule();\n return;\n }\n if (sizeChanged) this.schedule();\n };\n\n private readonly handleScroll = (): void => {\n const viewport = this.connectedViewport;\n if (viewport) {\n const nextOffset = viewport.scrollTop;\n this.scrollDirection =\n nextOffset > this.scrollOffset\n ? 'forward'\n : nextOffset < this.scrollOffset\n ? 'backward'\n : this.scrollDirection;\n this.scrollOffset = nextOffset;\n }\n this.schedule();\n };\n\n private sync(): void {\n if (!this.connected) return;\n const state = this.options.state();\n if (!state.enabled) {\n this.emit(null);\n return;\n }\n\n if (this.pendingReset) {\n this.scrollOffset = 0;\n if (state.viewport) state.viewport.scrollTop = 0;\n this.pendingReset = false;\n }\n const index = this.ensureIndex(state.items);\n const plan = resolveTableVirtualPlanFromIndex(index, {\n scrollOffset: this.scrollOffset,\n viewportSize: this.viewportSize || state.viewportSize,\n scrollDirection: this.scrollDirection,\n pinnedRowIds: state.pinnedRowIds,\n });\n this.emit(plan);\n }\n\n private emit(plan: TableVirtualPlan | null): void {\n if (sameTableVirtualPlan(this.plan, plan)) return;\n this.plan = plan;\n this.options.windowChanged(plan);\n }\n\n private ensureIndex(items: readonly TableVirtualItem[]): TableVirtualIndex {\n if (!this.indexDirty && this.index && this.indexedItems === items) return this.index;\n if (this.indexedItems !== items && (this.measures.size > 0 || this.measuredIds.size > 0)) {\n const itemIds = new Set(items.map(item => item.id));\n for (const id of this.measures.keys()) {\n if (!itemIds.has(id)) this.measures.delete(id);\n }\n for (const id of this.measuredIds) {\n if (!itemIds.has(id)) this.measuredIds.delete(id);\n }\n }\n this.indexedItems = items;\n this.index = createTableVirtualIndex(items, tableVirtualItemSizes(items, this.measures));\n this.indexDirty = false;\n return this.index;\n }\n\n private unbindViewport(): void {\n this.connectedViewport?.removeEventListener('scroll', this.handleScroll);\n this.connectedViewport = null;\n this.resizeObserver?.disconnect();\n this.resizeObserver = null;\n this.measurementObserver?.disconnect();\n this.measurementObserver = null;\n this.measurementTargets.clear();\n this.viewportWidth = 0;\n this.viewportSize = 0;\n }\n\n private cancelFrame(): void {\n if (this.frame === null) return;\n this.caf(this.frame);\n this.frame = null;\n }\n}\n",
|
|
1761
|
+
"content": "import {\n createTableVirtualIndex,\n findTableVirtualIndexAtOffset,\n resolveTableVirtualPlanFromIndex,\n sameTableVirtualPlan,\n tableVirtualItemSizes,\n type TableVirtualItem,\n type TableVirtualIndex,\n type TableVirtualPlan,\n} from './table-virtual-model';\n\nexport interface TableVirtualControllerState {\n enabled: boolean;\n items: readonly TableVirtualItem[];\n pinnedRowIds: ReadonlySet<string>;\n viewport: HTMLElement | null;\n viewportSize: number;\n}\n\nexport interface TableVirtualControllerOptions {\n state: () => TableVirtualControllerState;\n windowChanged: (plan: TableVirtualPlan | null) => void;\n requestAnimationFrame?: (callback: FrameRequestCallback) => number;\n cancelAnimationFrame?: (handle: number) => void;\n}\n\nconst MEASURE_EPSILON_PX = 0.5;\n\n/** Include the card gap in the recycled item's advance, not just its border box.\n * Reads are limited to mounted variable-size cards, never the complete dataset. */\nfunction measuredItemSize(element: HTMLElement, blockSize: number): number {\n return (\n blockSize +\n (element.getAttribute('data-responsive-card') === 'true'\n ? parseFloat(getComputedStyle(element).marginBlockEnd) || 0\n : 0)\n );\n}\n\n/** Binds viewport scroll/resize to a recycled row window. Scroll offset stays off render state. */\nexport class TableVirtualController {\n private connected = false;\n private connectedViewport: HTMLElement | null = null;\n private resizeObserver: ResizeObserver | null = null;\n private measurementObserver: ResizeObserver | null = null;\n private measurementTargets = new Set<HTMLElement>();\n private viewportWidth = 0;\n private viewportSize = 0;\n private frame: number | null = null;\n private measures = new Map<string, number>();\n private measuredIds = new Set<string>();\n private plan: TableVirtualPlan | null = null;\n private index: TableVirtualIndex | null = null;\n private indexedItems: readonly TableVirtualItem[] | null = null;\n private indexDirty = true;\n private scrollOffset = 0;\n private scrollAtEnd = false;\n private scrollDirection: 'backward' | 'forward' | 'none' = 'none';\n private pendingReset = false;\n private readonly raf: (callback: FrameRequestCallback) => number;\n private readonly caf: (handle: number) => void;\n\n constructor(private readonly options: TableVirtualControllerOptions) {\n this.raf =\n options.requestAnimationFrame ??\n (typeof requestAnimationFrame === 'function'\n ? callback => requestAnimationFrame(callback)\n : callback => setTimeout(callback, 16) as unknown as number);\n this.caf =\n options.cancelAnimationFrame ??\n (typeof cancelAnimationFrame === 'function'\n ? handle => cancelAnimationFrame(handle)\n : handle => clearTimeout(handle));\n }\n\n connect(): void {\n this.connected = true;\n this.refresh();\n }\n\n disconnect(): void {\n this.connected = false;\n this.cancelFrame();\n this.unbindViewport();\n }\n\n refresh(): void {\n if (!this.connected) return;\n const { viewport, enabled } = this.options.state();\n if (!enabled || !viewport) {\n this.unbindViewport();\n this.emit(null);\n return;\n }\n if (viewport !== this.connectedViewport) {\n this.unbindViewport();\n this.connectedViewport = viewport;\n this.viewportWidth = viewport.clientWidth;\n this.viewportSize = viewport.clientHeight;\n this.scrollOffset = viewport.scrollTop;\n viewport.addEventListener('scroll', this.handleScroll, { passive: true });\n if (typeof ResizeObserver !== 'undefined') {\n this.resizeObserver = new ResizeObserver(this.handleViewportResize);\n this.resizeObserver.observe(viewport);\n }\n }\n this.sync();\n }\n\n schedule = (): void => {\n if (!this.connected || this.frame !== null) return;\n this.frame = this.raf(() => {\n this.frame = null;\n this.sync();\n });\n };\n\n /** Drop cached heights after column, density, or wrap geometry changes. */\n invalidateMeasures(): void {\n this.measures.clear();\n this.measuredIds.clear();\n this.indexDirty = true;\n this.schedule();\n }\n\n /** Rebuild from a new index and pin the viewport to the start. */\n resetToTop(): void {\n this.scrollAtEnd = false;\n this.pendingReset = true;\n this.measures.clear();\n this.measuredIds.clear();\n this.indexDirty = true;\n this.scrollOffset = 0;\n this.scrollDirection = 'none';\n const viewport = this.connectedViewport ?? this.options.state().viewport;\n if (viewport) viewport.scrollTop = 0;\n this.schedule();\n }\n\n sizeFor(item: TableVirtualItem): number {\n if (!item.variableSize) return item.estimatedSize;\n return this.measures.get(item.id) ?? item.estimatedSize;\n }\n\n currentPlan(): TableVirtualPlan | null {\n return this.plan;\n }\n\n currentViewportSize(): number {\n return this.viewportSize;\n }\n\n /** Reveal a supplied row without requiring its recycled DOM node to exist first. */\n scrollRowIntoView(rowId: string, contentOffset = 0, stickyInset = 0): boolean {\n const state = this.options.state();\n const viewport = this.connectedViewport ?? state.viewport;\n if (!state.enabled || !viewport) return false;\n\n const index = this.ensureIndex(state.items);\n const rowIndex = index.rowIndexById.get(rowId);\n if (rowIndex == null) return false;\n\n const item = state.items[rowIndex];\n const groupHeaderIndex = item?.groupId ? index.headerIndexByGroup.get(item.groupId) : undefined;\n const groupInset = groupHeaderIndex == null ? 0 : (index.sizes[groupHeaderIndex] ?? 0);\n const leadingInset = Math.max(0, stickyInset) + groupInset;\n const itemStart = Math.max(0, contentOffset) + (index.prefix[rowIndex] ?? 0);\n const itemEnd = Math.max(0, contentOffset) + (index.prefix[rowIndex + 1] ?? itemStart);\n const viewportSize = this.viewportSize || state.viewportSize || viewport.clientHeight || 0;\n const currentOffset = viewport.scrollTop;\n const visibleStart = currentOffset + leadingInset;\n const visibleEnd = currentOffset + viewportSize;\n let nextOffset: number;\n\n if (itemStart < visibleStart) nextOffset = itemStart - leadingInset;\n else if (itemEnd > visibleEnd) nextOffset = itemEnd - viewportSize;\n else return true;\n\n const maxOffset = Math.max(0, Math.max(0, contentOffset) + index.totalSize - viewportSize);\n nextOffset = Math.max(0, Math.min(nextOffset, maxOffset));\n this.scrollDirection =\n nextOffset > currentOffset ? 'forward' : nextOffset < currentOffset ? 'backward' : 'none';\n viewport.scrollTop = nextOffset;\n this.scrollOffset = viewport.scrollTop;\n this.sync();\n return true;\n }\n\n /** Measure painted rows and correct scroll when estimates were wrong. */\n collectMeasurements(root: ParentNode | null): void {\n if (!root || !this.connected || !this.options.state().enabled) return;\n const painted = root.querySelectorAll<HTMLElement>('[data-virtual-measure=\"true\"]');\n this.syncMeasurementTargets(painted);\n if (painted.length === 0) return;\n\n const state = this.options.state();\n const measurements: Array<{ id: string; height: number }> = [];\n painted.forEach(element => {\n const id = element.getAttribute('data-virtual-id');\n if (!id || this.measuredIds.has(id)) return;\n measurements.push({\n id,\n height: measuredItemSize(element, element.getBoundingClientRect().height),\n });\n });\n this.applyMeasurements(state.items, measurements);\n }\n\n private applyMeasurements(\n items: readonly TableVirtualItem[],\n measurements: readonly { id: string; height: number }[]\n ): void {\n if (measurements.length === 0) return;\n const currentIndex = this.ensureIndex(items);\n const prefixBefore = currentIndex.prefix;\n const viewport = this.connectedViewport;\n const scrollTop = viewport?.scrollTop ?? this.scrollOffset;\n const anchorIndex = findTableVirtualIndexAtOffset(prefixBefore, scrollTop);\n const anchorOffset = prefixBefore[anchorIndex] ?? 0;\n let changed = false;\n for (const { id, height } of measurements) {\n if (!Number.isFinite(height) || height <= 0) continue;\n const itemIndex = currentIndex.itemIndexById.get(id);\n const item = itemIndex == null ? undefined : items[itemIndex];\n if (!item?.variableSize) continue;\n this.measuredIds.add(id);\n const previous = this.measures.get(id);\n if (Math.abs(item.estimatedSize - height) < MEASURE_EPSILON_PX) {\n if (previous != null) {\n this.measures.delete(id);\n changed = true;\n }\n continue;\n }\n if (previous != null && Math.abs(previous - height) < MEASURE_EPSILON_PX) continue;\n this.measures.set(id, height);\n changed = true;\n }\n if (!changed) return;\n this.indexDirty = true;\n const prefixAfter = this.ensureIndex(items).prefix;\n const nextAnchor = prefixAfter[anchorIndex] ?? 0;\n const delta = nextAnchor - anchorOffset;\n if (viewport && this.scrollAtEnd) {\n // Newly measured cards can grow below the current anchor. A user who\n // reached the end should stay there, not stop before the final records.\n viewport.scrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight);\n this.scrollOffset = viewport.scrollTop;\n } else if (viewport && Math.abs(delta) >= MEASURE_EPSILON_PX) {\n viewport.scrollTop = Math.max(0, viewport.scrollTop + delta);\n this.scrollOffset = viewport.scrollTop;\n }\n this.sync();\n }\n\n private syncMeasurementTargets(elements: NodeListOf<HTMLElement>): void {\n if (typeof ResizeObserver === 'undefined') return;\n if (!this.measurementObserver) {\n this.measurementObserver = new ResizeObserver(entries => {\n const state = this.options.state();\n const measurements = entries.map(entry => ({\n id: (entry.target as HTMLElement).getAttribute('data-virtual-id') ?? '',\n height: measuredItemSize(\n entry.target as HTMLElement,\n entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height\n ),\n }));\n this.applyMeasurements(state.items, measurements);\n });\n }\n const next = new Set(elements);\n for (const element of this.measurementTargets) {\n if (!next.has(element)) this.measurementObserver.unobserve(element);\n }\n for (const element of next) {\n if (!this.measurementTargets.has(element)) this.measurementObserver.observe(element);\n }\n this.measurementTargets = next;\n }\n\n private readonly handleViewportResize = (entries: ResizeObserverEntry[]): void => {\n const entry = entries.find(candidate => candidate.target === this.connectedViewport);\n const width = entry?.contentRect.width ?? this.connectedViewport?.clientWidth ?? 0;\n const size = entry?.contentRect.height ?? this.connectedViewport?.clientHeight ?? 0;\n const sizeChanged = size !== this.viewportSize;\n this.viewportSize = size;\n if (width !== this.viewportWidth) {\n this.viewportWidth = width;\n const items = this.options.state().items;\n const hasVariableSize =\n this.indexedItems === items && this.index\n ? this.index.hasVariableSize\n : items.some(item => item.variableSize);\n if (hasVariableSize) this.invalidateMeasures();\n else if (sizeChanged) this.schedule();\n return;\n }\n if (sizeChanged) this.schedule();\n };\n\n private readonly handleScroll = (): void => {\n const viewport = this.connectedViewport;\n if (viewport) {\n const nextOffset = viewport.scrollTop;\n this.scrollAtEnd =\n !!this.index?.hasVariableSize &&\n nextOffset > 0 &&\n nextOffset + viewport.clientHeight >= viewport.scrollHeight - 1;\n this.scrollDirection =\n nextOffset > this.scrollOffset\n ? 'forward'\n : nextOffset < this.scrollOffset\n ? 'backward'\n : this.scrollDirection;\n this.scrollOffset = nextOffset;\n }\n this.schedule();\n };\n\n private sync(): void {\n if (!this.connected) return;\n const state = this.options.state();\n if (!state.enabled) {\n this.emit(null);\n return;\n }\n\n if (this.pendingReset) {\n this.scrollOffset = 0;\n if (state.viewport) state.viewport.scrollTop = 0;\n this.pendingReset = false;\n }\n const index = this.ensureIndex(state.items);\n const plan = resolveTableVirtualPlanFromIndex(index, {\n scrollOffset: this.scrollOffset,\n viewportSize: this.viewportSize || state.viewportSize,\n scrollDirection: this.scrollDirection,\n pinnedRowIds: state.pinnedRowIds,\n });\n this.emit(plan);\n }\n\n private emit(plan: TableVirtualPlan | null): void {\n if (sameTableVirtualPlan(this.plan, plan)) return;\n this.plan = plan;\n this.options.windowChanged(plan);\n }\n\n private ensureIndex(items: readonly TableVirtualItem[]): TableVirtualIndex {\n if (!this.indexDirty && this.index && this.indexedItems === items) return this.index;\n // Appending/replacing application data must not opt into following new rows.\n if (this.indexedItems !== items) this.scrollAtEnd = false;\n if (this.indexedItems !== items && (this.measures.size > 0 || this.measuredIds.size > 0)) {\n const itemIds = new Set(items.map(item => item.id));\n for (const id of this.measures.keys()) {\n if (!itemIds.has(id)) this.measures.delete(id);\n }\n for (const id of this.measuredIds) {\n if (!itemIds.has(id)) this.measuredIds.delete(id);\n }\n }\n this.indexedItems = items;\n this.index = createTableVirtualIndex(items, tableVirtualItemSizes(items, this.measures));\n this.indexDirty = false;\n return this.index;\n }\n\n private unbindViewport(): void {\n this.scrollAtEnd = false;\n this.connectedViewport?.removeEventListener('scroll', this.handleScroll);\n this.connectedViewport = null;\n this.resizeObserver?.disconnect();\n this.resizeObserver = null;\n this.measurementObserver?.disconnect();\n this.measurementObserver = null;\n this.measurementTargets.clear();\n this.viewportWidth = 0;\n this.viewportSize = 0;\n }\n\n private cancelFrame(): void {\n if (this.frame === null) return;\n this.caf(this.frame);\n this.frame = null;\n }\n}\n",
|
|
1636
1762
|
"type": "registry:ui"
|
|
1637
1763
|
},
|
|
1638
1764
|
{
|