Compare commits

..
4 Commits
Author SHA1 Message Date
logan c94c97f604 docs: move plan 009 to completed
Build & publish Arch package / arch-package (push) Successful in 2m4s
CI / check (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m59s
2026-08-13 15:18:22 -04:00
logan 4801ba4480 docs: close plan 009, and what a decision phase found
Two of Phase 2's three judgement calls were answered by reading the
code rather than by choosing: there is no artist badge to make a
button, and a track badge stops reading as noise the moment it means
something. The third went the other way — `EntityRecording` reads like
a placeholder and is real work.
2026-08-13 15:18:17 -04:00
logan 40bc968cf8 test(e2e): a real click on the badge acts without opening the card
Only this tier can say it: the badge sits inside a card whose own click
navigates, so what matters is that a real gesture files the request
*and* leaves the page where it was.

It clicks a locator rather than a measured point. The first version
read a bounding box the moment the search settled, but cover art is
still arriving then and a card that grows moves the badge — so the
click landed on the card and opened the album, which is precisely the
regression the test exists to catch, reported as a failure to file a
request.

The phase 1 label assertion moves with the component: a control is
named after what activating it does, so the badge that said "is queued
for download" now says "Cancel the request for …".
2026-08-13 15:17:14 -04:00
logan e61b7456df feat(explore): make the library badge request what it is on
007 turned this badge from a `<button>` whose handler was a
`stopPropagation()` and a TODO into `role="img"`, on the rule that a
control which cannot act is worse than none — and wrote down what would
change the answer: a `<button>` again *with* a handler, never a handler
bolted onto something already shaped like one. This is that.

A call site opts in by passing `request-mbid`, so where a badge is
redundant it stays a badge: `explore-album-details`'s header has "Want
this" in words directly below it, and its template says so by not
opting in. An `in-library` badge is never a button either, because
there is nothing left to ask for — that is what keeps the tab stops 007
gave back from being spent on nothing.

The copy is the action, not the state, and it is deliberately about the
request list rather than the library: "Want album X" / "Cancel the
request for album X". Clicking still adds nothing to the library, which
is what made the original "Add … to library" a promise the control
could not keep.

Tracks are requestable too. `EntityRecording` is not a placeholder in
the request model — `Reconciler.tracklistFor` has a deliberate branch
for it, because one expected title is what lets filename matching score
a single-track download at all. Artists are not: there is no artist
badge anywhere, and a discography subscription belongs on the Follow
button that can say what it commits to.

The click is swallowed again, for the opposite reason to before: with
an action of its own, a click on the badge no longer means what the
card means. Enter and Space are stopped for the same reason — every
card holding one is a role=button or role=option with its own handler.
2026-08-13 15:17:06 -04:00
11 changed files with 663 additions and 50 deletions
+62
View File
@@ -2264,3 +2264,65 @@ Six more things worth keeping:
question. Same shape as `getCoverUrl()`, `track-index.ts` and
`page-header` — when a rule is written per call site, the call sites
do not disagree, they are all incomplete in the same way.
## A decision phase earns its keep by finding it was not a decision
Plan 009 phases 2 and 3: the badge becomes a button where it can act.
The generalisation: **the questions worth taking a phase over are the
ones the code can answer, and you cannot tell which those are without
asking them.** Phase 2 was written as three judgement calls. Two turned
out not to be:
- "An artist badge would commit a user to a whole discography" —
describing a badge that **does not exist**. `top-results-row` renders
`nothing` for an artist and no other site passes
`entity-type="artist"` to the component at all. Artist subscription
already had a labelled Follow button.
- "Should a track inside a requested album show something different" —
evaporated. It read as noise only while a plus on a track meant
nothing; once it means *want just this one*, the mixed row is the
interface working.
The third — whether a track can be requested at all — went the other
way and is the more useful lesson. **`EntityRecording` reads like a
placeholder and is load-bearing.** It would have cost nothing to rule
tracks out as unsupported, and `Reconciler.tracklistFor` has an
explicit branch for them whose comment explains that a one-entry
expected tracklist is what lets filename matching score a single-track
download at all. A feature removed by assumption leaves no trace that
it was ever there.
Five more things worth keeping:
- **A test that passes on the neutered build is not a test, and the
vacuous ones are the negative assertions.** "Keeps its click off the
card it sits on" asserted that nothing bubbled — free when there is
no button, since `?.click()` on null is a silent no-op. It passed on
the neutered build while its seven neighbours failed. It asserts the
click *did the thing it was swallowed for* as well now. Same family
as `overflow: hidden` permitting programmatic scrolling, and the tell
was identical: **it could not fail.**
- **A measured coordinate is stale before it is used.** The e2e gesture
read a bounding box the moment the search settled; cover art is still
arriving then and a card that grows moves the badge, so the click
landed on the card and opened the album — reported as *a failure to
file a request*, which is a different bug. A Playwright locator
re-resolves and waits for the element to stop moving. Prefer one to
`mouse.click(x, y)` whenever the thing being clicked is in a list
that is still loading, which is most lists here.
- **A fix moves its own assertions, and that is not churn.** Phase 1's
spec asserted the badge announced "… is queued for download". A
*control* is named after what activating it does, so two commits
later it is "Cancel the request for …". Naming a thing after its
state is correct right up until it grows an action.
- **An opt-in makes a redundancy visible.** The badge could have known
which pages have a "Want this" button; instead a call site passes
`request-mbid` or does not, so `explore-album-details`'s header
declines in its own template. The rule is greppable and the component
has no list of exceptions to go stale.
- **Verify a control with the gesture, not with the event.** A synthetic
`MouseEvent` does not prove hit-testing, and a `.click()` on a shadow
child does not prove the icon inside it is `pointer-events: none`.
Both were checked with a real mouse (`mousemove`/`mousedown`/
`mouseup`) and a real Tab/Enter before either was believed.
@@ -1,6 +1,6 @@
# 009 — The badge that cannot act, and the state it already had
**Status:** active — Phase 1 shipped; Phase 2 is a decision, not written yet.
**Status:** complete — all three phases shipped.
**Branch:** main
**Created:** 2026-08-13
**Follows:** 008-the-last-audit
@@ -165,27 +165,112 @@ Six things, and the first is the plan's own framing.
## Phase 2 — what a badge click means, per entity
*(decision, before code — not started)*
*(Decided 2026-08-13, before any code.)*
What Phase 1 leaves for it, now as observations rather than guesses:
**A badge is a button where it is the only way to act, and what it
toggles is a request — never a download.**
- On the album page the badge and the "Want this" button now say the
same thing twice, four centimetres apart. That is an argument for the
badge being **read-only there** and clickable only where there is no
button — or for the button going.
- A requested album shows an amber hourglass while every track in its
tracklist shows a plus, which is correct per the rule and reads as
busy. Worth deciding whether a track inside a requested album should
render *nothing* rather than a plus.
- An artist badge would mean a discography subscription, which is the
heaviest commitment in the download subsystem behind the smallest
control in the app.
Two of the three questions were answered by the code rather than by a
judgement, which is the point of asking them before writing anything.
**There is no artist badge, and there never was.** The worry that one
20 px circle would commit a user to a whole discography does not apply:
`top-results-row` renders `nothing` for an artist, and no other site
passes `entity-type="artist"` to this component at all. Artist
subscription already has a home — `explore-artist-details`'s
`renderFollowAction()`, a labelled button with the scope beside it,
which is where a commitment that never completes belongs.
**A track badge is honoured end to end.** `EntityRecording` is not a
placeholder in the request model: `Reconciler.tracklistFor` has a
deliberate branch for it ("A track request is its own tracklist") whose
comment explains that the single expected title is what lets filename
matching score a one-song download at all. So a track badge promises
something the backend can keep, and it is a button too.
That also disposes of the second observation. An hourglass on an album
over a row of plusses read as noise while a plus meant nothing; once a
plus on a track means *want just this one*, the mixed row is the
interface working. No special case, and none of the four surfaces needs
to know what contains what.
**The album detail header keeps its badge read-only.** "Want this" sits
directly below it saying the same thing in words. The rule is not "a
badge is decorative on detail pages" — it is that a call site **opts in
by supplying the MBID to act on**, so a redundancy is visible in the
template rather than hidden in the component.
**And it is a request, not an acquisition.** The old copy said "Add …
to library", which 007 called the button's promise written into the
copy — and it would still be a lie, because clicking adds a row to the
request list and nothing to the library. The name is the action, in the
words the rest of the app already uses: **"Want …"**, and **"Cancel the
request for …"** when it is already wanted. No confirmation: the action
is one click to undo, which is the whole test for whether a dialog is
owed.
---
## Phase 3 — the button
*(scope depends on Phase 2)*
Ships what Phase 2 decided: `request-mbid` as the opt-in, a `<button>`
where a call site passes one and the entity is not already owned, and
`toggleRequest()` beside `libraryStatusFor()` because
`explore-album-details`'s "Want this" asks the same question and two
implementations of *what wanting something means* is what Phase 1 was
about.
### Phase 3 — what actually shipped
Seven of the eight call sites opt in; the album header does not.
`make ui-test` 685 → **695**; `make e2e` 92 → **93**.
Verified in the running app with a **real mouse gesture and a real
keyboard path**, not a synthetic event: click the badge → the request
is filed, the badge becomes an hourglass, the album page does not
open. Tab → the badge takes focus with its own ring inside the card's;
Enter → same, and the card's own Enter handler does not fire.
Pinned by `library-status.test.ts` (+10, watched failing on the
pre-fix build — 8 of 18) and `requested-badge.spec.ts` (+1).
#### Where the plan was wrong — Phase 3
Five things, and the first two are the plan asking questions the code
had already answered.
- **Two thirds of the Phase 2 decision was not a decision.** "An artist
badge would mean a discography subscription" describes a badge that
does not exist — `top-results-row` renders `nothing` for an artist
and no other site passes `entity-type="artist"` at all. And "should a
track inside a requested album show something different" evaporated
the moment a plus on a track meant *want just this one*. A decision
phase is worth having; two of its three items were answered by
reading rather than by choosing, which is the cheaper half of it
working.
- **`EntityRecording` is load-bearing and reads like a placeholder.**
It would have been easy to rule tracks out as unsupported; the
reconciler has an explicit branch for them whose comment explains
that a one-entry expected tracklist is what lets filename matching
score a single-track download at all. Ruling it out would have been a
feature removed by assumption.
- **A test that passes on the neutered build is not a test.** "Keeps
its click off the card it sits on" asserted that nothing bubbled —
which is free when there is no button to click, since `?.click()` on
null is a silent no-op. It passed on the neutered build. It asserts
the click *did the thing it was swallowed for* as well now, and fails
there like the other seven.
- **A measured coordinate is stale before it is used.** The e2e gesture
read a bounding box the moment the search settled; cover art is still
arriving then, and a card that grows moves the badge, so the click
landed on the card and opened the album — reported as a failure to
file a request, which is a different bug entirely. A locator
re-resolves and waits for the element to stop moving.
- **A fix moves its own assertions.** Phase 1's spec asserted the
badge's name was "… is queued for download"; a control is named after
what activating it does, so it is "Cancel the request for …" now. The
spec was right when it was written and wrong two commits later, which
is the ordinary cost of naming a thing after its state.
---
+48 -12
View File
@@ -785,21 +785,57 @@ result would silently reorder a queue — and because `cover-grid`'s drag
cache stores them per album. A `libraryID` of 0 means "every library",
matching an unset library filter.
**A badge is not a button, and a control that cannot act is worse than
none.** `library-status-indicator` — the tick/plus on every Explore
**A badge is a button only where it can act, and it says which.**
`library-status-indicator` — the tick/hourglass/plus on every Explore
card and track row — was a `<button>` whose click handler was a
`stopPropagation()` and a comment saying to wire up the download client
later: 20 of the 66 tab stops on a results page announced themselves as
buttons and did nothing (46 and 0 after). It is `role="img"` with a
label until there is something to click, and its unowned label says
"… is not in your library" rather than "Add … to library", which was
the button's promise written into the copy. When the download client
lands, the change is a `<button>` *with* a handler — not a handler
bolted onto something already shaped like one. Two smaller things came
with it: a `<span>` does not get `box-sizing: border-box` from the UA
stylesheet the way a `<button>` does (the badge grew 36→38px, caught by
a stored screenshot), and with no click of its own the badge is part of
its card, so a click on it means what the card means.
buttons and did nothing. 007 made it `role="img"` on the rule that a
control which cannot act is worse than none, and named the condition
that would change the answer: a `<button>` again *with* a handler,
never a handler bolted onto something already shaped like one.
It is that now, and three rules hold it up. **A call site opts in** by
passing `request-mbid`, so a redundancy is visible in the template
rather than hidden in the component — `explore-album-details`'s header
has "Want this" in words directly below it and does not opt in. **An
owned entity is never a button**, because there is nothing left to ask
for, which is what stops the returned tab stops being spent on nothing.
And **the name is the action and the action is a request**: "Want album
X" / "Cancel the request for album X". Clicking adds a row to the
request list and nothing to the library, which is exactly what made the
original "Add … to library" a promise the control could not keep.
Two entities and not the third. A track is requestable because
`EntityRecording` is real work in the backend — `Reconciler.tracklistFor`
has a branch for it, since one expected title is what lets filename
matching score a single-track download at all. An artist is not: there
is no artist badge anywhere (`top-results-row` renders `nothing` for
one), and a discography subscription — never satisfied, expanding into
child requests — belongs on `explore-artist-details`'s Follow button,
which can say what it commits to.
Two smaller things, both still true: a `<span>` does not get
`box-sizing: border-box` from the UA stylesheet the way a `<button>`
does (the badge grew 36→38px, caught by a stored screenshot, and both
branches now set it), and the click is swallowed again — for the
opposite reason to before. With no action of its own the badge was part
of its card and a click on it meant what the card means; with one, it
does not. Enter and Space are stopped for the same reason, since every
card holding one is a `role="button"` or `role="option"` with its own
handler.
**Its third state was declared for a year and produced by nothing.**
`queued` was styled amber, given an hourglass and given the sentence
"… is queued for download", and all eight call sites were a two-way
ternary — so an album already on the request list showed a plus and
said it was not in the library, on the same page as a filled button
reading "Wanted". `utils/library-status.ts` is that rule written once:
`libraryStatusFor()` (owning outranks wanting; a *satisfied* request is
not queued, because nothing is coming; a request is by MBID, so a track
inside a requested album is not itself requested) and `toggleRequest()`
beside it, because the "Want this" button asks the same question and
two definitions of *what wanting means* is the fault this replaced.
**A grid moves by a row, and `offsetTop` cannot tell you how wide a row
is.** `utils/roving-grid.ts` measured columns by counting cards sharing
+45 -3
View File
@@ -95,9 +95,51 @@ test.describe('the requested badge', () => {
.poll(() => badgeStatus(app, TITLE), { timeout: 10_000 })
.toBe('queued');
// And it says so where it counts. The label is the whole point:
// the plus used to be accompanied by "is not in your library".
expect(await badgeLabel(app, TITLE)).toContain('queued for download');
// And it says so where it counts. The name is the whole point
// the plus used to be accompanied by "is not in your library"
// and since phase 3 it is a control, so the name is the action it
// performs rather than the state it is in.
expect(await badgeLabel(app, TITLE)).toBe(
`Cancel the request for album "${TITLE}"`,
);
});
test('clicking the badge wants the album and does not open it', async ({
app,
}) => {
// Only this tier can say this. The badge sits inside a card whose
// own click navigates, so the assertion is that a real gesture on
// the badge files a request *and* leaves the page where it was —
// and a synthetic MouseEvent is not evidence of either.
await clearRequest(app);
await app.getByTestId('nav-explore').click();
await search(app, TITLE);
// A locator rather than measured coordinates, and the difference
// is not style. The first version read a bounding box the moment
// the search settled and clicked it — but cover art is still
// arriving then, and a card that grows moves the badge, so the
// click landed on the card and opened the album. A locator
// re-resolves and waits for the element to stop moving.
const button = app
.locator(`library-status-indicator[label="${TITLE}"] button`)
.first();
// A badge that is not a button makes every assertion below vacuous.
await expect(button, 'the badge is not a button').toBeVisible();
await button.click();
await expect
.poll(() => badgeStatus(app, TITLE), { timeout: 10_000 })
.toBe('queued');
expect(
await app.evaluate(() => !!document.querySelector('explore-album-details')),
'the click reached the card underneath',
).toBe(false);
requestId = 1; // so afterAll cleans up regardless of order
});
test('the requested state renders a real icon', async ({ app }) => {
@@ -2295,6 +2295,8 @@ export class ExploreAlbumDetails extends LitElement {
status=${libraryStatusFor(Boolean(track.inLibrary), track.mbid)}
entity-type="track"
label=${track.title}
request-mbid=${track.mbid}
request-artist=${this.artistName}
></library-status-indicator>
</div>
`,
@@ -2117,6 +2117,8 @@ export class ExploreArtistDetails extends LitElement {
status=${libraryStatusFor(Boolean(t.inLibrary || t.localId), t.recordingMbid)}
entity-type="track"
label=${t.trackName}
request-mbid=${t.recordingMbid}
request-artist=${t.artistName ?? ''}
></library-status-indicator>
</div>
`,
@@ -2229,6 +2231,8 @@ export class ExploreArtistDetails extends LitElement {
status=${libraryStatusFor(Boolean(rg.inLibrary || rg.localId), rg.releaseGroupMbid)}
entity-type="album"
label=${rg.title}
request-mbid=${rg.releaseGroupMbid}
request-artist=${this.artist?.name ?? ''}
size="18"
></library-status-indicator>
</div>
@@ -2358,6 +2362,8 @@ export class ExploreArtistDetails extends LitElement {
status=${status}
entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${this.artist?.name ?? ''}
></library-status-indicator>
</div>
</div>
@@ -1854,6 +1854,8 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
status=${libraryStatusFor(this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary), rg.mbid)}
entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${rg.artistCredit ?? ''}
></library-status-indicator>
</div>
</div>
@@ -1892,6 +1894,8 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
status=${libraryStatusFor(this.libraryMBIDs.has(r.mbid) || Boolean(r.inLibrary), r.mbid)}
entity-type="track"
label=${r.title}
request-mbid=${r.mbid}
request-artist=${r.artistCredit ?? ''}
></library-status-indicator>
</div>
`,
@@ -1,6 +1,9 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
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';
/**
* Library status for an entity (artist, album, or track).
@@ -17,18 +20,23 @@ export type LibraryStatus = 'in-library' | '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 badge, not a control.** It was 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,
* and every one of them announced itself as a button. A control that
* cannot act is worse than no control: it costs the keyboard user the
* tab stop *and* the expectation.
* **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.
*
* So it is `role="img"` with a label, until there is something to
* click. When the download-client integration lands, the right change
* is to make it a `<button>` again *with a handler* — not to add the
* handler to something already shaped like a button.
* 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
@@ -65,6 +73,31 @@ export class LibraryStatusIndicator extends LitElement {
@property({ type: Number })
size = 20;
/**
* 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. */
private get actionable(): boolean {
return (
this.requestMbid !== '' &&
this.status !== 'in-library' &&
this.entityType !== 'artist'
);
}
static override styles = css`
:host {
display: inline-flex;
@@ -96,7 +129,8 @@ export class LibraryStatusIndicator extends LitElement {
/* 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. */
* 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);
@@ -116,6 +150,29 @@ export class LibraryStatusIndicator extends LitElement {
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
@@ -145,18 +202,81 @@ export class LibraryStatusIndicator extends LitElement {
: '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) {
return this.status === 'queued'
? `Cancel the request for ${kind}${name}`
: `Want ${kind}${name}`;
}
switch (this.status) {
case 'in-library':
return `${capitalize(kind)}${name} is in your library`;
case 'queued':
return `${capitalize(kind)}${name} is queued for download`;
default:
// Not "Add … to library": nothing here adds anything.
// The old copy was the button's promise written out.
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) {
@@ -164,12 +284,29 @@ export class LibraryStatusIndicator extends LitElement {
}
const title = this.tooltip();
const icon = 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}
>
${icon}
</button>
`;
}
return html`
<span class="badge" role="img" title=${title} aria-label=${title}>
${this.iconName()
? html`<wa-icon name=${this.iconName()} aria-hidden="true"></wa-icon>`
: nothing}
${icon}
</span>
`;
}
@@ -341,6 +341,8 @@ export class TopResultsRow extends LitElement {
status=${status}
entity-type=${entityType}
label=${r.name}
request-mbid=${r.mbid}
request-artist=${r.artistCredit ?? ''}
size="22"
></library-status-indicator>`}
</div>
+61
View File
@@ -1,4 +1,6 @@
import { downloadStore } from '@store/download-store';
import { libraryStore } from '@store/library-store';
import type { download } from '@go/models';
import type { LibraryStatus } from '../components/library-status-indicator/library-status-indicator';
/**
@@ -43,3 +45,62 @@ export function libraryStatusFor(
return 'not-in-library';
}
/** What a badge can ask for. Artists are deliberately absent: a
* discography subscription is `explore-artist-details`'s Follow
* button, which can say what it is committing to. */
export type RequestableEntity = 'album' | 'track';
const ENTITY: Record<RequestableEntity, string> = {
album: 'release-group',
track: 'recording',
};
/**
* Add or drop a request for one entity, and report which way it went.
*
* The counterpart to `libraryStatusFor`, here rather than in the badge
* because the badge is one of several things that can ask —
* `explore-album-details`'s "Want this" button is the other, and two
* implementations of "what does wanting something mean" is exactly what
* phase 1 was about.
*
* Returns `'wanted'` or `'cancelled'` so a caller can announce what
* happened; throws if the backend refused, because a badge that
* silently does nothing is what this whole plan is about.
*/
export async function toggleRequest(input: {
mbid: string;
entity: RequestableEntity;
title: string;
artist?: string;
}): Promise<'wanted' | 'cancelled'> {
const existing = downloadStore.requestFor(input.mbid);
if (existing) {
await downloadStore.removeRequest(existing.id);
return 'cancelled';
}
// A request belongs to a library because that is where its files
// will land. There is always at least one by the time anything is
// on screen — the first-run wizard blocks every pointer event until
// there is — but an explicit failure beats a request filed against
// library 0, which no import would ever match.
const libraryId = await libraryStore.getDefaultLibraryId();
if (!libraryId) throw new Error('no library to add this to');
await downloadStore.addRequest({
mbid: input.mbid,
entity: ENTITY[input.entity],
libraryId,
artist: input.artist ?? '',
title: input.title,
scope: 'future',
secondary: false,
} as download.RequestInput);
return 'wanted';
}
+178 -2
View File
@@ -21,8 +21,16 @@ import '@components/explore-view/explore-view';
import type { Request } from '@store/download-store';
import { libraryStatusFor } from '@utils/library-status';
import { Events } from '../../src/events';
import { emit, flush, stub } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
import { notificationStore } from '@store/notification-store';
import {
calls,
emit,
flush,
lastArgs,
stub,
stubFailure,
} from '@test/support/harness';
import { fixture, shadow, shadowAll, update } from '@test/support/render';
const SEARCH = 'explore.Service.SearchLocal';
@@ -183,3 +191,171 @@ describe('<explore-view> badges', () => {
);
});
});
/**
* Plan 009 phase 3: the badge becomes a button where it can act.
*
* 007 made it `role="img"` because a control that cannot act is worse
* than none, and wrote down what would change the answer: a `<button>`
* *with* a handler. Both halves of that are asserted here — the badge
* branch is still a badge (the tests in `chrome.test.ts` pin it, and
* they pass unchanged because a call site has to opt in), and the
* button branch actually files a request.
*/
describe('<library-status-indicator> as a control', () => {
beforeEach(async () => {
stub('download.Service.ListRequests', []);
stub('download.Service.AddRequest', 7);
stub('download.Service.RemoveRequest', null);
stub('library.Library.GetAllLibrariesWithTrackCounts', [
{ id: 3, name: 'Music' },
]);
notificationStore.clear();
await withRequests([]);
});
const badge = (props: Record<string, unknown> = {}) =>
fixture('library-status-indicator', {
entityType: 'album',
label: 'Abbey Road',
...props,
});
it('is a button only where a call site opted in', async () => {
const inert = await badge();
const control = await badge({ requestMbid: 'rg-1' });
expect(shadow(inert, 'button')).toBeNull();
expect(shadow(inert, '.badge')?.getAttribute('role')).toBe('img');
expect(shadow(control, 'button')).not.toBeNull();
});
it('is never a button for something already owned', async () => {
// There is nothing left to ask for, so the tab stop would cost the
// keyboard user exactly what 007 gave back.
const el = await badge({ requestMbid: 'rg-1', status: 'in-library' });
expect(shadow(el, 'button')).toBeNull();
});
it('is named after what activating it does', async () => {
const el = await badge({ requestMbid: 'rg-1' });
expect(shadow(el, '.badge')?.getAttribute('aria-label')).toBe(
'Want album "Abbey Road"',
);
await update(el, { status: 'queued' });
expect(shadow(el, '.badge')?.getAttribute('aria-label')).toBe(
'Cancel the request for album "Abbey Road"',
);
});
it('still describes rather than offers where it cannot act', async () => {
const el = await badge();
expect(shadow(el, '.badge')?.getAttribute('aria-label')).toBe(
'Album "Abbey Road" is not in your library',
);
});
it('files a request for the entity it is on', async () => {
const el = await badge({ requestMbid: 'rg-1', requestArtist: 'The Beatles' });
shadow<HTMLElement>(el, 'button')?.click();
await flush();
expect(lastArgs('download.Service.AddRequest')?.[0]).toMatchObject({
mbid: 'rg-1',
entity: 'release-group',
title: 'Abbey Road',
artist: 'The Beatles',
libraryId: 3,
});
});
it('asks for a recording when it is on a track', async () => {
const el = await badge({
entityType: 'track',
label: 'Come Together',
requestMbid: 'rec-1',
});
shadow<HTMLElement>(el, 'button')?.click();
await flush();
expect(lastArgs('download.Service.AddRequest')?.[0]).toMatchObject({
entity: 'recording',
});
});
it('cancels a request it already made', async () => {
await withRequests([request({ id: 42, mbid: 'rg-1' })]);
const el = await badge({ requestMbid: 'rg-1', status: 'queued' });
shadow<HTMLElement>(el, 'button')?.click();
await flush();
expect(lastArgs('download.Service.RemoveRequest')).toEqual([42]);
expect(calls('download.Service.AddRequest')).toEqual([]);
});
it('keeps its click off the card it sits on', async () => {
// The inverse of the badge branch, and for the opposite reason:
// with an action of its own, a click on it no longer means what
// the card means.
const el = await badge({ requestMbid: 'rg-1' });
const button = shadow<HTMLElement>(el, 'button');
let bubbled = 0;
el.addEventListener('click', () => {
bubbled += 1;
});
button?.click();
await flush();
// Both halves, because "nothing bubbled" is free on a build with
// no button to click: `?.click()` on null is a silent no-op and
// this passed on the neutered build until it also asserted that
// the click did the thing it was swallowed for.
expect([button !== null, bubbled, calls('download.Service.AddRequest')
.length]).toEqual([true, 0, 1]);
});
it('keeps Enter and Space off it too', async () => {
// Every card holding one of these is a role=button or role=option
// with its own Enter/Space handler, so without this a keyboard
// activation would file the request *and* open the page.
const el = await badge({ requestMbid: 'rg-1' });
const seen: string[] = [];
el.addEventListener('keydown', (e) => seen.push((e as KeyboardEvent).key));
for (const key of ['Enter', ' ', 'ArrowDown']) {
shadow<HTMLElement>(el, 'button')?.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, composed: true }),
);
}
// ArrowDown is not ours: the grid still moves by it.
expect(seen).toEqual(['ArrowDown']);
});
it('says so when the request could not be filed', async () => {
stubFailure('download.Service.AddRequest', 'nope');
const el = await badge({ requestMbid: 'rg-1' });
shadow<HTMLElement>(el, 'button')?.click();
await flush();
expect(notificationStore.getAll().map((n) => n.level)).toEqual([
'transient',
]);
// The badge is where it was, which is why the toast is transient.
expect(shadow(el, 'button')).not.toBeNull();
});
});