Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c94c97f604 | ||
|
|
4801ba4480 | ||
|
|
40bc968cf8 | ||
|
|
e61b7456df |
@@ -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.
|
||||
|
||||
+100
-15
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user