/* ========================================================================== Table — Data display component for structured tabular data. ========================================================================== */ /* -------------------------------------------------------------------------- Table Root — Outer container for table + optional footer. Primary variant: gray background with the table body as a white card inside. -------------------------------------------------------------------------- */ .table-root { @apply relative grid w-full overflow-clip; grid-template-columns: minmax(0, 1fr); } /* -------------------------------------------------------------------------- Scroll Container — Allows the table to scroll horizontally while the wrapper and footer remain at full width. The parent grid with minmax(0, 1fr) creates a hard width boundary that table layout cannot push beyond. -------------------------------------------------------------------------- */ .table__scroll-container { @apply scrollbar overflow-x-auto; scrollbar-gutter: auto; } .table-root--primary { @apply bg-surface-secondary px-1 pb-1; border-radius: min(32px, calc(var(--radius) * 2.5)); } .table-root--secondary { /* No background, padding, or rounding on the root */ } /* Secondary: header is a standalone rounded card. Background is on cells (not ) because Firefox does not support */ .table-root--secondary .table__header { @apply border-b-0 bg-transparent; } .table-root--secondary .table__column { @apply bg-surface-secondary; } /* Round only the truly first/last column header. Non-virtualized: column is a child of . Virtualized: column is wrapped in a `VirtualizerItem` (role="presentation") so `:first-child` / `:last-child` on `.table__column` would match every column (each is the only child of its wrapper). Walk up to the row instead. */ .table-root--secondary :is( th.table__column:first-child, [role="row"] > [role="presentation"]:first-of-type > .table__column ) { border-start-start-radius: min(32px, var(--radius-2xl)); border-end-start-radius: min(32px, var(--radius-2xl)); } .table-root--secondary :is( th.table__column:last-child, [role="row"] > [role="presentation"]:last-of-type > .table__column ) { border-start-end-radius: min(32px, var(--radius-2xl)); border-end-end-radius: min(32px, var(--radius-2xl)); } /* Secondary: body has no shadow or rounded corners */ .table-root--secondary .table__body { @apply shadow-none; /* Non-virtualized */ & tr:first-child td:first-child, & tr:first-child td:last-child, & tr:last-child td:first-child, & tr:last-child td:last-child { @apply rounded-none; } /* Virtualized: undo the rounding applied by .table__body:not(tbody) */ &:not(tbody) { @apply overflow-visible rounded-none; } } /* Secondary: rows are transparent and keep bottom border on every row */ .table-root--secondary .table__row { .table__cell { @apply border-b border-separator-tertiary/50 bg-transparent; } @media (hover: hover) { &:hover .table__cell, &[data-hovered="true"] .table__cell { @apply bg-default/50; } } } /* -------------------------------------------------------------------------- Table Content ( element) — the white card inside the root -------------------------------------------------------------------------- */ .table__content { @apply w-full border-separate border-spacing-0 text-sm; } /* Primary variant: table gets the white surface card look */ .table-root--primary .table__content { @apply overflow-clip; } /* -------------------------------------------------------------------------- Table Header () -------------------------------------------------------------------------- */ .table__header { @apply border-b border-separator/50 bg-surface-secondary; } /* -------------------------------------------------------------------------- Table Column () -------------------------------------------------------------------------- */ .table__body { /* Non-virtualized: round corners via first/last cells (border-radius on itself has no effect). Logical properties so corners follow `dir` in RTL. */ & tr:first-child td:first-child { border-start-start-radius: min(32px, var(--radius-2xl)); } & tr:first-child td:last-child { border-start-end-radius: min(32px, var(--radius-2xl)); } & tr:last-child td:first-child { border-end-start-radius: min(32px, var(--radius-2xl)); } & tr:last-child td:last-child { border-end-end-radius: min(32px, var(--radius-2xl)); } /* Virtualized: elements are divs, not tr/td. VirtualizerItem wrappers break :first-child/:last-child on rows and cells, so round the body itself and clip overflow instead. */ &:not(tbody) { @apply relative h-full overflow-clip; border-radius: min(32px, var(--radius-2xl)); } } /* -------------------------------------------------------------------------- Table Row () -------------------------------------------------------------------------- */ .table__row { @apply relative h-full; /* Bottom separator */ @apply border-b border-separator/50; /* Last row in body has no bottom border */ &:last-child { @apply border-b-0; } /* Hover state — applied to cells so Firefox clips bg to border-radius */ @media (hover: hover) { &:hover .table__cell, &[data-hovered="true"] .table__cell { @apply bg-surface/40; } } /* Selected state */ &[data-selected="true"] .table__cell { @apply bg-surface/10; } /* Disabled state */ &[aria-disabled="true"], &[data-disabled="true"] { @apply status-disabled; } /* Row focus ring: see “Table row focus ring” block below (after `.table__cell`). */ &:focus-visible, &[data-focus-visible="true"] { @apply outline-none; } /* Dragging state */ &[data-dragging="true"] { @apply opacity-50; } /* Drop target state */ &[data-drop-target="true"] .table__cell { @apply bg-accent-soft; } } /* -------------------------------------------------------------------------- Table Cell (
) -------------------------------------------------------------------------- */ .table__column { @apply relative px-4 py-2.5 text-left text-xs font-medium text-muted; /* Separator between columns — a short vertical line on the right edge */ &::after { content: ""; @apply pointer-events-none absolute end-0 top-1/2 h-4 w-px -translate-y-1/2 rounded-sm bg-separator; } &:last-child:not(:only-child)::after { content: none; } /* Sortable columns */ &[data-allows-sorting="true"] { cursor: var(--cursor-interactive); } @media (hover: hover) { &[data-allows-sorting="true"]:hover, &[data-allows-sorting="true"][data-hovered="true"] { @apply text-foreground; } } /* Focus visible — inset shadow for consistent table focus styling */ &:focus-visible, &[data-focus-visible="true"] { @apply rounded-lg outline-none; box-shadow: inset 0 0 0 2px var(--focus); } } /* Virtualized: each column is wrapped in a `VirtualizerItem` (role="presentation"), so `.table__column:last-child:not(:only-child)::after` above never matches. Hide the trailing separator on the rightmost wrapper instead. */ [role="row"] > [role="presentation"]:last-of-type:not(:only-of-type) > .table__column::after { content: none; } /* -------------------------------------------------------------------------- Sortable Column Header — wraps the column label and an optional indicator icon. Rendered inside a sortable `Table.Column`'s render prop callback. -------------------------------------------------------------------------- */ .table__sortable-column-header { @apply flex items-center justify-between; } /* -------------------------------------------------------------------------- Sortable Column Indicator — chevron that flips based on `data-direction`. Defaults to pointing up (ascending) and rotates 180° for descending. -------------------------------------------------------------------------- */ .table__sortable-column-indicator { @apply size-3 transform transition-transform duration-100 ease-out; &[data-direction="descending"] { @apply rotate-180; } } /* -------------------------------------------------------------------------- Table Body (
) -------------------------------------------------------------------------- */ .table__cell { @apply h-full bg-surface px-4 py-3 align-middle text-sm text-foreground; @apply border-b border-separator-tertiary/50; /* Focus visible — inset shadow so focus stays within the cell boundaries */ &:focus-visible, &[data-focus-visible="true"] { @apply rounded-lg outline-none; box-shadow: inset 0 0 0 2px var(--focus); } } /* -------------------------------------------------------------------------- Table row focus ring — must follow `.table__cell` (cascade). One continuous inset ring split across cells; `:is(.table__cell, .table__column)` + direct vs `> *:…` covers virtualized wrappers. `:is(:focus-visible, [data-focus-visible])` matches row keyboard focus from RAC. -------------------------------------------------------------------------- */ .table__row:is(:focus-visible, [data-focus-visible="true"]) :is(.table__cell, .table__column) { @apply shadow-none; } .table__row:is(:focus-visible, [data-focus-visible="true"]) > :is(.table__cell, .table__column):only-child, .table__row:is(:focus-visible, [data-focus-visible="true"]) > *:only-child :is(.table__cell, .table__column) { @apply rounded-lg shadow-[inset_0_0_0_2px_var(--focus)] outline-none; } .table__row:is(:focus-visible, [data-focus-visible="true"]) > :is(.table__cell, .table__column):first-child:not(:only-child), .table__row:is(:focus-visible, [data-focus-visible="true"]) > *:first-child:not(:only-child) :is(.table__cell, .table__column) { @apply rounded-l-lg shadow-[inset_2px_0_0_0_var(--focus),inset_0_2px_0_0_var(--focus),inset_0_-2px_0_0_var(--focus)] outline-none; } .table__row:is(:focus-visible, [data-focus-visible="true"]) > :is(.table__cell, .table__column):last-child:not(:only-child), .table__row:is(:focus-visible, [data-focus-visible="true"]) > *:last-child:not(:only-child) :is(.table__cell, .table__column) { @apply rounded-r-lg shadow-[inset_-2px_0_0_0_var(--focus),inset_0_2px_0_0_var(--focus),inset_0_-2px_0_0_var(--focus)] outline-none; } .table__row:is(:focus-visible, [data-focus-visible="true"]) > :is(.table__cell, .table__column):not(:first-child):not(:last-child):not(:only-child), .table__row:is(:focus-visible, [data-focus-visible="true"]) > *:not(:first-child):not(:last-child):not(:only-child) :is(.table__cell, .table__column) { @apply shadow-[inset_0_2px_0_0_var(--focus),inset_0_-2px_0_0_var(--focus)] outline-none; } /* -------------------------------------------------------------------------- Tree / expandable rows — indent the tree column by depth (any level). React Aria sets `--table-row-level` (1-based) on each body row and `data-level` on cells; tree column cells expose `[data-tree-column]`. The row custom property inherits onto cells (1rem per level). -------------------------------------------------------------------------- */ .table__cell[data-tree-column] { padding-inline-start: calc(1rem * var(--table-row-level, 1)); } /* -------------------------------------------------------------------------- Table Footer (div outside , sits on gray bg) -------------------------------------------------------------------------- */ .table__footer { @apply flex items-center px-4 py-2.5; } /* -------------------------------------------------------------------------- Resizable Container — wraps table to enable column resizing via React Aria. -------------------------------------------------------------------------- */ .table__resizable-container { @apply relative scrollbar overflow-auto; scrollbar-gutter: auto; } /* -------------------------------------------------------------------------- Column Resizer — drag handle between resizable columns. Replaces the ::after separator when present. -------------------------------------------------------------------------- */ .table__column-resizer { /* Match the ::after separator: absolute, right edge, short vertical line */ @apply absolute end-0 top-1/2 h-4 w-px -translate-y-1/2 rounded-sm bg-separator; /* Make it interactive — wider hit area with transparent padding */ @apply box-content translate-x-1/2 cursor-col-resize touch-none px-2; /* Reset appearance */ @apply border-none bg-clip-content outline-none; &[data-hovered="true"], &:hover { @apply h-full w-0.5 bg-accent; } &[data-resizing="true"] { @apply h-full w-0.5 bg-accent; } &[data-focus-visible="true"], &:focus-visible { @apply h-full w-0.5 bg-focus; } } /* Hide the ::after separator when a resizer is present in the column */ .table__column:has(.table__column-resizer)::after { content: none; } /* -------------------------------------------------------------------------- Load More Item — sentinel row for infinite scrolling. Non-virtualized: with
inside. Virtualized:
with
inside. -------------------------------------------------------------------------- */ .table__load-more { & td, & [role="rowheader"] { @apply py-3 text-center; & > * { @apply mx-auto; } } } /* -------------------------------------------------------------------------- Load More Content — styled container for the loading indicator inside the load-more sentinel row. -------------------------------------------------------------------------- */ .table__load-more-content { @apply flex items-center justify-center gap-2 py-2; }