Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 101 additions & 54 deletions static/app/components/core/entityHeader/entityHeader.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,12 @@ Only `title` is required. The grid template is built from whichever other slots
stats={{
label: 'Replay stats',
items: [
{type: 'text', label: 'Dead Clicks', value: 0},
{type: 'text', label: 'Rage Clicks', value: 2},
{type: 'link', label: 'Errors', value: 1, to: '/replays/1/?t_main=errors'},
{label: 'Dead Clicks', value: 0},
{label: 'Rage Clicks', value: 2},
{
label: {type: 'link', content: 'Errors', to: '/replays/1/?t_main=errors'},
value: 1,
},
],
}}
metadata={{
Expand Down Expand Up @@ -92,9 +95,12 @@ Only `title` is required. The grid template is built from whichever other slots
stats={{
label: 'Replay stats',
items: [
{type: 'text', label: 'Dead Clicks', value: 0},
{type: 'text', label: 'Rage Clicks', value: 2},
{type: 'link', label: 'Errors', value: 1, to: '/replays/1/?t_main=errors'},
{label: 'Dead Clicks', value: 0},
{label: 'Rage Clicks', value: 2},
{
label: {type: 'link', content: 'Errors', to: '/replays/1/?t_main=errors'},
value: 1,
},
],
}}
metadata={{
Expand Down Expand Up @@ -127,8 +133,8 @@ Only `title` is required. The grid template is built from whichever other slots

```jsx
stats={{label: 'Replay stats', items: [
isVideoReplay ? null : {type: 'text', label: t('Dead Clicks'), value: deadClicks},
{type: 'text', label: t('Errors'), value: errors.length},
isVideoReplay ? null : {label: t('Dead Clicks'), value: deadClicks},
{label: t('Errors'), value: errors.length},
]}}
```

Expand All @@ -150,8 +156,8 @@ A declared slot holds its space before its contents arrive. Passing `metadata` r
stats={{
label: 'Replay stats',
items: [
{type: 'text', label: 'Dead Clicks', value: 0},
{type: 'text', label: 'Errors', value: 1},
{label: 'Dead Clicks', value: 0},
{label: 'Errors', value: 1},
],
}}
metadata={{
Expand All @@ -176,8 +182,8 @@ A declared slot holds its space before its contents arrive. Passing `metadata` r
stats={{
label: 'Replay stats',
items: [
{type: 'text', label: 'Dead Clicks', value: 0},
{type: 'text', label: 'Errors', value: 1},
{label: 'Dead Clicks', value: 0},
{label: 'Errors', value: 1},
],
}}
metadata={{
Expand All @@ -199,7 +205,7 @@ Slots that resolve on their own schedule carry their own flag, additive to the h

```jsx
stats={{label: 'Replay stats', items: [
{type: 'text', label: t('Events'), value: count, isLoading: isCountPending},
{label: t('Events'), value: count, isLoading: isCountPending},
]}}
```

Expand Down Expand Up @@ -243,7 +249,7 @@ A destination outside the product takes `externalHref` in place of `to`, such as

A link title is accent-coloured and carries no underline, the same treatment a stat or metadata link gets — one link treatment across the header. Colour is doing the work here, so reserve `link` for a destination the title genuinely implies; a title that merely _could_ link is better left as text with the destination offered elsewhere.

Note where the link sits in each of the three. A stat's **label** navigates, because a link named by a bare count says nothing. A metadata item's **value** navigates, because the value is where the user is going. A title navigates **whole** — it is the entity, so there is no half to choose between.
Note where the link sits in each of the three. A stat chooses **per half** — its label, its value, or both — because the two can lead to different places: the measure, or the things it counts. A metadata item's **value** navigates, because the value is where the user is going. A title navigates **whole** — it is the entity, so there is no half to choose between.

The title is still an `h2`, now with a link inside it, which is how most of the web marks up an article title. A screen reader announces both — "Replay user, anonymous@example.com, heading level 2, link" — and the link carries the same name as the heading, so a links list reads that rather than a bare address.

Expand Down Expand Up @@ -326,7 +332,7 @@ tags={[isLive ? <Tag key="live" variant="success">{t('Live')}</Tag> : null]}
A stat is one measurement: a `label` naming it and a `value`. Both are rendered, on a shared baseline.

```jsx
stats={{label: 'Replay stats', items: [{type: 'text', label: t('Rage Clicks'), value: 2}]}}
stats={{label: 'Replay stats', items: [{label: t('Rage Clicks'), value: 2}]}}
```

Keep the value to the single number the label names. Anything that explains it belongs in a tooltip, not in the row — a stat that renders its own detail inline fights the density the row is built for.
Expand All @@ -339,48 +345,70 @@ The label comes first, on screen and in the DOM: "Errors 3", not "3 Errors". A s

Each half is read as itself — "Errors", then "3" — rather than composed into one announcement. Nothing is hidden and nothing is renamed, except the dash shown for an empty value (see No data, below), so what a screen reader reads is what is on screen.

A `link` stat's anchor wraps the label only, and is named by it: a links list reads "Errors", which says where it leads. The count is not part of the name and does not need to be — it sits in the same list item, which is context enough for the link's purpose.
A link is named by the text it wraps. A label link reads "Errors" in a links list; a value link reads "7". Neither is renamed to fold in the other half — the other half sits in the same list item, which is context enough for the link's purpose, and what a screen reader reads stays what is on screen.

### Types

A stat declares whether it is `text` or a `link`. That is not only documentation. A stat whose type depended on its data would render a different element once that data arrived, and swapping a span for an anchor mid-load moves the row. So a stat that links, links from the first render — including when its count is still zero.
Each half — `label` and `value` — declares whether it is `text` or a `link`. That is not only documentation. A half whose type depended on its data would render a different element once that data arrived, and swapping a span for an anchor mid-load moves the row. So a half that links, links from the first render — including when its count is still zero.

A plain string for `label`, or a number, string or `null` for `value`, is shorthand for a `text` half. The object form is `{type, content, tooltip?}`, and a `link` half adds where it leads: `to` inside the product, or `externalHref` outside it, plus an optional `onClick` for analytics.

```jsx
{type: 'text', label: t('Rage Clicks'), value: 2}
{type: 'link', label: t('Errors'), value: errors.length, to: errorsTab}
{label: t('Rage Clicks'), value: 2}
{label: {type: 'link', content: t('Dead Clicks'), to: breadcrumbsTab}, value: deadClicks}
{label: t('Errors'), value: {type: 'link', content: errors.length, to: errorsUrl}}
```

The value may change as often as it likes. The type may not.
The content may change as often as it likes. The type may not.

### Which half links

Link the **label** when the destination is about what the stat measures — the tab where dead clicks are listed, the docs that explain an install size. Link the **value** when the destination is the things the value counts — the errors behind an error count.

On a `link` stat it is the **label** that navigates, not the value: a link named by its own digits says nothing in a screen reader's links list.
Both halves can link, to the same place or to different ones. Two links to the same place cost a keyboard user a second tab stop for nothing, so prefer one unless both destinations earn their place.

### Tooltips

`labelTooltip` covers a label that is jargon on its own. `valueTooltip` carries a breakdown — a replay's errors can come from several projects, so the stat shows the total and the tooltip splits it by project. Both take structured content, so either can be built from `Tooltip.Header`, `Tooltip.Grid` and `Tooltip.Row`.
A tooltip belongs to the half it explains, as that half's `tooltip`. On the label it covers a label that is jargon on its own. On the value it carries a breakdown — a replay's errors can come from several projects, so the stat shows the total and the tooltip splits it by project. Either takes structured content, so it can be built from `Tooltip.Header`, `Tooltip.Grid` and `Tooltip.Row`.

Each tooltip has one trigger, on its own half, and either or both can be set on any stat type. Tab visits the label, then the value, and each opens only its own tooltip. On a `link` stat the label tooltip attaches to the link itself rather than nesting a second tab stop inside it, and the value tooltip is a separate trigger after the link.
A tooltip needs the object form, even on a `text` half: shorthand has nowhere to put one.

Colour says it navigates; an underline says it explains. A link label is accent-coloured, a tooltipped label is underlined, and one that does both is both — as the three stats below are, in order. A single treatment would mean a link that only navigates looked like one that explains, and an underline reads as "hover for more" to most people, so that is the meaning it keeps.
Each tooltip has one trigger, on its own half. Tab visits the label, then the value, and each opens only its own tooltip. On a `link` half the tooltip attaches to the link itself rather than nesting a second tab stop inside it.

Colour says it navigates; an underline says it explains. A link is accent-coloured, a tooltipped half is underlined, and one that does both is both — as the stats below show. A single treatment would mean a link that only navigates looked like one that explains, and an underline reads as "hover for more" to most people, so that is the meaning it keeps.

<Storybook.Demo resizable direction="column" align="stretch">
<EntityHeader
title={{type: 'text', label: 'Issue type', value: 'ValueError'}}
stats={{
label: 'Issue stats',
items: [
{type: 'link', label: 'Events', value: 168, to: '/issues/1/events/'},
{label: {type: 'link', content: 'Events', to: '/issues/1/events/'}, value: 168},
{
type: 'text',
label: 'Users',
label: {
type: 'text',
content: 'Users',
tooltip: 'Distinct users affected in the last 90 days.',
},
value: 22,
labelTooltip: 'Distinct users affected in the last 90 days.',
},
{
type: 'link',
label: 'Dead Clicks',
label: {
type: 'link',
content: 'Dead Clicks',
to: '/issues/1/replays/',
tooltip: 'A click that did not change anything on the page.',
},
value: 4,
to: '/issues/1/replays/',
labelTooltip: 'A click that did not change anything on the page.',
},
{
label: 'Errors',
value: {
type: 'link',
content: 3,
to: '/issues/?query=is:unresolved',
tooltip: '2 from javascript, 1 from python',
},
},
],
}}
Expand All @@ -393,19 +421,32 @@ Colour says it navigates; an underline says it explains. A link label is accent-
stats={{
label: 'Issue stats',
items: [
{type: 'link', label: 'Events', value: 168, to: '/issues/1/events/'},
{label: {type: 'link', content: 'Events', to: '/issues/1/events/'}, value: 168},
{
type: 'text',
label: 'Users',
label: {
type: 'text',
content: 'Users',
tooltip: 'Distinct users affected in the last 90 days.',
},
value: 22,
labelTooltip: 'Distinct users affected in the last 90 days.',
},
{
type: 'link',
label: 'Dead Clicks',
label: {
type: 'link',
content: 'Dead Clicks',
to: '/issues/1/replays/',
tooltip: 'A click that did not change anything on the page.',
},
value: 4,
to: '/issues/1/replays/',
labelTooltip: 'A click that did not change anything on the page.',
},
{
label: 'Errors',
value: {
type: 'link',
content: 3,
to: '/issues/?query=is:unresolved',
tooltip: '2 from javascript, 1 from python',
},
},
],
}}
Expand All @@ -418,16 +459,16 @@ Pass `value: null` for a stat that applies to this entity but has nothing to sho

Four states look alike and are not:

- **No data** — `value: null`. The stat applies; there is nothing to show.
- **No data** — `value: null`. The stat applies, and the data was never collected. It will not arrive later; if it will, the stat is loading.
- **Zero** — `value: 0`. A real measurement, read as "Errors", "0". A count that is genuinely zero is not empty.
- **Loading** — `isLoading`. The value is on its way; the stat renders a skeleton, not a dash.
- **Does not apply** — `null` for the whole item. The stat is left out, as for dead clicks on a video replay.

Do not pass a placeholder string such as `'-'`, `'—'` or `'N/A'`. A screen reader reads it aloud as the value — "Cost", "dash" — and nothing tells it the stat is empty. The header warns in development when it sees one.

An empty `link` stat is still a link, named by its label ("Errors"), since a stat's type must not change once its data arrives. Its value half reads "No data".
An empty value under a link label is still there, read "No data" after the label link ("Errors"). An empty `link` value is still a link, since a half's type must not change once its data arrives — named by what it shows, so it reads "No data", and accent-coloured rather than muted, since the colour says it navigates.

Say why there is no value in `valueTooltip`: the reason shows in the value's tooltip. Keep that reason readable on its own, without links: nothing inside a tooltip can be reached by keyboard.
Say why there is no value in the value's `tooltip`. Keep that reason readable on its own, without links: nothing inside a tooltip can be reached by keyboard.

Screen readers cannot reach the reason yet. Focus on the value reads "No data", but tooltip content is not announced until `Tooltip` itself is fixed, so do not put anything there that a screen reader user needs.

Expand All @@ -437,31 +478,37 @@ Screen readers cannot reach the reason yet. Focus on the value reads "No data",
stats={{
label: 'Build stats',
items: [
{type: 'text', label: 'Install Size', value: '12 MB'},
{label: 'Install Size', value: '12 MB'},
{
type: 'text',
label: 'Download Size',
value: null,
valueTooltip: 'Download size is not reported for this platform.',
value: {
type: 'text',
content: null,
tooltip: 'Download size is not reported for this platform.',
},
},
{type: 'link', label: 'Errors', value: null, to: '/issues/'},
{label: {type: 'link', content: 'Errors', to: '/issues/'}, value: null},
{label: 'Issues', value: {type: 'link', content: null, to: '/issues/'}},
],
}}
/>
</Storybook.Demo>

```jsx
// ❌ Read aloud as "Download Size", "dash"
{type: 'text', label: t('Download Size'), value: '-'}
{label: t('Download Size'), value: '-'}

// ✅ Read as "Download Size", "No data", with the reason on the value
{
type: 'text',
label: t('Download Size'),
value: null,
valueTooltip: t('Download size is not reported for this platform.'),
value: {
type: 'text',
content: null,
tooltip: t('Download size is not reported for this platform.'),
},
}
{type: 'link', label: t('Errors'), value: null, to: errorsUrl}
{label: {type: 'link', content: t('Errors'), to: errorsTab}, value: null}
{label: t('Issues'), value: {type: 'link', content: null, to: issuesUrl}}
```

## People
Expand Down Expand Up @@ -505,7 +552,7 @@ Two values rather than one joined string is the point: a browser is its name _an

With one value, `label` is its key — "Started at". With two, it is the parent of both, and `secondary.label` names the child: "version" under "Browser" composes to "Browser version", which is what the row announces and what the qualifier shows on hover.

A value can navigate. `type: 'link'` renders it as one, named from the key and the value together so a links list reads "Release 8.42.0" rather than a bare version. Note the mirror of a stat, where the _label_ links instead: there a link named by a bare count says nothing, while here the value is where the user is going.
A value can navigate. `type: 'link'` renders it as one, named from the key and the value together so a links list reads "Release 8.42.0" rather than a bare version. Unlike a stat, where both halves are on screen and each link is named by its own text, a metadata key is usually hidden — so the link has to carry it.

```jsx
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ const props: EntityHeaderProps = {
stats: {
label: 'Replay stats',
items: [
{type: 'text', label: 'Dead Clicks', value: 0},
{type: 'text', label: 'Rage Clicks', value: 2},
{type: 'text', label: 'Errors', value: 1},
{label: 'Dead Clicks', value: 0},
{label: 'Rage Clicks', value: 2},
{label: 'Errors', value: 1},
],
},
};
Expand Down Expand Up @@ -75,8 +75,22 @@ describe('EntityHeader', () => {
stats={{
label: 'Replay stats',
items: [
{type: 'link', label: 'Errors', value: 3, to: '/replays/1/'},
{type: 'text', label: 'Errors', value: 3},
{label: {type: 'link', content: 'Errors', to: '/replays/1/'}, value: 3},
{label: 'Errors', value: {type: 'link', content: 3, to: '/replays/1/'}},
{
label: {type: 'link', content: 'Errors', to: '/replays/1/'},
value: {type: 'link', content: 3, to: '/issues/'},
},
{
label: {
type: 'link',
content: 'Errors',
to: '/replays/1/',
tooltip: 'Why',
},
value: {type: 'link', content: 3, to: '/issues/', tooltip: 'Split'},
},
{label: 'Errors', value: 3},
],
}}
/>
Expand All @@ -96,14 +110,20 @@ describe('EntityHeader', () => {
stats={{
label: 'Build stats',
items: [
{type: 'text', label: 'Install Size', value: '12 MB'},
{type: 'text', label: 'Cost', value: null},
{type: 'link', label: 'Errors', value: null, to: '/replays/1/'},
{label: 'Install Size', value: '12 MB'},
{label: 'Cost', value: null},
{label: {type: 'link', content: 'Errors', to: '/replays/1/'}, value: null},
{
label: 'Issues',
value: {type: 'link', content: null, to: '/issues/'},
},
{
type: 'text',
label: 'Download Size',
value: null,
valueTooltip: 'Not reported for this platform',
value: {
type: 'text',
content: null,
tooltip: 'Not reported for this platform',
},
},
],
}}
Expand All @@ -129,7 +149,7 @@ describe('EntityHeader', () => {
}}
stats={{
label: 'Replay stats',
items: [{type: 'text', label: 'Errors', value: 0}],
items: [{label: 'Errors', value: 0}],
}}
/>
),
Expand Down
Loading
Loading