diff --git a/website/src/components/Playground/DesignSystem/Avatar.tsx b/website/src/components/Playground/DesignSystem/Avatar.tsx
index 32c5421c7ad8..61df5eadcdcc 100644
--- a/website/src/components/Playground/DesignSystem/Avatar.tsx
+++ b/website/src/components/Playground/DesignSystem/Avatar.tsx
@@ -1,7 +1,24 @@
import React from 'react';
-/** A round profile picture; 32px, or `size` to match the text beside it (smaller in a byline or a comment) */
-export function Avatar({ src, size = 32 }: { src: string; size?: number }) {
+/** A round profile picture; 32px, or `size` to match the text beside it
+ * (smaller in a byline or a comment). Without a `src`, its `name`'s
+ * initials on a disc (32px, 22px in a table row), hidden from screen readers
+ * since the name is written beside it */
+export function Avatar({
+ src,
+ name = '',
+ size = 32,
+}: {
+ src?: string;
+ name?: string;
+ size?: number;
+}) {
+ if (!src)
+ return (
+
+ {initials(name)}
+
+ );
return (
);
}
+
+/** The first letters of a name's first and last words ("Maya Chen" → "MC") */
+function initials(name: string) {
+ const [first = '', ...rest] = name.trim().split(/\s+/);
+ return (first[0] ?? '') + (rest.at(-1)?.[0] ?? '');
+}
diff --git a/website/src/components/Playground/DesignSystem/base.css b/website/src/components/Playground/DesignSystem/base.css
index 21e5772796a0..54314e04d3d4 100644
--- a/website/src/components/Playground/DesignSystem/base.css
+++ b/website/src/components/Playground/DesignSystem/base.css
@@ -54,7 +54,18 @@
var(--ds-accent) var(--ds-accent-wash),
transparent
);
+ /* a pressed control's (or a chosen segment's) opaque wash */
+ --ds-accent-pressed: color-mix(
+ in srgb,
+ var(--ds-accent) var(--ds-accent-press),
+ var(--ds-surface)
+ );
--ds-on-accent: #fff;
+ /* a surface raised off a well (a segmented control's pill) */
+ --ds-raised: var(--ds-surface);
+ --ds-raised-shadow:
+ 0 1px 2px var(--pg-shadow-edge),
+ 0 0 0 1px color-mix(in srgb, var(--ds-text) 6%, transparent);
/* Infima's own vars, re-pointed so what reads them (the rt-* fields, links,
the error panel's text) follows this palette in both themes */
--ifm-color-emphasis-0: var(--ds-surface);
@@ -93,6 +104,9 @@ html[data-theme='dark'] .playground-preview {
--ds-accent-strong: #7cc1f4;
--ds-accent-wash: 14%;
--ds-on-accent: #04121f;
+ /* a step up from the well, with no shadow to cast */
+ --ds-raised: color-mix(in srgb, var(--ds-text) 14%, var(--ds-surface-2));
+ --ds-raised-shadow: none;
/* fields sit in the well, a step above the panel */
--ifm-color-emphasis-0: var(--ds-surface-2);
}
diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css
index 8f3319a63362..bc3f52e7e519 100644
--- a/website/src/components/Playground/DesignSystem/design-system.css
+++ b/website/src/components/Playground/DesignSystem/design-system.css
@@ -52,10 +52,12 @@ div.playground-preview h1:first-child {
margin-bottom: 0;
}
/* A group's label over its rows (a section's "Comments", a board column's
- name): small, muted and semibold, so weight alone marks the group. A
- row's own title (a post's h3 in a list) stays a heading */
+ name, a table's column): small, muted and semibold, so weight alone
+ marks the group. A row's own title (a post's h3 in a list) stays a
+ heading */
.playground-preview section > h3,
-.playground-preview .boardColumn > h4 {
+.playground-preview .boardColumn > h4,
+.playground-preview th {
margin: 0 0 6px;
font: 600 var(--ds-text-sm) / 1.4 var(--ifm-font-family-base);
letter-spacing: 0;
@@ -285,14 +287,10 @@ div.playground-preview h1:first-child {
color: var(--ds-accent);
}
.playground-preview button:where(:not([class])):active {
- background: color-mix(
- in srgb,
- var(--ds-accent) var(--ds-accent-press),
- var(--ds-surface)
- );
+ background: var(--ds-accent-pressed);
}
.playground-preview
- button:where(:not([class]), .primary, .upvote):focus-visible {
+ button:where(:not([class]), .primary, .upvote, .sort):focus-visible {
outline: 2px solid var(--ds-accent);
outline-offset: 2px;
}
@@ -459,13 +457,17 @@ div.playground-preview h1:first-child {
.playground-preview .listItem:has(> .upvote) {
align-items: flex-start;
}
-.playground-preview button.upvote::before {
+/* an arrow pointing up: an upvote's, a sorted column's (each sizes its own) */
+.playground-preview button.upvote::before,
+.playground-preview button.sort::after {
content: '';
- width: 12px;
- height: 7px;
background: currentColor;
clip-path: polygon(50% 0, 100% 100%, 0 100%);
}
+.playground-preview button.upvote::before {
+ width: 12px;
+ height: 7px;
+}
@media (hover: hover) {
.playground-preview button.upvote:hover,
.playground-preview .stepper > button:hover {
@@ -475,11 +477,7 @@ div.playground-preview h1:first-child {
}
.playground-preview button.upvote:active,
.playground-preview .stepper > button:active {
- background: color-mix(
- in srgb,
- var(--ds-accent) var(--ds-accent-press),
- var(--ds-surface)
- );
+ background: var(--ds-accent-pressed);
color: var(--ds-accent);
}
/* Figures side by side (`dl.stats`, each a div of dt then dd): the number
@@ -706,14 +704,18 @@ div.playground-preview h1:first-child {
.playground-preview .listItem > :is(.meta, .value):last-child {
margin-left: auto;
}
-/* A row's figure (`value`: a watchlist row's price, a NumberFlow or a
- skeleton holding its place): at the row's end in tabular digits, a step
- up in weight, so a list of them reads as a column. The row's name is its
- h4 (a coin's symbol), with a `small` line under it when it has one */
+/* A figure (`value`: a watchlist row's price, a table's count, a NumberFlow
+ or a skeleton holding its place): tabular digits, so a column of them
+ lines up */
+.playground-preview .value {
+ font-variant-numeric: tabular-nums;
+}
+/* in a list row, at the row's end and a step up in weight, so a list of
+ them reads as a column. The row's name is its h4 (a coin's symbol), with
+ a `small` line under it when it has one */
.playground-preview .listItem > .value {
flex: none;
font-weight: 600;
- font-variant-numeric: tabular-nums;
}
.playground-preview .listItem:has(> .value) h4 {
margin-bottom: 0;
@@ -755,11 +757,7 @@ div.playground-preview h1:first-child {
.playground-preview
.buttonGroup
> button:not([class], [aria-busy='true']):active {
- background: color-mix(
- in srgb,
- var(--ds-accent) var(--ds-accent-press),
- var(--ds-surface)
- );
+ background: var(--ds-accent-pressed);
}
/* a busy segment's light runs inside the group's edge (outranking the
segment's border: 0) */
@@ -770,6 +768,214 @@ div.playground-preview h1:first-child {
.playground-preview .buttonGroup > button:focus-visible {
outline-offset: -2px;
}
+/* in an app header, level with the wordmark */
+.playground-preview .appHeader .buttonGroup {
+ margin-top: 0;
+}
+/* A filter's choices (All, Admins, Members) on a track of the well's
+ color, in equal segments; the chosen one (`aria-pressed`) in full ink on a
+ pill that slides to it. The pill shares the first segment's grid cell and
+ moves by the chosen one's index (up to the fourth). In an app header it
+ sits level with the wordmark; on a phone it fits beside it */
+.playground-preview .segmented {
+ --segment: 0;
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: 1fr;
+ width: fit-content;
+ max-width: 100%;
+ padding: 2px;
+ border-radius: 999px;
+ background: var(--ds-surface-2);
+}
+.playground-preview .segmented:has(> :nth-child(2)[aria-pressed='true']) {
+ --segment: 1;
+}
+.playground-preview .segmented:has(> :nth-child(3)[aria-pressed='true']) {
+ --segment: 2;
+}
+.playground-preview .segmented:has(> :nth-child(4)[aria-pressed='true']) {
+ --segment: 3;
+}
+.playground-preview .segmented::before,
+.playground-preview .segmented > :first-child {
+ grid-area: 1 / 1;
+}
+.playground-preview .segmented::before {
+ content: '';
+ border-radius: 999px;
+ background: var(--ds-raised);
+ box-shadow: var(--ds-raised-shadow);
+ transform: translateX(calc(100% * var(--segment)));
+ transition: transform var(--motion-snappy);
+}
+@media (prefers-reduced-motion: reduce) {
+ .playground-preview .segmented::before {
+ transition: none;
+ }
+}
+.playground-preview .segmented > button {
+ /* above the pill, which its transform lifts over unpositioned siblings */
+ position: relative;
+ margin: 0;
+ padding: 4px 12px;
+ border: 0;
+ border-radius: 999px;
+ background: none;
+ color: var(--ds-text-muted);
+}
+.playground-preview .segmented > button[aria-pressed='true'] {
+ color: var(--ds-text);
+ font-weight: 600;
+}
+@media (hover: hover) {
+ .playground-preview .segmented > button:not([aria-pressed='true']):hover {
+ color: var(--ds-text);
+ }
+}
+.playground-preview .segmented > button:focus-visible {
+ outline-offset: -2px;
+}
+/* A table (a team's members): rows on the sheet with no rules, stripes or
+ hover, set apart by space, its first and last columns flush with the app
+ header's text. Headers are a group's label; `value` cells (a count, a
+ year) sit at the column's end. Infima's borders and stripes are dropped */
+.playground-preview table {
+ --ifm-table-stripe-background: transparent;
+ width: 100%;
+ margin: 0 0 var(--ds-space-4);
+ border-collapse: separate;
+ border-spacing: 0;
+}
+/* under a control (a picker) it stands a step off */
+.playground-preview * + table {
+ margin-top: var(--ds-space-3);
+}
+.playground-preview :is(thead, tr, th, td) {
+ border: 0;
+}
+/* 8px between columns is what keeps a 4-column row on a phone's sheet
+ (a name with its avatar, a role's pill, two figures) */
+.playground-preview td {
+ padding: var(--ds-space-2);
+ vertical-align: baseline;
+}
+.playground-preview th {
+ padding: 0 var(--ds-space-2) var(--ds-space-1);
+ text-align: start;
+ white-space: nowrap;
+}
+.playground-preview :is(th, td):first-child {
+ padding-left: 0;
+}
+.playground-preview :is(th, td):last-child {
+ padding-right: 0;
+}
+.playground-preview :is(th, td).value {
+ text-align: end;
+}
+/* A person in a row (a team member): `Avatar` with a `name` draws the
+ name's initials on a disc, 32px on its own and 22px in a table cell,
+ where it leads the name and sits in the text's line, 6px before it */
+.playground-preview .avatar {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: none;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: var(--ds-surface-2);
+ color: var(--ds-text-muted);
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ line-height: 1;
+ vertical-align: middle;
+}
+.playground-preview td > .avatar {
+ width: 22px;
+ height: 22px;
+ margin: -3px 6px -3px 0;
+ font-size: 0.625rem;
+}
+/* the name stays on one line, a step (12px) from what follows it */
+.playground-preview td:has(> .avatar) {
+ padding-right: var(--ds-space-3);
+ white-space: nowrap;
+}
+/* A label on a row (`pill`: a member's role): a small bold word on a disc
+ of the well's color, `primary` washed in the accent (an admin). Its disc
+ reaches past its text, so the word stays in line with its column's
+ header */
+.playground-preview .pill {
+ display: inline-block;
+ margin-inline: -7px;
+ padding: 1px 7px;
+ border-radius: 999px;
+ background: var(--ds-surface-2);
+ color: var(--ds-text-muted);
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: 1.5;
+}
+.playground-preview .pill.primary {
+ background: var(--ds-accent-soft);
+ color: var(--ds-accent);
+}
+/* A sortable column's header (`button.sort` in its `th`, `aria-sort` on
+ the sorted one's): its label, then an arrow that points the way the
+ column runs, in the accent; the sorted column's label takes the text's
+ color. The arrow always holds its place, so a header never shifts; on
+ another column it shows faintly under the pointer. In a `value` column
+ it leads the label, so the label ends where the figures do */
+.playground-preview button.sort {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ margin: -2px -4px;
+ padding: 2px 4px;
+ border: 0;
+ border-radius: var(--ds-radius-xs);
+ background: none;
+ color: inherit;
+ font: inherit;
+ cursor: pointer;
+ transition: color var(--motion-snappy);
+}
+.playground-preview th.value > button.sort {
+ flex-direction: row-reverse;
+}
+.playground-preview button.sort::after {
+ width: 8px;
+ height: 5px;
+ opacity: 0;
+ transition:
+ opacity var(--motion-snappy),
+ transform var(--motion-snappy);
+}
+.playground-preview button.sort:focus-visible {
+ outline-offset: 0;
+}
+.playground-preview button.sort:active,
+.playground-preview th[aria-sort] > button.sort {
+ color: var(--ds-text);
+}
+@media (hover: hover) {
+ .playground-preview button.sort:hover {
+ color: var(--ds-text);
+ }
+ .playground-preview button.sort:hover::after {
+ opacity: 0.4;
+ }
+}
+.playground-preview th[aria-sort] > button.sort::after {
+ background: var(--ds-accent);
+ opacity: 1;
+}
+.playground-preview th[aria-sort='descending'] > button.sort::after {
+ transform: rotate(180deg);
+}
/* Actions under rows (a clock's Refresh) stand 16px off the last one */
.playground-preview .listItem + button {
margin-top: var(--ds-space-2);
diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md
index 39cf6df9db28..022041087166 100644
--- a/website/src/components/Playground/README.md
+++ b/website/src/components/Playground/README.md
@@ -235,24 +235,28 @@ controls keep an edge). Before restyling a class, grep `docs/` and
style to a new component.
Styled elements: `h2` (page title), `button` (`primary` fills one;
-`aria-busy` shows pending), text inputs, `nav`, `fieldset`, `progress`.
-
-| Class | Use for |
-| ------------------------------------------ | ------------------------------------------------------------------------------ |
-| `appHeader` + `appTitle` | the app's top bar and wordmark |
-| `card` | a group (no box: the sheet is the card) |
-| `list`, `listItem` (`nogap`, `spaced`) | rows; checkbox rows highlight and fade when done |
-| `meta` | secondary text; last in a `listItem`, it sits at the row's end |
-| `value` | a row's figure (a price); last in a `listItem`, at the row's end, tabular |
-| `byline`, `author` (`Avatar` takes `size`) | who wrote something |
-| `buttonGroup` | a row of actions as one segmented control |
-| `cancel` (``) | a row's remove button |
-| `skeleton` | a value still loading; ``, the default fallback, draws a page |
-| `toaster`, `toast` | notifications: render ``, call `toast.success()` or `toast.error()` |
-| `boardColumn` | a kanban column |
-| `upvote` (a button) | an upvote with its count; empty, the arrow alone |
-| `stats` (a `dl`) | figures side by side, each large over its label; a lone one fills the row |
-| `stepper` (− `output` +) | a quantity's buttons around its count (one button alone: Add); in a row |
+`aria-busy` shows pending), text inputs, `nav`, `fieldset`, `progress`,
+`table`.
+
+| Class | Use for |
+| ------------------------------------------ | ------------------------------------------------------------------------------- |
+| `appHeader` + `appTitle` | the app's top bar and wordmark |
+| `card` | a group (no box: the sheet is the card) |
+| `list`, `listItem` (`nogap`, `spaced`) | rows; checkbox rows highlight and fade when done |
+| `meta` | secondary text; last in a `listItem`, it sits at the row's end |
+| `value` | a row's figure (a price); last in a `listItem`, at the row's end, tabular |
+| `byline`, `author` (`Avatar` takes `size`) | who wrote something; `Avatar` with a `name` and no `src` draws its initials |
+| `pill` (`primary`) | a label on a row (a member's role); `primary` washes it in the accent |
+| `buttonGroup` | segmented actions (Fail Soft, Fail Hard) |
+| `segmented` | a filter's 2-4 choices on a track; `aria-pressed` marks the pick, a pill slides |
+| `cancel` (``) | a row's remove button |
+| `skeleton` | a value still loading; ``, the default fallback, draws a page |
+| `toaster`, `toast` | notifications: render ``, call `toast.success()` or `toast.error()` |
+| `boardColumn` | a kanban column |
+| `upvote` (a button) | an upvote with its count; empty, the arrow alone |
+| `stats` (a `dl`) | figures side by side, each large over its label; a lone one fills the row |
+| `stepper` (− `output` +) | a quantity's buttons around its count (one button alone: Add); in a row |
+| `table` (`value` cells), `sort` (a button) | rule-free rows (no hover); a `th`'s `button.sort` sorts, `aria-sort` marks it |
## Auto-run
diff --git a/website/src/components/Playground/monaco/typeLibs.ts b/website/src/components/Playground/monaco/typeLibs.ts
index d7f279837081..3b506e1400d0 100644
--- a/website/src/components/Playground/monaco/typeLibs.ts
+++ b/website/src/components/Playground/monaco/typeLibs.ts
@@ -188,7 +188,7 @@ const PREVIEW_SCOPE_DECLARATIONS = `declare function render(component:JSX.Elemen
declare function uuid(): string;
declare function CurrentTime(props: {}):JSX.Element;
declare function CancelButton(props: { onClick?: () => void }):JSX.Element;
- declare function Avatar(props: { src: string; size?: number }):JSX.Element;
+ declare function Avatar(props: { src?: string; name?: string; size?: number }):JSX.Element;
declare function Formatted({ downColor, formatter, formatterFn, timeout, transition, transitionLength, upColor, value, stylePrefix, }: NumberProps):JSX.Element
declare function TextInput(props:Omit, 'size'> & { label?: React.ReactNode; loading?: boolean; size?: 'large' | 'medium' | 'small'; }):JSX.Element;
declare function TextArea(props:React.TextareaHTMLAttributes & { label?: React.ReactNode;}):JSX.Element;
diff --git a/website/src/fixtures/team.ts b/website/src/fixtures/team.ts
new file mode 100644
index 000000000000..ac9d87a2462c
--- /dev/null
+++ b/website/src/fixtures/team.ts
@@ -0,0 +1,21 @@
+import { RestEndpoint } from '@data-client/rest';
+
+// out of every sort order, so the first one already moves the rows
+const members = [
+ { id: '4', name: 'Maya Chen', isAdmin: true, posts: 412, joined: 2018 },
+ { id: '7', name: 'Leo Fischer', isAdmin: false, posts: 36, joined: 2024 },
+ { id: '2', name: 'Ama Owusu', isAdmin: false, posts: 187, joined: 2020 },
+ { id: '9', name: 'Sofia Reyes', isAdmin: true, posts: 95, joined: 2022 },
+ { id: '3', name: 'Ravi Kumar', isAdmin: false, posts: 268, joined: 2019 },
+ { id: '5', name: 'Hana Sato', isAdmin: false, posts: 71, joined: 2023 },
+ { id: '8', name: 'Elias Berg', isAdmin: true, posts: 153, joined: 2017 },
+];
+
+export const teamFixtures = [
+ {
+ endpoint: new RestEndpoint({ path: '/users' }),
+ args: [],
+ response: members,
+ delay: 150,
+ },
+];