"use client" /** * DataTable — generic reusable table (no pagination) * * Column features: * • Resizable — drag trailing-edge handle on any non-locked column * • Drag-to-reorder — drag header cell for free (unpinned) columns * • Pin Left / Pin Right / Unpin — per-column context menu * • Sort Asc / Desc — per-column context menu (sortable columns) * • Wrap Text / Unwrap — per-column context menu * • Per-column quick search * • Row selection (checkboxes + floating bulk action bar) * • Group by (collapsible group rows) * • Hidden columns * * WCAG 2.1 AA: * ✓ aria-sort on sortable * ✓ aria-label on every icon-only button * ✓ Select / Actions columns: sr-only header text + resolved labels for controls * ✓ Row checkboxes: visible on row focus-within, stop row click propagation (default control size; extended hit slop on Checkbox) * ✓ Bulk-action bar: role="status" aria-live="polite" * ✓ Resize handles: `verticalResizeSeparatorAria()` (role="separator" + valuemin/max/now) */ import * as React from "react" import { createPortal } from "react-dom" import { cn } from "../../lib/utils" import { EDGE_RESIZE_HANDLE_CLASS, verticalResizeSeparatorAria } from "../../lib/edge-resize-handle" import { bindVerticalScrollListeners, getStickyTableHeaderOffset, } from "../../lib/page-scroll-port" import { DATA_TABLE_GRID_INSET_CLASS, DATA_TABLE_TOOLBAR_INSET_CLASS, warnIfStackedTableEdgeInset, } from "../../lib/table-edge-inset" import { runWithPreservedPageScroll } from "../../lib/preserve-page-scroll" import { rafThrottle } from "../../lib/raf-throttle" import { Button } from "../ui/button" import { railTriggerProps } from "../ui/floating-sheet-panel" import { ScrollRegion } from "../ui/scroll-region" /** * DataTable cells use `max-w-0` + `overflow-hidden` for truncation. Outset * focus rings paint outside the control and get clipped to corner stubs. * Force inset rings on common focusable descendants (hub cell renderers, * links, icon buttons, checkboxes, Tip `tabIndex={0}` glyphs). * * `p-1 -m-1` covers what `ring-inset` cannot: a plain `outline`, which several * focusable descendants use instead of a ring and which has no inset form. The * padding grows the clip box while the negative margin keeps the content box the * same size and in the same place, so truncation is unchanged and a link or Tip * glyph flush with the cell's leading edge keeps its whole indicator. */ const CELL_CLIP_FOCUS_INSET_CLASS = "p-1 -m-1 [&_a:focus-visible]:ring-inset [&_button:focus-visible]:ring-inset [&_[role=checkbox]:focus-visible]:ring-inset [&_[tabindex]:focus-visible]:ring-inset" import { FilterBar, FilterBarAddButton, FilterBarClearButton, } from "../ui/filter-bar" import { FilterButton } from "../ui/filter-button" import { Input } from "../ui/input" import { Kbd, KbdGroup } from "../ui/kbd" import { ResponsiveActionRow, useResponsiveActionWidth, useWrapCrowding, } from "../ui/responsive-action-row" import type { ResponsiveAction } from "../ui/responsive-action-row" import { BulkSelectionBar } from "./bulk-selection-bar" import { Tip } from "../ui/tip" import { useDismissBeforeOverlay } from "../../hooks/use-dismiss-before-overlay" import { useModKeyLabel, useAltKeyLabel } from "../../hooks/use-mod-key-label" import { useIsMobile } from "../../hooks/use-mobile" import { useSidebarReflowZoom } from "../../hooks/use-sidebar-reflow-zoom" import { isEditableTarget } from "../../lib/editable-target" import { augmentColumnsWithCompanionFilters, filterableColumns, resolveColumnFilter, } from "../../lib/column-filter" import type { FilterFieldRuntimeContext } from "../../lib/column-filter-context" import { filterPresetForCellKind } from "../../lib/column-cell-kind" import { initialsFromDisplayName } from "../../lib/column-filter-range-policy" import { AvatarInitials } from "../ui/avatar" import { Badge } from "../ui/badge" import { StatusBadge } from "../ui/status-badge" import { PeopleAvatarRailCell, PillCell, type PersonStub, } from "../data-views/table-cells" import { groupRowAccessibilityLabel, GROUP_ROW_EMPTY_KEY } from "../../lib/group-row-key" import { Checkbox } from "../ui/checkbox" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Shortcut, } from "../ui/dropdown-menu" import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger, } from "../ui/popover" import { TableSearchRecentsPopover } from "./table-search-recents-popover" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "../ui/tooltip" import { OPERATOR_LABELS, FILTER_TEXT_MASK_FORMAT_HINT } from "../../lib/table-properties-types" import type { ActiveFilter } from "../../lib/table-properties-types" import { getConditionalCellBackground, ruleHasActiveValues } from "../../lib/conditional-rule-match" import { formatYmdForDisplay } from "../../lib/date-filter" import { useIncrementalRender } from "../../lib/lazy-loading" import { useIsClient } from "../../hooks/use-is-client" import { FilterDateCalendar } from "./filter-date-calendar" import { FilterDateRangeControl } from "./filter-date-range-control" import { FilterPersonOptionList } from "./filter-person-option-list" import { FilterRangeControl } from "./filter-range-control" import { FilterTextValueInput } from "./filter-text-value-input" import type { DataTableProps, ColumnDef, ColumnCellKind, SortDir, StatusBadgeTone, } from "./types" import { useTableState } from "./use-table-state" /** When `ColumnDef.label` is empty, use a standard name for select/actions columns. */ function defaultColumnHeaderLabel(key: string): string | undefined { switch (key) { case "select": return "Select" case "actions": return "Actions" default: return undefined } } /** * Side padding for a column whose content is a control rather than text. * * `px-3` is right for text, which has nothing to lose by starting 12px in. It is * wrong for a fixed-size control: a 48px actions column left 24px of cell for a * 32px icon button, and since cells clip (`max-w-0` + `overflow-hidden`, so long * text truncates) the button lost 4px off each side along with the near edges of * its focus ring. Keyboard focus then read as a ring sliced flat top and bottom. * * 4px keeps the control's own box intact in a 48px column and still reads as a * gutter, because the control is centred and narrower than the cell. */ const CONTROL_COLUMN_PADDING = "px-1" /** * The group divider's own box. Shared by the row in the table and by the * clone that pins under the column header, because the clone only reads as * the same bar if it is measured and painted as one — the pin maths uses the * real row's height for the hand-off. */ const GROUP_BAR_CLASS = "flex w-full items-center gap-2 px-4 py-1 text-xs font-semibold leading-none text-muted-foreground tracking-wide bg-dt-group-bg select-none" /** * With a checkbox leading the bar, the bar's own start padding is dropped: the * checkbox is centred inside a slot the width of the select column instead, so * it lands on the same axis as the row checkboxes beneath it rather than 4px to * their right (the column is 40px with a 16px control, so its centre is 20px; * `px-4` put the bar's box at 16px). */ const GROUP_BAR_WITH_SELECT_CLASS = "ps-0 gap-3" const GROUP_DIVIDER_BADGE_SIZE = "sm" as const /** Slightly shorter than table-row chips so the band lands ~30px tall. */ const GROUP_CHIP_CLASS = "!h-5 !min-h-0 gap-1 !px-1.5 !py-0 !text-xs leading-none" function isBadgeGroupKind( kind: ColumnCellKind | undefined, ): kind is "status" | "pill" { return kind === "status" || kind === "pill" } /** Status or pill chip for a group heading. */ function groupBadgeNode( label: string, kind: "status" | "pill", option?: { tone?: StatusBadgeTone; icon?: string }, ): React.ReactNode { if (kind === "status") { return ( ) } return ( ) } /** Group band fill — the same token the status badge uses for its background. */ function groupBarTint(tone: StatusBadgeTone): string { return `var(--status-badge-${tone}-fill)` } /** * `select` is the group's own checkbox, and the slot it sits in is the select * column's box. `gap-3` after it matches the data columns' `px-3`, so the group * label starts on the same axis as the first column's text. * * The pinned clone passes a non-interactive mirror, so the bar does not change * shape as it pins. */ function groupBarContent( groupLabel: React.ReactNode, count: number, select?: React.ReactNode, selectSlotWidth?: number, ): React.ReactNode { return ( <> {select ? ( {select} ) : null} {groupLabel} {count} ) } /** * What `useTableState` groups rows under when the grouped field is empty. * The divider says `None` instead: a lone dash is not a word, and the band is * a heading (`exxat-copy-discipline`). */ const GROUP_EMPTY_KEY = GROUP_ROW_EMPTY_KEY /** Cell kinds whose own icon would say less than the group's name already does. */ const GROUP_KIND_PLAIN: ReadonlySet = new Set([ "text", "tags", "attachment", "external-link", ]) /** * A group heading in the grouped column's own vocabulary, for columns that do * not ship `filter.options` to render from. * * A person column groups on the display name, so the divider can rebuild the * identity the cells show — avatar plus name, no email, because the band is one * line and a heading, not a record (`exxat-person-identity-display`). Every * other kind leads with the icon its filter menu uses, so "Type", "Level", and * "Due" dividers are told apart at a glance by the same mark the reader already * associates with that column. */ function groupKindNode( label: string, kind: ColumnCellKind | undefined, ): React.ReactNode { if (!kind) return label if (kind === "person") { return ( {label} ) } if (GROUP_KIND_PLAIN.has(kind)) return label return (