Current section

Files

Jump to
noora css table.css
Raw

css/table.css

.noora-table {
box-sizing: border-box;
position: relative;
box-shadow: var(--noora-border-light-default);
border-radius: var(--noora-radius-4);
background: var(--noora-surface-table-header);
padding: var(--noora-spacing-0);
width: 100%;
/* The horizontal scroll lives on an inner wrapper rather than `.noora-table` itself, so the
* scrollbar (and the space it occupies) only exist when the table actually overflows. The wrapper
* is auto-height and grows to fit the scrollbar below the content, so the last row is never
* clipped, and non-scrolling tables get no scrollbar gutter at all. */
& [data-part="scroll-container"] {
border-radius: var(--noora-radius-4);
width: 100%;
overflow-x: auto;
overscroll-behavior-x: none;
/* Browsers make a horizontally scrollable region keyboard-focusable so it can be scrolled
* with the arrow keys. Replace the default full-bleed focus ring with a restrained inset
* one that follows the table's rounded corners, so a focused scroll area reads as a focused
* control rather than a selected table. */
&:focus-visible {
outline: 2px solid
light-dark(
var(--noora-neutral-light-1000),
var(--noora-neutral-dark-1200)
);
outline-offset: -2px;
}
}
/* Floating horizontal scrollbar, driven by the `NooraTable` hook. While the table is on screen
* but its native scrollbar is below the viewport, this proxy is fixed to the viewport bottom and
* synced with the scroll container; once the table's bottom scrolls into view, it hides and the
* native scrollbar takes over. Hidden whenever JS is unavailable. */
& [data-part="scrollbar"] {
display: none;
position: fixed;
bottom: 0;
z-index: 2;
overflow-x: auto;
overscroll-behavior-x: none;
&[data-visible] {
display: block;
}
& > [data-part="scrollbar-content"] {
height: 1px;
}
}
/* Custom scrollbar, used where the native one cannot match the webkit-styled design: Firefox
* (no `::-webkit-scrollbar` support, so no thumb insets) and overlay-scrollbar platforms
* (the bar paints over content and hides at rest). The `NooraTable` hook suppresses the native
* bar, keeps the thumb in sync, and — mirroring the proxy scrollbar in webkit browsers — pins
* this bar to the viewport bottom while the table extends below it. */
& [data-part="overlay-scrollbar"] {
display: none;
position: absolute;
right: var(--noora-spacing-4);
/* With the 16px lane the hook pads below the body plus the root's 4px, this centers the 8px
* thumb — 6px to the last row above and 6px to the table edge below, matching webkit. */
bottom: 6px;
left: var(--noora-spacing-4);
z-index: 2;
height: 8px;
&[data-visible] {
display: block;
}
& > [data-part="overlay-thumb"] {
position: absolute;
top: 0;
height: 100%;
border-radius: var(--noora-radius-4);
background-color: light-dark(
var(--noora-neutral-light-600),
var(--noora-neutral-dark-600)
);
touch-action: none;
}
}
& [data-part="scroll-container"],
& [data-part="scrollbar"] {
/* `scrollbar-color` is inherited, and any non-`auto` value (e.g. an app-level global on
* `html`) makes Chromium ignore the `::-webkit-scrollbar` styling below, falling back to a
* full-width scrollbar. Reset both standard properties so the detailed styling applies
* regardless of the host app's globals. */
scrollbar-color: auto;
scrollbar-width: auto;
/* Firefox cannot style the scrollbar through `::-webkit-scrollbar`, and Chromium ignores those
* pseudo-elements whenever the standard `scrollbar-*` properties are set. Scoping the standard
* properties to Firefox lets Chromium/Safari fall through to the detailed styling below. */
@supports (scrollbar-width: thin) and (not selector(::-webkit-scrollbar)) {
/* #c1c1c1/#6b6b6b are the browser's default scrollbar thumb grays, so the table scrollbar
* matches the native ones elsewhere on the page. */
scrollbar-color: light-dark(
var(--noora-neutral-light-600),
var(--noora-neutral-dark-600)
)
transparent;
scrollbar-width: thin;
}
&::-webkit-scrollbar {
width: 12px;
height: 12px;
background: transparent;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-track {
margin: 0 var(--noora-spacing-2);
background: transparent;
}
&::-webkit-scrollbar-thumb {
border-color: transparent;
border-style: solid;
border-width: 2px 4px;
border-radius: var(--noora-radius-4);
background-color: light-dark(
var(--noora-neutral-light-600),
var(--noora-neutral-dark-600)
);
background-clip: padding-box;
}
}
& table {
position: relative;
border-collapse: collapse;
border-spacing: 0;
width: 100%;
min-width: max-content;
/* Table elements do not support `box-shadow`, which we are using for borders. In order to still have a shadow around the body, we are
* adding a pseudo element with standard layouting around the table and add a box-shadow around it. */
&:has(td):after {
position: absolute;
top: 44px;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
box-shadow: var(--noora-border-light-default);
border-radius: var(--noora-radius-4);
pointer-events: none;
content: "";
}
& tbody {
background: transparent;
& tr {
&[data-state="expanded"] td {
background-color: var(--noora-surface-background-secondary);
}
/* No pointer cursor: the row is passive and only the disclosure button toggles it.
* The hover tint stays as a lightweight affordance that the row is interactive. */
&[data-expandable] {
&:hover td {
background-color: var(--noora-surface-background-secondary);
}
}
/* Navigable/clickable cells are the containing block for the stretched row-link overlay
* (`<td>` works in every engine; `<tr>` does not, which is why the overlay is per-cell). */
& td[data-selectable] {
position: relative;
}
&:hover {
& td[data-selectable] {
background: var(--noora-surface-background-secondary);
cursor: pointer;
}
}
& td {
background-color: var(--noora-surface-background-primary);
& > [data-part="expand-cell"] {
display: flex;
align-items: center;
gap: var(--noora-spacing-5);
padding-left: var(--noora-spacing-5);
/* The disclosure trigger: a bare, icon-only button wrapping the chevron. It carries
* the interaction (focus, click) so the row can stay passive. */
& > [data-part="expand-toggle"] {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--noora-radius-2);
background: none;
color: inherit;
cursor: pointer;
appearance: none;
&:focus-visible {
outline: none;
box-shadow: var(--noora-button-border-secondary-focus);
}
/* The chevron is a 16px icon; the crossfade transition wraps it in a span, so the
* size is applied to the nested svg rather than a direct child. */
& svg {
width: 16px;
height: 16px;
}
}
}
& > [data-part="expand-cell"] > [data-part="cell"] {
padding-left: 0;
}
&[data-part="expanded-content"] {
position: relative;
background-color: var(--noora-surface-background-secondary);
/* Vertical padding must stay zero: the expanded row is always in the DOM, collapsed
* to zero height, and any padding here would leave a visible sliver between rows. */
padding-top: 0;
padding-bottom: 0;
padding-left: var(--noora-spacing-8);
&::before {
position: absolute;
top: 0;
bottom: 0;
left: var(--noora-spacing-7);
background-color: var(--noora-surface-border-primary);
width: 1px;
content: "";
}
/* Animated reveal: the row's `data-state` drives a grid-row transition 0fr <-> 1fr.
* A transition (not mount/unmount keyframes) means expanding starts instantly,
* collapsing can never be cut short by element removal, and an interrupted toggle
* reverses from wherever it currently is. */
& [data-part="expand-wrapper"] {
display: grid;
grid-template-rows: 0fr;
@media (prefers-reduced-motion: no-preference) {
transition: grid-template-rows 200ms var(--ease-out-cubic);
}
tr[data-state="expanded"] + tr & {
grid-template-rows: 1fr;
}
& > [data-part="expand-wrapper-content"] {
min-height: 0;
overflow: hidden;
/* Hidden from interaction and find-in-page while collapsed; the delay holds
* visibility through the collapse so content doesn't vanish mid-shrink. */
visibility: hidden;
transition: visibility 0s 200ms;
tr[data-state="expanded"] + tr & {
visibility: visible;
transition-delay: 0s;
}
}
}
}
}
}
& tr:not(:first-child):not([data-part="expanded-row"]) td {
box-shadow: 0 1px 0 0
light-dark(
var(--noora-neutral-light-400),
var(--noora-neutral-dark-900)
)
inset;
}
/* Expanded rows are excluded: they are always in the DOM, collapsed to zero height,
* and these paddings would otherwise win the cascade over the expanded-content cell's
* zero padding (same specificity, later in source) and leave a sliver between rows. */
& tr:not(:last-child):not([data-part="expanded-row"]) td {
padding-bottom: 2px;
}
& tr:not(:first-child):not([data-part="expanded-row"]) td {
padding-top: 2px;
}
/* Border radius on the `tbody` itself is unsupported, so we're selectively adding radius to specific cells. */
& tr:first-child {
& td:first-child {
border-top-left-radius: var(--noora-radius-4);
}
& td:last-child {
border-top-right-radius: var(--noora-radius-4);
}
}
& tr:last-child {
& td:first-child {
border-bottom-left-radius: var(--noora-radius-4);
}
& td:last-child {
border-bottom-right-radius: var(--noora-radius-4);
}
}
/* When the last row is expandable, the final tbody child is its always-rendered expanded
* row. While that row is collapsed to zero height, the visible row above it carries the
* bottom radius instead. */
&
tr[data-state="collapsed"]:nth-last-child(2):has(
+ tr[data-part="expanded-row"]
) {
& td:first-child {
border-bottom-left-radius: var(--noora-radius-4);
}
& td:last-child {
border-bottom-right-radius: var(--noora-radius-4);
}
}
}
}
& th {
/* Border-box so the hook's measured column widths (which include padding) can be pinned
* back as `min-width` without compounding. */
box-sizing: border-box;
position: sticky;
top: 0;
background-color: var(--noora-surface-table-header);
padding: var(--noora-spacing-5) var(--noora-spacing-7)
var(--noora-spacing-5) var(--noora-spacing-7);
color: var(--noora-surface-label-primary);
font: var(--noora-font-weight-medium) var(--noora-font-body-medium);
text-align: left;
white-space: nowrap;
& > [data-part="sort-link"] {
color: unset;
text-decoration: unset;
&:hover [data-part="icon"] {
color: var(--noora-surface-label-primary);
}
/* The sort-direction arrow (the only header icon carrying `data-state`) is an indicator,
* not a button, so it stays `label-secondary` even while the header is hovered. */
&:hover [data-part="icon"][data-state] {
color: var(--noora-surface-label-secondary);
}
}
& [data-part="icon"] {
display: inline-block;
position: relative;
top: 2.5px;
left: var(--noora-spacing-2);
width: var(--noora-icon-size-medium);
height: var(--noora-icon-size-medium);
/* We cannot make `th` a flex element for vertical alignment and `gap`, so we are using `top` to vertically align, and `left` for
* spacing. */
color: var(--noora-surface-label-secondary);
& svg {
width: 100%;
height: 100%;
}
}
}
/* Floating header, driven by the `NooraTable` hook: a fixed-position clone of the `thead`,
* appended to `body`, pinned to the viewport top while the table is scrolled past it. It also
* carries the `noora-table` class so the cloned header cells pick up the regular styling. */
&.noora-table-floating-header {
display: none;
position: fixed;
top: 0;
z-index: 2;
box-shadow: 0 1px 0 0
light-dark(var(--noora-neutral-light-400), var(--noora-neutral-dark-900));
/* Keep the table's top corner radius while detached, for consistency with the resting
* header. */
border-radius: 0;
overflow: hidden;
&[data-visible] {
display: block;
}
}
/* Whole-row navigation, built from real anchors so browsers keep their native link affordances
* (hover URL preview, Safari/Firefox link previews, Cmd/middle-click → new tab) with no JS.
*
* The first cell holds the one focusable, announced link wrapping its content; its `::after`
* stretches the hit area over that cell. Every other cell holds an empty, `aria-hidden` overlay
* anchor that stretches over its own cell, widening the pointer/hover target to the whole row
* while staying out of the focus order and a11y tree. Each overlay is scoped to a `<td>` rather
* than spanning the `<tr>` because only `<td>` is a valid containing block in WebKit — a single
* `<tr>`-spanning overlay escapes to the `<table>` and swallows the whole grid. */
& [data-part="row-link"] {
display: block;
color: inherit;
text-decoration: none;
&::after {
position: absolute;
inset: 0;
content: "";
}
&:focus-visible {
outline: 2px solid
light-dark(
var(--noora-neutral-light-1000),
var(--noora-neutral-dark-1200)
);
outline-offset: -2px;
}
}
& [data-part="row-link-overlay"] {
position: absolute;
inset: 0;
}
/* The stretched overlays paint above static cell content (so clicks on text navigate the row),
* so any genuinely interactive control inside a navigable cell must be lifted above them to stay
* clickable — mirrors the interactive-target allowlist the JS forwarder used to honor. */
&
td[data-selectable]
:is(
a,
button,
input,
select,
textarea,
label,
[role="button"],
[tabindex]
):not([data-part="row-link"], [data-part="row-link-overlay"]) {
position: relative;
z-index: 1;
}
& [data-part="cell"] {
display: flex;
align-items: center;
box-sizing: border-box;
max-width: 100%;
&[data-type="text"] {
display: flex;
align-items: center;
gap: var(--noora-spacing-2);
padding: var(--noora-spacing-7);
font: var(--noora-font-body-medium);
& [data-part="icon"] {
display: inline-flex;
width: var(--noora-icon-size-medium);
height: var(--noora-icon-size-medium);
color: var(--noora-surface-label-primary);
& svg {
width: 100%;
height: 100%;
}
}
& [data-part="label"] {
color: var(--noora-surface-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
& [data-part="sublabel"] {
color: var(--noora-surface-label-secondary);
}
}
&[data-type="text_and_description"] {
display: flex;
align-items: center;
gap: var(--noora-spacing-5);
padding: var(--noora-spacing-5) var(--noora-spacing-7);
& [data-part="icon"] {
display: flex;
border-radius: var(--noora-radius-4);
color: var(--noora-surface-label-primary);
&:not(:has(img)) {
box-shadow: var(--noora-border-light-default);
padding: 10px;
}
& img {
border-radius: var(--noora-radius-4);
width: 40px;
height: 40px;
}
& svg {
width: var(--noora-icon-size-large);
height: var(--noora-icon-size-large);
}
}
& [data-part="column"] {
display: flex;
flex-direction: column;
gap: var(--noora-spacing-2);
}
& [data-part="label"] {
color: var(--noora-surface-label-primary);
font: var(--noora-font-weight-medium) var(--noora-font-body-medium);
}
& [data-part="description"] {
color: var(--noora-surface-label-secondary);
font: var(--noora-font-body-small);
}
}
&[data-type="badge"],
&[data-type="status_badge"],
&[data-type="button"],
&[data-type="link_button"],
&[data-type="tag"] {
padding: var(--noora-spacing-7);
}
&[data-type="button"] {
display: flex;
gap: var(--noora-spacing-4);
}
}
& .noora-table-empty-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: var(--noora-spacing-10) var(--noora-spacing-0);
& > [data-part="icon"] {
margin-bottom: var(--noora-spacing-8);
box-shadow: var(--noora-border-light-default);
border-radius: var(--noora-radius-3);
background: var(--noora-surface-background-primary);
padding: var(--noora-spacing-3);
width: var(--noora-icon-size-large);
height: var(--noora-icon-size-large);
color: var(--noora-surface-label-primary);
& svg {
width: 100%;
height: 100%;
}
}
& > [data-part="title"] {
color: var(--noora-surface-label-secondary);
font: var(--noora-font-weight-medium) var(--noora-font-body-medium);
}
& > [data-part="subtitle"] {
margin-top: var(--noora-spacing-4);
color: var(--noora-surface-label-tertiary);
font: var(--noora-font-body-small);
}
}
}