Comment cleanup

This commit is contained in:
ThePetrovich 2026-08-21 22:09:17 +08:00
parent c6cbc8fe70
commit f18276ab01
7 changed files with 336 additions and 97 deletions

View file

@ -1,6 +1,7 @@
# yksa-web-kit # yksa-web-kit
Common UI modules and packages (navbar, chrome, vendored libs). Should be used across all new projects in YKSA TM/TC family. Common UI: navbar, page chrome, and vendored front-end libs, shared across the
YKSA TM/TC services.
## Install ## Install

View file

@ -42,7 +42,7 @@ def test_the_navbar_and_footer_are_on_every_page():
@pytest.mark.django_db @pytest.mark.django_db
def test_the_service_cannot_reorder_the_chrome(): def test_the_service_cannot_reorder_the_chrome():
"""navbar, then main, then footer a service that wants otherwise has to """navbar, then main, then footer: a service that wants otherwise has to
stop extending the kit, which is the point.""" stop extending the kit, which is the point."""
html = Client().get("/privacy/").content.decode() html = Client().get("/privacy/").content.decode()
assert html.index("<nav") < html.index("<main") < html.index("<footer") assert html.index("<nav") < html.index("<main") < html.index("<footer")
@ -94,6 +94,24 @@ def test_page_title_links_its_parent():
assert "W-1" in html assert "W-1" in html
def test_page_title_walks_the_whole_trail_when_given_one():
"""A page two levels down names its grandparent, so the trail reaches the
section root instead of starting halfway along it."""
html = render_to_string("yksa/ui/_page_title.html", {
"title": "Status", "parent": "W-1", "parent_url": "/widgets/w-1/",
"grandparent": "Widgets", "grandparent_url": "/widgets/",
})
assert html.index("/widgets/\"") < html.index("/widgets/w-1/")
assert html.count("<a ") == 2
def test_page_title_without_a_grandparent_is_unchanged():
html = render_to_string("yksa/ui/_page_title.html", {
"title": "W-1", "parent": "Widgets", "parent_url": "/widgets/",
})
assert html.count("<a ") == 1
# --- the estate-wide rules -------------------------------------------------- # --- the estate-wide rules --------------------------------------------------

View file

