Packages
noora
0.82.6
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);
}
}
&: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. A single anchor in the first cell carries the link; its `::after`
* overlay stretches across the row (which is the positioning context) so the entire row is the
* click target, while the DOM holds just one link per row. Trade-off: the overlay sits over the
* row's text, so text in navigable rows isn't selectable. */
& tr:has([data-part="row-link"]) {
position: relative;
}
& [data-part="row-link"] {
color: inherit;
text-decoration: none;
&::after {
position: absolute;
inset: 0;
z-index: 0;
content: "";
}
&:focus-visible {
outline: none;
&::after {
outline: 2px solid
light-dark(
var(--noora-neutral-light-1000),
var(--noora-neutral-dark-1200)
);
outline-offset: -2px;
}
}
}
/* Controls in the row's other cells must sit above the overlay to stay clickable. */
&
tr:has([data-part="row-link"])
:is(a, button, input, select, [tabindex]):not([data-part="row-link"]) {
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)
var(--noora-spacing-5) var(--noora-spacing-5);
& [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);
}
}
}