Merge pull request #1001 from Donkie/fix/library-header-affordances

fix(library): make the group header's destinations findable
This commit is contained in:
Donkie authored and GitHub committed 2026-08-02 08:15:07 +02:00
commit f8db2a0f09
8 files changed
+206 -58

No files matched your search

+8 -2
View File
@@ -57,8 +57,14 @@ jobs:
#
# --diff-filter=MD keeps only Modified and Deleted paths: edits to files that ALREADY exist
# on the base branch. Added (A) files are handled separately per client below.
edited="$(git diff --no-renames --diff-filter=MD --name-only "$BASE_SHA" "$HEAD_SHA" || true)"
added="$(git diff --no-renames --diff-filter=A --name-only "$BASE_SHA" "$HEAD_SHA" || true)"
# Three dots, not two. `git diff A B` compares the two trees as they stand, so every
# commit that landed on the base branch after this PR forked shows up as if the PR had
# changed it -- a Weblate sync merging to master made unrelated PRs fail with
# "client_v2/locales/sv/common.json" in the offending list. `git diff A...B` diffs from
# the MERGE BASE instead, which is exactly "what this PR changed" and stays correct no
# matter how far master has moved on.
edited="$(git diff --no-renames --diff-filter=MD --name-only "$BASE_SHA...$HEAD_SHA" || true)"
added="$(git diff --no-renames --diff-filter=A --name-only "$BASE_SHA...$HEAD_SHA" || true)"
# Active client: edits to existing non-English files are blocked, new languages are fine.
offending_v2="$(printf '%s\n' "$edited" \
+2
View File
@@ -290,6 +290,8 @@
"library.nUnused": "×{{count}} unused",
"library.noLocation": "no location",
"library.noValues": "No values",
"library.openGroup": "Open {{name}}",
"library.openManufacturer": "Open manufacturer {{name}}",
"library.section.extra": "Extra fields",
"library.section.filament": "Filament",
"library.section.spool": "Spool",
+11 -1
View File
@@ -106,11 +106,19 @@ export function mapGroup(g: Json): GroupSummary {
let badge = '';
let colors: string[] = [];
let direction: MultiColorDirection | undefined;
// Filament groups carry their manufacturer separately from the subtitle text
// so the header can render it as a link to the vendor (see GroupHeader).
let vendorId: string | undefined;
let vendorName: string | undefined;
if (field === 'filament' && g.filament) {
const f: Json = g.filament;
title = f.name ?? '(unnamed filament)';
subtitle = `${f.vendor?.name ?? 'No manufacturer'} · ${f.diameter} mm`;
subtitle = `${f.diameter} mm`;
if (f.vendor) {
vendorId = String(f.vendor.id);
vendorName = f.vendor.name ?? '(unnamed manufacturer)';
}
badge = f.material ?? '';
colors = colorsFromApi(f);
direction = f.multi_color_direction ?? undefined;
@@ -136,6 +144,8 @@ export function mapGroup(g: Json): GroupSummary {
title,
subtitle,
badge,
vendorId,
vendorName,
colors,
direction,
spoolCount: g.spool_count ?? 0,
+4
View File
@@ -29,6 +29,10 @@ export interface GroupSummary {
title: string;
subtitle: string;
badge: string;
/** Filament groups only: the manufacturer, so the header can link to it.
* Both absent when the filament has no manufacturer set. */
vendorId?: string;
vendorName?: string;
colors: string[];
/** Multi-color layout, when grouping by a single filament; undefined otherwise. */
direction?: MultiColorDirection;
@@ -51,8 +51,13 @@
font: inherit;
text-decoration: none;
}
/* Underlined on hover, not just recoloured: a crumb is a short piece of dim
text in a corner, and colour alone left people reading it as a caption rather
than as the way to the manufacturer (#989). */
.crumb:hover {
color: var(--accent-link-hover);
text-decoration: underline;
text-underline-offset: 2px;
}
.current {
color: var(--text-2);
@@ -1,7 +1,11 @@
<script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve --
Both hrefs come from a src/lib/library/params.ts helper, which already resolves
against the deploy base path; resolving again would double-apply it. */
import Swatch from '../Swatch.svelte';
import MaterialBadge from '../MaterialBadge.svelte';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import Factory from '@lucide/svelte/icons/factory';
import type { GroupHeaderInfo } from '$lib/utils/library';
import * as m from '$lib/paraglide/messages';
@@ -11,46 +15,87 @@
* its inspector, so the header is a real `<a>`. Absent for groupings with
* no entity to open (material/location) — then it's inert. */
href?: string;
/** Filament groups: link to the manufacturer's inspector. Absent when the
* filament has no manufacturer (the name then renders as a muted note). */
vendorHref?: string;
sticky?: boolean;
/** Whether the group's spools are folded away behind this header. */
collapsed?: boolean;
ontoggle?: () => void;
}
let { group, href, sticky = false, collapsed = false, ontoggle }: Props = $props();
let { group, href, vendorHref, sticky = false, collapsed = false, ontoggle }: Props = $props();
</script>
<!-- The row is two controls, not one: the body opens the group's entity (where it
has one), and the disclosure at the trailing edge folds its spools away. They
are siblings rather than nested so both are real, keyboard-reachable controls.
<!-- The row is three controls, not one: the body opens the group's entity (where it
has one), the manufacturer opens ITS entity, and the disclosure at the trailing
edge folds the spools away. They are siblings rather than nested — anchors can't
nest, and all three should be real, keyboard-reachable controls.
Living in the header — which is sticky — is what keeps the fold reachable while
you are scrolled deep inside a long group, which is exactly when you want it. -->
you are scrolled deep inside a long group, which is exactly when you want it.
The entity link is only the title, but its stretched ::after (see the styles)
extends its hit area over the whole row, so the row-wide click target survives
the two links being siblings. That is also what makes `.main:hover` mean
"anywhere on the row except the other two controls", which is the precision the
hover affordance below needs. -->
<div class="header" class:sticky class:link={href}>
<svelte:element
this={href ? 'a' : 'div'}
class="main"
{href}
data-sveltekit-keepfocus={href ? '' : undefined}
data-sveltekit-noscroll={href ? '' : undefined}
>
{#if group.colors.length}
<Swatch colors={group.colors} direction={group.direction} size={24} radius={6} />
{/if}
<div class="body">
<div class="line">
<span class="title">{group.title}</span>
{#if group.badge}<MaterialBadge label={group.badge} />{/if}
{#if href}
<!-- The entity link is an overlay covering the whole row, not a box around the
title: it has to be a sibling of the manufacturer link (anchors can't
nest), and covering the row keeps the generous click target the header
always had. It comes first so `~` can reach the title it labels. -->
<a
class="main"
{href}
aria-label={m['library.openGroup']({ name: group.title })}
data-sveltekit-keepfocus
data-sveltekit-noscroll
></a>
{/if}
{#if group.colors.length}
<Swatch colors={group.colors} direction={group.direction} size={24} radius={6} />
{/if}
<div class="body">
<div class="line">
<!-- A group header reads as a heading, and headings don't normally lead
anywhere — so the link has to say so itself, by taking the underline
and colour every link gets on hover. -->
<span class="title">{group.title}</span>
{#if group.badge}<MaterialBadge label={group.badge} />{/if}
</div>
{#if group.vendorName || group.subtitle}
<div class="sub">
<!-- The manufacturer is already printed on every filament group, so this
is the shortest possible route to one: no menu, no search, no detour
through a filament. Its own pill-shaped hover is what separates it
from the row-wide link it sits inside. -->
{#if group.vendorName && vendorHref}
<a
class="vendor"
href={vendorHref}
title={m['library.openManufacturer']({ name: group.vendorName })}
data-sveltekit-keepfocus
data-sveltekit-noscroll
>
<Factory size={11} />
<span class="vname">{group.vendorName}</span>
</a>
{:else if group.vendorName}
<span class="vendor-none">{group.vendorName}</span>
{/if}
{#if group.vendorName && group.subtitle}<span class="dot" aria-hidden="true">·</span>{/if}
{#if group.subtitle}<span class="stext">{group.subtitle}</span>{/if}
</div>
{#if group.subtitle}<div class="sub">{group.subtitle}</div>{/if}
</div>
<!-- The group's two aggregates, stacked to mirror the title/subtitle opposite
them: what it holds, and how much of it. The count is also what says the
rows below are ALL of this group's spools, not the first few. -->
<div class="meta">
<span class="weight">{group.meta}</span>
<span class="count">{group.count}</span>
</div>
</svelte:element>
{/if}
</div>
<!-- The group's two aggregates, stacked to mirror the title/subtitle opposite
them: what it holds, and how much of it. The count is also what says the
rows below are ALL of this group's spools, not the first few. -->
<div class="meta">
<span class="weight">{group.meta}</span>
<span class="count">{group.count}</span>
</div>
<button
class="disc"
type="button"
@@ -66,16 +111,22 @@
<style>
.header {
/* Positioned so the entity link's stretched ::after resolves against the row.
Nothing in here may clip overflow, or that hit area would be cut down to
whichever box does the clipping (hence the per-span truncation below). */
position: relative;
display: flex;
align-items: stretch;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px 0 9px 14px;
border-top: 1px solid var(--border-soft);
border-left: 2px solid transparent;
background: none;
box-sizing: border-box;
}
/* Only entity-backed headers (filament/vendor) lead anywhere, so only they take
the row-wide hover; the disclosure keeps its own, quieter one either way. */
the row-wide hover; the other two controls keep their own on top of it. */
.header.link:hover {
background: var(--surface-2);
}
@@ -85,21 +136,6 @@
z-index: 1;
background: var(--surface-sunken);
}
.main {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
padding: 9px 0 9px 14px;
color: inherit;
text-align: left;
text-decoration: none;
font: inherit;
}
.header.link .main {
cursor: pointer;
}
.body {
min-width: 0;
flex: 1;
@@ -108,21 +144,83 @@
display: flex;
align-items: baseline;
gap: 7px;
white-space: nowrap;
overflow: hidden;
min-width: 0;
}
/* The row-sized hit area itself: transparent and unpainted, it only takes clicks
and hovers. Sized by the row rather than by the title so assistive tooling and
tap-target audits measure the target people actually press. The manufacturer
link and the disclosure lift themselves above it. */
.main {
position: absolute;
inset: 0;
cursor: pointer;
}
/* The swatch is positioned (it layers its colour bands), so it would otherwise
paint above the overlay and swallow clicks on that corner of the row. It is
decorative here — the row's link is what it belongs to. */
.header :global(.swatch) {
pointer-events: none;
}
.title {
font-weight: 600;
font-size: 13px;
}
.sub {
font-size: 11px;
color: var(--text-dim);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Driven from the overlay, so the title reacts to the row being hovered anywhere
— but NOT to the two controls stacked above it, which lead somewhere else. */
.main:hover ~ .body .title {
color: var(--accent-link);
text-decoration: underline;
text-underline-offset: 2px;
}
.sub {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
font-size: 11px;
color: var(--text-dim);
margin-top: 2px;
}
.vname,
.stext {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dot {
flex: none;
}
/* Above the stretched link, or it would never be clickable. The padding makes it
a ~28px tap target (over the 24px WCAG 2.2 floor) while the matching negative
margin keeps it laid out as if it had none — so the header stays as tight as
it was and the name still lines up under the title. */
.vendor {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
padding: 7px;
margin: -7px;
border-radius: var(--radius-sm);
/* --accent-link is tuned for the lighter surfaces links normally sit on; on
the sunken header it only reaches 4.2:1 in the light theme. --accent-soft
is the same hue and clears AA on this surface in both themes (4.9 / 7.3). */
color: var(--accent-soft);
text-decoration: none;
cursor: pointer;
}
.vendor:hover {
background: var(--surface-raised);
color: var(--accent-link-hover);
}
.vendor-none {
font-style: italic;
}
.meta {
display: flex;
flex-direction: column;
@@ -140,20 +238,28 @@
color: var(--text-faint);
}
/* Full-height so the hit target is the whole right edge of the header — far
easier to hit on a phone than the glyph, without drawing a heavier control. */
easier to hit on a phone than the glyph, without drawing a heavier control.
Above the stretched link for the same reason the manufacturer is. */
.disc {
position: relative;
z-index: 1;
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 34px;
margin: -9px 0;
padding: 0;
border: none;
background: none;
color: var(--text-faint);
cursor: pointer;
}
/* Its own hover, distinct from the row's, so the fold reads as a separate
control rather than as part of the link it sits next to. */
.disc:hover {
background: var(--surface-raised);
color: var(--text-2);
}
.chev {
@@ -132,6 +132,9 @@
title: group.title,
subtitle: group.subtitle,
badge: group.badge,
// Filament groups always name their manufacturer — as a link where there is
// one to link to, and otherwise as a note that there isn't.
vendorName: group.field === 'filament' ? (group.vendorName ?? m['add.noManufacturer']()) : undefined,
colors: group.colors,
direction: group.direction,
meta: weightAuto(group.totalRemaining),
@@ -147,10 +150,19 @@
? params.selectHrefFromState(libraryState, 'vendor', group.key)
: undefined
);
// The second destination in a filament header: its manufacturer. This is the
// only route to a vendor that doesn't go through a menu, the search box, or an
// inspector you had to know to open first (#989).
let vendorHref = $derived(
group.field === 'filament' && group.vendorId
? params.selectHrefFromState(libraryState, 'vendor', group.vendorId)
: undefined
);
</script>
<div bind:this={el}>
<GroupHeader group={header} sticky href={headerHref} {collapsed} ontoggle={toggle} />
<GroupHeader group={header} sticky href={headerHref} {vendorHref} {collapsed} ontoggle={toggle} />
{#if !collapsed}
{#each inUse as vm (vm.spool.id)}
<SpoolRow {vm} {showSwatch} indent={26} context={group.field} />
+3
View File
@@ -31,6 +31,9 @@ export interface GroupHeaderInfo {
title: string;
subtitle: string;
badge: string;
/** Filament groups: the manufacturer's name, rendered as a link to it when the
* header also gets a `vendorHref` (and as a muted note when it can't be). */
vendorName?: string;
colors: string[];
direction?: MultiColorDirection;
/** Aggregate remaining weight across the whole group, formatted. */