@ -1,5 +1,5 @@
/* ============================================================================= /* =============================================================================
YKSA UI kit. Ships with yksa-web-kit; see ecosystem/ui-kit.md. YKSA UI kit.
Requires Bootstrap >= 5.3: the state pills below are built on the Requires Bootstrap >= 5.3: the state pills below are built on the
--bs-*-bg-subtle / --bs-*-text-emphasis / --bs-*-border-subtle families, and --bs-*-bg-subtle / --bs-*-text-emphasis / --bs-*-border-subtle families, and
@ -34,6 +34,23 @@
--yksa-z-sticky: 1020; --yksa-z-sticky: 1020;
--yksa-z-chrome: 1030; --yksa-z-chrome: 1030;
--yksa-z-floating: 1035; --yksa-z-floating: 1035;
/* One hover treatment for the whole estate: the surface's ground goes one
step darker and nothing else moves. Every hoverable thing -- table rows,
list rows, step rows, catalog cards, disclosures -- uses this token, so a
page cannot end up with three ways of saying "you are pointing at this".
Borders, shadows and colour shifts are all reserved for *state*, which is
a property of the record; hover is a property of the pointer. */
--yksa-hover-bg: var(--bs-tertiary-bg);
}
/* Bootstrap's own hoverable components, pointed at the same token. */
.table {
--bs-table-hover-bg: var(--yksa-hover-bg);
}
.list-group {
--bs-list-group-action-hover-bg: var(--yksa-hover-bg);
} }
/* Bootstrap declares --bs-dropdown-zindex on .dropdown-menu, not on :root, so it /* Bootstrap declares --bs-dropdown-zindex on .dropdown-menu, not on :root, so it
@ -43,10 +60,6 @@
--bs-dropdown-zindex: var(--yksa-z-floating); --bs-dropdown-zindex: var(--yksa-z-floating);
} }
/* -----------------------------------------------------------------------------
Navbar and footer -- layout-identical across every service
-------------------------------------------------------------------------- */
.custom-navbar { .custom-navbar {
height: var(--navbar-height); height: var(--navbar-height);
padding-top: 0; padding-top: 0;
@ -96,32 +109,66 @@
object-fit: contain; object-fit: contain;
} }
/* An open dropdown is as much "the item you are on" as an active one. Without
this the toggle dropped back to the resting colours the moment its own menu
appeared, which reads as the click having missed. */
.nav-full-height.nav-link:hover, .nav-full-height.nav-link:hover,
.nav-full-height.nav-link.active { .nav-full-height.nav-link.active,
.nav-full-height.nav-link.show {
color: #fff !important; color: #fff !important;
background-color: var(--bs-primary); background-color: var(--bs-primary);
} }
/* ----------------------------------------------------------------------------- /* Muted text inside a highlighted nav item takes the item's colour.
State -- the sanctioned rendering of status (ui/_state.html)
-------------------------------------------------------------------------- */
Bootstrap's text utilities set `color` with `!important`, so a muted span --
a countdown, a hint -- kept its resting grey when the item turned solid primary
underneath it: grey on blue, the one combination the theme has no contrast for.
`inherit` needs `!important` here for the same reason the utility does.
Deliberately only the muted utility, not every descendant. A badge and a state
pill are separate surfaces carrying their own background, and flattening them
to the parent's colour would erase what they are for. */
.nav-full-height.nav-link:hover .text-body-secondary,
.nav-full-height.nav-link.active .text-body-secondary,
.nav-full-height.nav-link.show .text-body-secondary,
/* Same problem, same fix, one row down: a dropdown item with a muted hint or
sub-line under its label -- the outcome menu on the pass console, the duty
menu in the header -- highlights on hover, focus and keyboard selection, and
the muted line stayed grey against it. Any menu that explains its options has
this shape, so the rule belongs on `.dropdown-item` rather than on each one. */
.dropdown-item:hover .text-body-secondary,
.dropdown-item:focus .text-body-secondary,
.dropdown-item:active .text-body-secondary,
.dropdown-item.active .text-body-secondary {
color: inherit !important;
}
/* `inline-block`, not `inline-flex`, and this is the whole of the table-alignment
fix. An inline-flex box's baseline is its last flex item's baseline, and its
padding and border hang *below* that -- so in a table cell the pill sits low,
and no amount of `vertical-align` corrects it without also fighting the line
height it inherits. An inline-block shares the row's baseline with the text
beside it and straddles it symmetrically, which is why Bootstrap's own `.badge`
has always looked right in a table. The icon is centred by bootstrap-icons'
`vertical-align: -.125em`, the same way it is in running text.
Nothing here sets `line-height`: the pill takes the surface's, so it matches
the row it sits in rather than being a second type size. */
.yksa-state { .yksa-state {
display: inline-flex; display: inline-block;
align-items: center;
gap: .25rem;
font-size: .75rem; font-size: .75rem;
font-weight: 700; font-weight: 700;
padding: .05rem .4rem; padding: .05rem .4rem;
white-space: nowrap; white-space: nowrap;
border: 1px solid transparent; border: 1px solid transparent;
/* An inline-flex box baseline-aligns on its own last line box, which in a
table cell or a run of text leaves it sitting a few pixels low. It is a
self-contained box; align it on the middle instead. */
vertical-align: middle;
} }
/* health */ .yksa-state > .bi,
.yksa-chip > .bi {
margin-right: .25rem;
}
.yksa-state-ok, .yksa-state-ok,
.yksa-state-complete { .yksa-state-complete {
color: var(--bs-success-text-emphasis); color: var(--bs-success-text-emphasis);
@ -157,23 +204,51 @@
border-color: var(--bs-border-color); border-color: var(--bs-border-color);
} }
/* Neutral metadata chip. Not a coloured badge, because it carries no status. */ /* Neutral metadata chip. Not a coloured badge, because it carries no status.
Same box model as .yksa-state, for the same reason. */
.yksa-chip { .yksa-chip {
display: inline-flex; display: inline-block;
align-items: center;
gap: .25rem;
font-size: .75rem; font-size: .75rem;
padding: 0 .35rem; padding: 0 .35rem;
white-space: nowrap; white-space: nowrap;
color: var(--bs-secondary-color); color: var(--bs-secondary-color);
background: var(--bs-tertiary-bg); background: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color); border: 1px solid var(--bs-border-color);
vertical-align: middle;
} }
/* ----------------------------------------------------------------------------- /* A borderless icon button for dense rows: a table row's remove control, an
Typography helpers overflow `...` toggle, a field's clear. Bordered buttons in those places draw a
-------------------------------------------------------------------------- */ box around every row and the boxes become the pattern the eye follows.
This exists because the alternative was being spelled out by hand, differently,
in five templates -- `btn btn-sm btn-link p-0 text-body-secondary`, sometimes
with `border-0`, sometimes with `px-1`, once with an `fs-5` on the glyph. It is
a button, not a link: `.btn-link` is reserved for navigation, and a control
styled as a link but wired to a POST invites a middle-click that silently
does nothing. */
.yksa-btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
min-height: 1.75rem;
padding: 0 .25rem;
color: var(--bs-secondary-color);
background: none;
border: none;
line-height: 1;
}
.yksa-btn-icon:hover,
.yksa-btn-icon:focus-visible {
color: var(--bs-body-color);
background: var(--yksa-hover-bg);
}
.yksa-btn-icon.is-danger:hover,
.yksa-btn-icon.is-danger:focus-visible {
color: var(--bs-danger);
}
.yksa-label { .yksa-label {
font-size: .75rem; font-size: .75rem;
@ -207,6 +282,36 @@
margin-inline: 0.3rem; margin-inline: 0.3rem;
} }
/* A run of metadata separated by rules. Use this rather than dropping bare
`.yksa-sep`s between text nodes.
The margins above are symmetric, but the *source* around them is not: a
template that puts the separator at the start of a line contributes a collapsed
space on its left and none on its right, so the rule sits visibly off-centre
between the two items it divides. Nobody sees that while writing the template
and everybody sees it on the page.
A flex container fixes it at the root: whitespace-only text nodes never become
flex items, so indentation stops existing, and the gap is the only spacing.
Each item goes in its own element -- an unwrapped text node *would* become an
anonymous flex item, and then the whole run is one item and the gap does
nothing. That is the reason for the spans. */
.yksa-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 .5rem;
}
.yksa-meta-tight {
gap: 0 .3rem;
}
/* Inside the run the gap does the spacing; the rule's own margins would double it. */
.yksa-meta > .yksa-sep {
margin-inline: 0;
}
/* Tabular numerals in the body face. Monospace is reserved for verbatim machine /* Tabular numerals in the body face. Monospace is reserved for verbatim machine
text -- hex dumps, TLE lines, command mnemonics. */ text -- hex dumps, TLE lines, command mnemonics. */
.yksa-num { .yksa-num {
@ -228,10 +333,6 @@
font-size: .8em; font-size: .8em;
} }
/* -----------------------------------------------------------------------------
Containers
-------------------------------------------------------------------------- */
/* A container that supplies its own padding must not also inherit the trailing /* A container that supplies its own padding must not also inherit the trailing
margin of whatever it ends with. Bootstrap does this for `p` in a few places margin of whatever it ends with. Bootstrap does this for `p` in a few places
and not at all for `dl`, `ul` or `table`, so the fix was being applied by hand and not at all for `dl`, `ul` or `table`, so the fix was being applied by hand
@ -248,16 +349,20 @@
margin-bottom: 0; margin-bottom: 0;
} }
/* ----------------------------------------------------------------------------- /* A definition list ends with a `dd`, which carries a bottom margin of its own.
Alerts `mb-0` on the `dl` does not reach it, so the gap survives the very utility
applied to remove it -- which is how several fact lists ended up looking
bottom-heavy inside an otherwise even card. */
dl:last-child > dd:last-child {
margin-bottom: 0;
}
The estate theme paints alerts in solid colour, not the tinted panel Bootstrap /* Alerts here paint in solid colour, not Bootstrap's default tinted panel: a
ships by default. That changes what belongs inside one: a utility text colour utility text colour computed against the page ground is unreadable on a
is computed against the page ground and is unreadable on a saturated one, and saturated one, and an outline button drawn in `--bs-secondary` all but
an outline button drawn in `--bs-secondary` all but vanishes. vanishes -- hence the overrides below.
-------------------------------------------------------------------------- */
/* Muted text inside an alert is the alert's own colour, softened. All three of Muted text inside an alert is the alert's own colour, softened. All three of
these resolve to --bs-secondary-color, which is computed against the page. */ these resolve to --bs-secondary-color, which is computed against the page. */
.alert .text-body-secondary, .alert .text-body-secondary,
.alert .yksa-label, .alert .yksa-label,
@ -269,26 +374,51 @@
/* Outline buttons take their colour from the alert rather than from the palette, /* Outline buttons take their colour from the alert rather than from the palette,
and invert to the alert's ground on hover. The semantic distinction between and invert to the alert's ground on hover. The semantic distinction between
`-secondary` and `-danger` is lost here, which is correct: the alert already `-secondary` and `-danger` is lost here, which is correct: the alert already
says which of those it is, and a red outline on a red ground says nothing. */ says which of those it is, and a red outline on a red ground says nothing.
Every value comes from `--bs-alert-color` / `--bs-alert-bg`, which the alert
variant declares and the button inherits, so one rule covers all six variants
in both text directions. Not `currentColor`: inside `:hover` that resolves to
the *hover* colour, so a hover background of `currentColor` paints the button
the colour it is about to be and the hover state disappears. */
.alert .btn-outline-primary, .alert .btn-outline-primary,
.alert .btn-outline-secondary, .alert .btn-outline-secondary,
.alert .btn-outline-success, .alert .btn-outline-success,
.alert .btn-outline-danger, .alert .btn-outline-danger,
.alert .btn-outline-warning { .alert .btn-outline-warning {
--bs-btn-color: inherit; --bs-btn-color: var(--bs-alert-color);
--bs-btn-border-color: currentColor; --bs-btn-border-color: var(--bs-alert-color);
--bs-btn-hover-color: var(--bs-alert-bg); --bs-btn-hover-color: var(--bs-alert-bg);
--bs-btn-hover-bg: currentColor; --bs-btn-hover-bg: var(--bs-alert-color);
--bs-btn-hover-border-color: currentColor; --bs-btn-hover-border-color: var(--bs-alert-color);
--bs-btn-active-color: var(--bs-alert-bg); --bs-btn-active-color: var(--bs-alert-bg);
--bs-btn-active-bg: currentColor; --bs-btn-active-bg: var(--bs-alert-color);
--bs-btn-active-border-color: currentColor; --bs-btn-active-border-color: var(--bs-alert-color);
--bs-btn-disabled-color: var(--bs-alert-color);
--bs-btn-disabled-border-color: var(--bs-alert-color);
--bs-btn-focus-shadow-rgb: 255, 255, 255; --bs-btn-focus-shadow-rgb: 255, 255, 255;
} }
/* ----------------------------------------------------------------------------- /* A filled button on a solid alert is the alert's own colour inverted -- the same
Layout pair, the other way round. `btn-light` was doing this by hand and only happened
-------------------------------------------------------------------------- */ to be right on the four dark variants. */
.alert .btn-contrast {
--bs-btn-color: var(--bs-alert-bg);
--bs-btn-bg: var(--bs-alert-color);
--bs-btn-border-color: var(--bs-alert-color);
--bs-btn-hover-color: var(--bs-alert-bg);
--bs-btn-hover-bg: var(--bs-alert-color);
--bs-btn-hover-border-color: var(--bs-alert-color);
--bs-btn-active-color: var(--bs-alert-bg);
--bs-btn-active-bg: var(--bs-alert-color);
--bs-btn-active-border-color: var(--bs-alert-color);
--bs-btn-focus-shadow-rgb: 255, 255, 255;
opacity: 1;
}
.alert .btn-contrast:hover {
opacity: .9;
}
/* A record in a list is a row with a left status marker, not a card. */ /* A record in a list is a row with a left status marker, not a card. */
.yksa-row { .yksa-row {
@ -300,39 +430,107 @@
} }
.yksa-row:hover { .yksa-row:hover {
background: var(--bs-tertiary-bg); background: var(--yksa-hover-bg);
} }
.yksa-row.is-in-progress { border-left-color: var(--bs-primary); } .yksa-row.is-in-progress { border-left-color: var(--bs-primary); }
.yksa-row.is-complete { border-left-color: var(--bs-success); } .yksa-row.is-complete { border-left-color: var(--bs-success); }
.yksa-row.is-pending { border-left-color: var(--bs-warning); } .yksa-row.is-pending { border-left-color: var(--bs-warning); }
.yksa-row.is-failed { border-left-color: var(--bs-danger); } .yksa-row.is-failed { border-left-color: var(--bs-danger); }
/* Withdrawn/skipped: still a record, but nothing here is work. The border
stays the resting grey and the row's text steps back, which is the same
thing `skipped` says in the state vocabulary. */
.yksa-row.is-skipped { color: var(--bs-secondary-color); }
/* The one saturated element allowed on a surface: the active shift, the pass in /* A table row that *is* a record: the row is the link to it. Sanctioned where a
contact, the source currently polling. Two on a page means one is wrong. */ card's equivalent is not -- a row is one target, while a card is a container
.yksa-row.is-active { of several. `.stretched-link` goes on the cell that names the record; this
border-left-color: var(--bs-success); class is only here to give that anchor something to stretch against.
background: var(--bs-success-bg-subtle);
Sibling links in the row are lifted above it, the same lift `.card-footer`
needed and for the same reason: the stretched anchor covers the whole row, so
without this a press on the station's own link silently opens the record
instead. A row with no sibling links needs no lift and pays nothing for it. */
.yksa-row-link {
position: relative;
} }
/* Catalog card: a card summarising one record, where the whole card is the link .yksa-row-link a:not(.stretched-link) {
to it. The hover treatment is defined once, here, because three services had a position: relative;
grid of these and none of them said what hovering meant -- the cursor changed z-index: 2;
over the title only, and the padding around it was dead. The card carries this }
class and the title anchor carries .stretched-link. */
/* The one emphasised element allowed on a surface: the active shift, the pass in
contact, the source currently polling. Two on a page means one is wrong.
It is a heavier border in the accent colour, not a tint. A row already carries
a coloured left border and usually a state pill; adding a filled ground makes
three ways of saying one thing, and the strongest of the three drowns the row's
own content. Emphasis is the *weight* of the existing signal, not a new one. */
.yksa-row.is-active {
border-left-color: var(--bs-success);
border-left-width: 6px;
padding-left: calc(.5rem - 2px);
}
/* Catalog card: a card summarising one record. The card is a container, not a
control: its title is the link and nothing else in it moves under the pointer.
Two earlier versions made the whole card hot -- one recoloured the border and
title, one painted the hover ground -- and both put a card-sized reaction
behind a card-sized target that also held its own footer buttons, so the page
read as a grid of giant buttons and a press near a footer control was a coin
toss. Hover is reserved for rows, which are one target each.
`position: relative` stays so a card can still host absolutely-positioned
corner chrome; it no longer backs a .stretched-link. */
.yksa-card-link { .yksa-card-link {
position: relative; position: relative;
} }
.yksa-card-link:hover { /* The title is a link and says so in the link colour, the way odms has always
border-color: var(--bs-primary); drawn it. `.text-decoration-none` on the anchor suppresses the underline at
background: var(--bs-tertiary-bg); rest so a grid of forty titles is not forty underlines; hover brings it back,
which is this card's one hover affordance. Both rules have to out-specify that
utility, hence the element-qualified selectors. */
.yksa-card-link .card-title a,
.yksa-card-link h2 a,
.yksa-card-link h3 a {
color: var(--bs-link-color);
} }
/* The title is already the link; underlining it on hover would be the only .yksa-card-link .card-title a:hover,
moving part in a grid of forty. The border carries the affordance instead. */ .yksa-card-link h2 a:hover,
.yksa-card-link:hover .stretched-link { .yksa-card-link h3 a:hover,
color: var(--bs-primary); .yksa-card-link .card-title a:focus-visible,
.yksa-card-link h2 a:focus-visible,
.yksa-card-link h3 a:focus-visible {
color: var(--bs-link-hover-color);
text-decoration: underline;
}
/* Row chrome that appears under the pointer: the retract on one entry of a long
append-only stream, where forty always-visible trash icons would be the loudest
thing on the page. It keeps its box at rest, so revealing it never reflows the
row. Focus counts as pointing -- without that clause the control exists only
for mouse users. */
.yksa-reveal {
opacity: 0;
}
.yksa-reveal-host:hover .yksa-reveal,
.yksa-reveal:focus-visible,
.yksa-reveal-host:focus-within .yksa-reveal {
opacity: 1;
}
/* The fact grid inside a catalog card: two columns of `.yksa-label` + figure.
It was `.meta-col` in odms.css, defined against `.sat-row-card`, which is why
ops built its own with a `dl.row` instead and got a different rhythm and a
stray bottom margin. One card, one grid. */
.yksa-card-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .25rem .75rem;
} }
/* Empty state: a sentence plus, where there is one, the action that resolves /* Empty state: a sentence plus, where there is one, the action that resolves
@ -369,10 +567,6 @@
margin-bottom: 0; margin-bottom: 0;
} }
/* -----------------------------------------------------------------------------
Cookie banner
-------------------------------------------------------------------------- */
.cookie-banner { .cookie-banner {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
@ -387,10 +581,6 @@
color: #bfdbfe; color: #bfdbfe;
} }
/* -----------------------------------------------------------------------------
Responsive
-------------------------------------------------------------------------- */
@media (max-width: 991px) { @media (max-width: 991px) {
/* Collapsed, the nav is a stacked list; the full-height tab treatment would /* Collapsed, the nav is a stacked list; the full-height tab treatment would
put a border around every row. */ put a border around every row. */
@ -407,33 +597,40 @@
} }
} }
/* ----------------------------------------------------------------------------- /* A filter chip is a toggle, so it looks like one at both ends of its state: an
Chip filters (yksa/includes/chip_filters.html + src/chip-filters.js) outlined chip at rest, a filled one when it is on. It used to be `badge
-------------------------------------------------------------------------- */ bg-secondary` at rest, which made an unselected filter a solid grey block --
as loud as the selected one and easy to read as already applied. The class is
self-contained now; callers need no `badge bg-*` alongside it. */
.filter-chip { .filter-chip {
display: inline-block;
font-size: .75rem;
line-height: 1.5;
padding: .05rem .5rem;
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
transition: background-color 0.12s ease; transition: background-color 0.12s ease;
} }
.filter-chip:hover { .filter-chip:hover {
filter: brightness(110%); background-color: var(--yksa-hover-bg);
} }
.filter-chip.active { .filter-chip.active {
background-color: var(--bs-primary) !important;
color: #fff; color: #fff;
background-color: var(--bs-primary);
border-color: var(--bs-primary);
} }
/* ----------------------------------------------------------------------------- .filter-chip.active:hover {
Prose and tables background-color: var(--bs-primary);
}
Long-form text rendered from the database (policies, descriptions) and the
table cells that hold it. Everything else that used to live in main.css was
qsl-only and did not come across.
-------------------------------------------------------------------------- */
/* Long-form text rendered from the database (policies, descriptions) and the
table cells that hold it. */
.text-content { .text-content {
overflow: auto; overflow: auto;
hyphens: auto; hyphens: auto;
@ -459,6 +656,10 @@ td ul:last-child {
--bs-alert-border-color: #c42526; --bs-alert-border-color: #c42526;
} }
/* A quiet border for grouped buttons that sit inside another bordered surface --
a catalog card's footer -- where the button group's own outline would read as a
second box around the first. `--bs-border-color` rather than a black alpha: the
alpha approximated exactly this colour on white and disappeared on anything else. */
.btn-outline-bordered { .btn-outline-bordered {
border-color: rgba(0, 0, 0, 0.066); border-color: var(--bs-border-color);
} }

View file

@ -42,6 +42,9 @@
active.push(chip.dataset.value); active.push(chip.dataset.value);
}); });
hidden.value = active.join(","); hidden.value = active.join(",");
// Live surfaces (a server-side table, a chart) listen here rather than
// waiting for a submit. A hidden input never fires `change` on its own.
hidden.dispatchEvent(new Event("change", { bubbles: true }));
} }
document.querySelectorAll("[data-filter-group]").forEach(function (group) { document.querySelectorAll("[data-filter-group]").forEach(function (group) {
@ -49,6 +52,7 @@
var chip = event.target.closest(".filter-chip"); var chip = event.target.closest(".filter-chip");
if (!chip || !group.contains(chip)) return; if (!chip || !group.contains(chip)) return;
chip.classList.toggle("active"); chip.classList.toggle("active");
chip.setAttribute("aria-pressed", chip.classList.contains("active") ? "true" : "false");
syncHidden(group); syncHidden(group);
}); });
}); });

View file

@ -3,7 +3,7 @@ The estate skeleton. Every service's own base.html extends this and fills the
blocks; nothing here is meant to be copied into a service. blocks; nothing here is meant to be copied into a service.
The navbar is written out here rather than {% include %}d because blocks inside The navbar is written out here rather than {% include %}d because blocks inside
an included template cannot be overridden and the navbar's layout has to be an included template cannot be overridden, and the navbar's layout has to be
identical everywhere while its items differ per service. Same for the footer's identical everywhere while its items differ per service. Same for the footer's
position. Fill `navbar_items` and `navbar_tools`; change nothing else. position. Fill `navbar_items` and `navbar_tools`; change nothing else.
@ -38,7 +38,13 @@ Blocks a service normally fills:
<link rel="stylesheet" href="{% static 'yksa/css/kit.css' %}"> <link rel="stylesheet" href="{% static 'yksa/css/kit.css' %}">
{% block extra_head %}{% endblock extra_head %} {% block extra_head %}{% endblock extra_head %}
</head> </head>
<body class="d-flex flex-column min-vh-100 bg-light"> {% comment %}
`bg-body-tertiary`, not `bg-light`. `bg-light` is a fixed near-white that does
not follow `data-bs-theme`, so it was the one element standing between the
estate and a working dark theme: every card and control below it recoloured
and the page they sat on did not.
{% endcomment %}
<body class="d-flex flex-column min-vh-100 bg-body-tertiary">
<nav class="navbar navbar-expand-lg custom-navbar border-bottom sticky-top"> <nav class="navbar navbar-expand-lg custom-navbar border-bottom sticky-top">
<div class="{% block navbar_container_class %}container{% endblock navbar_container_class %}"> <div class="{% block navbar_container_class %}container{% endblock navbar_container_class %}">
<a class="navbar-brand nav-full-height" href="{% block brand_url %}/{% endblock brand_url %}"> <a class="navbar-brand nav-full-height" href="{% block brand_url %}/{% endblock brand_url %}">
@ -163,7 +169,6 @@ Blocks a service normally fills:
{% include "yksa/includes/_footer.html" %} {% include "yksa/includes/_footer.html" %}
<script src="{% static 'yksa/src/bootstrap.bundle.min.js' %}"></script> <script src="{% static 'yksa/src/bootstrap.bundle.min.js' %}"></script>
<script src="{% static 'yksa/src/cookie-banner.js' %}"></script> <script src="{% static 'yksa/src/cookie-banner.js' %}"></script>
{% comment %}Shows the toasts yksa/includes/_messages.html renders.{% endcomment %}
<script src="{% static 'yksa/src/toast.js' %}"></script> <script src="{% static 'yksa/src/toast.js' %}"></script>
{% block extra_js %}{% endblock extra_js %} {% block extra_js %}{% endblock extra_js %}
</body> </body>

View file

@ -17,10 +17,14 @@ Optional context:
* empty_message: text shown when ``chips`` is empty (defaults to a generic * empty_message: text shown when ``chips`` is empty (defaults to a generic
placeholder via {{ empty_message|default:_("No options yet.") }}). placeholder via {{ empty_message|default:_("No options yet.") }}).
Each chip is a real <button>, so it is focusable and answers to Enter/Space; a
<span> was reachable by mouse only.
The accompanying JS (static/src/chip-filters.js) toggles ``.filter-chip.active`` The accompanying JS (static/src/chip-filters.js) toggles ``.filter-chip.active``
on click and re-emits the comma-separated value into the hidden input keyed by on click and re-emits the comma-separated value into the hidden input keyed by
``data-filter-csv``. Submitting the form sends the result; servers split on ``data-filter-csv``, then fires `change` on it so a live table can redraw without
comma. No XHR -- keeps URLs bookmarkable. a submit. Submitting the form sends the result; servers split on comma. No XHR by
default -- keeps URLs bookmarkable.
{% endcomment %} {% endcomment %}
{% load i18n %} {% load i18n %}
<div class="card mb-3"> <div class="card mb-3">
@ -29,8 +33,9 @@ comma. No XHR -- keeps URLs bookmarkable.
{% if chips %} {% if chips %}
<div class="d-flex flex-wrap gap-1" data-filter-group="{{ name }}"> <div class="d-flex flex-wrap gap-1" data-filter-group="{{ name }}">
{% for chip in chips %} {% for chip in chips %}
<span class="badge bg-secondary filter-chip {% if chip.selected %}active{% endif %}" <button type="button" class="filter-chip {% if chip.selected %}active{% endif %}"
data-value="{{ chip.value }}">{{ chip.label }}</span> aria-pressed="{% if chip.selected %}true{% else %}false{% endif %}"
data-value="{{ chip.value }}">{{ chip.label }}</button>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}

View file

@ -5,9 +5,14 @@ fixed. Never pick a heading level for its size.
{% trans "Widgets" as parent %}{% url 'public:widget-list' as parent_url %} {% trans "Widgets" as parent %}{% url 'public:widget-list' as parent_url %}
{% include "yksa/ui/_page_title.html" with title=widget.name parent=parent parent_url=parent_url %} {% include "yksa/ui/_page_title.html" with title=widget.name parent=parent parent_url=parent_url %}
A page one level deeper names its grandparent too, so the trail reaches the
section root rather than starting halfway down it:
{% include "yksa/ui/_page_title.html" with title=_("Status") parent=widget.name parent_url=... grandparent=_("Widgets") grandparent_url=... %}
Put it inside the page header's flex row; the row owns the actions on the right. Put it inside the page header's flex row; the row owns the actions on the right.
{% endcomment %} {% endcomment %}
<h1 class="h3 mb-0"> <h1 class="h3 mb-0">
{% if parent %}<a class="text-decoration-none" href="{{ parent_url }}">{{ parent }}</a> / {% endif %}{{ title }} {% if grandparent %}<a class="text-decoration-none" href="{{ grandparent_url }}">{{ grandparent }}</a> / {% endif %}{% if parent %}<a class="text-decoration-none" href="{{ parent_url }}">{{ parent }}</a> / {% endif %}{{ title }}
</h1> </h1>
{% if subtitle %}<span class="text-body-secondary small">{{ subtitle }}</span>{% endif %} {% if subtitle %}<span class="text-body-secondary small">{{ subtitle }}</span>{% endif %}