Display
Table
Tabla de datos con orden, selección y dos modos de móvil
Preview
| Tallas | ||||
|---|---|---|---|---|
| Accordion | Display | sm · md | 3 | |
| Badge | Display | xs · sm · md | 4 | |
| Breadcrumb | Navigation | sm · md | 3 | |
| Burger | Navigation | sm · md · lg | 5 | |
| Button | Actions | xs · sm · md · lg | 9 | |
| ButtonEmail | Actions | xs · sm · md | 6 | |
| ButtonLink | Actions | xs · sm · md · lg | 5 | |
| ButtonScroll | Actions | md · lg | 3 |
El control mobile decide qué hace la tabla cuando su hueco baja de 480 px. Con scroll se desliza de lado con la primera columna fija. Con stack cada fila pasa a ser una tarjeta y el orden se elige en un desplegable. Al elegir uno se enciende Estrechar, porque en un escenario ancho los dos se ven igual. En una pantalla táctil hoverable no se nota, porque no hay puntero.
size
mobile
sorting.icon
framed
hoverable
striped
selection
Code
import css from "./Table.module.css";
import { useEffect, useRef, useState } from "react";
import { CaretDownIcon } from "@phosphor-icons/react/dist/csr/CaretDown";
import { CaretUpIcon } from "@phosphor-icons/react/dist/csr/CaretUp";
import { CaretUpDownIcon } from "@phosphor-icons/react/dist/csr/CaretUpDown";
import Checkbox from "@/components/library/react/Checkbox/Checkbox";
type Size = "sm" | "md";
type Direction = "ascending" | "descending";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface TableColumn {
key: string;
label: string;
// Required: numbers align to the end, text to the start; a column has to say which.
align: "start" | "end";
sortable?: boolean;
}
interface TableRow {
id: string;
[key: string]: string | number;
}
// Capabilities travel whole: a sorting without its announcement cannot be written.
interface TableSorting {
key: string;
direction: Direction;
icon: "hover" | "always";
announcement: (label: string, direction: Direction) => string;
onChange: (key: string, direction: Direction) => void;
}
interface TableSelection {
selected: string[];
allAriaLabel: string;
rowAriaLabel: (row: TableRow) => string;
onChange: (selected: string[]) => void;
}
interface TableProps {
size: Size | ResponsiveSize;
columns: TableColumn[];
rows: TableRow[];
// Names the table (visually hidden caption) and its scroll region.
"aria-label": string;
mobile: "scroll" | "stack";
framed?: boolean;
hoverable?: boolean;
striped?: boolean;
sorting?: TableSorting;
selection?: TableSelection;
}
const DIRECTION_ICONS = { ascending: CaretUpIcon, descending: CaretDownIcon };
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function Table({
size,
columns,
rows,
"aria-label": ariaLabel,
mobile,
framed,
hoverable,
striped,
sorting,
selection,
}: TableProps) {
const [announcement, setAnnouncement] = useState("");
const [scrollable, setScrollable] = useState(true);
const region = useRef<HTMLDivElement>(null);
const mounted = useRef(false);
// "All" needs rows: every() over an empty list is true and would tick the box.
const picked = rows.filter((row) => selection?.selected.includes(row.id)).length;
const all = rows.length > 0 && picked === rows.length;
const some = picked > 0 && !all;
const sort = (column: TableColumn) => {
if (!sorting) return;
const flip = sorting.key === column.key && sorting.direction === "ascending";
sorting.onChange(column.key, flip ? "descending" : "ascending");
};
// Ids from other pages stay put: the table only knows the rows it was given.
const toggleRow = (id: string) => {
if (!selection) return;
const { selected } = selection;
selection.onChange(
selected.includes(id) ? selected.filter((current) => current !== id) : [...selected, id],
);
};
const toggleAll = () => {
if (!selection) return;
const { selected } = selection;
const ids = rows.map((row) => row.id);
selection.onChange(
picked > 0
? selected.filter((id) => !ids.includes(id))
: [...selected, ...ids],
);
};
// The fields, never the object: the caller rebuilds it on every render.
// A live region born with text is never read, so the first run stays silent.
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
return;
}
if (!sorting) return;
const sorted = columns.find((column) => column.key === sorting.key);
if (sorted) setAnnouncement(sorting.announcement(sorted.label, sorting.direction));
}, [sorting?.key, sorting?.direction]);
useEffect(() => {
const box = region.current;
if (!box) return;
const observer = new ResizeObserver(() => {
setScrollable(box.scrollWidth > box.clientWidth);
});
[box, ...box.children].forEach((element) => observer.observe(element));
return () => observer.disconnect();
}, []);
return (
<div
className={`
${css.table}
${getSizeClasses(size)}
${css[mobile]}
${framed ? css.framed : ""}
${hoverable ? css.hoverable : ""}
${striped ? css.striped : ""}
${selection ? css.selectable : ""}
${sorting?.icon === "hover" ? css.icon_hover : ""}
`}
>
<div
ref={region}
className={css.table_region}
role="region"
aria-label={ariaLabel}
tabIndex={scrollable ? 0 : undefined}
>
<table className={css.region_table} role="table">
<caption className={css.table_caption}>{ariaLabel}</caption>
<thead className={css.table_head} role="rowgroup">
<tr role="row">
{selection && (
<th
className={`${css.head_cell} ${css.select}`}
role="columnheader"
scope="col"
>
<Checkbox
size={size}
aria-label={selection.allAriaLabel}
indeterminate={some}
checked={all}
onChange={toggleAll}
/>
</th>
)}
{columns.map((column, index) => {
const direction = sorting?.key === column.key ? sorting.direction : undefined;
const SortIcon = direction ? DIRECTION_ICONS[direction] : CaretUpDownIcon;
return (
<th
key={column.key}
className={`
${css.head_cell}
${index === 0 ? css.first : ""}
${column.align === "end" ? css.end : ""}
`}
role="columnheader"
scope="col"
aria-sort={direction}
>
{sorting && column.sortable ? (
<button
type="button"
className={css.cell_sort}
onClick={() => sort(column)}
>
{column.label}
<SortIcon
className={`${css.sort_icon} ${direction ? "" : css.idle}`}
aria-hidden="true"
/>
</button>
) : (
column.label
)}
</th>
);
})}
</tr>
</thead>
<tbody className={css.table_body} role="rowgroup">
{rows.map((row) => {
const selected = selection?.selected.includes(row.id) ?? false;
return (
<tr
key={row.id}
className={`${css.body_row} ${selected ? css.selected : ""}`}
role="row"
>
{selection && (
<td className={`${css.row_cell} ${css.select}`} role="cell">
<Checkbox
size={size}
aria-label={selection.rowAriaLabel(row)}
checked={selected}
onChange={() => toggleRow(row.id)}
/>
</td>
)}
{columns.map((column, index) =>
index === 0 ? (
<th
key={column.key}
className={`
${css.row_cell}
${css.first}
${column.align === "end" ? css.end : ""}
`}
role="rowheader"
scope="row"
data-label={column.label}
>
{row[column.key]}
</th>
) : (
<td
key={column.key}
className={`${css.row_cell} ${column.align === "end" ? css.end : ""}`}
role="cell"
data-label={column.label}
>
{row[column.key]}
</td>
),
)}
</tr>
);
})}
</tbody>
</table>
</div>
<p className={css.table_status} aria-live="polite">
{announcement}
</p>
</div>
);
}
.table {
/* The root is the container its own layout queries. Inline-size containment
drops the content width, so a flex parent would shrink it to 0 without this. */
width: 100%;
container-type: inline-size;
/* Keeps the sticky cells' z-index inside: container-type creates no stacking
context, and loose they cover a popover opened over the table. */
isolation: isolate;
font-family: var(--font-body);
font-size: var(--text-14);
font-variant-numeric: tabular-nums;
color: var(--color-text);
&.sm {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
&.sm_base {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_base {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
@media (min-width: 480px) {
&.sm_mobile {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_mobile {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
}
@media (min-width: 768px) {
&.sm_tablet {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_tablet {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_desktop {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
}
@media (min-width: 1440px) {
&.sm_wide {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_wide {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
}
.table_region {
overflow-x: auto;
/* The box paints nothing until framed: the ring keeps the small radius. */
border-radius: var(--radius-4);
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
&.framed .table_region {
border: var(--border-surface) solid var(--color-border-card);
border-radius: var(--radius-12);
}
/* separate, not collapse: collapsed borders belong to the table and stay
behind when a sticky cell scrolls; here each cell paints its own. */
.region_table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.table_caption,
.table_status {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.head_cell,
.row_cell {
height: var(--table-row);
padding: 0 var(--table-pad);
border-block-end: var(--border-surface) solid var(--color-border);
text-align: start;
white-space: nowrap;
}
.head_cell {
background: var(--color-bg);
font-weight: var(--weight-500);
color: var(--color-text-secondary);
&[aria-sort] {
font-weight: var(--weight-600);
color: var(--color-text);
}
}
/* Opaque on purpose: the sticky cells hide what scrolls beneath them. */
.body_row {
--table-fill: var(--color-bg);
}
.row_cell {
background: var(--table-fill);
font-weight: var(--weight-400);
}
.first {
font-weight: var(--weight-500);
}
.end {
text-align: end;
}
.select {
width: 1%;
padding-inline-end: 0;
}
&.framed .body_row:last-child .row_cell {
border-block-end: 0;
}
.cell_sort {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
margin-inline: -6px;
border: 0;
border-radius: var(--radius-4);
background: none;
cursor: pointer;
font: inherit;
color: inherit;
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
.end .cell_sort {
flex-direction: row-reverse;
}
.sort_icon {
flex-shrink: 0;
font-size: 14px;
}
.idle {
opacity: 0.5;
}
&.icon_hover .idle {
opacity: 0;
transition: opacity 0.15s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
@media (hover: hover) {
&.icon_hover .cell_sort:hover .idle {
opacity: 0.5;
}
}
&.icon_hover .cell_sort:focus-visible .idle {
opacity: 0.5;
}
/* No transition: sorting and paging move rows between nodes, and a fade would leave a trail. */
&.striped .body_row:nth-child(even):not(.selected) {
--table-fill: color-mix(in srgb, var(--color-text) 3%, var(--color-bg));
}
@media (hover: hover) {
&.hoverable .body_row:hover:not(.selected) {
--table-fill: var(--color-surface-opaque);
}
}
.body_row.selected {
--table-fill: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg));
}
&.scroll {
.select,
.first {
position: sticky;
inset-inline-start: 0;
z-index: 1;
}
/* Transparent until something slides under the pinned column, so lighting it never moves a
pixel. */
.table_region {
container-type: scroll-state;
}
.first {
border-inline-end: var(--border-surface) solid transparent;
}
@container scroll-state(scrollable: inline-start) {
.first {
border-inline-end-color: var(--color-border);
}
}
}
/* The selection cell is the Checkbox root (24) plus the start padding. */
&.scroll.selectable .first {
inset-inline-start: calc(24px + var(--table-pad));
}
&.stack {
/* display: none, never visually hidden: hidden sort buttons would stay in the Tab order. */
.table_head {
display: none;
}
.region_table {
display: block;
}
.table_body {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.body_row {
display: block;
position: relative;
padding: var(--space-8) var(--space-12);
border: var(--border-surface) solid var(--color-border-card);
border-radius: var(--radius-12);
}
.body_row.selected {
border-color: var(--color-accent);
}
.row_cell {
display: flex;
justify-content: space-between;
gap: var(--space-12);
height: auto;
padding-block: 6px;
padding-inline: 0;
border: 0;
background: none;
white-space: normal;
&::before {
content: attr(data-label);
font-weight: var(--weight-500);
color: var(--color-text-secondary);
}
}
.first::before,
.select::before {
content: none;
}
/* Off the type scale: a step above the 14 of its lines. With the card's 8 of
padding, 2 more sets this 24 line level with the Checkbox at 10. */
.first {
padding-block-start: 2px;
font-size: 15px;
line-height: 24px;
}
.select {
position: absolute;
inset-inline-start: 10px;
inset-block-start: 10px;
width: auto;
padding: 0;
}
}
/* Room for the checkbox, which sits in the top corner of the card. */
&.stack.selectable .row_cell:not(.select) {
padding-inline-start: 36px;
}
&.framed.stack .table_region {
border: 0;
}
@container (min-width: 480px) {
&.stack {
.table_head {
display: table-header-group;
}
.region_table {
display: table;
}
.table_body {
display: table-row-group;
}
.body_row {
display: table-row;
position: static;
padding: 0;
border: 0;
border-radius: 0;
}
.row_cell {
display: table-cell;
height: var(--table-row);
padding: 0 var(--table-pad);
border-block-end: var(--border-surface) solid var(--color-border);
background: var(--table-fill);
white-space: nowrap;
&::before {
content: none;
}
}
.first {
font-size: inherit;
line-height: inherit;
}
.select {
position: static;
width: 1%;
padding-block: 0;
padding-inline: var(--table-pad) 0;
}
}
&.stack.selectable .row_cell:not(.select) {
padding-inline-start: var(--table-pad);
}
&.framed.stack .table_region {
border: var(--border-surface) solid var(--color-border-card);
}
}
}
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch } from "vue";
import { PhCaretDown, PhCaretUp, PhCaretUpDown } from "@phosphor-icons/vue";
import Checkbox from "@/components/library/vue/Checkbox/Checkbox.vue";
type Size = "sm" | "md";
type Direction = "ascending" | "descending";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface TableColumn {
key: string;
label: string;
// Required: numbers align to the end, text to the start; a column has to say which.
align: "start" | "end";
sortable?: boolean;
}
interface TableRow {
id: string;
[key: string]: string | number;
}
// Capabilities travel whole: a sorting without its announcement cannot be written.
interface TableSorting {
key: string;
direction: Direction;
icon: "hover" | "always";
announcement: (label: string, direction: Direction) => string;
onChange: (key: string, direction: Direction) => void;
}
interface TableSelection {
selected: string[];
allAriaLabel: string;
rowAriaLabel: (row: TableRow) => string;
onChange: (selected: string[]) => void;
}
interface TableProps {
size: Size | ResponsiveSize;
columns: TableColumn[];
rows: TableRow[];
// Names the table (visually hidden caption) and its scroll region.
ariaLabel: string;
mobile: "scroll" | "stack";
framed?: boolean;
hoverable?: boolean;
striped?: boolean;
sorting?: TableSorting;
selection?: TableSelection;
}
const { size, columns, rows, sorting, selection } = defineProps<TableProps>();
const announcement = ref("");
const scrollable = ref(true);
const region = useTemplateRef<HTMLDivElement>("region");
let observer: ResizeObserver | undefined;
// "All" needs rows: every() over an empty list is true and would tick the box.
const picked = computed(
() => rows.filter((row) => selection?.selected.includes(row.id)).length,
);
const all = computed(() => rows.length > 0 && picked.value === rows.length);
const some = computed(() => picked.value > 0 && !all.value);
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
const sort = (column: TableColumn) => {
if (!sorting) return;
const flip = sorting.key === column.key && sorting.direction === "ascending";
sorting.onChange(column.key, flip ? "descending" : "ascending");
};
// Ids from other pages stay put: the table only knows the rows it was given.
const toggleRow = (id: string) => {
if (!selection) return;
const { selected } = selection;
selection.onChange(
selected.includes(id) ? selected.filter((current) => current !== id) : [...selected, id],
);
};
const toggleAll = () => {
if (!selection) return;
const { selected } = selection;
const ids = rows.map((row) => row.id);
selection.onChange(
picked.value > 0
? selected.filter((id) => !ids.includes(id))
: [...selected, ...ids],
);
};
// Two sources, compared field by field: the caller rebuilds the object on every
// render. No immediate: a live region born with text is never read.
watch([() => sorting?.key, () => sorting?.direction], () => {
if (!sorting) return;
const sorted = columns.find((column) => column.key === sorting.key);
if (sorted) announcement.value = sorting.announcement(sorted.label, sorting.direction);
});
onMounted(() => {
const box = region.value;
if (!box) return;
observer = new ResizeObserver(() => {
scrollable.value = box.scrollWidth > box.clientWidth;
});
[box, ...box.children].forEach((element) => observer?.observe(element));
});
onUnmounted(() => observer?.disconnect());
</script>
<template>
<div
class="table"
:class="[
sizeClasses,
mobile,
{
framed,
hoverable,
striped,
selectable: selection,
icon_hover: sorting?.icon === 'hover',
},
]"
>
<div
ref="region"
class="table_region"
role="region"
:aria-label="ariaLabel"
:tabindex="scrollable ? 0 : undefined"
>
<table class="region_table" role="table">
<caption class="table_caption">{{ ariaLabel }}</caption>
<thead class="table_head" role="rowgroup">
<tr role="row">
<th v-if="selection" class="head_cell select" role="columnheader" scope="col">
<Checkbox
:size="size"
:aria-label="selection.allAriaLabel"
:indeterminate="some"
:modelValue="all"
@update:modelValue="toggleAll()"
/>
</th>
<th
v-for="(column, index) in columns"
:key="column.key"
class="head_cell"
:class="{ first: index === 0, end: column.align === 'end' }"
role="columnheader"
scope="col"
:aria-sort="sorting?.key === column.key ? sorting.direction : undefined"
>
<button
v-if="sorting && column.sortable"
type="button"
class="cell_sort"
@click="sort(column)"
>
{{ column.label }}
<PhCaretUpDown
v-if="sorting.key !== column.key"
class="sort_icon idle"
aria-hidden="true"
/>
<PhCaretUp
v-else-if="sorting.direction === 'ascending'"
class="sort_icon"
aria-hidden="true"
/>
<PhCaretDown v-else class="sort_icon" aria-hidden="true" />
</button>
<template v-else>{{ column.label }}</template>
</th>
</tr>
</thead>
<tbody class="table_body" role="rowgroup">
<tr
v-for="row in rows"
:key="row.id"
class="body_row"
:class="{ selected: selection?.selected.includes(row.id) }"
role="row"
>
<td v-if="selection" class="row_cell select" role="cell">
<Checkbox
:size="size"
:aria-label="selection.rowAriaLabel(row)"
:modelValue="selection.selected.includes(row.id)"
@update:modelValue="toggleRow(row.id)"
/>
</td>
<template v-for="(column, index) in columns" :key="column.key">
<th
v-if="index === 0"
class="row_cell first"
:class="{ end: column.align === 'end' }"
role="rowheader"
scope="row"
:data-label="column.label"
>
{{ row[column.key] }}
</th>
<td
v-else
class="row_cell"
:class="{ end: column.align === 'end' }"
role="cell"
:data-label="column.label"
>
{{ row[column.key] }}
</td>
</template>
</tr>
</tbody>
</table>
</div>
<p class="table_status" aria-live="polite">{{ announcement }}</p>
</div>
</template>
<style scoped>
.table {
/* The root is the container its own layout queries. Inline-size containment
drops the content width, so a flex parent would shrink it to 0 without this. */
width: 100%;
container-type: inline-size;
/* Keeps the sticky cells' z-index inside: container-type creates no stacking
context, and loose they cover a popover opened over the table. */
isolation: isolate;
font-family: var(--font-body);
font-size: var(--text-14);
font-variant-numeric: tabular-nums;
color: var(--color-text);
&.sm {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
&.sm_base {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_base {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
@media (min-width: 480px) {
&.sm_mobile {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_mobile {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
}
@media (min-width: 768px) {
&.sm_tablet {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_tablet {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_desktop {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
}
@media (min-width: 1440px) {
&.sm_wide {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_wide {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
}
.table_region {
overflow-x: auto;
/* The box paints nothing until framed: the ring keeps the small radius. */
border-radius: var(--radius-4);
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
&.framed .table_region {
border: var(--border-surface) solid var(--color-border-card);
border-radius: var(--radius-12);
}
/* separate, not collapse: collapsed borders belong to the table and stay
behind when a sticky cell scrolls; here each cell paints its own. */
.region_table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.table_caption,
.table_status {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.head_cell,
.row_cell {
height: var(--table-row);
padding: 0 var(--table-pad);
border-block-end: var(--border-surface) solid var(--color-border);
text-align: start;
white-space: nowrap;
}
.head_cell {
background: var(--color-bg);
font-weight: var(--weight-500);
color: var(--color-text-secondary);
&[aria-sort] {
font-weight: var(--weight-600);
color: var(--color-text);
}
}
/* Opaque on purpose: the sticky cells hide what scrolls beneath them. */
.body_row {
--table-fill: var(--color-bg);
}
.row_cell {
background: var(--table-fill);
font-weight: var(--weight-400);
}
.first {
font-weight: var(--weight-500);
}
.end {
text-align: end;
}
.select {
width: 1%;
padding-inline-end: 0;
}
&.framed .body_row:last-child .row_cell {
border-block-end: 0;
}
.cell_sort {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
margin-inline: -6px;
border: 0;
border-radius: var(--radius-4);
background: none;
cursor: pointer;
font: inherit;
color: inherit;
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
.end .cell_sort {
flex-direction: row-reverse;
}
.sort_icon {
flex-shrink: 0;
font-size: 14px;
}
.idle {
opacity: 0.5;
}
&.icon_hover .idle {
opacity: 0;
transition: opacity 0.15s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
@media (hover: hover) {
&.icon_hover .cell_sort:hover .idle {
opacity: 0.5;
}
}
&.icon_hover .cell_sort:focus-visible .idle {
opacity: 0.5;
}
/* No transition: sorting and paging move rows between nodes, and a fade would leave a trail. */
&.striped .body_row:nth-child(even):not(.selected) {
--table-fill: color-mix(in srgb, var(--color-text) 3%, var(--color-bg));
}
@media (hover: hover) {
&.hoverable .body_row:hover:not(.selected) {
--table-fill: var(--color-surface-opaque);
}
}
.body_row.selected {
--table-fill: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg));
}
&.scroll {
.select,
.first {
position: sticky;
inset-inline-start: 0;
z-index: 1;
}
/* Transparent until something slides under the pinned column, so lighting it never moves a
pixel. */
.table_region {
container-type: scroll-state;
}
.first {
border-inline-end: var(--border-surface) solid transparent;
}
@container scroll-state(scrollable: inline-start) {
.first {
border-inline-end-color: var(--color-border);
}
}
}
/* The selection cell is the Checkbox root (24) plus the start padding. */
&.scroll.selectable .first {
inset-inline-start: calc(24px + var(--table-pad));
}
&.stack {
/* display: none, never visually hidden: hidden sort buttons would stay in the Tab order. */
.table_head {
display: none;
}
.region_table {
display: block;
}
.table_body {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.body_row {
display: block;
position: relative;
padding: var(--space-8) var(--space-12);
border: var(--border-surface) solid var(--color-border-card);
border-radius: var(--radius-12);
}
.body_row.selected {
border-color: var(--color-accent);
}
.row_cell {
display: flex;
justify-content: space-between;
gap: var(--space-12);
height: auto;
padding-block: 6px;
padding-inline: 0;
border: 0;
background: none;
white-space: normal;
&::before {
content: attr(data-label);
font-weight: var(--weight-500);
color: var(--color-text-secondary);
}
}
.first::before,
.select::before {
content: none;
}
/* Off the type scale: a step above the 14 of its lines. With the card's 8 of
padding, 2 more sets this 24 line level with the Checkbox at 10. */
.first {
padding-block-start: 2px;
font-size: 15px;
line-height: 24px;
}
.select {
position: absolute;
inset-inline-start: 10px;
inset-block-start: 10px;
width: auto;
padding: 0;
}
}
/* Room for the checkbox, which sits in the top corner of the card. */
&.stack.selectable .row_cell:not(.select) {
padding-inline-start: 36px;
}
&.framed.stack .table_region {
border: 0;
}
@container (min-width: 480px) {
&.stack {
.table_head {
display: table-header-group;
}
.region_table {
display: table;
}
.table_body {
display: table-row-group;
}
.body_row {
display: table-row;
position: static;
padding: 0;
border: 0;
border-radius: 0;
}
.row_cell {
display: table-cell;
height: var(--table-row);
padding: 0 var(--table-pad);
border-block-end: var(--border-surface) solid var(--color-border);
background: var(--table-fill);
white-space: nowrap;
&::before {
content: none;
}
}
.first {
font-size: inherit;
line-height: inherit;
}
.select {
position: static;
width: 1%;
padding-block: 0;
padding-inline: var(--table-pad) 0;
}
}
&.stack.selectable .row_cell:not(.select) {
padding-inline-start: var(--table-pad);
}
&.framed.stack .table_region {
border: var(--border-surface) solid var(--color-border-card);
}
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "sm" | "md" | ResponsiveSize | required | Altura de cada fila (40/48) y su padding lateral (12/16). El texto se queda en 14 y el Checkbox de la selección toma la misma talla. Fijo o responsive. |
| columns | { key: string; label: string; align: "start" | "end"; sortable?: boolean }[] | required | Columnas en orden. La primera es la cabecera de cada fila. align es obligatorio, porque las cifras van al final y el texto al principio. |
| rows | { id: string; [key: string]: string | number }[] | required | Filas ya filtradas, ordenadas y paginadas por quien usa la tabla. Cada celda pinta row[column.key] tal cual; id identifica la fila en la selección. |
| aria-label | string | required | Nombre de la tabla y de su región de scroll para lectores de pantalla. |
| mobile | "scroll" | "stack" | required | Qué hace en poco ancho. scroll desplaza en horizontal con la primera columna fija; stack convierte cada fila en una tarjeta por debajo de 480 px de su contenedor, sin cabecera. |
| framed | boolean | — | Contorno de tarjeta alrededor de la tabla. |
| hoverable | boolean | — | Tiñe la fila bajo el puntero. La seleccionada no cambia. |
| striped | boolean | — | Tiñe una fila de cada dos. |
| sorting | { key: string; direction: "ascending" | "descending"; icon: "hover" | "always"; announcement: (label, direction) => string; onChange: (key, direction) => void } | — | Activa la ordenación entera o nada: estado, icono, el texto que anuncia cada cambio al lector y el callback. Sin él, las cabeceras son texto. |
| selection | { selected: string[]; allAriaLabel: string; rowAriaLabel: (row) => string; onChange: (selected) => void } | — | Activa la columna de checkbox entera o nada: los ids marcados, los nombres para el lector y el callback. "Todos" son las filas recibidas; los ids de otras páginas se conservan. Con alguna marcada, el de la cabecera las desmarca. |