mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 01:52:17 -05:00
Merge pull request #1001 from Donkie/fix/library-header-affordances
fix(library): make the group header's destinations findable
This commit is contained in:
8 files changed
+206
-58
No files matched your search
@@ -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" \
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user