Files
2026-07-13 12:12:04 +08:00

397 lines
14 KiB
CSS

/* ==========================================================================
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 <thead>) 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 <th> child of <tr>.
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 (<table> 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 (<thead>)
-------------------------------------------------------------------------- */
.table__header {
@apply border-b border-separator/50 bg-surface-secondary;
}
/* --------------------------------------------------------------------------
Table Column (<th>)
-------------------------------------------------------------------------- */
.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 (<tbody>)
-------------------------------------------------------------------------- */
.table__body {
/* Non-virtualized: round corners via first/last cells
(border-radius on <tbody> 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 (<tr>)
-------------------------------------------------------------------------- */
.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 (<td>)
-------------------------------------------------------------------------- */
.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 <table>, 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: <tr> with <td colSpan=N> inside.
Virtualized: <div> with <div role="rowheader"> 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;
}