import { html, css } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { USWDSBaseComponent } from '../../utils/base-component.js'; import { USWDSVirtualScroller } from '../../utils/performance-helpers.js'; import { initializeTable } from './usa-table-behavior.js'; // Import official USWDS compiled CSS import '../../styles/styles.css'; export interface TableColumn { key: string; label: string; sortable?: boolean; sortType?: 'text' | 'number' | 'date' | 'percentage'; sticky?: boolean; } export interface TableRow { [key: string]: string | number | Date; } /** * USA Table Web Component * * Minimal wrapper around USWDS table functionality. * Uses USWDS-mirrored behavior pattern for 100% behavioral parity. * * @element usa-table * @fires table-sort - Dispatched when a sortable column is clicked * * @see README.mdx - Complete API documentation, usage examples, and implementation notes * @see CHANGELOG.mdx - Component version history and breaking changes * @see TESTING.mdx - Testing documentation and coverage reports * * @uswds-js-reference https://github.com/uswds/uswds/tree/develop/packages/usa-table/src/index.js * @uswds-css-reference https://github.com/uswds/uswds/tree/develop/packages/usa-table/src/styles/_usa-table.scss * @uswds-docs https://designsystem.digital.gov/components/table/ * @uswds-guidance https://designsystem.digital.gov/components/table/#guidance * @uswds-accessibility https://designsystem.digital.gov/components/table/#accessibility */ @customElement('usa-table') export class USATable extends USWDSBaseComponent { static override styles = css` :host { display: block; } :host([hidden]) { display: none; } `; @property({ type: String }) caption = ''; @property({ type: Array }) headers: TableColumn[] = []; // Alias for headers to match common API expectations get columns() { return this.headers; } set columns(value: TableColumn[]) { this.headers = value; } @property({ type: Array }) data: TableRow[] = []; @property({ type: Boolean }) striped = false; @property({ type: Boolean }) borderless = false; @property({ type: Boolean }) compact = false; @property({ type: Boolean }) stacked = false; @property({ type: Boolean }) stackedHeader = false; @property({ type: Boolean }) stickyHeader = false; @property({ type: Boolean }) scrollable = false; @property({ type: String }) sortColumn = ''; @property({ type: String, attribute: 'sort-direction' }) sortDirection: 'asc' | 'desc' = 'asc'; @property({ type: Boolean }) virtual = false; @property({ type: Number }) rowHeight = 48; @property({ type: Number }) containerHeight = 400; private virtualScroller?: USWDSVirtualScroller; private visibleRange = { start: 0, end: 0 }; private slottedContent: string = ''; // Store cleanup function from behavior private cleanup?: () => void; // Track initialization state for tests private _initialized = false; /** Returns true if USWDS behavior has been initialized */ get initialized(): boolean { return this._initialized; } // Computed property for visible data private get visibleData(): TableRow[] { if (!this.virtual) { return this.data; } const { start, end } = this.visibleRange; return this.data.slice(start, end + 1); } // Light DOM is handled by USWDSBaseComponent override connectedCallback() { super.connectedCallback(); // Set web component managed flag to prevent USWDS auto-initialization conflicts this.setAttribute('data-web-component-managed', 'true'); // Capture any initial content before render (avoid innerHTML in light DOM) if (this.childNodes.length > 0 && this.data.length === 0 && this.headers.length === 0) { this.slottedContent = Array.from(this.childNodes) .map(node => node.nodeType === Node.TEXT_NODE ? node.textContent : (node as Element).outerHTML || '') .join(''); // Clear content to prevent duplication this.innerHTML = ''; } if (this.virtual) { this.setupVirtualScrolling(); } // Note: USWDS initialization moved to firstUpdated() to ensure DOM is ready } override disconnectedCallback() { super.disconnectedCallback(); this.cleanup?.(); this.virtualScroller?.destroy(); } override async firstUpdated(changedProperties: Map) { // ARCHITECTURE: Script Tag Pattern // USWDS is loaded globally via script tag in .storybook/preview-head.html // Components just render HTML - USWDS enhances automatically via window.USWDS // ARCHITECTURE: USWDS-Mirrored Behavior Pattern // Uses dedicated behavior file (usa-table-behavior.ts) that replicates USWDS source exactly super.firstUpdated(changedProperties); // Wait for DOM to be fully rendered await this.updateComplete; await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); // Ensure required elements exist before initialization this.ensureRequiredElements(); // Initialize using mirrored USWDS behavior // Pass `this` as root so selectOrMatches can find sortable headers within this component this.cleanup = initializeTable(this); // Mark as initialized for tests this._initialized = true; this.dispatchEvent(new CustomEvent('table-initialized', { bubbles: true, composed: true })); // Bridge USWDS behavior with component state // Listen for clicks on sort buttons to sync USWDS sorting with component properties this.setupSortSync(); } override updated(changedProperties: Map) { super.updated(changedProperties); if (changedProperties.has('data') && this.virtual) { this.updateVirtualData(); } if (changedProperties.has('virtual') && this.virtual && !this.virtualScroller) { this.setupVirtualScrolling(); } // Apply captured content using DOM manipulation this.applySlottedContent(); // Ensure announcement region persists after re-renders // (Lit re-renders wipe out USWDS-set live region content otherwise) this.ensureRequiredElements(); } private applySlottedContent() { if (this.slottedContent) { const slotElement = this.querySelector('slot:not([name])'); if (slotElement && this.data.length === 0 && this.headers.length === 0) { // Parse content safely using DOMParser instead of innerHTML const parser = new DOMParser(); const doc = parser.parseFromString(`
${this.slottedContent}
`, 'text/html'); const tempDiv = doc.querySelector('div'); if (tempDiv) { // Replace slot with parsed nodes const nodes = Array.from(tempDiv.childNodes); if (nodes.length > 0) { // Insert nodes before slot nodes.forEach(node => { slotElement.parentNode?.insertBefore(node, slotElement); }); // Remove the slot slotElement.remove(); } } } } } private setupVirtualScrolling() { if (!this.virtual || this.data.length === 0) return; // Find or create scrollable container const scrollContainer = (this.querySelector('.usa-table-virtual-container') as HTMLElement) || this.createVirtualContainer(); this.virtualScroller = new USWDSVirtualScroller(scrollContainer, { itemHeight: this.rowHeight, containerHeight: this.containerHeight, overscan: 5, onRender: (start: number, end: number) => { this.visibleRange = { start, end }; this.requestUpdate(); // Trigger re-render with new visible range }, }); this.virtualScroller.setTotalItems(this.data.length); // Listen for virtual render events - only when USWDS table isn't handling scroll // Note: This is functional CSS for virtualization performance, not overlapping with USWDS table sorting if (!(this as any).uswdsInitialized) { scrollContainer.addEventListener( 'virtual-render', this.handleVirtualRender.bind(this) as EventListener ); } } private createVirtualContainer(): HTMLElement { const container = document.createElement('div'); container.className = 'usa-table-virtual-container'; return container; } private updateVirtualData() { if (!this.virtualScroller) return; this.virtualScroller.setTotalItems(this.data.length); } private handleVirtualRender(event: CustomEvent) { const { startIndex, endIndex } = event.detail; this.visibleRange = { start: startIndex, end: endIndex }; this.requestUpdate(); // Trigger re-render with new visible range } private getSortValue(value: string | number | Date, _column: TableColumn): string { if (value === undefined || value === null) return ''; return String(value); } /** * Format cell value based on column type */ private formatCellValue(value: string | number | Date, column: TableColumn): string { if (value === undefined || value === null) return 'undefined'; switch (column.sortType) { case 'percentage': { const numValue = typeof value === 'number' ? value : parseFloat(String(value)); return isNaN(numValue) ? String(value) : `${numValue}%`; } case 'date': { if (value instanceof Date) { return isNaN(value.getTime()) ? 'Invalid Date' : value.toLocaleDateString(); } const dateValue = new Date(String(value)); return isNaN(dateValue.getTime()) ? 'Invalid Date' : dateValue.toLocaleDateString(); } case 'number': { const num = typeof value === 'number' ? value : parseFloat(String(value)); return isNaN(num) ? String(value) : num.toString(); } case 'text': default: return String(value); } } /** * Setup sync between USWDS behavior and component state * Listens for USWDS sort actions and updates component properties */ private setupSortSync() { // Listen for clicks on sortable headers or sort buttons (USWDS creates buttons dynamically) this.addEventListener('click', (event: Event) => { const target = event.target as HTMLElement; // Try to find sort button first (USWDS pattern) const sortButton = target.closest('.usa-table__header__button') as HTMLElement | null; let header: HTMLTableHeaderCellElement | null = null; if (sortButton) { // Found the USWDS-created button, get its parent header header = sortButton.closest('th[data-sortable]') as HTMLTableHeaderCellElement; } else { // No button found - check if clicked directly on sortable header (test scenario) header = target.closest('th[data-sortable]') as HTMLTableHeaderCellElement; } if (header) { // Get column index from header position const allHeaders = Array.from(header.parentNode?.children || []) as HTMLElement[]; const headerIndex = allHeaders.indexOf(header); if (headerIndex >= 0 && headerIndex < this.headers.length) { const column = this.headers[headerIndex]; // Check if we're sorting a new column or toggling the same column // CRITICAL: Read aria-sort from USWDS behavior instead of managing our own state! // The USWDS behavior has already set the aria-sort attribute correctly. // We just need to sync our component state with it. const currentAriaSort = header.getAttribute('aria-sort'); let newDirection: 'asc' | 'desc'; if (currentAriaSort === 'ascending') { newDirection = 'asc'; } else if (currentAriaSort === 'descending') { newDirection = 'desc'; } else { // Fallback - should not happen if USWDS behavior ran first newDirection = 'asc'; } // Capture old data before sorting const oldData = this.data; // Update component state synchronously this.sortColumn = column.key; this.sortDirection = newDirection; // Sort the component's data array synchronously this.sortData(); // Force a re-render after sorting with correct old/new values this.requestUpdate('data', oldData); // Dispatch event synchronously for external listeners this.dispatchEvent(new CustomEvent('table-sort', { detail: { column: column.key, direction: this.sortDirection, sortType: column.sortType || 'text' }, bubbles: true, composed: true })); // DO NOT update aria-sort here! USWDS behavior has already set it correctly. // We are just syncing our component state with what USWDS did. } } }); } /** * Sort the table data based on current sort column and direction */ private sortData() { if (!this.sortColumn || !this.data) return; const column = this.headers.find(h => h.key === this.sortColumn); if (!column || !column.sortable) return; // Create a sorted copy of the data const sortedData = [...this.data].sort((a, b) => { const aValue = a[this.sortColumn]; const bValue = b[this.sortColumn]; // Handle null/undefined values if (aValue == null && bValue == null) return 0; if (aValue == null) return 1; if (bValue == null) return -1; let comparison = 0; switch (column.sortType) { case 'number': case 'percentage': { const aNum = typeof aValue === 'number' ? aValue : parseFloat(String(aValue)); const bNum = typeof bValue === 'number' ? bValue : parseFloat(String(bValue)); comparison = aNum - bNum; break; } case 'date': { const aDate = aValue instanceof Date ? aValue : new Date(String(aValue)); const bDate = bValue instanceof Date ? bValue : new Date(String(bValue)); comparison = aDate.getTime() - bDate.getTime(); break; } case 'text': default: comparison = String(aValue).localeCompare(String(bValue)); break; } return this.sortDirection === 'asc' ? comparison : -comparison; }); // Assign the sorted array to trigger Lit's reactivity // In light DOM, we need to ensure the array reference changes this.data = sortedData; } /** * Ensure required DOM elements exist before USWDS initialization * USWDS table expects certain elements for accessibility features */ private ensureRequiredElements() { const table = this.querySelector('table'); if (!table) return; // Ensure table has proper USWDS classes if (!table.classList.contains('usa-table')) { table.classList.add('usa-table'); } // Create announcement region if it doesn't exist // This must persist across renders (not in template) so USWDS updates aren't wiped out const nextEl = table.nextElementSibling; if (!nextEl || !nextEl.classList.contains('usa-table__announcement-region')) { // Only create if it truly doesn't exist // Check if it exists elsewhere in the container first (might have been moved by Lit) const existingRegion = this.querySelector('.usa-table__announcement-region') as HTMLElement; if (existingRegion) { // Move it to the correct position (after table) table.after(existingRegion); } else { // Create new one const liveRegion = document.createElement('div'); liveRegion.className = 'usa-table__announcement-region usa-sr-only'; liveRegion.setAttribute('aria-live', 'polite'); liveRegion.setAttribute('aria-atomic', 'true'); table.after(liveRegion); } } } private renderHeaderCell(column: TableColumn) { if (column.sortable) { // CRITICAL: Do NOT render aria-sort attribute until after first sort! // USWDS behavior adds aria-sort dynamically via setAttribute() in sortRows(). // Rendering aria-sort="none" initially breaks USWDS's assumption that // unsorted headers have NO aria-sort attribute. // // USWDS pattern (from usa-table-behavior.ts): // - Unsorted headers: no aria-sort attribute (checked via getAttribute(SORTED) === null) // - After first click: setAttribute(SORTED, ...) adds the attribute // - unsetSort(): removeAttribute(SORTED) removes it when unsorting // // The component syncs state but lets USWDS control the attribute lifecycle. return html` ${column.label} `; } return html`${column.label}`; } private renderDataCell( row: TableRow, column: TableColumn, _rowIndex: number, columnIndex: number ) { const value = row[column.key]; const formattedValue = this.formatCellValue(value, column); // Add data-sort-value for proper sorting when display value differs const sortValue = this.getSortValue(value, column); // Add USWDS utility classes for numeric data const cellClasses = []; if (column.sortType === 'number' || column.sortType === 'percentage') { cellClasses.push('font-mono-sm', 'text-tabular', 'text-right'); } // First column should be th with scope="row" for accessibility if (columnIndex === 0) { return html` ${formattedValue} `; } return html` ${formattedValue} `; } private renderTableRow(row: TableRow, rowIndex: number) { return html` ${this.headers.map((column, columnIndex) => this.renderDataCell( row, column, this.virtual ? this.visibleRange.start + rowIndex : rowIndex, columnIndex ) )} `; } private renderCaption() { // Always render caption (USWDS requires it), but hide with usa-sr-only if not set const captionClass = this.caption ? '' : 'usa-sr-only'; const captionText = this.caption || 'Data table'; return html` ${captionText} `; } private renderTableHead() { if (this.headers.length === 0) return ''; return html` ${this.headers.map(column => this.renderHeaderCell(column))} `; } private renderEmptyRow() { return html` No data available `; } private renderTableBody() { return html` ${this.visibleData.length === 0 ? this.renderEmptyRow() : this.visibleData.map((row, rowIndex) => this.renderTableRow(row, rowIndex))} `; } private renderTable() { const tableClasses = [ 'usa-table', this.striped ? 'usa-table--striped' : '', this.borderless ? 'usa-table--borderless' : '', this.compact ? 'usa-table--compact' : '', this.stacked ? 'usa-table--stacked' : '', this.stackedHeader ? 'usa-table--stacked-header' : '', this.stickyHeader ? 'usa-table--sticky-header' : '', ] .filter(Boolean) .join(' '); return html` ${this.renderCaption()} ${this.renderTableHead()} ${this.renderTableBody()}
`; } // Use light DOM for USWDS compatibility protected override createRenderRoot(): HTMLElement { return this as any; } override render() { // Always include table container wrapper for USWDS compatibility const containerClasses = [ 'usa-table-container', this.scrollable ? 'usa-table-container--scrollable' : '', ] .filter(Boolean) .join(' '); return html`
${this.renderTable()}
`; } }