Files
yellowjacket/frontend/src/components/library-status-indicator/library-status-indicator.ts
T
logan 19c68d73a7 fix(ui): keep the count in a partial badge that can act
A control is named after what activating it does, so an actionable
badge said "Request album X" — and `partial` is actionable, because an
album you hold nine of twelve tracks of has three left to ask for.
That made the one state the ring exists for the one state whose name
did not mention it.

The argument the `partial` branch already carries does not stop
applying because the badge became clickable: a ring says "some" to a
sighted user and nothing to anyone else. The name is now the action and
the count.
2026-08-19 00:38:05 -04:00

463 lines
16 KiB
TypeScript

import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { toggleRequest } from '@utils/library-status';
import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error';
import {
ICON_CAN_REQUEST,
ICON_IN_LIBRARY,
ICON_REQUESTED,
} from '@utils/icon-language';
/**
* Library status for an entity (artist, album, or track).
*
* - `in-library`: the entity is already in the user's local library.
* - `partial`: some of it is here and the rest is known to be missing
* — an album whose files declare twelve tracks where nine are held.
* Only ever correct when that total is *known*; see `owned` /
* `expected` below.
* - `queued`: the entity has been handed off to a download client but
* hasn't arrived yet. Reserved for future download-client plumbing.
* - `not-in-library` (default): the entity is not owned and has not
* been requested.
*/
export type LibraryStatus =
| 'in-library'
| 'partial'
| 'queued'
| 'not-in-library';
/**
* Tri-state library status indicator: a small circular badge embedded
* in track rows, album cards, and artist cards.
*
* **It is a button only where it can act, and a badge everywhere
* else.** It used to be a `<button>` whose click handler was a
* `stopPropagation()` and a comment saying to wire up the download
* client later, so an Explore results page offered 20 keyboard stops
* (of 66) that promised an action and performed none. 007 made it
* `role="img"` for that reason and wrote down what would change the
* answer: a `<button>` again *with* a handler, never a handler bolted
* onto something already shaped like one.
*
* A call site opts in by passing `request-mbid`. Where it does, this
* is a `<button>` that toggles a durable **request** — and the copy
* says so, because clicking still adds nothing to the library. Where
* it does not (`explore-album-details`'s header, which has "Want this"
* in words directly below it) it stays exactly what it was.
*
* An `in-library` badge is never a button under either: there is
* nothing left to ask for.
*
* Colours and glyphs:
* - in-library → green circle, check mark
* - queued → amber circle, bookmark ("on your list")
* - not-in-library → grey circle, plus sign
*
* Usage:
*
* <library-status-indicator
* status="in-library"
* entity-type="album"
* label="Abbey Road"
* ></library-status-indicator>
*/
@customElement('library-status-indicator')
export class LibraryStatusIndicator extends LitElement {
/** Current status. */
@property({ type: String })
status: LibraryStatus = 'not-in-library';
/**
* Entity kind for tooltip/aria-label phrasing. Purely cosmetic
* right now but required so the label text makes sense regardless
* of where the indicator is rendered.
*/
@property({ type: String, attribute: 'entity-type' })
entityType: 'artist' | 'album' | 'track' = 'track';
/** Optional label of the entity — used for the tooltip text. */
@property({ type: String })
label = '';
/** Render size in CSS pixels. Default is 20. */
@property({ type: Number })
size = 20;
/**
* How many of `expected` are held, for `status="partial"`.
*
* These are only meaningful when the caller *knows* the total. A
* tag that never declared one is a third state, and a caller with
* no total must pass `in-library`, not a ring at 0% — most of an
* untagged library would otherwise wear an incompleteness mark
* nothing in the data supports.
*/
@property({ type: Number })
owned = 0;
/** The declared track total behind `owned`. */
@property({ type: Number })
expected = 0;
/**
* MBID to request when this is clicked. Supplying it is what makes
* this a control; omitting it leaves a badge. Only `album` and
* `track` are requestable — see `utils/library-status.ts`.
*/
@property({ type: String, attribute: 'request-mbid' })
requestMbid = '';
/** Display-cache artist for the request list. Matching is by MBID. */
@property({ type: String, attribute: 'request-artist' })
requestArtist = '';
@state()
private busy = false;
/**
* True when this can act: a call site opted in, and there is
* something left to ask for.
*
* `partial` is deliberately actionable — an album you hold nine of
* twelve tracks of has three left to request, which is exactly the
* case worth asking about. Only `in-library` is complete enough to
* have nothing to ask for.
*/
private get actionable(): boolean {
return (
this.requestMbid !== '' &&
this.status !== 'in-library' &&
this.entityType !== 'artist'
);
}
static override styles = css`
:host {
display: inline-flex;
align-items: center;
justify-content: center;
--indicator-size: 20px;
--indicator-bg: transparent;
--indicator-fg: #fff;
--indicator-border: transparent;
}
:host([status='in-library']) {
--indicator-bg: #1db954;
--indicator-fg: #000;
}
:host([status='queued']) {
--indicator-bg: #f5a623;
--indicator-fg: #000;
}
/* The ring draws its own arc, so the badge behind it stays
* empty rather than taking a fill that would show through. */
:host([status='partial']) {
--indicator-bg: transparent;
--indicator-fg: #f5a623;
}
svg {
width: 100%;
height: 100%;
/* Start the arc at twelve o'clock; SVG angles start east. */
transform: rotate(-90deg);
}
circle {
fill: none;
stroke-width: 3;
}
.ring-track {
stroke: rgba(255, 255, 255, 0.18);
}
.ring-fill {
stroke: #f5a623;
stroke-linecap: round;
}
:host([status='not-in-library']) {
--indicator-bg: rgba(255, 255, 255, 0.08);
--indicator-fg: rgba(255, 255, 255, 0.65);
--indicator-border: rgba(255, 255, 255, 0.2);
}
.badge {
/* A <button> gets box-sizing: border-box from the UA
* stylesheet and a <span> does not, so dropping the button
* grew the badge by its 1px border on each side — 36px to
* 38px, caught by the stored screenshot. Set explicitly so
* the two branches of render() are the same size. */
box-sizing: border-box;
width: var(--indicator-size);
height: var(--indicator-size);
min-width: var(--indicator-size);
min-height: var(--indicator-size);
border-radius: 50%;
background: var(--indicator-bg);
color: var(--indicator-fg);
border: 1px solid var(--indicator-border);
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
}
wa-icon {
font-size: calc(var(--indicator-size) * 0.55);
line-height: 1;
pointer-events: none;
}
button.badge {
cursor: pointer;
font: inherit;
}
button.badge:hover:not(:disabled) {
filter: brightness(1.25);
}
button.badge:disabled {
cursor: default;
opacity: 0.6;
}
/* The card underneath draws its own focus ring, and this sits
* inside it — so the badge needs one of its own or a keyboard
* user cannot tell which of the two has focus. */
button.badge:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
/* Prevent the button from intercepting drag gestures on album
* cards — the parent typically owns the drag behaviour. */
:host {
user-select: none;
}
`;
/**
* The glyph for each state.
*
* `queued` is a **bookmark**, not the hourglass it used to be. An
* hourglass says "wait, this is under way", which overstates what a
* request is: nothing may be downloading, nothing may ever be found,
* and the user can leave one sitting on the list indefinitely. A
* bookmark says the honest thing — it is on your list.
*
* The *other* state is the outline of that same bookmark, not a
* plus. Two states of one toggle have to read as each other's
* opposite, and a plus and a bookmark do not — this badge showed a
* plus on the same page as a "Request this" button already using
* the outline/solid pair, forty pixels away. That is the fault
* `utils/library-status.ts` was written for, one layer down: it
* made the two agree on what wanting *means* and left them
* disagreeing on what it looks like.
*/
private iconName(): string {
switch (this.status) {
case 'in-library':
return ICON_IN_LIBRARY;
case 'queued':
return ICON_REQUESTED;
default:
return ICON_CAN_REQUEST;
}
}
/** The held fraction, clamped — extras do not overfill the ring. */
private fraction(): number {
if (this.expected <= 0) return 0;
return Math.min(1, Math.max(0, this.owned / this.expected));
}
private tooltip(): string {
const kind =
this.entityType === 'album'
? 'album'
: this.entityType === 'artist'
? 'artist'
: 'track';
const name = this.label ? ` "${this.label}"` : '';
// A control is named after what activating it does; a badge is
// named after what it is. Both are still deliberately about the
// *request list* rather than the library — clicking this adds a
// row to one and nothing to the other, and "Add … to library"
// was the old button's promise written into the copy.
if (this.actionable) {
if (this.status === 'queued') {
return `Cancel the request for ${kind}${name}`;
}
// A partly-held album is actionable *and* has a count, and
// the count does not survive being named after the action
// alone. The `partial` case below says why it matters — a
// ring says "some" to a sighted user and nothing to anyone
// else — and that argument does not stop applying because
// the badge became clickable. This branch used to say only
// "Request album X", so the one state the ring exists for
// was the one state whose name did not mention it.
if (this.status === 'partial') {
return `Request the rest of ${kind}${name}${this.owned} of ${this.expected} tracks are in your library`;
}
return `Request ${kind}${name}`;
}
switch (this.status) {
case 'in-library':
return `${capitalize(kind)}${name} is in your library`;
case 'partial':
// The count is the whole point — a ring alone says
// "some" to a sighted user and nothing to anyone else.
return `${this.owned} of ${this.expected} tracks of ${kind}${name} are in your library`;
case 'queued':
return `${capitalize(kind)}${name} is queued for download`;
default:
return `${capitalize(kind)}${name} is not in your library`;
}
}
/**
* Toggle the request.
*
* The click is swallowed, which it was before too — but for the
* opposite reason. 007 removed a `stopPropagation()` that guarded
* nothing, on the rule that with no action of its own the badge is
* part of its card and a click on it should mean what the card
* means. Now it has one, so it does not.
*/
private async onActivate(event: Event) {
event.stopPropagation();
event.preventDefault();
if (this.busy || !this.actionable) return;
this.busy = true;
try {
await toggleRequest({
mbid: this.requestMbid,
entity: this.entityType === 'album' ? 'album' : 'track',
title: this.label,
artist: this.requestArtist,
});
} catch (err) {
console.error('could not update the request list', err);
// Transient: the badge visibly stayed where it was, so
// there is nothing for the user to do about it that they
// are not already doing.
notificationStore.transient({
text: describeError(err, 'That request could not be updated.'),
tone: 'error',
});
} finally {
this.busy = false;
}
}
/**
* Keep Enter and Space from reaching the card underneath.
*
* A `<button>` fires `click` on both by itself, so this only has to
* stop the keydown propagating — every card holding one of these is
* a `role="button"` or `role="option"` with its own Enter/Space
* handler, and without this a keyboard activation would both file
* the request and open the page.
*/
private onKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.stopPropagation();
}
}
override render() {
// Sync the host CSS variable with the configured size.
if (this.size && this.size !== 20) {
this.style.setProperty('--indicator-size', `${this.size}px`);
}
const title = this.tooltip();
// The ring stands in for the icon wherever the icon would go —
// including inside the button, because a partly-held album is
// actionable (it has tracks left to request) and must still
// show how much of it is here.
const glyph = this.status === 'partial'
? this.renderRing()
: this.iconName()
? html`<wa-icon name=${this.iconName()} aria-hidden="true"></wa-icon>`
: nothing;
if (this.actionable) {
return html`
<button
class="badge"
type="button"
title=${title}
aria-label=${title}
?disabled=${this.busy}
@click=${this.onActivate}
@keydown=${this.onKeydown}
>
${glyph}
</button>
`;
}
return html`
<span class="badge" role="img" title=${title} aria-label=${title}>
${glyph}
</span>
`;
}
/**
* The progress arc. Drawn as a stroked circle rather than a conic
* gradient so the ring keeps a constant width at every `size` and
* the arc's ends stay round.
*/
private renderRing() {
const radius = 8;
const circumference = 2 * Math.PI * radius;
const offset = circumference * (1 - this.fraction());
return html`
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle class="ring-track" cx="10" cy="10" r=${radius}></circle>
<circle
class="ring-fill"
cx="10"
cy="10"
r=${radius}
stroke-dasharray=${circumference}
stroke-dashoffset=${offset}
></circle>
</svg>
`;
}
}
function capitalize(s: string): string {
return s.length > 0 ? (s[0] ?? '').toUpperCase() + s.slice(1) : s;
}
declare global {
interface HTMLElementTagNameMap {
'library-status-indicator': LibraryStatusIndicator;
}
}