{% from "ui/attrs.html" import attrs %}
{% from "ui/icon.html" import icon %}
{# badge(label, variant) — variant: primary|secondary|outline|destructive|success|warning|info #}
{% macro badge(label, variant="") -%}
{{ label }}
{%- endmacro %}
{#
card(title, description, size, actions, padded) — a bordered panel, called as
a block macro.
{% call card("Throughput", "last 7 days") %}
…
{% endcall %}
`actions` takes pre-rendered markup for the right-hand side of the header:
{% set filters %}{% call button_group() %}…{% endcall %}{% endset %}
{% call card("5 tasks", actions=filters) %}…{% endcall %}
A parameter rather than a second slot: a two-slot call block runs its body
once per slot, and the common case is a heavy body with no actions at all.
`padded=false` drops the inner so a table sits flush against the
card's edges. With a header as well, the body gets a rule above it; a flush
body butting into header text reads as one block.
#}
{% macro card(title=none, description=none, size="", actions=none, padded=true) -%}
{%- set has_header = title or description or actions -%}
{%- endmacro %}
{#
Never build a class by interpolation (`text-{{ tone }}`): Tailwind finds
classes by scanning source text, so an interpolated one is absent from the
stylesheet. A closed lookup keeps every value in this file verbatim, where
the scan finds it.
#}
{% set _TONES = {
"success": "text-success",
"warning": "text-warning",
"info": "text-info",
"destructive": "text-destructive",
"muted": "text-muted-foreground",
} %}
{# stat(label, value, hint, icon_name, tone) — the KPI tile. #}
{% macro stat(label, value, hint=none, icon_name=none, tone=none) -%}
{%- endmacro %}
{#
metric_group(items) — a compact row of counts, the small sibling of stat().
`items` is a list of (label, value) pairs. Use it inside a card when the
numbers are context for something else; use stat() when they are the point of
the page.
#}
{% macro metric_group(items, cols=3) -%}
{%- set _GRID = {2: "grid-cols-2", 3: "grid-cols-3", 4: "grid-cols-4"} -%}
{% for label, value in items %}
{{ label }}
{{ value }}
{% endfor %}
{%- endmacro %}
{# progress(value, label) — value is 0-100. #}
{#
`id` makes the bar addressable, which is what `form(progress=…)` needs: an
upload's percentage arrives in the browser, so something has to write it into
markup the server rendered once at zero.
The three hooks below are that contract. A bar has three places the same
number is written — the width, `aria-valuenow`, and the printed percentage —
and moving one without the others is worse than moving none: a bar that fills
while the label still reads 0%, or fills for a sighted reader while a screen
reader is still told nothing has happened. They are `data-*` rather than more
ids because there is one of each per bar, and an id per part would have to be
derived from `id` and kept in step at both ends.
#}
{% macro progress(value, label=none, id=none) -%}
{%- endmacro %}
{#
bullet_list() / list_item() — prose lists inside a card.
{% call bullet_list() %}
{% call list_item() %}Templates are compiled once per process.{% endcall %}
{% endcall %}
#}
{% macro bullet_list(tone="muted") -%}
{{ caller() }}
{%- endmacro %}
{% macro list_item() -%}
{{ caller() }}
{%- endmacro %}
{#
caption(text) — a standalone line of secondary prose.
`card`, `section` and `page_header` each render a description, but each of
those is the second line of a title block and cannot move away from its
heading. This is the line that stands alone: under a table, beneath a
control, at the foot of a form. An app template cannot write one by hand —
the colour it needs (`text-muted-foreground`) is a utility the checker
rejects, and a bare `
` renders at body weight.
Takes the text as an argument, or a body when the caption holds a `link()`:
{{ caption("Counts refresh every five minutes.") }}
{% call caption() %}
Source: {{ link("the trial registry", href=registry_url) }}
{% endcall %}
#}
{% macro caption(text=none) -%}
{{- caller() if caller is defined else text -}}
{%- endmacro %}
{#
link(label, href) — an inline link in body copy.
Always underlined: a link distinguished by colour alone fails for a reader
who cannot see the colour.
#}
{% macro link(label, href) -%}
{{ label }}
{%- endmacro %}
{# kbd(keys) — a keyboard key or a literal token in running text. #}
{% macro kbd(keys) -%}
{{ keys }}
{%- endmacro %}
{#
code_block(source, label, wrap) — source, a log, a config, a payload.
Roadmap 0.9, pulled forward. Basecoat has no component for this and `card`
does not reach it: a code region has three problems a bare
leaves open.
1. It scrolls, so it has to be reachable. A mouse can wheel a scroll
container that nothing can focus; a keyboard cannot reach it at all.
`tabindex="0"` fixes that, and it is why this clears CHARTER §8's bar on
wrapping a native element.
2. A focusable region needs a name, or a screen reader announces an
unlabelled stop in the tab order. `role="region"` is applied only when
there is a label for it, because an unnamed region is worse than none.
3. Long lines. Pass `wrap=true` for emitted markup: one line of HTML holding
an inline SVG is not something to scroll sideways through.
The content is printed as text and stays text. Highlighting is client-side —
a page that wants it reads `textContent` back and marks up the tokens — which
keeps the source legible with scripting off and keeps this macro from having
to know any language.
#}
{% macro code_block(source, label=none, wrap=false) -%}
{{ source }}
{%- endmacro %}
{#
item_list() / item(title, description, …) — a list of rows that are not a
table: settings, results, definitions, a feed.
Roadmap 0.9. Basecoat ships `.item-group` / `.item`, and its markup contract
is load-bearing: `.item > section` is the text column, `.item > figure` the
leading icon, `.item > aside` the trailing actions. An app should not have to
re-derive that from a stylesheet.
{% call item_list() %}
{{ item("router.py", "reads the request, calls the service") }}
{{ item("service.py", "the actual work", icon_name="gauge") }}
{% endcall %}
`description` may be markup, so a row can hold a badge or a link without the
caller reaching for |safe.
Basecoat clamps `.item p` to two lines, which suits a feed and not a
definition list; `clamp=false` releases it. It is an attribute rather than a
class because the rule lives in `fjkit.css`, beside the other `data-*`
extensions of Basecoat's API.
#}
{% macro item_list() -%}
{{ caller() }}
{%- endmacro %}
{% macro item(title, description=none, icon_name=none, actions=none, href=none, clamp=true) -%}
{%- set tag = "a" if href else "div" -%}
<{{ tag }} class="item"{% if not clamp %} data-clamp="false"{% endif %}
{%- if href %} href="{{ href }}"{% endif %}
{%- if kwargs %}{{ attrs(kwargs) }}{% endif %}>
{%- if icon_name %}{{ icon(icon_name, 16) }}{% endif %}
{{ title }}
{%- if description %}
{{ description }}
{% endif %}
{%- if actions %}{% endif %}
{{ tag }}>
{%- endmacro %}
{#
description_list(layout) / description_item(term, value) — term-and-value pairs.
A native `
`, so a screen reader announces each value as the definition of
the term beside it. `item_list` covers most of what this is used for and does
not carry that relationship: it is a list of things, and this is a list of
facts about one thing.
Every pair is wrapped in a `
` — permitted inside `
` since HTML 5.2,
and required here, because a grid over bare `
`/`
` children lays them
out in document order and cannot keep a pair on one row.
{% call description_list() %}
{{ description_item("Status", badge("Active", "success")) }}
{{ description_item("Owner", task.owner) }}
{% endcall %}
`value` may be markup, like `item`'s `description`, so a row can hold a badge
or a link without the caller reaching for `|safe`. Where the value is longer
than one expression, `{% call description_item("Notes") %}…{% endcall %}` puts
it in the body instead; `value` wins if both are given.
Two layouts, and the difference is only where the term sits. `rows` moves it
beside the value once the viewport is wide enough for two columns. `stacked`
keeps it above at every width, which is what a card too narrow for a label
column needs. Neither is a class the caller writes — see `fjkit.css`, where
the `
` margin reset that both depend on also lives.
#}
{% set _DL_LAYOUTS = ("rows", "stacked") %}
{% macro description_list(layout="rows") -%}
{%- endmacro %}
{#
avatar(name, src, size, badge_tone, badge_icon)
`name` is required and `src` is not: the initials are what the image falls
back to, and an avatar with no name has nothing to show when the URL 404s.
Basecoat stacks both and lets the absolutely positioned `` cover the
initials once it loads, so the fallback costs nothing and needs no
JavaScript.
`name` also becomes the `alt` text, so the accessible name and the visible
initials cannot disagree.
The badge is a status dot. Upstream's example writes `bg-green-600` on it;
here the colour is a role (A3), because a dot meaning "online" has to survive
a rebrand exactly like a badge meaning "done".
#}
{% set _AVATAR_BADGE_TONES = {
"success": "bg-success",
"warning": "bg-warning",
"info": "bg-info",
"destructive": "bg-destructive",
"muted": "bg-muted-foreground",
} %}
{% macro avatar(name, src=none, size="", initials=none, badge_tone=none, badge_icon=none) -%}
{#
Two letters at most: "Ada Lovelace" is AL, "Ada" is A, and a five-word name
is not five letters. Pass `initials` where the default does not survive —
a handle, a non-Latin script.
#}
{%- set letters = initials if initials is not none else (name.split() | map("first") | join)[:2] | upper -%}
{%- if src %}{% endif %}
{#
The initials carry the accessible name when there is no image, and are
hidden from the tree when there is: otherwise the avatar is announced
twice, once as the alt text and once as two stray letters.
#}
{{ letters }}
{%- if badge_tone or badge_icon %}
{%- if badge_icon %}{{ icon(badge_icon, 12) }}{% endif -%}
{%- endif %}
{%- endmacro %}
{#
avatar_group(overflow)
Overlapping avatars, with an optional "+3" at the end. `overflow` is a number
rather than a rendered label so the macro writes the "+" itself: a caller
passing "3 more" breaks the ring geometry the CSS gives `[data-count]`.
#}
{% macro avatar_group(overflow=none, label=none) -%}