import { NgTemplateOutlet } from '@angular/common'; import { afterNextRender, booleanAttribute, ChangeDetectionStrategy, Component, computed, contentChildren, DestroyRef, effect, ElementRef, inject, input, numberAttribute, output, signal, TemplateRef, viewChild, } from '@angular/core'; import { ShippingModeService } from '@africanies/africanies-core'; import { AfricaniesIconComponent } from '@africanies/africanies-icons'; import type { PaginationMetaModel, ShippingMode, } from '@africanies/africanies-models'; import { AfricaniesUiI18n } from '../i18n/africanies-ui-i18n'; import { ButtonComponent } from '../button/button.component'; import { EmptyStateComponent } from '../feedback/empty-state.component'; import { ErrorIndicatorComponent } from '../feedback/error-indicator.component'; import { ErrorStateComponent } from '../feedback/error-state.component'; import { LoadingStateComponent } from '../feedback/loading-state.component'; import type { AfricaniesNavItem } from '../navigation/nav-item'; import { SegmentComponent } from '../navigation/segment/segment.component'; import { PaginationComponent } from '../pagination/pagination.component'; import { RefreshComponent } from '../refresh/refresh.component'; import { CellDefDirective } from './cell-def.directive'; import { HeaderCellDefDirective } from './header-cell-def.directive'; import { RowDetailDefDirective } from './row-detail-def.directive'; import { TableColumn, TableSortChange } from './table-column'; /** * Presentational data table with template-based cells. * * Columns come from the `columns` input; cell bodies come from projected * `` templates. Columns without a matching * template fall back to rendering `row[key]` as plain text. * * Optional toolbar: top-left **shipping mode** segment on its own row above * Refresh / Clear / Filter / Export (on by default; `[showShippingMode]="false"` * to hide) and **Refresh** (`showRefresh` → {@link refreshClick}) via * {@link RefreshComponent}. Refresh is hidden while the body shows empty or * error — those states expose Retry instead. While {@link refreshing} is true * the rows stay on screen — the refresh icon spins (do not swap to a blocking * loader). Use {@link loading} when the page of data is changing (pagination / * size): rows stay on screen, a keep-rows overlay covers the grid, and the * built-in pager shows a circular spinner beside the page controls; the body * loader appears only when there are no rows yet (first load) or the host * cleared rows for a shipping-mode switch (STN ↔ SFN). Toolbar and column * headers stay mounted. Top-right **Clear** (when {@link filterCount} > 0), * **Filters**, then **Export** (`showFilter` / `showExport`). Clear emits * {@link filterClearClick}; use {@link clearAppliedListFilters} in the host. * The table does not own fetch, filter, or export logic — the host handles * those events. * * Optional footer pager: pass {@link meta} to embed {@link PaginationComponent}; * the host still owns refetch via {@link pageChange} / {@link sizeChange}. The * pager also writes `page` / `size` to the URL (same keys as filters). Rows * are never sliced client-side. While {@link loading} or {@link refreshing} is * true and rows are on screen, the pager shows a circular spinner and is * disabled — so a page change is visible when the toolbar is scrolled away. * * **Async body:** first load, empty results, and hard errors render inside the * grid (headers + toolbar stay). Bind {@link loading}, {@link error}, and * {@link emptyMessage}. Empty/error Retry emits {@link refreshClick}. A stale * error (rows still on screen) shows {@link ErrorIndicatorComponent} above the * grid. Do not wrap list tables in {@link AsyncStateComponent} — that tears * down Filters / Export / pager. * * Sorting is server-driven — sortable headers emit {@link TableSortChange} * and never reorder local rows. * * **Expandable rows:** project `` * templates. A leading chevron column appears; the expanded panel sticks to the * scrollport width (so it does not scroll horizontally with the columns) and * lays out label/value pairs in a wrapping auto-fill grid. * * @typeParam T - Row record shape. * * @example * ```html * * … * … * * ``` * * **Sticky columns:** a column with `key: 'actions'` stays pinned to the * right while the grid scrolls horizontally. The expand trigger column pins * to the left when rows are expandable. Override with * {@link TableColumn.sticky}. Sticky columns use a slightly thicker bottom * border (and opaque fill) so dividers stay visible while scrolling. * * **Sticky header (desktop):** when {@link stickyHeader} is true (default), * large screens scroll the grid inside a capped viewport and keep `th` pinned * to the top of that scrollport. Mobile keeps horizontal-only overflow so * page scroll is unchanged. */ @Component({ selector: 'africanies-table', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ NgTemplateOutlet, ButtonComponent, AfricaniesIconComponent, EmptyStateComponent, ErrorIndicatorComponent, ErrorStateComponent, LoadingStateComponent, PaginationComponent, RefreshComponent, SegmentComponent, ], host: { class: 'block w-full min-w-0', }, styles: ` /* Full-row hover: sticky action cells use an opaque fill that would otherwise mask a background. Paint every cell the same way. */ :host tr.africanies-table-row:hover > td { background-color: #e9eef5; } :host-context(.dark) tr.africanies-table-row:hover > td { background-color: #272729; } /* Expand panel sticks to the scrollport (not the wide table). Width is also set from measured clientWidth so it stays viewport-bound even when container query units are flaky inside tables. */ :host .africanies-table-scrollport { container-type: inline-size; container-name: africanies-table; } :host .africanies-table-expand-panel { position: sticky; left: 0; z-index: 1; box-sizing: border-box; width: 100cqi; max-width: 100cqi; overflow-x: hidden; } /* Corner pulse on Filters when the list is actively filtered. */ @keyframes africanies-table-filter-pulse-ping { 0% { transform: scale(1); opacity: 0.75; } 70%, 100% { transform: scale(2.25); opacity: 0; } } @keyframes africanies-table-filter-pulse-core { 0%, 100% { transform: scale(1); opacity: 0.85; } 50% { transform: scale(1.15); opacity: 1; } } :host .africanies-table-filter-pulse-ping { animation: africanies-table-filter-pulse-ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite; } :host .africanies-table-filter-pulse-core { animation: africanies-table-filter-pulse-core 1.5s ease-in-out infinite alternate; } @media (prefers-reduced-motion: reduce) { :host .africanies-table-filter-pulse-ping, :host .africanies-table-filter-pulse-core { animation: none; } } `, template: `
@if ( showShippingMode() || showRefresh() || showFilter() || showExport() ) {
@if (showShippingMode()) { } @if (showToolbarRefresh() || showFilter() || showExport()) {
@if (showToolbarRefresh()) { }
@if (showFilter() && filterCount() > 0) { } @if (showFilter()) { @if (filterCount() > 0) { } } @if (showExport()) { }
}
} @if (staleError(); as staleMessage) { }
@if (isExpandable()) { } @for (col of columns(); track col.key) { } @switch (bodyKind()) { @case ('loading') { } @case ('error') { } @case ('empty') { } @default { @for (row of rowList(); track rowId(row, $index); let i = $index) { @if (isExpandable()) { } @for (col of columns(); track col.key) { } @if (isExpandable()) { } } } }
@if (someRowsExpanded()) { } @else { } @if (headerTemplateFor(col.key); as headerTpl) { } @else if (col.sortable) { } @else { {{ col.header }} }
@if (templateFor(col.key); as tpl) { } @else { {{ defaultCellText(row, col.key) }} }
@for ( detail of rowDetailDefs(); track detail.label() ) {
{{ detail.label() }}
}
@if (paginationLoading()) { }
@if (meta(); as pager) { }
`, }) /** * Presentational data table with template-based cells. */ export class TableComponent { private readonly destroyRef = inject(DestroyRef); protected readonly shipping = inject(ShippingModeService); private readonly i18n = inject(AfricaniesUiI18n); /** Local STN / SFN segment options for {@link showShippingMode}. */ protected readonly shippingModeItems = computed((): AfricaniesNavItem[] => [ { id: 'stn', label: this.i18n.t( 'africaniesUi.table.shippingToNigeria', 'Shipping to Nigeria', ), }, { id: 'sfn', label: this.i18n.t( 'africaniesUi.table.shippingFromNigeria', 'Shipping from Nigeria', ), }, ]); protected readonly shippingModeAriaLabel = computed(() => this.i18n.t('africaniesUi.table.shippingMode', 'Shipping mode'), ); protected readonly clearLabel = computed(() => this.i18n.t('africaniesUi.table.clear', 'Clear'), ); protected readonly clearFiltersAriaLabel = computed(() => this.i18n.t('africaniesUi.table.clearFilters', 'Clear filters'), ); protected readonly expandAllLabel = computed(() => this.i18n.t('africaniesUi.table.expandAll', 'Expand all'), ); protected readonly collapseAllLabel = computed(() => this.i18n.t('africaniesUi.table.collapseAll', 'Collapse all'), ); protected readonly resolvedRefreshLabel = computed(() => { const label = this.refreshLabel(); return label === 'Refresh' ? this.i18n.t('africaniesUi.table.refresh', 'Refresh') : label; }); protected readonly resolvedFilterLabel = computed(() => { const label = this.filterLabel(); return label === 'Filters' ? this.i18n.t('africaniesUi.table.filters', 'Filters') : label; }); protected readonly resolvedExportLabel = computed(() => { const label = this.exportLabel(); return label === 'Export' ? this.i18n.t('africaniesUi.table.export', 'Export') : label; }); protected readonly resolvedEmptyMessage = computed(() => { const message = this.emptyMessage(); return message === 'No results found.' ? this.i18n.t('africaniesUi.table.empty', 'No results found.') : message; }); /** * Segment selection mirror of {@link ShippingModeService.mode}. Kept local * so a denied {@link ShippingModeService.requestModeChange} can snap the * pills back when the service mode did not change. */ protected readonly shippingModeActiveId = signal('sfn'); /** Scrollport that owns horizontal overflow for the main grid. */ private readonly scrollport = viewChild>('scrollport'); /** * Visible width of {@link scrollport}. Applied to the expand panel so * details stay within the viewport and do not scroll with the columns. */ protected readonly scrollportWidthPx = signal(0); constructor() { effect(() => { this.shippingModeActiveId.set(this.shipping.mode()); }); afterNextRender(() => { const el = this.scrollport()?.nativeElement; if (!el) { return; } const sync = (): void => { this.scrollportWidthPx.set(el.clientWidth); }; sync(); const observer = new ResizeObserver(sync); observer.observe(el); this.destroyRef.onDestroy(() => observer.disconnect()); }); } /** * When false, hides row expansion even if {@link RowDetailDefDirective} * templates are projected. Defaults to true. */ readonly expandable = input(true, { transform: booleanAttribute }); /** * Stable row id for expansion state. Defaults to the row index in `rows`. */ readonly rowTrackBy = input<(row: T, index: number) => string | number>( (_row, index) => index, ); /** Emitted when a row is expanded or collapsed. */ readonly rowExpandChange = output<{ row: T; expanded: boolean }>(); /** Column definitions (header chrome + keys). */ readonly columns = input.required[]>(); /** * When true (default), pin column headers while scrolling the grid on * large screens (`lg+`). Uses a capped scrollport so sticky works with * horizontal overflow. Set false to keep the previous unconstrained layout. */ readonly stickyHeader = input(true, { transform: booleanAttribute }); /** * Row records to render. Empty arrays show {@link EmptyStateComponent} in * the body unless {@link loading} or {@link error} is set. Missing `data` * during load is treated as `[]`. */ readonly rows = input.required(); /** * Optional current sort so header indicators stay in sync with the * server-side `order` param. Omitted / null means no column is active. */ readonly sort = input(null); /** * Show the Shipping to / from Nigeria segment above the table (top-left). * On by default; set `[showShippingMode]="false"` to hide (e.g. modals). * Writes through {@link ShippingModeService.requestModeChange} so feature * guards still run. */ readonly showShippingMode = input(true, { transform: booleanAttribute }); /** * Show a Refresh button above the table (top-left) once rows are on screen. * Hidden during in-grid empty/error — those use Retry in the body. Host refetches * from {@link refreshClick}. */ readonly showRefresh = input(false, { transform: booleanAttribute }); /** Label on the refresh trigger. Defaults to `Refresh`. */ readonly refreshLabel = input('Refresh'); /** * Background refetch in flight. Rows stay visible; the refresh icon spins. * Bind to a soft refetch flag (e.g. refresh button) — not page changes. * Prefer {@link loading} when the page of rows is changing. */ readonly refreshing = input(false, { transform: booleanAttribute }); /** * Page of data is loading (first load, shipping-mode switch, pagination, * page size). When rows are already on screen, a keep-rows overlay covers * the grid and the pager shows a spinner; when there are no rows (first * load or the host dropped STN/SFN rows after a mode switch), the body shows * {@link LoadingStateComponent}. Distinct from {@link refreshing}. */ readonly loading = input(false, { transform: booleanAttribute }); /** Copy under the loading spinner. Defaults to `Loading…`. */ readonly loadingLabel = input('Loading…'); /** * Hard fetch failure. Empty rows → {@link ErrorStateComponent} in the body. * Rows still present → {@link ErrorIndicatorComponent} above the grid. * Retry emits {@link refreshClick}. */ readonly error = input(null); /** * Copy for the in-grid empty state when there are no rows and no error. */ readonly emptyMessage = input('No results found.'); /** * Show a Filters button above the table (top-right). Host should open * {@link FilterDrawerService} from {@link filterClick}. */ readonly showFilter = input(false, { transform: booleanAttribute }); /** Label on the filter trigger. Defaults to `Filters`. */ readonly filterLabel = input('Filters'); /** * Optional badge count on the filter trigger (active filters). Hidden when 0. * When greater than 0, also shows a corner pulse so a filtered list is * easier to spot. */ readonly filterCount = input(0, { transform: numberAttribute }); /** * Show an Export button to the right of Filters. Host handles the download * from {@link exportClick}. */ readonly showExport = input(false, { transform: booleanAttribute }); /** Label on the export trigger. Defaults to `Export`. */ readonly exportLabel = input('Export'); /** * When set, embeds {@link PaginationComponent} under the grid. Host still * owns refetch via {@link pageChange} / {@link sizeChange} — rows are never * sliced here. */ readonly meta = input(null); /** * Emitted when a sortable header is activated. Consumers must refetch; * this component never sorts `rows` locally. */ readonly sortChange = output(); /** Emitted when the top-left Refresh control is clicked. */ readonly refreshClick = output(); /** * Emitted when Clear next to Filters is clicked ({@link filterCount} > 0). * Host should call {@link clearAppliedListFilters} (or equivalent). */ readonly filterClearClick = output(); /** Emitted when the top-right Filters control is clicked. */ readonly filterClick = output(); /** Emitted when the Export control is clicked. */ readonly exportClick = output(); /** * Target 1-based page after prev/next. Host should refetch; this component * does not change `rows`. */ readonly pageChange = output(); /** * New page size from the size dropdown. Host should refetch at page 1; * this component does not change `rows`. */ readonly sizeChange = output(); /** Projected cell templates keyed by column. */ private readonly cellDefs = contentChildren(CellDefDirective); /** Projected header cell templates keyed by column. */ private readonly headerCellDefs = contentChildren(HeaderCellDefDirective); /** Projected label / value templates for expanded rows. */ protected readonly rowDetailDefs = contentChildren(RowDetailDefDirective); private readonly expandedRowIds = signal>(new Set()); protected readonly rowList = computed(() => this.rows() ?? []); protected readonly isExpandable = computed( () => this.expandable() && this.rowDetailDefs().length > 0, ); protected readonly colSpan = computed( () => this.columns().length + (this.isExpandable() ? 1 : 0), ); protected readonly errorMessage = computed( () => this.error()?.trim() || this.i18n.t('africaniesUi.table.error', 'Something went wrong.'), ); protected readonly staleError = computed(() => { const message = this.error()?.trim(); if (!message || this.loading() || this.rowList().length === 0) { return null; } return message; }); protected readonly bodyKind = computed( (): 'loading' | 'error' | 'empty' | 'rows' => { if (this.loading() && this.rowList().length === 0) { return 'loading'; } if (this.error()?.trim() && this.rowList().length === 0) { return 'error'; } if (this.rowList().length === 0) { return 'empty'; } return 'rows'; }, ); protected readonly paginationLoading = computed( () => (this.loading() || this.refreshing()) && this.rowList().length > 0, ); /** Top-left Refresh — only when rows are on screen (empty/error use in-body Retry). */ protected readonly showToolbarRefresh = computed( () => this.showRefresh() && this.bodyKind() === 'rows', ); protected readonly filterAriaLabel = computed(() => { const label = this.filterLabel().trim() || 'Filters'; const count = this.filterCount(); if (count <= 0) { return label; } return `${label}, ${count} active`; }); /** * Applies a shipping-mode segment selection through * {@link ShippingModeService.requestModeChange}. * * @param id - Segment id (`stn` / `sfn`) or null. */ protected onShippingModeChange(id: string | null): void { if (id !== 'stn' && id !== 'sfn') { return; } this.shippingModeActiveId.set(id); if (id === this.shipping.mode()) { return; } this.shipping.requestModeChange(id as ShippingMode).subscribe((ok) => { if (!ok) { this.shippingModeActiveId.set(this.shipping.mode()); } }); } protected readonly visibleRowIds = computed(() => this.rowList().map((row, index) => this.rowId(row, index)), ); protected readonly someRowsExpanded = computed(() => { const open = this.expandedRowIds(); return this.visibleRowIds().some((id) => open.has(id)); }); /** Lookup map rebuilt when projected cell defs change. */ private readonly cellTemplateMap = computed(() => { const map = new Map>(); for (const def of this.cellDefs()) { map.set(def.africaniesCellDef(), def.template); } return map; }); /** Lookup map rebuilt when projected header cell defs change. */ private readonly headerCellTemplateMap = computed(() => { const map = new Map>(); for (const def of this.headerCellDefs()) { map.set(def.africaniesHeaderCellDef(), def.template); } return map; }); /** * Resolves the projected template for a column key, if any. * * @param key - {@link TableColumn.key} to look up. * @returns Matching template, or `null` for the plain-text fallback. */ protected templateFor(key: string): TemplateRef | null { return this.cellTemplateMap().get(key) ?? null; } /** * Resolves the projected header template for a column key, if any. * @param key */ protected headerTemplateFor(key: string): TemplateRef | null { return this.headerCellTemplateMap().get(key) ?? null; } /** * Builds the `let-row` context for a projected cell template. * * @param row - Current row record. * @returns NgTemplateOutlet context with `$implicit` set to `row`. */ protected cellContext(row: T): { $implicit: T } { return { $implicit: row }; } /** * Header row chrome. Soft fill lives on each `th` ({@link headerSoftClass}) * so sticky columns do not double-tint and paint over neighbor labels. * @returns Header row class list. */ protected headerRowClass(): string { return 'border-b border-border dark:border-white/10'; } /** * Opaque header fill (sticky-safe). Neutral chrome — not mode-accented. * Light `#f0f2f5` / dark elevated ink surface. * @returns Background utility classes. */ protected headerSoftClass(): string { return 'bg-[#f0f2f5] dark:bg-[#2a2c31]'; } /** * Body row classes — row marker for hover styles, plus hide cell borders on * the last / expanded row. * @param row - Current row. * @param index - Index within {@link rows}. * @returns Tailwind class list. */ protected bodyRowClass(row: T, index: number): string { if (this.isExpandable() && this.isRowExpanded(row, index)) { // Only the bottom edge — keep the top divider so the row still // separates from the row above. Detail panel owns the separator. return 'africanies-table-row group [&>td]:border-b-0'; } return 'africanies-table-row group last:[&>td]:border-b-0'; } /** * Horizontal pin edge for a column. `key: 'actions'` sticks right unless * {@link TableColumn.sticky} opts out. * * @param col - Column definition. * @returns `'left'` / `'right'`, or `null` when the column scrolls with the grid. */ protected stickyEdge(col: TableColumn): 'left' | 'right' | null { if (col.sticky === false) { return null; } if (col.sticky === 'left' || col.sticky === 'right') { return col.sticky; } return col.key === 'actions' ? 'right' : null; } /** * Header classes for the expand/collapse trigger column (pinned left). * @returns Header class list. */ protected expandHeaderClass(): string { return ( 'w-10 border-b-[1.5px] border-border px-2 py-4 dark:border-white/10 ' + this.headerSoftClass() + ' ' + this.stickyPinClass('left', 'head') ); } /** * Body classes for the expand/collapse trigger column (pinned left). * Uses a slightly thicker bottom border than scrolling cells so the * sticky fill still reads a clear divider (no top-shadow fallback). * @param index - Index within {@link rows}. * @returns Body cell class list. */ protected expandBodyClass(index: number): string { const row = this.rowList()[index]; const expanded = row !== undefined && this.isRowExpanded(row, index); const border = expanded ? '' : 'border-b-[1.5px] border-border dark:border-white/10 '; return ( 'w-10 px-2 py-4 align-middle ' + border + this.stickyPinClass('left', 'body') ); } /** * Header cell classes, including sticky pin when set. * @param col - Column definition. * @returns Header class list. */ protected headerCellClass(col: TableColumn): string { const sticky = this.stickyEdge(col); const border = sticky ? 'border-b-[1.5px] ' : 'border-b '; return ( border + 'border-border whitespace-normal wrap-break-word px-[1.125rem] py-4 font-medium text-neutral-500 dark:border-white/10 dark:text-neutral-400 ' + this.headerSoftClass() + ' ' + this.stickySurfaceClass(col, 'head') + (sticky ? '' : this.headerStickClass()) ); } /** * Vertical pin for scrolling header cells on desktop. Pinned left/right * headers get `lg:top-0` from {@link stickyPinClass} instead. */ protected headerStickClass(): string { if (!this.stickyHeader()) { return ''; } return ' lg:sticky lg:top-0 lg:z-[3] print:static'; } /** * Body cell classes, including sticky pin when set. * Sticky expand/actions columns use a 1.5px bottom border; scrolling cells * stay at 1px. Expanded rows drop the bottom border (see {@link bodyRowClass}). * @param col - Column definition. * @param index - Index within {@link rows}. * @returns Body cell class list. */ protected bodyCellClass(col: TableColumn, index: number): string { const row = this.rowList()[index]; const expanded = row !== undefined && this.isRowExpanded(row, index); const sticky = this.stickyEdge(col); const rowLine = expanded ? '' : sticky ? 'border-b-[1.5px] border-border dark:border-white/10 ' : 'border-b border-border dark:border-white/10 '; const wrap = col.key === 'actions' ? 'whitespace-nowrap ' : 'whitespace-normal wrap-break-word '; return ( rowLine + 'px-[1.125rem] py-4 align-middle ' + wrap + this.stickySurfaceClass(col, 'body') ); } /** * Sticky pin utilities for a known edge (expand column or data column). * @param edge - Left or right pin. * @param surface - Header vs body fill tokens. * @returns Sticky utilities. */ protected stickyPinClass( edge: 'left' | 'right', surface: 'head' | 'body', ): string { // Expand (left) sits above scrolling cells; actions (right) match. // Header needs a higher z so it stays above sticky body cells. const side = edge === 'right' ? 'sticky right-0 z-[1]' : 'sticky left-0 z-[1]'; if (surface === 'head') { // Fill comes from {@link headerSoftClass} on the cell — do not re-tint. // Corner pins also stick vertically on desktop when stickyHeader is on. const vertical = this.stickyHeader() ? ' z-[2] lg:top-0 lg:z-[4] print:static' : ' z-[2]'; return `${side}${vertical}`; } const fill = 'bg-surface dark:bg-ink-surface'; return `${side} ${fill}`; } /** * Sticky pin plus an opaque fill matching the table surface, so scrolled * cells do not show through. * @param col - Column definition. * @param surface - Header vs body fill tokens. * @returns Sticky utilities, or empty when the column scrolls. */ protected stickySurfaceClass( col: TableColumn, surface: 'head' | 'body', ): string { const edge = this.stickyEdge(col); if (!edge) { return ''; } return this.stickyPinClass(edge, surface); } /** * Stable id for a row — used for expand/collapse state. * * @param row - Current row. * @param index - Index within {@link rows}. * @returns String form of {@link rowTrackBy} for this row. */ protected rowId(row: T, index: number): string { return String(this.rowTrackBy()(row, index)); } /** * Whether a row's detail panel is open. * @param row - Current row. * @param index - Index within {@link rows}. * @returns True when this row's detail panel is expanded. */ protected isRowExpanded(row: T, index: number): boolean { return this.expandedRowIds().has(this.rowId(row, index)); } /** * Toggle expanded state for a row. * @param row * @param index */ protected toggleRowExpanded(row: T, index: number): void { const id = this.rowId(row, index); const expanded = this.expandedRowIds().has(id); this.expandedRowIds.update((set) => { const next = new Set(set); if (expanded) { next.delete(id); } else { next.add(id); } return next; }); this.rowExpandChange.emit({ row, expanded: !expanded }); } /** Expand every visible row. */ protected expandAllRows(): void { this.expandedRowIds.set(new Set(this.visibleRowIds())); } /** Collapse every visible row. */ protected collapseAllRows(): void { this.expandedRowIds.set(new Set()); } /** * Default cell text when no `africaniesCellDef` template is registered. * * WHY stringify via `String(...)`: keeps null/undefined visible as empty * rather than the literal words "null"/"undefined", while still showing * numbers and booleans without forcing consumers to template every column. * * @param row - Current row. * @param key - Column key into the row. * @returns Display string for the cell. */ protected defaultCellText(row: T, key: string): string { const value = (row as Record)[key]; if (value === null || value === undefined) { return ''; } return String(value); } /** * Glyph for the active sort column; empty for inactive sortable headers. * * @param key - Column key under the header button. * @returns Sort direction glyph (`↑` / `↓` / `↕`). */ protected sortIndicator(key: string): string { const current = this.sort(); if (!current || current.key !== key) { return '↕'; } return current.direction === 'asc' ? '↑' : '↓'; } /** * Toggles or starts sort for a column and emits {@link sortChange}. * * @param key - Sortable column key that was clicked. */ protected onSortHeaderClick(key: string): void { const current = this.sort(); const direction: TableSortChange['direction'] = current?.key === key && current.direction === 'asc' ? 'desc' : 'asc'; this.sortChange.emit({ key, direction }); } }