Packages
noora
0.84.1
0.84.1
0.84.0
0.83.2
0.83.1
0.83.0
0.82.7
0.82.6
0.82.5
0.82.4
0.82.3
0.82.2
0.82.1
0.82.0
0.81.4
0.81.3
0.81.2
0.81.1
0.81.0
0.80.1
0.80.0
0.79.1
0.79.0
0.78.1
0.78.0
0.77.4
0.77.3
0.77.2
0.77.1
0.77.0
0.76.0
0.75.0
0.74.0
0.73.0
0.72.0
0.71.0
0.70.0
0.69.1
0.69.0
0.68.0
0.67.0
0.66.0
0.65.0
0.64.2
0.64.1
0.64.0
0.63.2
0.63.1
0.63.0
0.62.0
0.61.0
0.60.0
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.0
0.54.0
0.53.1
0.53.0
0.52.1
0.52.0
0.51.0
0.50.1
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.3
0.44.2
0.44.1
0.44.0
0.43.0
0.42.0
0.41.0
0.40.6
0.40.5
0.40.4
0.40.3
0.40.2
0.40.1
0.40.0
0.39.1
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.1
0.32.0
0.31.0
0.30.0
0.29.2
0.29.1
0.29.0
0.28.5
0.28.4
0.28.3
0.28.2
0.28.1
0.28.0
0.27.0
0.26.1
0.26.0
0.25.0
0.24.0
0.23.1
0.23.0
0.22.1
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.1
0.12.0
0.11.2
0.11.1
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.2
0.2.0
0.1.0
0.1.0-rc.2
0.1.0-rc.1
0.1.0-alpha.6
0.1.0-alpha.5
0.1.0-alpha.4
0.1.0-alpha.3
0.1.0-alpha.2
0.1.0-alpha.1
A component library for Phoenix LiveView applications
Current section
Files
Jump to
Current section
Files
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);
}
/* Truncation, on by default (opt out with `truncate={false}`). The table is
* `min-width: max-content`, so an unbounded value (e.g. a generate command with dozens of
* target arguments) would otherwise size its column to the full text and push every other
* column off-screen. Capping the column and clipping each line with an ellipsis keeps the
* rest of the row visible. */
&[data-truncate] [data-part="column"] {
min-width: 0;
max-width: 30rem;
}
&[data-truncate] [data-part="label"],
&[data-truncate] [data-part="description"] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&[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);
}
}
}