Compare commits

...
Author SHA1 Message Date
logan 76e1c444cc feat(android): tap to play, hold to select
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m30s
CI / e2e (pull_request) Successful in 9m41s
Phase 1 of #63, and the design the issue asks for as one piece is
.planning/plans/active/019-android-touch-model.md.

**A finger has no second button and no modifier keys**, so the primary
action has to be the primary gesture: tap plays the row, and the hold
that opened a context menu now enters selection mode with that row
selected.

Three decisions in it, and two diverge from the report.

**The predicate is the pointer, not the platform or the viewport.**
`pointerType === 'touch'`, per event, which is already how long-press.ts
decided and is the only such test in the frontend. This is #64's rule --
named after the capability -- and it carries #64's warning: keyed on a
width, an Android *tablet* at 600px gets click-selects/double-click-plays
on a touchscreen, which is the inversion this issue exists to fix, on
the platform it exists for. A touchscreen laptop cannot be described by
a width at all. Per event, a mouse keeps desktop semantics on the very
same row, and there is no second declaration of what a phone does.

**There is no double-tap, and the number is why.** The report asks for
single tap to play *and* double tap for the menu. Those cannot both be
honoured: the first tap of a double tap is indistinguishable from a
single tap until the interval expires, so "tap plays" becomes "tap
waits". Measured on the device, the play command to TrackChanged is
155/123/85/56/91 ms -- median ~100 -- and the app's own
DOUBLE_CLICK_GRACE_MS is 250. That is 3.5x the primary interaction,
250ms of it spent deliberately doing nothing, on every track anyone
plays, to reach a menu the hold already reaches. So the menu and the
selection action bar are the same surface, which is also the platform's
convention and removes a concept rather than adding one.

**Tap-to-play and selection mode ship together**, because splitting
them is a regression dressed as an increment: a touch user selects by
tapping today and acts through the long-press menu, so moving tap to
play on its own would leave a window with no way to select forty tracks
at all.

**What lets this reassign the hold without touching one of the fourteen
context menus**: the layer announces `yj-tap` / `yj-long-press`
(composed, cancelable) and acts on nothing. A component claims one with
preventDefault. An **unclaimed long press still becomes a
`contextmenu`**, so the card grids, Explore, the playlist rows and
every other menu behave exactly as they did, and only lists that opt in
get selection mode. An unclaimed *tap* does nothing at all and the
click follows normally, which is what leaves every button in the app
alone -- only a claimed tap has its click swallowed, or playing a track
would also select it.

**And the device found the one thing no browser tier can see.**
Chrome 113's Android WebView fires its own `contextmenu` on a long
press. long-press.ts stood down when a trusted one arrived, which was
right while both paths ended in a context menu; they no longer do, so
standing down means the gesture silently does the *old* thing.
Measured, before the fix, holding a track row:

    {"log":["contextmenu isTrusted=true"],
     "state":{"bar":null,"menuActive":true,"selected":1}}

`yj-long-press` was never announced, the menu opened, and all 26 tests
passed -- dispatched pointer events do not make a browser synthesise
one. So the native event is a **trigger, not a competitor**: the
gesture is announced from it and only a claim suppresses it. Unclaimed
it propagates untouched, which is the same "browser wins" outcome
reached by asking instead of assuming.

The tier could not find that and can hold it, because this module has
always told its own events apart by identity rather than isTrusted, so
an untrusted one from a test takes exactly the browser's path.

Verified on the device by *performing* the gestures rather than
describing the page -- `adb shell input tap` and `input swipe x y x y
700` reach the WebView as real pointer events, which is new here and is
written down in the plan with the pixel mapping. Tap plays; a hold
raises the bar with one selected and no menu; a tap toggles to two,
back to one, and the mode ends with the last row; an album card still
opens its context menu.

29 new tests. The e2e spec is rewritten to assert **both** halves --
the row selects, and a card elsewhere still opens the real menu --
because a spec that only checked the row would pass on a build that had
silently broken the other thirteen.

Phases 2-4 (swipe to queue, the other three surfaces, and what #67
inherits) are in the plan and not in this commit.
2026-08-22 00:23:33 -04:00
logan 4f32d4e13c Merge pull request 'Android: raise every remaining control to the 44px touch floor' (#198) from 186-touch-targets-settings into main
CI / check (push) Successful in 2m32s
CI / e2e (push) Successful in 9m18s
Closes #186
2026-08-22 03:22:16 +00:00
logan 4f628b1f52 fix(ui): raise the last controls below the touch floor
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 9m36s
The rest of #186's second table, and one thing it could not have said.

    .section-toggle          187x15  autotag
    .folders-menu-trigger     32x18  autotag
    .back-button              32x32  artist-details
    Requests / Downloads tabs 85x34, 96x34
    .search-mode-tab          89x26, 79x26  explore
    explore search input     325x18  in a 36px box

**back-button was six controls, not one.** The issue names it in
artist-details because that is the view the sweep opened; the same
declaration is byte-identical in artist-details, genre-details,
playlist-details, smart-playlist-details, explore-artist-details and
explore-album-details, 32px in all six. So it is styles/back-button.
css.ts now, adopted by each, and a source sweep fails on a seventh
copy -- because the failure this invites is not a size changing, it is
somebody adding a detail view and writing `.back-button` out again,
which no device sweep would catch for the same reason this one did
not. That is icon-language.test.ts's shape, and the argument for it
here is the inverse of the column arrows': one declaration covering
36 controls is cheap to fix, and six declarations of one control are
six chances to miss five.

It is a real 44px box rather than padding with the width handed back:
a detail header runs no fit pass, and this button has a visible
background, so a hit area larger than the circle would be a control
bigger than it looks. The size is #55's, reached there for the same
reason -- "the way out is 44px on a phone".

**The explore search box was two faults.** The row was 36px *and* the
input inside it was 18, so eight pixels at each edge were not a target
at all: a tap near the top of the box landed on the container and did
nothing. The container is 44 and the input stretches to it.

**The Downloads tabs take padding rather than a min-size**, because
the mark for the selected tab is its bottom border -- a min-size
centres the label and leaves the underline 10px beneath it.

page-action-check-now (113x29) is in that table and is not here: it is
a PageAction, so #195 raised it with the rest of the header's actions
and touch-targets.test.ts already covers it.

**The Downloads tabs needed a min-size as well as the padding, and CI
is what said so.** Padding alone made them 44px on this machine and
**43px in the container**: the total is 13 + 13 + 2 + whatever line box
the font gives 13px text, and ubuntu:24.04's is a pixel shorter than
Arch's. A height computed from a font's line box is not a height you
control -- which is #195's "stated as a property on the strength of one
engine" one layer down, in the same PR that recorded it. The padding
stays, because it is what keeps the underline against the label; the
min-size is the floor.

Caught by the new test rather than by a person, which is the half of
this that worked.

Verified on the device, sweeping each view the way the issue was
filed: explore, downloads, autotag and artist-details now report
**one** control under the floor apiece, and it is the skip link, which
#186 already ruled out as keyboard-only. .search-mode-tab 89x44 and
79x44, the search input 325x44, the Downloads tabs 85x44 and 96x44,
.section-toggle 174x44, .folders-menu-trigger 44x44, .back-button
44x44.

All 12 new tests fail on main, the source sweep naming all six copies.
make ui-test 1041 pass; make e2e 236 pass on chromium, which is half
an answer -- CI had the other half, and used it.

Closes #186
2026-08-21 23:08:25 -04:00
logan 2100f0022f fix(settings): raise every Settings control to the touch floor
#56 named 44px and #195 took the page header there. Settings is the
other half of #186 and much the larger one: swept on the reference
device (TLP301, 424x439) with all eleven config-sections expanded,
**120 controls** were under the floor -- not the 93 the issue's table
implies, and config-field is eight of them.

The bulk is behind the disclosures, which is why nobody had counted it:

    36  .column-arrow-btn          16x14   <- smallest in the app
    29  .column-toggle             16x16
    26  shortcut-capture button    80x25
     8  download format checkbox   16x16
     7  config-field select        335x30
     6  wa-input / wa-button       204x20, 185x21

**The density argument, measured rather than guessed, and it is
smaller than it looks.** The rows were already near the floor --
.column-item is 335x36 and .shortcut-row 335x37; it is the controls
*inside* them that were 14-25px. So a control grows into the row it
already occupies and the row goes 36 to 44. Measured after: the two
column lists went 373->447 and 690->850, +234px over the whole page.
Half a screen of extra scroll on a page that already scrolls, against
36 targets of 16x14.

**Settings is cheaper than the header was, and for a stated reason.**
There is no overflow fit on this page, so the header's "only width is
contested" rule does not bind at all and nothing here needs padding
with a negative margin. Height is a min-size, and the two square
controls can simply be square.

Three shapes, because one rule does not fit three kinds of control:

**A native checkbox is targeted through its label.** It cannot grow
its hit area without growing its paint, and a 44px checkbox is not
what anyone wants -- so .column-label is a real <label for> now and
the column's *name* is the target, 70x44 rather than 16x16. That is
the argument config-field already makes one file over ("a real label
association also makes the label text a click target, which is
behaviour, not annotation"), and here it is the whole fix. The
download formats already had the label; they only needed the height.

**The arrows take padding, which is invisible.** They carry
background: none and a transparent border, so 16x14 -> 44x44 changes
nothing anyone can see until hover -- #186's Direction exactly.

**Web Awesome's controls come from the library's own API.** Their
height is decided inside somebody else's shadow root, and
--wa-form-control-height is the variable that decides it. A custom
property inherits through a shadow boundary, so a :host declaration
reaches them; styles/wa-touch-floor.css.ts is that, once, adopted
rather than written at :root in index.css -- a :root rule would be
invisible to the component tier, which renders a component and no page
stylesheet.

**Two controls no sweep can see are fixed by name**, and they are the
trap this issue keeps setting. config-field's toggle has an <input>
that is opacity: 0; width: 0; height: 0, so a walk of every input
skips it as a zero-sized node -- what a finger hits is the <label>,
which measured **34x19**, smaller than anything in either of #186's
tables and absent from both. It is 44x44 with the pill still painted
at 2.5em x 1.4em and negative inline margins keeping it flush with the
inputs above. And shortcut-capture's reset button renders only for a
shortcut somebody has rebound, so a sweep of a fresh install never
meets it.

Verified on the device, same method as the sweep that filed it:
120 controls under the floor before, 42 after. All 42 are accounted
for -- 37 are checkboxes whose labels measure 70x44 and 57x44, four
are wa-input's inner input at 204x**42**, which is the control
measured *inside* its own 1px border (part=base is 238x44), and one is
the skip link, which #186 already ruled out as keyboard-only.

The e2e suite passes, top-bar-fit and header-action-overflow included
-- but that is **chromium**, which is half an answer, and saying so is
the whole of what #195's second commit was about. What can be argued
rather than run: library-filter is the only thing here in a container
that measures itself, and its width did not change. The fit measures
inline size.

Two page-header screenshots are refreshed because they are this
issue's own debris -- #195's taller sort control, merged last session,
with its references never re-recorded. app-sidebar's and
now-playing's are deliberately left: they are unrelated drift, and
blessing an unrelated screenshot is how the sidebar reference came to
still list a destination #27 retired. That is #196.
2026-08-21 22:55:54 -04:00
logan 52038dc5ae Merge pull request 'Android: raise the page header and the phone search button to the touch floor' (#195) from 186-touch-targets-page-header into main
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 9m21s
2026-08-22 00:32:08 +00:00
logan 0d331666d6 fix(shell): make the header's touch targets cost no width
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 9m28s
The first pass grew the two square controls to 44px as boxes, which
added 22px to the header. That fit at every width Chromium was checked
at and **clipped the overflow trigger at 320x600 in WebKit** -- the
engine closest to what ships, and the one no machine here can run:

    every action is reachable at 320x600 (400% zoom)
    - Array []
    + Array [ "more" ]

Two things were wrong, and only one of them was the code.

**The claim was checked on one engine and stated as a property.** The
previous commit said #69's fit "does not move ... the check rather than
the assumption", on the strength of running that spec against chromium
alone. CI runs both browsers precisely because they are not the same
answer.

**And the box was the wrong thing to grow**, which the issue already
said: "reached by growing the *hit* area rather than the visual weight
where the two can differ -- padding on the control, not size on the
icon". #69's pass measures inline size, so a taller control is free and
a wider one is not.

So height stays a box -- the header has the room and nothing measures
it -- and width is padding with a negative margin handing the space
back, which is the seek bar's shape from #187. Measured in the
component tier at 320px: the arrow's rect is 45x44 and it occupies 29,
the overflow trigger 44x44 occupying 38, the search button 44x44
occupying 40. Those three occupancies are what they were before any of
this, so the fit pass sees a header identical to main's and the
320px case cannot regress.

The arrow's target is lopsided for #187's reason: the select is 6px to
its left and there is open space to its right, so it takes the side
with nothing to steal from. The overflow trigger's can be symmetric,
the actions row having an 8px gap.

`search-trigger` is border-box, so its 44px min-width is the whole
target and the margin alone gives the four pixels back.

The new assertion is the one that would have caught this: every grown
control must carry negative inline margins, because that is what keeps
the box out of the fit. The rect assertions stay -- getBoundingClientRect
includes padding, so the target is still measured directly rather than
inferred.
2026-08-21 20:12:51 -04:00
logan 6a5a3c33dc fix(shell): raise the page header's controls to the touch floor
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m34s
CI / e2e (pull_request) Failing after 9m43s
#56 sized the playback transport for a thumb and named 44px; the queue
header keeps it. Nothing else was resized, so the controls a user meets
on *every* screen sat between a third and two thirds of the app's own
floor. Measured on the reference device at 424x439: page-sort 99x23,
page-sort-direction **28x21**, page-actions-more 38x27, and
search-trigger 40x40.

**Both questions the issue left open are answered by one measurement.**
The header is 63px tall and its controls are 20-23px, so the vertical
room was already there; the select and its direction arrow are 6px
apart, so the horizontal room was not.

That makes this min-size rather than padding with a negative margin,
which is what the seek bar needed (#187), and the difference decides
everything else. There the painted track had to stay thin, so the
target was grown past its own box and had to be checked against its
neighbours. Here the control *is* the target: the boxes are flex items,
so the gap keeps them apart and **no two targets can overlap by
construction**.

From which:

**There is no phone branch.** A 44px control on a desktop is merely
large, and a second declaration of what a phone shows is a second thing
to keep in step -- which is why this component has never had one. It
also avoids a media query no tier here renders, which is exactly how
the seek bar's phone rule came to be dead for months.

**#69's overflow fit does not move.** That pass measures inline size,
so the height costs it nothing, and only the two square controls grow
the header's content -- by 22px in total. header-action-overflow.spec.ts
passes unchanged at all four of its widths, which was the check rather
than the assumption. Verified on the device that the count is still
shown at 424px, so nothing has started yielding.

search-trigger is the sharpest case and is fixed in the same pass: #57
created it as the phone's replacement for the header search box, so it
exists *only* where there is a thumb, and it shipped at 40x40 under a
comment calling that "the smallest a touch target should be". That was
the floor restated four pixels short rather than a second opinion about
it, and the comment now says so.

Unlike #187 this can be measured rather than inferred: the controls are
plain elements and the rule is a min-size, so it holds at every width
and a real Chromium rendering a real page-header gives the actual
answer. The tests fail with the device's own numbers -- 29x21, 38, 40.

Verified on the device: every control in the header is now at least
44x44, and so is the phone's search button.

**This is the Direction's first step, not all of it.** config-field's
93 Settings controls and explore-view's search row are the second pass;
Settings is a form with one shape for every row and wants its own
argument. #186 stays open for them.
2026-08-21 19:45:20 -04:00
logan 1668b9e0d2 Merge pull request 'Android: a seek bar you can actually hit, and the phone rule that never applied' (#193) from 187-seek-bar-hit-area into main
CI / check (push) Successful in 2m31s
CI / e2e (push) Successful in 9m29s
2026-08-21 22:25:52 +00:00
37 changed files with 3099 additions and 709 deletions
@@ -0,0 +1,253 @@
# 019 — The Android touch model
**Issue:** #63 (`Area/Library-UI`, `Kind/Feature`, `Priority/High`)
**Depends on:** #60 (bottom-sheet menus) — closed, merged as PR #176
**Relates:** #67 (inline links into the menu), #71 ("More" nav), #54
(native feel), #5/#8 (selection, drag to queue — the desktop semantics
being diverged from)
**Status:** in flight.
#73 puts #60 first in Phase 4 because it is "the presentation every
other item needs", and this is the next one. The Direction on #63 asks
for the interaction model to be designed as one piece before any of it
is built, because it *reassigns an existing gesture* rather than adding
one — `utils/long-press.ts` currently owns the 500ms hold, and every
context menu in the app is downstream of it.
This document is that design. Everything below is a measurement, or an
argument for one of the choices #63 leaves open.
---
## The mapping
| gesture | pointer is a finger | pointer is a mouse |
|---|---|---|
| single tap / click | **play the row** | select the row |
| double | — | play the row |
| long press (500ms) | **enter selection mode** | — |
| right-click | — | context menu |
| swipe right | **add to queue** | — |
| drag | reorder / drag to playlist | reorder / drag to playlist |
Three of those are #63's report unchanged. Two are decisions it left
open, and one is a deliberate divergence.
---
## Decision 1 — the predicate is the pointer, not the platform
#63 says "the row component needs a platform-aware interaction layer
rather than shared handlers". It needs an interaction layer; it should
not be platform-aware.
**The question a row has to answer is not "am I on Android" or "is the
viewport under 600px" but "what made this event".** `pointerType ===
'touch'`, read off the event that is being handled, which is already
how `long-press.ts` decides (`if (e.pointerType !== 'touch') return`)
and is the only such test in the frontend today.
This is #64's rule — the predicate is named after the capability, not
the platform — and it carries #64's warning with it. Keyed on a width:
- an Android **tablet** at 600px or more gets click-selects /
double-click-plays on a touchscreen, which is the exact inversion
this issue exists to fix, on the platform it exists for;
- a **touchscreen laptop** cannot be described at all, because both
pointers are live in the same session on the same row;
- and a narrow desktop window gets phone semantics with a mouse.
Per event, all three are right for free, and there is no second
declaration of what a phone does — the thing CLAUDE.md declines to add
every time it comes up.
**Measured, so this is not an assumption about the WebView.** On the
reference device (TLP301, Android 14, WebView Chrome 113, 424x439),
driving a real tap with `adb shell input tap`:
```
[["down","touch",78,94],["touchstart","touchstart",0,0],["up","touch",78,94]]
```
`PointerEvent` exists, `pointerType` is `"touch"`, `maxTouchPoints` is
5, and `(pointer: coarse)` / `(hover: none)` both match.
---
## Decision 2 — there is no double-tap, and the number is why
#63 asks for *single tap → play* **and** *double tap → context menu*.
Those two cannot both be honoured. The first tap of a double tap is
indistinguishable from a single tap until the interval expires, so
"tap plays" necessarily becomes "tap waits to find out whether you
meant something else, then plays". The app already owns that constant:
`utils/explore-link.ts` holds a navigation for `DOUBLE_CLICK_GRACE_MS
= 250` for precisely this reason.
**What it would be added to, measured on the device.** Six runs, from
the play command to the backend's `TrackChanged`:
```
155, 123, 85, 56, 91 ms median ~100
```
So the app's primary interaction is ~100ms, and a double-tap
discriminator makes it ~350 — **3.5x, of which 250ms is spent
deliberately doing nothing** — paid on every track anyone ever plays,
in order to reach a menu.
It is also against the platform's convention, which counts for more
than usual here because this is the phone build and nothing else:
long-press is *how you select* on Android (Gmail, Files, Photos),
double-tap is zoom or nothing, and a list's menu is either the
long-press sheet or a per-row overflow.
**So the menu and the selection action bar become the same surface**,
which is the convention and removes a concept rather than adding one.
Long-press selects the row it was made on and raises the action bar;
the bar's actions *are* the context menu's actions, contextualised to
whatever is selected — one row or forty. #60's bottom sheet stays
behind it as the overflow, so `contextMenuStyles`, `MenuKeyboard` and
`menu-surface` are reused rather than reimplemented.
---
## Decision 3 — tap-to-play and selection mode ship together
The obvious phase order is "tap plays first, it is the smallest
change". It is wrong, and the reason is a capability that exists today
and is easy to miss.
**A touch user can already multi-select**: tap selects (the desktop
semantics, which a finger currently gets), and the long-press menu then
acts on the selection. Move tap to play without shipping selection mode
in the same change and there is a window — a release, if it lands — in
which selecting forty tracks to add to a playlist is impossible on a
phone. That is a regression dressed as an increment.
So phase 1 is both, or neither.
---
## What the code looks like now
| surface | how it binds | selection |
|---|---|---|
| `track-list` | delegated on the virtualizer: `click`, `dblclick`, `contextmenu`, `dragstart` | `SelectionController` |
| `queue-panel` | delegated, same shape | `SelectionController` |
| `playlist-details` | per row | `SelectionController` |
| `smart-playlist-details` | per row | `SelectionController` |
All four already share `SelectionController`, and all four resolve a
row from an event by `data-index` / `data-file-path` on the row. So the
gesture layer has one shape to talk to, and "selection mode" is a flag
on the controller they already have rather than a fifth concept.
`utils/long-press.ts` is one document-capture listener that synthesises
a `contextmenu` — the seam that needed no component to opt in. **This
plan keeps that shape and changes what the gesture means**, which is
why it is a rewrite of that file rather than a second listener set: two
document listeners both claiming the 500ms hold is the fault the file's
own header warns about.
---
## Two measurements that decide the implementation
**`touch-action` is `auto` on both the virtualizer and the rows.** With
`auto` the browser owns panning on both axes, so a horizontal drag can
be claimed as a scroll and our gesture ends in `pointercancel`
mid-swipe. A row that wants a horizontal swipe has to declare
`touch-action: pan-y`: the browser keeps the vertical pan (which is the
virtualizer's scroll, and must stay native or the list stutters) and
hands us the horizontal axis. This is the single most likely way for
swipe-to-queue to "work in Chromium and not on the phone".
**The row is 424x52 on the device**, so a swipe threshold in px is a
fraction of a row height, not of a screen.
**And the third one was found by building phase 1 and then running it**
— it is not something any browser tier can report. Chrome 113's Android
WebView **fires its own `contextmenu` on a long press**. `long-press.ts`
stood down when a trusted one arrived, which was right while both paths
ended in the same place; once a hold can mean selection mode they end
in different places, and standing down means the gesture silently does
the *old* thing. Measured, before the fix:
```
{"log":["contextmenu isTrusted=true"],
"state":{"bar":null,"menuActive":true,"selected":1}}
```
`yj-long-press` was never announced at all, the context menu opened,
and all 26 tests in the component tier passed — dispatched pointer
events do not make a browser synthesise a `contextmenu`.
So the browser's event is a **trigger, not a competitor**: the gesture
is announced from it, and only a component that claims it suppresses
the native menu. Unclaimed, it propagates untouched. That is the same
"browser wins" outcome, reached by asking instead of assuming — and
verified both ways on the device, a track row entering selection mode
and an album card still opening its menu.
The tier could not *find* it and can *hold* it: a test cannot dispatch
a trusted event, but this module has always told its own apart by
identity rather than `isTrusted`, so an untrusted one from a test takes
exactly the browser's path.
---
## A tier note: this one can be driven, not only measured
`adb shell input tap|swipe` reaches the WebView as real pointer events,
which the log above is evidence of. So for the first time the Android
tier can *perform* the thing under test rather than describe the page
afterwards — a long press is `input swipe X Y X Y 600`, a swipe right
is `input swipe X Y X+N Y 120`.
Device CSS pixels from device pixels, on this phone:
`css = (device - 59) / 2.564` vertically, `css = device / 2.564`
horizontally (measured from the tap above: 200,300 arrived as 78,94).
This does not make the device a spec tier — it does not run in CI and
`make ui-test` still has to carry the assertions. It makes "does the
gesture actually fire on Chrome 113" answerable in seconds.
---
## Phases
**Phase 1 — the seam, tap-to-play, selection mode.** `utils/
touch-gestures.ts` replacing `long-press.ts`: pointer-typed
recognition of tap / long-press / horizontal swipe, dispatched as
composed custom events so a delegated listener in any shadow root
still works. `SelectionController` gains a mode. `track-list` acts on
tap and enters the mode on long press. The action bar.
**Phase 2 — swipe right to queue**, with the `touch-action: pan-y`
finding above and a reveal-and-snap affordance.
**Phase 3 — the other three surfaces**, which is mostly wiring, since
they already share the controller.
**Phase 4 — what this leaves behind.** The inline `explore-link`s in a
row are a single-click target inside a row whose single tap now plays;
that conflict is #67's, and this plan should not pre-empt its answer
beyond making tap-to-play win on touch.
---
## Open questions
1. **Does selection mode have an escape other than the bar's own
close?** Back is the platform's answer and the shell already owns
the history stack (#6/#55). Pushing an entry for a *mode* rather
than a place is the same argument #55 settled for the overlaid
queue, and it should probably be settled the same way — but the
queue is a screen and a selection mode is not, so it wants its own
paragraph rather than an assumption.
2. **Does a tap on a row's favourite icon still toggle it in normal
mode?** It is inside the row and the row now plays. It has to keep
working — it is a 44px target since #56 — so the gesture layer needs
the same "a control inside the row wins" rule the keyboard service
has for a focused control that owns a key.
-127
View File
@@ -1,127 +0,0 @@
import { test, expect } from '../support/fixtures.js';
/**
* Long-press is the touch route to a context menu (plan 016 B2 phase 3).
*
* The component tier proves the gesture in isolation, against markup it
* built itself. What it cannot prove is the half that made this one
* listener instead of six: that the synthetic event reaches the handler
* a *real* component bound — `track-list` delegates its `contextmenu`
* on the `lit-virtualizer` rather than binding one per row — and that
* the real `wa-popup` menu opens from it, which is a path with its own
* history of opening and then refusing to work (see
* `menu-keyboard.spec.ts`).
*
* The pointer events are dispatched rather than performed: this project
* runs Desktop Chrome and Desktop Safari, neither of which has touch,
* and a device tier does not exist. So this is honest about what it
* checks — the app's own listeners, on the app's own DOM, from the
* events a touch would produce — and not about a real finger.
*/
/** A common small phone, as in `phone-shell.spec.ts`. */
const PHONE = { width: 390, height: 844 };
/** Comfortably past the module's 500ms hold. */
const HELD = 900;
type Page = import('@playwright/test').Page;
/** The track list's menu panel, or null while it is not rendered. */
const panel = (page: Page) =>
page.evaluate(() => {
const el = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.context-menu-panel');
if (!el) return null;
return {
role: el.getAttribute('role'),
label: el.getAttribute('aria-label'),
items: el.querySelectorAll('[role="menuitem"]').length,
};
});
/**
* Press the first track row, optionally dragging partway through — the
* shape of a scroll that begins on a row, which must not open a menu.
*/
async function pressFirstRow(
page: Page,
opts: { driftY?: number } = {},
): Promise<void> {
await page.evaluate((drift) => {
// `.track-row`, not `[role="row"]`: the column header is a row too,
// and it is the *first* one -- a press on it is correctly ignored,
// which reads exactly like the gesture not working.
const row = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.track-row');
if (!row) throw new Error('no track row to press');
const box = row.getBoundingClientRect();
const x = Math.round(box.left + box.width / 2);
const y = Math.round(box.top + box.height / 2);
const send = (type: string, dy = 0) =>
row.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: x,
clientY: y + dy,
}),
);
send('pointerdown');
if (drift) send('pointermove', drift);
}, opts.driftY ?? 0);
}
test.describe('long-press opens the track menu', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test.afterEach(async ({ app }) => {
// Every other spec file runs against a desktop, and the viewport
// belongs to the shared context rather than to this file.
await app.setViewportSize({ width: 1440, height: 900 });
});
test('reaches the delegated handler and opens the real menu', async ({
app,
}) => {
await expect.poll(() => panel(app)).toBeNull();
await pressFirstRow(app);
await expect
.poll(() => panel(app), { timeout: HELD + 2000 })
.toMatchObject({ role: 'menu', label: 'Track actions' });
// The same panel Shift+F10 opens, items and all -- not an empty
// popup that happened to become visible.
expect((await panel(app))?.items).toBeGreaterThan(0);
});
test('does not open one for a press that turns into a scroll', async ({
app,
}) => {
await pressFirstRow(app, { driftY: 40 });
await app.waitForTimeout(HELD);
expect(await panel(app)).toBeNull();
});
});
+194
View File
@@ -0,0 +1,194 @@
import { test, expect } from '../support/fixtures.js';
/**
* The touch gestures against the real app (plan 019, #63; long-press
* from plan 016 B2).
*
* The component tier proves the gestures in isolation, against markup
* it built itself. What it cannot prove is the half that made this one
* document listener instead of six: that the announced gesture reaches
* the handler a *real* component bound — `track-list` delegates on the
* `lit-virtualizer` rather than binding per row — and that the real
* menu opens from it, a path with its own history of opening and then
* refusing to work (see `menu-keyboard.spec.ts`).
*
* **Both halves of the reassignment are here, and the second is the
* one that matters.** #63 makes a hold on a *track row* mean selection
* mode; every other surface in the app keeps the context menu it has
* had, because an unclaimed `yj-long-press` still becomes a
* `contextmenu`. A spec that only checked the row would pass on a
* build that had silently broken the other thirteen menus.
*
* The pointer events are dispatched rather than performed: this
* project runs Desktop Chrome and Desktop Safari, neither of which has
* touch. So this is honest about what it checks — the app's own
* listeners, on the app's own DOM, from the events a touch would
* produce — and not about a real finger. The finger is the Android
* tier, and it found something this cannot see: Chrome 113's WebView
* fires its own `contextmenu` on a long press, which is why the module
* announces the gesture from a native event rather than standing down.
*/
/** A common small phone, as in `phone-shell.spec.ts`. */
const PHONE = { width: 390, height: 844 };
/** Comfortably past the module's 500ms hold. */
const HELD = 900;
type Page = import('@playwright/test').Page;
/** A component's menu panel, or null while it is not rendered. */
const panel = (page: Page, host: string) =>
page.evaluate((tag) => {
const el = document
.querySelector(tag)
?.shadowRoot?.querySelector('.context-menu-panel');
if (!el) return null;
return {
role: el.getAttribute('role'),
label: el.getAttribute('aria-label'),
items: el.querySelectorAll('[role="menuitem"]').length,
};
}, host);
/** How many tracks the selection bar says are selected, or null. */
const selectionCount = (page: Page) =>
page.evaluate(() => {
const bar = document
.querySelector('track-list')
?.shadowRoot?.querySelector('selection-bar');
return bar ? (bar as unknown as { count: number }).count : null;
});
/**
* Press an element, optionally dragging partway through — the shape of
* a scroll that begins on a row, which must be neither gesture — and
* optionally lifting, which is what makes it a tap rather than a hold.
*/
async function press(
page: Page,
selector: { host: string; inner: string },
opts: { driftY?: number; lift?: boolean } = {},
): Promise<void> {
await page.evaluate(
({ host, inner, drift, lift }) => {
const el = document
.querySelector(host)
?.shadowRoot?.querySelector(inner);
if (!el) throw new Error(`no ${inner} in ${host} to press`);
const box = el.getBoundingClientRect();
const x = Math.round(box.left + box.width / 2);
const y = Math.round(box.top + box.height / 2);
const send = (type: string, dy = 0) =>
el.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: x,
clientY: y + dy,
}),
);
send('pointerdown');
if (drift) send('pointermove', drift);
if (lift) send('pointerup');
},
{
host: selector.host,
inner: selector.inner,
drift: opts.driftY ?? 0,
lift: opts.lift ?? false,
},
);
}
// `.track-row`, not `[role="row"]`: the column header is a row too, and
// it is the *first* one — a press on it is correctly ignored, which
// reads exactly like the gesture not working.
const TRACK_ROW = { host: 'track-list', inner: '.track-row' };
test.describe('a hold on a track row selects it', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test.afterEach(async ({ app }) => {
// Every other spec file runs against a desktop, and the viewport
// belongs to the shared context rather than to this file.
await app.setViewportSize({ width: 1440, height: 900 });
});
test('raises the selection bar rather than the context menu', async ({
app,
}) => {
await expect.poll(() => selectionCount(app)).toBeNull();
await press(app, TRACK_ROW);
await expect
.poll(() => selectionCount(app), { timeout: HELD + 2000 })
.toBe(1);
// The gesture is claimed, so the menu this hold used to open must
// not also be up -- on a phone that would be a sheet over the bar.
expect(await panel(app, 'track-list')).toBeNull();
});
test('is neither gesture when the press turns into a scroll', async ({
app,
}) => {
await press(app, TRACK_ROW, { driftY: 40 });
await app.waitForTimeout(HELD);
expect(await selectionCount(app)).toBeNull();
expect(await panel(app, 'track-list')).toBeNull();
});
});
test.describe('a hold anywhere else still opens the menu', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-albums').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'albums',
);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
test('reaches the delegated handler and opens the real menu', async ({
app,
}) => {
// The property that let #63 reassign the hold without touching one
// of the fourteen context menus: unclaimed, it is what it was.
// Without this half, breaking all of them passes the suite.
await expect.poll(() => panel(app, 'cover-grid')).toBeNull();
await press(app, { host: 'cover-grid', inner: '[role="option"]' });
await expect
.poll(() => panel(app, 'cover-grid'), { timeout: HELD + 2000 })
.toMatchObject({ role: 'menu' });
// The same panel Shift+F10 opens, items and all -- not an empty
// popup that happened to become visible.
expect((await panel(app, 'cover-grid'))?.items).toBeGreaterThan(0);
});
});
+8 -5
View File
@@ -70,7 +70,7 @@ import '@store/theme-store';
// registers the document keydown listener for global shortcuts.
import './src/services/keyboard-shortcut-service';
import { activateView, deactivateView } from '@utils/view-lifecycle';
import { installLongPressContextMenu } from '@utils/long-press';
import { installTouchGestures } from '@utils/touch-gestures';
import { openQueue, queuePanelElement } from '@utils/open-queue';
import { installTopBarFit } from './src/services/top-bar-fit';
import {
@@ -87,10 +87,13 @@ setBasePath('/dist/webawesome');
// the session.
registerBundledIcons();
// The touch equivalent of a right-click, installed once for every menu
// in the app rather than per component. Harmless on a desktop: it acts
// on `pointerType === 'touch'` only.
installLongPressContextMenu();
// Every touch gesture in the app, installed once rather than per
// component (plan 019). Harmless on a desktop: it acts on
// `pointerType === 'touch'` only, per event, so a mouse on a
// touchscreen keeps click-selects / double-click-plays on the very
// same row. An unclaimed long press still becomes a `contextmenu`,
// which is what leaves all fourteen menus untouched by #63.
installTouchGestures();
// The top bar decides what it can afford to show (#143). Here rather
// than in a component because the bar is light DOM in index.html and
@@ -14,6 +14,7 @@ import {
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/cover-grid/cover-grid.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
@customElement('artist-details')
export class ArtistDetails extends LitElement {
@@ -40,7 +41,7 @@ export class ArtistDetails extends LitElement {
/** Tracks the store's cached array reference to detect refreshes. */
private lastAlbumsRef: library.Album[] | null = null;
static override styles = [designTokens, css`
static override styles = [designTokens, backButton, css`
:host {
display: flex;
flex-direction: column;
@@ -66,31 +67,6 @@ export class ArtistDetails extends LitElement {
);
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon {
font-size: 16px; /* back button — outside type scale */
}
@@ -250,13 +250,20 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
}
/* Collapsible-section toggle used in the Pending header —
transparent button that inherits the header's type. */
transparent button that inherits the header's type.
187x**15** before this (#186), which was the smallest
control measured anywhere in the app until the column
arrows were counted. It is transparent and full-width
already, so the floor costs it a height and nothing
else. */
.section-toggle {
display: flex;
align-items: center;
gap: 0.35rem;
flex: 1;
min-width: 0;
min-block-size: 44px;
padding: 0;
background: transparent;
border: 0;
@@ -274,6 +281,8 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
color: var(--yj-text-tertiary, #888);
}
/* 32x18, and it has no background until hover -- so the
padding out to a square target is invisible (#186). */
.folders-menu-trigger {
background: transparent;
border: 0;
@@ -281,6 +290,8 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
font-size: 1.1rem;
line-height: 1;
padding: 0.1rem 0.4rem;
min-inline-size: 44px;
min-block-size: 44px;
border-radius: 3px;
cursor: pointer;
}
@@ -293,7 +304,10 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
.folders-refresh-trigger {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
min-inline-size: 44px;
min-block-size: 44px;
}
.folders-refresh-trigger:disabled {
@@ -85,6 +85,21 @@ export class ConfigField extends LitElement {
gap: 0.5em;
}
/* Every control here meets the app's 44px touch floor (#186).
This is the shape every row in Settings uses, so it is the
one rule that covers the most controls -- and it is the
*cheapest* place to reach the floor, because there is no
overflow fit on this page. The page header's had one (#69),
which is why that pass had to grow padding and hand the
width back with a negative margin; here the control is a
block in a column and a taller box costs nothing but the
height it takes.
Measured on the reference device before this: the select
335x30, the text and number inputs the same, the browse
button 30 tall, the colour swatch 33x33 and the toggle
**34x19**. */
input[type='text'],
input[type='number'] {
background: var(--yj-bg-elevated, #343a40);
@@ -95,6 +110,7 @@ export class ConfigField extends LitElement {
font-size: 0.85em;
font-family: inherit;
min-width: 0;
min-block-size: 44px;
flex: 1;
}
@@ -117,6 +133,7 @@ export class ConfigField extends LitElement {
font-size: 0.85em;
font-family: inherit;
cursor: pointer;
min-block-size: 44px;
flex: 1;
}
@@ -139,6 +156,7 @@ export class ConfigField extends LitElement {
font-size: 0.85em;
cursor: pointer;
white-space: nowrap;
min-block-size: 44px;
}
button:hover {
@@ -158,8 +176,12 @@ export class ConfigField extends LitElement {
}
input[type='color'] {
width: 2.5em;
height: 2.5em;
/* border-box, or the 2px border makes this 48 and the
assertion below reads as passing by four pixels of
border rather than by the rule. */
box-sizing: border-box;
width: 44px;
height: 44px;
border: 2px solid var(--yj-border, #444);
border-radius: 4px;
padding: 0;
@@ -187,12 +209,32 @@ export class ConfigField extends LitElement {
display: flex;
align-items: center;
justify-content: space-between;
min-block-size: 44px;
}
/* The toggle is the one control here whose target and paint
must differ, and it is also the one no sweep can see.
Its <input> is opacity: 0; width: 0; height: 0, so a
walk of every input on the page skips it as a zero-sized
node -- the thing a finger actually hits is this <label>,
which measured **34x19**. That is smaller than anything in
#186's original table and it is absent from it for exactly
that reason.
A 44px pill is not what a switch should look like, so the
box is 44px and the paint is not: .toggle-slider is a
2.5em x 1.4em child centred in it rather than an absolute
fill. The negative inline margins hand the extra width back
to the layout, so the pill stays flush with the right edge
of the inputs in the rows above it -- the header pass's
shape, used here for alignment rather than for a fit. */
.toggle-switch {
position: relative;
width: 2.5em;
height: 1.4em;
display: grid;
place-items: center;
inline-size: 44px;
block-size: 44px;
margin-inline: calc((2.5em - 44px) / 2);
}
.toggle-switch input {
@@ -202,9 +244,10 @@ export class ConfigField extends LitElement {
}
.toggle-slider {
position: absolute;
position: relative;
cursor: pointer;
inset: 0;
inline-size: 2.5em;
block-size: 1.4em;
background: var(--yj-bg-overlay, #495057);
border-radius: 1em;
transition: background 0.2s;
@@ -176,6 +176,13 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
cursor: pointer;
transition: background-color 0.15s ease;
white-space: nowrap;
/* The app's 44px touch floor (#56, #186), stated once for
all 41 buttons this page renders rather than per class.
Height is free here: Settings has no overflow fit, so
the header's "only width is contested" rule does not
bind, and the two classes that need more than a height
say so below. */
min-block-size: 44px;
}
button:disabled {
@@ -509,11 +516,24 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
margin: 0;
}
/* The two column lists are the densest thing in the app, and
the density argument is why they are shaped the way they
are rather than simply grown (#186).
Measured on the reference device: the row was already
335x36 -- it is the controls *inside* it that were 16x16 and
**16x14**, the smallest anywhere in this app, 36 of them.
So the fix grows the controls into the row they already
occupy and only takes the row from 36 to 44, which over the
two lists (10 and 19 items) is 232px of extra scroll on a
439px screen. Growing each control to its own 44px row
instead would have cost four screens. */
.column-item {
display: flex;
align-items: center;
align-items: stretch;
gap: 0.5em;
padding: 0.5em 0.75em;
padding: 0 0.75em;
min-block-size: 44px;
border-bottom: 1px solid
var(--yj-border-subtle, #333);
font-size: 0.85em;
@@ -531,8 +551,19 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
color: var(--yj-text-tertiary, #888);
}
/* A native checkbox cannot grow its hit area without growing
its paint, and a 44px checkbox is not what anyone wants. So
the target is the label instead: .column-label is a real
<label for> now, which makes the column's *name* the thing
you tap -- ~250x44 rather than 16x16.
That is the argument config-field already makes one file
over for its own labels: "a real label association also
makes the label text a click target for the control, which
is behaviour, not annotation". Here it is the whole fix. */
.column-toggle {
cursor: pointer;
align-self: center;
accent-color: var(
--yj-accent,
#ffd43b
@@ -541,20 +572,32 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
.column-label {
flex: 1;
display: flex;
align-items: center;
cursor: pointer;
min-block-size: 44px;
}
.view-note {
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-font-size-sm, 0.85rem);
margin-left: auto;
/* The row stretches its children so the label can be a
full-height target; this is text, not a target. */
align-self: center;
}
.column-arrows {
display: flex;
align-items: stretch;
gap: 0.15em;
margin-left: auto;
}
/* 16x14 before this, and they carry background: none and a
transparent border -- so padding out to 44px grows the
target and changes nothing anyone can see until hover,
which is precisely what #186's Direction asks for. */
.column-arrow-btn {
background: none;
border: 1px solid transparent;
@@ -564,6 +607,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
font-size: 0.65em;
line-height: 1;
padding: 0.2em 0.35em;
min-inline-size: 44px;
min-block-size: 44px;
transition:
color 0.15s,
border-color 0.15s;
@@ -711,6 +756,11 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
padding: 0.2em 0.4em;
letter-spacing: 2px;
border-radius: 4px;
/* Square, so it needs the width too -- the shared rule
above only gives it a height. It was 31x31, and it is
the only route to "Remove library", which is the case
#55 settled one component over: the way out is 44px. */
min-inline-size: 44px;
}
.overflow-btn:hover {
@@ -2003,6 +2053,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
class="column-item ${checked ? 'enabled' : 'disabled'}"
>
<input
id="view-${v.id}"
type="checkbox"
class="column-toggle"
aria-label="Show ${v.label} in the navigation"
@@ -2014,9 +2065,9 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
(e.target as HTMLInputElement).checked,
)}
/>
<span class="column-label">
<label class="column-label" for="view-${v.id}">
${v.label}
</span>
</label>
${note
? html`<span class="view-note">${note}</span>`
: nothing}
@@ -2212,6 +2263,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
class="column-item ${checked ? 'enabled' : 'disabled'}"
>
<input
id="column-${id}"
type="checkbox"
class="column-toggle"
aria-label="Show the ${columnLabel} column"
@@ -2222,11 +2274,12 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
id,
)}
/>
<span
<label
class="column-label"
for="column-${id}"
>
${columnLabel}
</span>
</label>
<span
class="column-arrows"
>
@@ -8,6 +8,7 @@ import '@awesome.me/webawesome/dist/components/switch/switch.js';
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
import '@awesome.me/webawesome/dist/components/callout/callout.js';
import { designTokens } from '../../styles/tokens.css';
import { waTouchFloor } from '../../styles/wa-touch-floor.css';
import type {
DownloadDescriptor,
DownloadProvider,
@@ -136,6 +137,7 @@ export class DownloadClients extends LitElement {
static override styles = [
designTokens,
waTouchFloor,
css`
:host {
display: block;
@@ -239,12 +241,17 @@ export class DownloadClients extends LitElement {
margin-top: 0.4em;
}
/* The checkbox is 16x16 and cannot grow without becoming
a 44px checkbox, but it is already wrapped in the label
that names it -- so the label is the target and only
needs the height (#186). Eight of them. */
.format-option {
display: flex;
align-items: center;
gap: 0.4em;
font-size: 0.9em;
cursor: pointer;
min-block-size: 44px;
}
`,
];
@@ -25,6 +25,11 @@ export class ShortcutCapture extends LitElement {
:host {
display: inline-block;
}
/* 80x25, twenty-six of them -- the most numerous control on
the Settings page after the column lists (#186). The floor
is a height here and nothing else: the width was already
past it, and the type stays where it is so a shortcut still
reads as a key rather than as a button. */
button {
font-family: inherit;
font-size: var(--yj-text-sm, 13px);
@@ -35,6 +40,7 @@ export class ShortcutCapture extends LitElement {
color: var(--yj-text-primary, #eee);
cursor: pointer;
min-width: 80px;
min-height: 44px;
text-align: center;
transition:
border-color 0.15s,
@@ -61,6 +67,11 @@ export class ShortcutCapture extends LitElement {
opacity: 0.7;
}
}
/* Reset renders only for a rebound shortcut, so a sweep of a
freshly-installed app never sees it -- it is not in #186's
tables for that reason, and it is a touch target the moment
anybody uses the feature. It also has no background, so the
padding out to 44px is invisible. */
.reset-btn {
font-size: var(--yj-text-xs, 11px);
padding: 2px 6px;
@@ -69,7 +80,8 @@ export class ShortcutCapture extends LitElement {
background: transparent;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
min-width: auto;
min-width: 44px;
min-height: 44px;
opacity: 0;
transition: opacity 0.15s;
}
@@ -93,8 +93,26 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
border-bottom: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
}
/* 85x34 and 96x34 before this (#186). A tab is the only
route to the panel it names, so it is the last control
that should be hard to hit -- and the underline that
marks the active one is drawn on the bottom border,
which a taller box moves further from the label. So the
height goes on *padding*, keeping the border against
the label rather than 10px below a centred one.
The min-size is the floor and is not redundant: padding
alone made this 44px here and **43px in CI**, because
the total is 13 + 13 + 2 + whatever line box the font
gives 13px text, and ubuntu:24.04's is a pixel shorter
than this machine's. A height computed from a font's
line box is not a height you control -- the same
mistake #195 made about a layout property measured on
one engine, one layer down, and caught here by the test
rather than by a person. */
.tab {
padding: 8px 14px;
min-block-size: 44px;
padding: 13px 14px;
font-size: 13px;
font-weight: 600;
color: var(--yj-text-secondary, #b3b3b3);
@@ -2,6 +2,7 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { srOnly } from '../../styles/sr-only.css';
import { unownedLabel, unownedStyles } from '@utils/ownership';
import {
@@ -355,6 +356,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
static override styles = [
designTokens,
backButton,
exploreLinkStyles,
contextMenuStyles,
srOnly,
@@ -379,25 +381,6 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
}
.back-button wa-icon {
font-size: 16px;
}
@@ -3,6 +3,7 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import {
LookupArtist,
BrowseReleaseGroups,
@@ -266,6 +267,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
static override styles = [
designTokens,
backButton,
exploreLinkStyles,
contextMenuStyles,
unownedStyles,
@@ -289,25 +291,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
}
.back-button wa-icon {
font-size: 16px;
}
@@ -256,15 +256,18 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
margin-bottom: 10px;
}
/* 89x26 and 79x26 before this (#186). */
.search-mode-tab {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: none;
border: 1px solid transparent;
border-radius: 6px;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
min-block-size: 44px;
padding: 5px 12px;
font-size: var(--yj-text-sm);
font-family: inherit;
@@ -289,7 +292,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
border-radius: 6px;
padding: 0 12px;
gap: 8px;
height: 36px;
min-height: 44px;
max-width: 520px;
transition: border-color 0.15s ease;
}
@@ -364,8 +367,15 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
flex-shrink: 0;
}
/* The input measured 325x**18** and the box around it 36,
which is two faults rather than one (#186): the row was
under the floor, and the input did not fill it, so eight
of those pixels were not a target at all. The container
is 44 and the input stretches to it -- a tap anywhere in
the box now lands on the input rather than beside it. */
input {
flex: 1;
align-self: stretch;
background: none;
border: none;
outline: none;
@@ -379,15 +389,21 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
color: var(--yj-text-tertiary, #888);
}
/* No background until hover, so the target grows and the
glyph does not. It is inside a 44px box already, hence
the width alone. */
.clear-button {
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
background: none;
border: none;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
padding: 0;
min-inline-size: 44px;
margin-inline-end: -12px;
font-size: var(--yj-text-sm);
flex-shrink: 0;
}
@@ -15,6 +15,7 @@ import { describeError } from '@utils/describe-error';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/track-list/track-list.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { list } from '@utils/binding';
@customElement('genre-details')
@@ -37,7 +38,7 @@ export class GenreDetails extends LitElement {
private scanCompleteCleanup: (() => void) | null =
null;
static override styles = [designTokens, css`
static override styles = [designTokens, backButton, css`
:host {
display: flex;
flex-direction: column;
@@ -77,31 +78,6 @@ export class GenreDetails extends LitElement {
);
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon {
font-size: 16px; /* back button — outside type scale */
}
@@ -166,7 +166,7 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
* gated on the device having hover rather than on width. A
* touch long-press synthesises a hover state in the WebView,
* so on a phone it flashed into view during the 500ms hold
* that utils/long-press.ts is measuring for a context menu
* that utils/touch-gestures.ts is measuring for a long press
* a control appearing because you were reaching for a
* different one. A phone user taps the album and plays from
* the detail view, so there is nothing to replace it with.
@@ -23,8 +23,14 @@ export class LibraryFilter extends LitElement {
align-items: center;
}
/* 120x32 on the reference device (#186). This control has two
placements since #57 -- the desktop top bar and Settings ->
Libraries -- and it is the only route to setSelectedLibrary
in either, so it is one of the controls #148 argued must not
simply be taken away. It is one component, so it reaches the
floor in one place. */
select {
height: 32px;
min-height: 44px;
padding: 0 8px;
border-radius: 6px;
border: 1px solid
@@ -298,6 +298,47 @@ export class PageHeader extends LitElement {
flex-shrink: 0;
}
/* Every control in this header meets the app's 44px touch
floor -- the number #56 set for the transport and the
queue header already keeps (#186).
It is min-size rather than padding with a negative
margin, which is what the seek bar needed (#187), and
the difference is worth stating because it decides
whether targets can collide. There the painted track had
to stay thin, so the target was grown past its own box
and had to be checked against its neighbours. Here the
control *is* the target: the boxes are flex items, so
the gap keeps them apart and no two can overlap by
construction.
There is no phone branch. With the target being the box,
a 44px control on a desktop is merely large, and a
second declaration of what a phone shows is a second
thing to keep in step -- which is the reason this
component has never had one. It also avoids a media
query that no tier here renders, which is exactly how
the seek bar's phone rule came to be dead for months.
**The height is the box and the width is not**, and that
asymmetry is the whole of what the overflow fit below
cares about. That pass measures inline size, so a taller
control costs it nothing and a wider one costs it
directly. Growing the two square controls to 44px wide
added 22px, which fits at every width Chromium was
checked at and clipped the overflow trigger at 320px in
**WebKit** -- the engine closest to what actually ships,
and the one no machine here can run. So the horizontal
half is padding with the margin cancelling it, which is
what the issue asked for in the first place: the target
grows and the layout does not.
The cost is that a horizontal target can now overlap a
neighbour, which the box version could not. The arrow's
is deliberately lopsided for the seek bar's reason
(#187): the select is 6px to its left and there is open
space to its right, so it takes the side with nothing to
steal from. */
.sort select {
font: inherit;
color: inherit;
@@ -306,6 +347,7 @@ export class PageHeader extends LitElement {
border-radius: 4px;
padding: 3px 6px;
cursor: pointer;
min-block-size: 44px;
}
.sort-dir {
@@ -318,6 +360,18 @@ export class PageHeader extends LitElement {
color: inherit;
cursor: pointer;
padding: 3px 5px;
/* 28x21 before this, the smallest control in the
header and the only one that failed the floor in
both directions.
Vertically the box grows, because the header has the
room and nothing measures it. Horizontally the box
must not: 28 + 2 + 14 is a 44px target over a 28px
layout box, weighted right because the select is 6px
to the left. */
min-block-size: 44px;
padding-inline: 5px 21px;
margin-inline: 0 -16px;
}
.sort-dir:hover {
@@ -377,10 +431,20 @@ export class PageHeader extends LitElement {
gap: 6px;
white-space: nowrap;
flex-shrink: 0;
justify-content: center;
min-block-size: 44px;
}
.more-button {
padding: 6px 10px;
/* 38x27, and it is the route to every collapsed
action, so it is the last control that should be
hard to hit -- and the one WebKit clipped at 320px
when this was 6px wider as a box. 38 + 3 + 3 is a
44px target over a 38px layout box; the actions row
has an 8px gap, so this one can be symmetric. */
padding-inline: 13px;
margin-inline: -3px;
}
/* The display: flex above outranks the UA stylesheet's
@@ -71,6 +71,7 @@ import {
exploreLinkStyles,
} from '@utils/explore-link';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { list } from '@utils/binding';
import {
ICON_PLAYLIST,
@@ -954,6 +955,7 @@ export class PlaylistDetails
static override styles = [
designTokens,
backButton,
contextMenuStyles,
exploreLinkStyles,
css`
@@ -981,31 +983,6 @@ export class PlaylistDetails
);
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon {
font-size: 16px;
}
@@ -61,11 +61,32 @@ export class SearchTrigger extends LitElement {
display: inline-flex;
align-items: center;
justify-content: center;
/* The smallest a touch target should be. The header's
own action buttons are smaller because they carry a
label; this one is a glyph. */
min-width: 40px;
min-height: 40px;
/* The app's touch floor, from #56 -- and this is the
control that should least have to argue for it: #57
created it as the phone's replacement for the header
search box, so it exists *only* where there is a
thumb.
It shipped at 40px under a comment calling that "the
smallest a touch target should be", which was the
floor being restated four pixels short rather than a
second opinion about it (#186). The rest of that
comment said the header's own action buttons are
smaller because they carry a label; they are 44px
now too, so that no longer distinguishes anything.
The extra width is a target rather than a box, for
page-header's reason: this button sits in that
header, whose overflow fit (#69) measures inline
size, and four pixels there is four pixels the
trigger for every collapsed action does not get at
320px. Height is free -- nothing measures it. */
min-width: 44px;
min-height: 44px;
/* Border-box, so the 44 above is the whole target and
the margin is what hands the four extra pixels back
to the row. */
margin-inline: -2px;
padding: 0;
background: none;
border: 1px solid var(--yj-border-subtle, #555);
@@ -0,0 +1,176 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import { ICON_MORE_ACTIONS } from '@utils/icon-language';
/**
* What a selection can have done to it, while a finger is holding one.
*
* This is the context menu (plan 019, #63). Not a second surface
* beside it — the same actions, contextualised to whatever is
* selected, in the shape Android puts them in.
*
* #63 asked for a double tap to open the menu instead. That mapping
* costs the app's primary interaction 250ms on every play, because the
* first tap of a double tap is indistinguishable from a single tap
* until the interval expires, and playing a track is ~100ms end to end
* on the reference device. So there is no double tap: a long press
* selects, this says what can be done, and the sheet behind "More" is
* the same `menu-surface` every other menu in the app opens.
*
* Four things about it are load-bearing.
*
* **It is presentational.** It takes a count and a list of actions and
* emits `selection-action` / `selection-exit`; it holds no selection
* and calls no store. The host already owns a `SelectionController`
* and an action handler, and a bar that reached for either would be a
* second definition of what "play the selection" means — the fault
* `utils/library-status.ts` exists to have fixed one feature over.
*
* **The bar is only what fits, and "More" is the rest.** A context
* menu can be nine items because it is a sheet; a bar is one row on a
* 424px screen. So the host passes the two or three worth a thumb and
* the overflow opens the menu it already renders, which is what keeps
* every action reachable at every size — plan 018's promise, and the
* reason this cannot simply drop the long tail.
*
* **Its controls are 44px** (#56, #186), and the count is a live
* region: the number changes under the user's finger as they tap rows,
* and nothing else on screen announces it.
*
* **It renders nothing at zero.** The mode ends when the last row is
* deselected — `SelectionController.toggleInMode` is where that is
* decided — so a bar with a count of none is a state this should never
* be asked to draw, and drawing it anyway would hide the fact that it
* has been.
*/
export interface SelectionAction {
id: string;
label: string;
icon: string;
danger?: boolean;
}
@customElement('selection-bar')
export class SelectionBar extends LitElement {
static override styles = [
designTokens,
css`
:host {
display: block;
}
.bar {
display: flex;
align-items: center;
gap: 0.25em;
padding: 0.25em 0.5em;
background: var(--yj-bg-elevated, #343a40);
border-top: 1px solid var(--yj-border-subtle, #333);
}
.count {
flex: 1;
min-width: 0;
font-size: var(--yj-text-md);
font-weight: 600;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 44px, from #56 and #186 -- this is a bar a thumb uses. */
button {
display: flex;
align-items: center;
justify-content: center;
min-inline-size: 44px;
min-block-size: 44px;
padding: 0 0.5em;
border: none;
border-radius: 4px;
background: none;
color: var(--yj-text-primary, #fff);
font-family: inherit;
font-size: var(--yj-text-md);
cursor: pointer;
}
button:hover {
background: var(--yj-bg-overlay, #495057);
}
button.danger {
color: var(--yj-error-text, #ff8787);
}
`,
];
/** How many items are selected. Zero renders nothing. */
@property({ type: Number }) count = 0;
/** The actions worth a thumb. The rest live behind "More". */
@property({ attribute: false }) actions: SelectionAction[] = [];
private emit(name: string, detail?: unknown) {
this.dispatchEvent(
new CustomEvent(name, { detail, bubbles: true, composed: true }),
);
}
override render() {
if (this.count <= 0) return nothing;
const noun = this.count === 1 ? 'track' : 'tracks';
return html`
<div class="bar" role="toolbar" aria-label="Selection actions">
<button
aria-label="Leave selection"
@click=${() => this.emit('selection-exit')}
>
<wa-icon name="xmark"></wa-icon>
</button>
<span class="count" role="status" aria-live="polite">
${this.count.toLocaleString()} ${noun} selected
</span>
${this.actions.map(
(action) => html`
<button
class=${action.danger ? 'danger' : ''}
aria-label=${action.label}
title=${action.label}
@click=${() =>
this.emit('selection-action', { id: action.id })}
>
<wa-icon name=${action.icon}></wa-icon>
</button>
`,
)}
<button
aria-label="More actions"
@click=${(e: MouseEvent) => {
const box = (
e.currentTarget as HTMLElement
).getBoundingClientRect();
this.emit('selection-more', {
x: box.left,
y: box.top,
});
}}
>
<wa-icon name=${ICON_MORE_ACTIONS}></wa-icon>
</button>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'selection-bar': SelectionBar;
}
}
@@ -62,6 +62,7 @@ import {
} from '@utils/explore-link';
import '@components/smart-playlist-editor/smart-playlist-editor.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { list } from '@utils/binding';
import {
ICON_PLAYLIST,
@@ -242,6 +243,7 @@ export class SmartPlaylistDetails
static override styles = [
designTokens,
backButton,
contextMenuStyles,
exploreLinkStyles,
css`
@@ -269,31 +271,6 @@ export class SmartPlaylistDetails
);
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon {
font-size: 16px;
}
@@ -10,6 +10,9 @@ import {
} from 'lit/decorators.js';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import type { GestureEvent } from '@utils/touch-gestures';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { PHONE_QUERY } from '@utils/breakpoints';
import {
@@ -77,7 +80,9 @@ import '@components/playlist-picker/playlist-picker.js';
import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
import {
ICON_PLAY,
ICON_PLAYLIST,
ICON_PLAY_NEXT,
ICON_QUEUE,
} from '@utils/icon-language';
@@ -1304,6 +1309,8 @@ export class TrackList
virt.removeEventListener('click', this.onDelegatedClick);
virt.removeEventListener('dblclick', this.onDelegatedDblClick);
virt.removeEventListener('contextmenu', this.onDelegatedContextMenu);
virt.removeEventListener('yj-tap', this.onRowTap);
virt.removeEventListener('yj-long-press', this.onRowLongPress);
virt.removeEventListener('dragstart', this.onDelegatedDragStart);
virt.removeEventListener('dragend', this.onTrackDragEnd);
}
@@ -1445,6 +1452,11 @@ export class TrackList
virt.addEventListener('contextmenu', this.onDelegatedContextMenu);
virt.addEventListener('dragstart', this.onDelegatedDragStart);
virt.addEventListener('dragend', this.onTrackDragEnd);
// Delegated like the rest: the gesture layer dispatches on the
// element the finger landed on, composed, so it arrives here
// through the same path a real click takes (plan 019).
virt.addEventListener('yj-tap', this.onRowTap);
virt.addEventListener('yj-long-press', this.onRowLongPress);
this.delegationAttached = true;
}
@@ -1708,6 +1720,62 @@ export class TrackList
if (hit) this.onTrackContextMenu(e, hit.track);
};
/**
* A finger tapped a row (plan 019, #63).
*
* On a desktop a click selects and a double-click plays; a finger
* inverts that, because there is no second button and no modifier
* key, so the primary action has to be the primary gesture.
*
* Claiming the gesture (`preventDefault`) is what tells the layer
* to swallow the click behind it -- otherwise playing a track
* would also select it, and the row would end up in both states.
* A tap this does *not* claim falls through as an ordinary click,
* which is what keeps the favourite icon working.
*/
private onRowTap = (e: GestureEvent) => {
const hit = this.resolveTrackFromEvent(e);
if (!hit) return;
// A control inside the row owns its own tap. The same rule the
// shortcut service has for a focused control that owns a key,
// and without it the 44px favourite target (#56) becomes a
// 44px play target.
if ((e.target as HTMLElement).closest('.fav-icon')) return;
e.preventDefault();
this.focusedIndex = hit.index;
if (this.selection.selectionMode) {
this.selection.toggleInMode(hit.track.FilePath, hit.index);
this.virtualizer?.requestUpdate();
return;
}
this.playFromRow(hit.index);
};
/**
* A finger held a row still for half a second.
*
* Claiming this is what makes it *selection mode* rather than the
* context menu it has been since plan 016 -- an unclaimed
* `yj-long-press` still becomes a `contextmenu`, which is how the
* card grids and Explore keep the behaviour they have.
*/
private onRowLongPress = (e: GestureEvent) => {
const hit = this.resolveTrackFromEvent(e);
if (!hit) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.enterSelectionMode(hit.track.FilePath, hit.index);
this.virtualizer?.requestUpdate();
};
private onDelegatedDragStart = (e: DragEvent) => {
const hit = this.resolveTrackFromEvent(e);
@@ -2238,6 +2306,45 @@ export class TrackList
this.saveSortPreferences();
};
/**
* The three worth a thumb. Everything else is behind "More",
* which opens the context menu this list already renders.
*
* A bar is one row on a 424px screen and the menu is nine items,
* so this is a subset by necessity rather than a second opinion
* about what matters -- and plan 018's promise that no action is
* unreachable is kept by the overflow, not by this list.
*/
private static readonly SELECTION_ACTIONS: SelectionAction[] = [
{ id: 'play', label: 'Play', icon: ICON_PLAY },
{ id: 'add-to-queue', label: 'Add to queue', icon: ICON_QUEUE },
{ id: 'play-next', label: 'Play next', icon: ICON_PLAY_NEXT },
];
private renderSelectionBar() {
// Only in selection mode: a mouse selection is modeless and
// shows its actions on right-click, which is where a desktop
// user looks for them.
if (!this.selection.selectionMode) return nothing;
return html`
<selection-bar
.count=${this.selection.selectionCount}
.actions=${TrackList.SELECTION_ACTIONS}
@selection-exit=${this.onSelectionExit}
@selection-action=${(e: CustomEvent<{ id: string }>) =>
this.onContextMenuAction(e.detail.id)}
@selection-more=${(e: CustomEvent<{ x: number; y: number }>) =>
this.ctxMenu.openAt(e.detail.x, e.detail.y)}
></selection-bar>
`;
}
private onSelectionExit = () => {
this.selection.exitSelectionMode();
this.virtualizer?.requestUpdate();
};
override render() {
const visibleTracks = this.cachedSortedTracks;
const cols = this.activeColumns;
@@ -2322,6 +2429,7 @@ export class TrackList
)}
</div>
</div>
${this.renderSelectionBar()}
`}
<menu-surface
+54
View File
@@ -0,0 +1,54 @@
import { css } from 'lit';
/**
* The way out of a detail view, at the app's 44px touch floor.
*
* #186's second table names `artist-details`' back button at
* **32x32**. It is the same declaration in **six** components —
* `artist-details`, `genre-details`, `playlist-details`,
* `smart-playlist-details`, `explore-artist-details` and
* `explore-album-details` — byte-identical, 32px in all six, and the
* sweep that filed the issue visited one of them.
*
* That is the argument for this file rather than six edits. A device
* sweep walks the views somebody thought to open, so six copies of a
* control is six chances for the next pass to miss five; the arrows
* and the toggles were each one declaration covering 36 and 29
* controls, and this is the same shape stated the other way round.
*
* **It is a real 44px box, not padding with the width handed back.**
* The header pass had to grow a hit area past its own layout box
* because `page-header` measures itself for #69's overflow fit; a
* detail view's header does not, so the control can simply be the
* target. It also *should* be — this button has a visible background,
* so a hit area larger than the circle would be a control that is
* bigger than it looks, which is the thing #187 accepts only where a
* thin painted track is the point.
*
* The size is #55's, arrived at for the same reason one component
* over: "the way out is 44px on a phone", when the queue panel's close
* button was 25x21 and, at phone width, the only pointer route off a
* full-screen surface. A detail view has the platform's back gesture
* as well, so this is less severe than the queue was — it is the same
* control wearing the same mistake.
*/
export const backButton = css`
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: none;
border-radius: 50%;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
}
`;
+48
View File
@@ -0,0 +1,48 @@
import { css } from 'lit';
/**
* A Web Awesome form control is at least the app's 44px touch floor.
*
* #56 named 44px and #186 found nothing but the transport had reached
* it. Web Awesome's form controls are the part of Settings this app
* does not draw: measured on the reference device (TLP301, 424x439),
* `wa-input`'s control is **204x20** and `wa-button` **185x21** — the
* shortest controls on the page, and the only ones whose height is
* decided inside somebody else's shadow root.
*
* `--wa-form-control-height` is that decision, and it is the library's
* own theming variable rather than a part or an internal — the default
* theme sets it at `:root` and every control that has a height reads
* it (button, input, select, radio). So this is `wa-slider-label`'s
* better half: the API first, and no reach into a shadow root at all.
*
* Three things about it are load-bearing.
*
* **A custom property inherits through a shadow boundary**, which is
* what lets a `:host` declaration reach a `wa-input` the host renders.
* That is also why it is a stylesheet a component adopts rather than a
* `:root` rule in `index.css`: a `:root` rule would cover every wa
* control in the app in one line and be invisible to the component
* tier, which renders a component and no page stylesheet. Here the
* floor is measurable where it is applied.
*
* **It is a flat 44px rather than a floor over the library's own
* expression.** The default is `round(calc(2 * padding-block + 1em *
* line-height), 1px)` — em-based, so `size="small"` is what produced
* the 20px above — and a `max(44px, …)` would have to restate that
* formula here, which is a copy of somebody else's arithmetic that
* goes stale silently. A flat value is safe because this app uses
* exactly two sizes, `small` and the default, and both are under the
* floor; a `size="large"` added later would be pinned down to 44 and
* should take that as the prompt to revisit this.
*
* **Only the height is pinned.** The font size still comes from
* `size="small"`, so a control grows its hit area without growing its
* visual weight — which is what #186's Direction asks for and what the
* page header's second pass had to be corrected to do.
*/
export const waTouchFloor = css`
:host {
--wa-form-control-height: 44px;
}
`;
-201
View File
@@ -1,201 +0,0 @@
/**
* Long-press as the touch equivalent of a right-click (plan 016 B2,
* phase 3).
*
* Every context menu in the app opens from a `contextmenu` event —
* `track-list` and `queue-panel` delegate one on their virtualizer,
* the card grids and both playlist detail views bind one per row, and
* `explore-artist-details` binds three. A phone has no right-click, so
* a phone reached none of them.
*
* **This is one document listener, not six components' worth of touch
* handling.** A press that stays still for `LONG_PRESS_MS` dispatches a
* synthetic `contextmenu` at the touch point on the element the touch
* actually landed on, and every existing handler — delegated or
* per-row, in any shadow root — runs unchanged. Six implementations of
* a gesture is exactly the fault `ContextMenuController` exists to
* prevent, and a seam that needs no component to opt in cannot be
* forgotten by the next component.
*
* Three things about it are load-bearing.
*
* **The target comes from `composedPath()[0]`, not from
* `elementFromPoint`**, which stops at the outermost shadow host: every
* menu in this app is bound inside one, so a synthetic event dispatched
* on the host reaches a delegated listener and no per-row one.
*
* **A browser that already does this must win.** Chromium fires a
* `contextmenu` on long-press itself; WebKitGTK and the Android WebView
* vary. So one arriving during the press cancels ours, and one arriving
* just after ours is swallowed at document capture — where nothing else
* has seen it yet. The two are told apart by **identity** (a `WeakSet`
* of the events this module made) rather than by `isTrusted`, so the
* suppressor cannot eat the event it exists to deliver, the rule holds
* for anything else in the app that synthesises one, and a test can
* stand in for a browser that fires its own.
*
* **The click that ends the gesture is swallowed.** A row's click
* selects, and a card's plays; without this, opening a menu also
* activates the thing under it. It is keyed on the gesture (cleared by
* the next `pointerdown`) rather than on a time window, so a quick tap
* on the menu that just opened is not eaten too.
*/
/** How long a press must hold still to mean "menu". */
export const LONG_PRESS_MS = 500;
/**
* How far a press may drift and still count. Below a finger's own
* jitter is a gesture nobody can perform; above ~12px it starts
* stealing the first frames of a scroll.
*/
export const MOVE_TOLERANCE_PX = 10;
/** The active installation, so a second call is a no-op rather than a
* second listener set. */
let uninstall: (() => void) | null = null;
/** The events this module dispatched. Identity, not `isTrusted`: see
* the note above. */
const ours = new WeakSet<Event>();
/**
* Install the gesture. Idempotent; returns the uninstaller (which the
* tests use — the app installs once and never removes it).
*/
export function installLongPressContextMenu(): () => void {
if (uninstall) return uninstall;
let timer: ReturnType<typeof setTimeout> | null = null;
let originX = 0;
let originY = 0;
let target: EventTarget | null = null;
/** A trusted `contextmenu` arrived for this press: the browser has
* it covered. */
let nativeSeen = false;
/** We opened a menu, and the click ending that gesture is not a
* click on anything. */
let swallowClick = false;
/** We dispatched one, so a trusted one arriving now is a duplicate. */
let justFired = false;
const cancel = (): void => {
if (timer !== null) clearTimeout(timer);
timer = null;
target = null;
};
const fire = (): void => {
timer = null;
const el = target;
target = null;
if (nativeSeen || !el) return;
justFired = true;
swallowClick = true;
const menu = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
// Or it stops at the shadow root the row lives in, and the
// delegated listeners never see it.
composed: true,
clientX: originX,
clientY: originY,
button: 2,
});
ours.add(menu);
el.dispatchEvent(menu);
};
const onPointerDown = (e: PointerEvent): void => {
// A new gesture: whatever the last one left behind is stale.
swallowClick = false;
justFired = false;
nativeSeen = false;
cancel();
if (e.pointerType !== 'touch' || !e.isPrimary) return;
originX = e.clientX;
originY = e.clientY;
target = e.composedPath()[0] ?? e.target;
timer = setTimeout(fire, LONG_PRESS_MS);
};
const onPointerMove = (e: PointerEvent): void => {
if (timer === null) return;
const drifted =
Math.abs(e.clientX - originX) > MOVE_TOLERANCE_PX ||
Math.abs(e.clientY - originY) > MOVE_TOLERANCE_PX;
if (drifted) cancel();
};
const onContextMenu = (e: Event): void => {
// Ours. Everything below is about somebody else's.
if (ours.has(e)) return;
if (timer !== null) {
// The browser got there first, so stand down rather than
// opening the same menu twice.
nativeSeen = true;
cancel();
return;
}
if (justFired) {
justFired = false;
e.preventDefault();
e.stopImmediatePropagation();
}
};
const onClick = (e: Event): void => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopImmediatePropagation();
};
// Capture throughout: a component handler that stops propagation
// (every context-menu handler in the app does) must not be able to
// hide the gesture from this, and the suppressors have to run
// before anything that would act on the event.
const opts = { capture: true } as const;
document.addEventListener('pointerdown', onPointerDown, opts);
document.addEventListener('pointermove', onPointerMove, opts);
document.addEventListener('pointerup', cancel, opts);
document.addEventListener('pointercancel', cancel, opts);
document.addEventListener('contextmenu', onContextMenu, opts);
document.addEventListener('click', onClick, opts);
// A scroll started by something other than the finger (momentum, a
// programmatic reveal) still means the press was not a press.
document.addEventListener('scroll', cancel, { capture: true, passive: true });
uninstall = () => {
cancel();
document.removeEventListener('pointerdown', onPointerDown, opts);
document.removeEventListener('pointermove', onPointerMove, opts);
document.removeEventListener('pointerup', cancel, opts);
document.removeEventListener('pointercancel', cancel, opts);
document.removeEventListener('contextmenu', onContextMenu, opts);
document.removeEventListener('click', onClick, opts);
document.removeEventListener('scroll', cancel, opts);
uninstall = null;
};
return uninstall;
}
@@ -19,6 +19,29 @@ export class SelectionController implements ReactiveController {
private host: SelectionHost;
private _selectedItems: Set<string> = new Set();
private lastSelectedIndex: number | null = null;
private _mode = false;
/**
* Whether the list is in *selection mode* (plan 019, #63).
*
* A finger has no modifier keys, so the ctrl/shift semantics this
* controller was written for cannot be expressed by touch at all.
* Selection mode is the platform's answer: a long press enters it,
* and while it is on, a tap toggles a row instead of playing it.
*
* It is a flag *here* rather than a fifth concept beside the
* controller because all four surfaces that select
* (`track-list`, `queue-panel` and both playlist detail views)
* already share this class -- so "is this list selecting" has one
* answer per list, in the object that already owns the selection
* it would otherwise contradict.
*
* A mouse never sets it. Desktop selection is unchanged and stays
* modeless, which is what `handleItemClick` still implements.
*/
get selectionMode(): boolean {
return this._mode;
}
constructor(host: SelectionHost) {
this.host = host;
@@ -136,8 +159,64 @@ export class SelectionController implements ReactiveController {
return true;
}
/**
* Enter selection mode with `key` selected.
*
* The row the gesture was made on is selected, rather than the
* mode opening empty: a long press is a statement about *that*
* row, and an action bar with nothing in it is a mode the user has
* to make a second gesture to escape.
*/
enterSelectionMode(key: string, index: number): void {
this._mode = true;
this._selectedItems = new Set([key]);
this.lastSelectedIndex = index;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/**
* Toggle one row, and leave the mode when the last one goes.
*
* Deselecting everything is how Android's own list surfaces exit
* selection mode, and it matters more here than convention: the
* mode changes what a tap *means*, so a mode with an empty
* selection is a list where tapping does nothing and nothing on
* screen says why.
*/
toggleInMode(key: string, index: number): void {
const next = new Set(this._selectedItems);
if (next.has(key)) next.delete(key);
else next.add(key);
this._selectedItems = next;
this.lastSelectedIndex = index;
if (next.size === 0) this._mode = false;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/** Leave selection mode, dropping the selection with it. */
exitSelectionMode(): void {
if (!this._mode && this._selectedItems.size === 0) return;
this._mode = false;
this._selectedItems = new Set();
this.lastSelectedIndex = null;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/** Clear the entire selection. */
clear(): void {
// The mode goes with it: every caller of this means "the
// selection is no longer meaningful", and a mode outliving the
// selection it was showing is the empty-mode trap above.
this._mode = false;
if (this._selectedItems.size === 0) return;
this._selectedItems = new Set();
@@ -176,6 +255,13 @@ export class SelectionController implements ReactiveController {
if (next.size === this._selectedItems.size) return;
this._selectedItems = next;
// A refetch that emptied the selection also ends the mode --
// otherwise removing the last selected track from the library
// leaves the list in a state where a tap selects and the bar
// is gone.
if (next.size === 0) this._mode = false;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
+327
View File
@@ -0,0 +1,327 @@
/**
* The touch gestures, as one document listener (plan 019, #63).
*
* This replaces `utils/long-press.ts` rather than sitting beside it,
* and that is the point: two document listeners both claiming the
* 500ms hold is exactly the fault that file's own header warns about.
* What it did — one capture listener, the target from
* `composedPath()[0]`, the browser's own gesture winning, the trailing
* click swallowed — is kept whole. What changes is what the gesture
* *means*.
*
* **A gesture is announced, not acted on.** Two composed, cancelable
* events are dispatched on the element the finger actually landed on:
*
* `yj-tap` a short press that did not drift
* `yj-long-press` a press that held still for LONG_PRESS_MS
*
* A component that wants the gesture handles it and calls
* `preventDefault()`. Nothing else changes. That shape is what lets
* this reassign the hold without touching a single one of the fourteen
* context menus downstream of it: **an unclaimed `yj-long-press` still
* becomes a synthetic `contextmenu`**, so a card grid, an Explore
* result or a playlist row behaves exactly as it did, and only the
* lists that opt in get selection mode.
*
* The same rule keeps taps honest. An unclaimed `yj-tap` does nothing
* at all and the browser's click follows normally, so every button,
* link and checkbox in the app is untouched by this file. Only a
* claimed tap has its click swallowed — otherwise playing a track
* would also select it.
*
* Five things are load-bearing.
*
* **The predicate is the pointer, not the platform** (plan 019,
* decision 1). `pointerType === 'touch'`, per event — so an Android
* tablet over 600px, a touchscreen laptop with a mouse also plugged
* in, and a narrow desktop window are all right for free, and there is
* no second declaration of what a phone does. Keyed on a viewport
* width, the first of those three gets desktop semantics on a
* touchscreen, which is the inversion #63 exists to fix, on the
* platform it exists for.
*
* **There is no double-tap**, and it is not an omission — see plan
* 019, decision 2. Measured on the reference device, the play command
* to `TrackChanged` is ~100ms; a double-tap discriminator has to hold
* every tap for the app's own `DOUBLE_CLICK_GRACE_MS` of 250 before it
* can act, which is 3.5x the primary interaction in the app to reach a
* menu that long-press already reaches.
*
* **The target comes from `composedPath()[0]`**, not
* `elementFromPoint`, which stops at the outermost shadow host: every
* list in this app delegates inside one, so an event dispatched on the
* host reaches a delegated listener and no per-row one.
*
* **A browser that fires its own `contextmenu` is a trigger, not a
* competitor**, and that is a change from `long-press.ts` rather than
* an inherited rule. It used to stand down when a trusted
* `contextmenu` arrived, because both paths ended in the same place: a
* context menu. They no longer do — ours may end in selection mode —
* so standing down means the gesture silently does the *old* thing.
*
* Measured on the reference device, which is the only tier that can
* see this: Chrome 113's WebView fires its own `contextmenu` on a long
* press, so a hold on a track row opened the context menu and
* `yj-long-press` was never announced at all. Every test in the
* component tier passed, because dispatched pointer events do not make
* a browser synthesise one.
*
* So a trusted `contextmenu` arriving mid-press *becomes* the long
* press: `yj-long-press` is announced from it, and only if a component
* claims it is the native event suppressed. Unclaimed, it propagates
* untouched and opens the menu it always did — which is the same
* "browser wins" outcome, now reached by asking rather than assuming.
*
* Ours and the browser's are still told apart by identity rather than
* `isTrusted` — a `WeakSet` of the events this module made — so the
* suppressor cannot eat the event it exists to deliver, and a test can
* stand in for a browser that fires one.
*
* **The click swallow is keyed on the gesture**, cleared by the next
* `pointerdown` rather than by a time window, so the first tap on a
* sheet that just opened is not eaten too.
*/
/** How long a press must hold still to mean "long press". */
export const LONG_PRESS_MS = 500;
/**
* How far a press may drift and still count. Below a finger's own
* jitter is a gesture nobody can perform; above ~12px it starts
* stealing the first frames of a scroll.
*/
export const MOVE_TOLERANCE_PX = 10;
/** Detail carried by both gesture events. */
export interface GestureDetail {
/** Where the finger was, in client coordinates — a menu opens here. */
x: number;
y: number;
}
export type GestureEvent = CustomEvent<GestureDetail>;
declare global {
interface HTMLElementEventMap {
'yj-tap': GestureEvent;
'yj-long-press': GestureEvent;
}
}
/** The active installation, so a second call is a no-op rather than a
* second listener set. */
let uninstall: (() => void) | null = null;
/** The events this module dispatched. Identity, not `isTrusted`. */
const ours = new WeakSet<Event>();
/**
* Install the gestures. Idempotent; returns the uninstaller (which the
* tests use — the app installs once and never removes it).
*/
export function installTouchGestures(): () => void {
if (uninstall) return uninstall;
let timer: ReturnType<typeof setTimeout> | null = null;
let originX = 0;
let originY = 0;
let target: EventTarget | null = null;
/** The press is still a candidate for a tap: it has neither
* drifted nor become a long press. */
let tapCandidate = false;
/** A trusted `contextmenu` arrived for this press: the browser has
* it covered. */
let nativeSeen = false;
/** A gesture was claimed, and the click ending it is not a click on
* anything. */
let swallowClick = false;
/** We dispatched a `contextmenu`, so a trusted one arriving now is
* a duplicate. */
let justFired = false;
const cancel = (): void => {
if (timer !== null) clearTimeout(timer);
timer = null;
target = null;
tapCandidate = false;
};
/**
* Announce a gesture on the element the finger landed on.
* Returns whether a component claimed it.
*/
const announce = (name: 'yj-tap' | 'yj-long-press', el: EventTarget): boolean => {
const event: GestureEvent = new CustomEvent<GestureDetail>(name, {
bubbles: true,
cancelable: true,
// Or it stops at the shadow root the row lives in, and the
// delegated listeners never see it.
composed: true,
detail: { x: originX, y: originY },
});
ours.add(event);
el.dispatchEvent(event);
return event.defaultPrevented;
};
const fireContextMenu = (el: EventTarget): void => {
justFired = true;
const menu = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
composed: true,
clientX: originX,
clientY: originY,
button: 2,
});
ours.add(menu);
el.dispatchEvent(menu);
};
const onLongPress = (): void => {
timer = null;
tapCandidate = false;
const el = target;
target = null;
if (nativeSeen || !el) return;
// The gesture happened either way, so the click that ends it is
// never a click on anything -- whether a list claimed it for
// selection mode or a card grid let it fall through to a menu.
swallowClick = true;
// An unclaimed long press is what it has always been. This is
// the whole reason the fourteen context menus need no change.
if (!announce('yj-long-press', el)) fireContextMenu(el);
};
const onPointerDown = (e: PointerEvent): void => {
// A new gesture: whatever the last one left behind is stale.
swallowClick = false;
justFired = false;
nativeSeen = false;
cancel();
if (e.pointerType !== 'touch' || !e.isPrimary) return;
originX = e.clientX;
originY = e.clientY;
target = e.composedPath()[0] ?? e.target;
tapCandidate = true;
timer = setTimeout(onLongPress, LONG_PRESS_MS);
};
const onPointerMove = (e: PointerEvent): void => {
if (timer === null) return;
const drifted =
Math.abs(e.clientX - originX) > MOVE_TOLERANCE_PX ||
Math.abs(e.clientY - originY) > MOVE_TOLERANCE_PX;
// A drifted press is neither gesture -- it is a scroll, and the
// virtualizer's, not ours.
if (drifted) cancel();
};
const onPointerUp = (): void => {
const el = target;
const wasTap = tapCandidate && timer !== null;
// Clears the long-press timer, so a tap cannot also become one.
cancel();
if (!wasTap || !el) return;
// Only a *claimed* tap swallows its click. An unclaimed one has
// to fall through untouched, or every button in the app stops
// working.
if (announce('yj-tap', el)) swallowClick = true;
};
const onContextMenu = (e: Event): void => {
// Ours. Everything below is about somebody else's.
if (ours.has(e)) return;
if (timer !== null) {
// The browser recognised the same hold this was timing.
// Use its event as the trigger rather than racing it --
// and rather than standing down, which is what the old
// rule did and which now silently means "do the thing this
// gesture used to do".
const el = e.composedPath()[0] ?? e.target;
nativeSeen = true;
cancel();
if (!el) return;
swallowClick = true;
// Claimed: the component wants selection mode, so the
// browser's menu must not also open. Unclaimed: let it
// through exactly as before.
if (announce('yj-long-press', el)) {
e.preventDefault();
e.stopImmediatePropagation();
}
return;
}
if (justFired) {
justFired = false;
e.preventDefault();
e.stopImmediatePropagation();
}
};
const onClick = (e: Event): void => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopImmediatePropagation();
};
// Capture throughout: a component handler that stops propagation
// (every context-menu handler in the app does) must not be able to
// hide the gesture from this, and the suppressors have to run
// before anything that would act on the event.
const opts = { capture: true } as const;
document.addEventListener('pointerdown', onPointerDown, opts);
document.addEventListener('pointermove', onPointerMove, opts);
document.addEventListener('pointerup', onPointerUp, opts);
document.addEventListener('pointercancel', cancel, opts);
document.addEventListener('contextmenu', onContextMenu, opts);
document.addEventListener('click', onClick, opts);
// A scroll started by something other than the finger (momentum, a
// programmatic reveal) still means the press was not a press.
document.addEventListener('scroll', cancel, { capture: true, passive: true });
uninstall = () => {
cancel();
document.removeEventListener('pointerdown', onPointerDown, opts);
document.removeEventListener('pointermove', onPointerMove, opts);
document.removeEventListener('pointerup', onPointerUp, opts);
document.removeEventListener('pointercancel', cancel, opts);
document.removeEventListener('contextmenu', onContextMenu, opts);
document.removeEventListener('click', onClick, opts);
document.removeEventListener('scroll', cancel, opts);
uninstall = null;
};
return uninstall;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,230 @@
/**
* The controls #186's second table found, outside Settings.
*
* Six one-off controls across four surfaces, and the reason they are a
* test rather than six stylesheet edits is `back-button`. The issue
* names it in `artist-details` at **32x32**; it is the same
* declaration, byte-identical, in *six* components — because a device
* sweep walks the views somebody thought to open, and five of them
* were not opened.
*
* So the assertion is over the whole set rather than over the one that
* was measured. That is `icon-language.test.ts`'s shape and it is here
* for the same reason: checking one call site checks one call site.
*
* | control | before | where |
* |---|---|---|
* | `.folders-menu-trigger` | **32x18** | autotag |
* | `.section-toggle` | 187x**15** | autotag |
* | `.back-button` | 32x32 | six detail views |
* | Requests / Downloads tabs | 85x**34**, 96x**34** | downloads |
* | `.search-mode-tab` | 89x**26**, 79x**26** | explore |
* | explore search input | 325x**18** in a 36px box | explore |
*
* `page-action-check-now` (113x29) is in that table and is **not**
* here: it is a `PageAction`, so #195 raised it with the rest of the
* page header's actions, and `touch-targets.test.ts` already covers
* it. Re-asserting it here would be a second statement of one rule.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import '@components/artist-details/artist-details';
import '@components/autotag-view/autotag-view';
import '@components/downloads-view/downloads-view';
import '@components/explore-album-details/explore-album-details';
import '@components/explore-artist-details/explore-artist-details';
import '@components/explore-view/explore-view';
import '@components/genre-details/genre-details';
import '@components/playlist-details/playlist-details';
import '@components/smart-playlist-details/smart-playlist-details';
import { flush, stub } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
/** The app's touch floor, from #56. */
const FLOOR = 44;
/**
* Every component that draws a back button.
*
* The list is here rather than derived because deriving it means
* reading the source, and this tier renders instead — but it is
* checked against the source by `the back button is one declaration`
* below, so a seventh view cannot join quietly.
*/
const BACK_BUTTON_VIEWS = [
'artist-details',
'genre-details',
'playlist-details',
'smart-playlist-details',
'explore-artist-details',
'explore-album-details',
] as const;
function boxOf(el: Element | null | undefined): { w: number; h: number } {
if (!el) return { w: 0, h: 0 };
const box = el.getBoundingClientRect();
return { w: Math.round(box.width), h: Math.round(box.height) };
}
describe('the way out of a detail view', () => {
beforeEach(() => {
for (const path of [
'library.Library.GetTracks',
'library.Library.GetAlbums',
'library.Library.GetArtists',
'library.Library.GetGenres',
'playlist.Service.GetAllPlaylists',
'playlist.Service.GetAllPlaylistsWithTracks',
]) {
stub(path, []);
}
});
it.each(BACK_BUTTON_VIEWS)('is 44px in <%s>', async (tag) => {
// #55 settled this one component over, when the queue panel's
// close button was 25x21 and, at phone width, the only pointer
// route off a full-screen surface: "the way out is 44px". A detail
// view has the platform's back gesture as well, so it is less
// severe -- and it is the same control wearing the same mistake.
const el = await fixture(tag);
await flush();
const back = shadow(el, '.back-button');
expect(back, `${tag} draws a back button`).toBeTruthy();
expect(boxOf(back)).toEqual({ w: FLOOR, h: FLOOR });
});
it('is one declaration, so a seventh view cannot miss it', async () => {
// The regression this exists for is not a size changing -- it is
// somebody adding a detail view and writing `.back-button` out
// again at 32px, which is exactly how there came to be six copies.
// A sweep of the running app would not catch it either, because a
// sweep visits the views you think to open.
const sources = import.meta.glob('../../src/components/**/*.ts', {
query: '?raw',
import: 'default',
eager: true,
}) as Record<string, string>;
expect(Object.keys(sources).length, 'the glob read something').toBeGreaterThan(0);
const redeclared = Object.entries(sources)
.filter(([, src]) => /^\s*\.back-button\s*(?::[a-z-]+\s*)?\{/m.test(src))
.map(([path]) => path);
expect(redeclared).toEqual([]);
});
});
describe('autotag', () => {
it('raises the two smallest controls the sweep found', async () => {
// 187x15 and 32x18. The section toggle was the smallest control
// measured anywhere in the app until the column arrows were
// counted, and autotag is off by default (#25), which is
// presumably why nobody had met either.
const el = await fixture('autotag-view');
await flush();
for (const selector of ['.section-toggle', '.folders-menu-trigger']) {
const control = shadowAll<HTMLElement>(el, selector).find(
(c) => c.getBoundingClientRect().height > 0,
);
if (!control) continue;
expect(boxOf(control).h, `${selector} height`).toBeGreaterThanOrEqual(FLOOR);
}
// The stylesheet is the assertion for whichever of the two this
// fixture does not render -- both are behind state a bare mount
// does not reach, and a test that silently checked nothing is the
// trap icon-language.test.ts's first assertion exists for.
const sheet = (el.constructor as typeof HTMLElement & { styles?: unknown })
.styles;
expect(String(sheet)).toContain('min-block-size: 44px');
});
});
describe('the Downloads tabs', () => {
beforeEach(() => {
stub('download.Service.ListDownloads', []);
stub('download.Service.ListRequests', []);
stub('download.Service.ListProviders', []);
});
it('are the only route to their panels, and are 44px', async () => {
const el = await fixture('downloads-view');
await flush();
const tabs = shadowAll<HTMLElement>(el, '[role="tab"]');
expect(tabs).toHaveLength(2);
for (const tab of tabs) {
expect(boxOf(tab).h, tab.textContent?.trim()).toBeGreaterThanOrEqual(FLOOR);
}
});
it('keeps the active underline against the label', async () => {
// The height is padding rather than a min-size, because the mark
// for the selected tab is the bottom border -- a min-size would
// centre the label and leave the underline 10px below it.
const el = await fixture('downloads-view');
await flush();
const tab = shadowAll<HTMLElement>(el, '[role="tab"]')[0]!;
const style = getComputedStyle(tab);
expect(parseFloat(style.paddingBlockStart)).toBeGreaterThan(8);
expect(style.paddingBlockStart).toBe(style.paddingBlockEnd);
});
});
describe("Explore's own search row", () => {
beforeEach(() => {
stub('explore.Service.GetShelves', { State: 'ready', Shelves: [] });
stub('explore.Service.GetIndexStatus', {});
});
it('raises the mode tabs', async () => {
const el = await fixture('explore-view');
await flush();
const tabs = shadowAll<HTMLElement>(el, '.search-mode-tab');
expect(tabs.length).toBeGreaterThan(0);
for (const tab of tabs) {
expect(boxOf(tab).h, tab.textContent?.trim()).toBeGreaterThanOrEqual(FLOOR);
}
});
it('makes the whole search box the input, not the middle 18px of it', async () => {
// Two faults, not one: the row was 36px and the input inside it
// was **18**, so half the box was not a target at all -- a tap
// near the top or bottom edge landed on the container and did
// nothing. The container is 44 and the input stretches to fill it.
const el = await fixture('explore-view');
await flush();
const box = shadow(el, '.search-container');
const input = shadow(el, '.search-container input');
expect(box, 'the search row renders').toBeTruthy();
expect(input, 'it holds an input').toBeTruthy();
expect(boxOf(box).h).toBeGreaterThanOrEqual(FLOOR);
expect(boxOf(input).h).toBeGreaterThanOrEqual(FLOOR);
});
});
-212
View File
@@ -1,212 +0,0 @@
/**
* Long-press as the touch route to a context menu (plan 016 B2 phase 3).
*
* These run in a real browser with real event dispatch, which is the
* only place the two things that make this hard are true: the synthetic
* event has to cross a shadow boundary to reach the listener a
* component actually bound, and the suppressors have to tell a trusted
* event from ours at document capture without eating the one they exist
* to deliver.
*
* The timings are real rather than faked, because the thing under test
* *is* a timing, and 600 ms twice is cheaper than a fake-timer harness
* that would also have to fake the pointer events.
*/
import { describe, expect, it, afterEach, beforeEach } from 'vitest';
import {
installLongPressContextMenu,
LONG_PRESS_MS,
MOVE_TOLERANCE_PX,
} from '@utils/long-press';
/** A press that has certainly resolved, either way. */
const HELD = LONG_PRESS_MS + 120;
/** A press that has certainly not. */
const BRIEF = Math.round(LONG_PRESS_MS / 4);
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
let uninstall: (() => void) | null = null;
let host: HTMLElement;
let inner: HTMLElement;
/** A row inside a shadow root, which is where every menu in this app
* is bound — an element in the light DOM would pass a weaker test. */
function mountRow(): { host: HTMLElement; inner: HTMLElement } {
const el = document.createElement('div');
const root = el.attachShadow({ mode: 'open' });
const row = document.createElement('div');
row.textContent = 'a track';
root.append(row);
document.body.append(el);
return { host: el, inner: row };
}
function press(
el: EventTarget,
type: string,
init: PointerEventInit = {},
): void {
el.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: 40,
clientY: 60,
...init,
}),
);
}
/**
* Record every `contextmenu` that reaches the listener, *as the
* listener sees it*.
*
* `target` is retargeted for the scope reading it, so an assertion made
* after dispatch has finished reports the shadow host however the event
* was dispatched - which is the same answer a broken implementation
* gives. It has to be read from inside the handler, where the component
* reads it.
*/
function recordMenus(el: EventTarget): { event: MouseEvent; target: EventTarget | null }[] {
const seen: { event: MouseEvent; target: EventTarget | null }[] = [];
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
// Every real handler does this; the gesture must work anyway.
e.stopPropagation();
seen.push({ event: e as MouseEvent, target: e.target });
});
return seen;
}
describe('long-press opens a context menu', () => {
beforeEach(() => {
uninstall = installLongPressContextMenu();
({ host, inner } = mountRow());
});
afterEach(() => {
uninstall?.();
uninstall = null;
host.remove();
});
it('dispatches one at the touch point, on the element touched', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(HELD);
expect(seen).toHaveLength(1);
expect(seen[0]?.event.clientX).toBe(40);
expect(seen[0]?.event.clientY).toBe(60);
// Dispatched on the row itself, not on its shadow host - which is
// the difference between a per-row handler firing and only a
// delegated one firing.
expect(seen[0]?.target).toBe(inner);
});
it('is cancelled by a press that moves', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', {
clientX: 40 + MOVE_TOLERANCE_PX + 5,
clientY: 60,
});
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('tolerates the jitter a finger cannot help', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', { clientX: 43, clientY: 62 });
await wait(HELD);
expect(seen).toHaveLength(1);
});
it('is cancelled by lifting early, and by a scroll', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
await wait(HELD);
expect(seen).toHaveLength(0);
press(inner, 'pointerdown');
press(inner, 'pointercancel');
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('ignores a mouse, which has a right button of its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown', { pointerType: 'mouse' });
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('swallows the click that ends the gesture, and only that one', async () => {
let clicks = 0;
inner.addEventListener('click', () => {
clicks += 1;
});
press(inner, 'pointerdown');
await wait(HELD);
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(0);
// The next tap is a tap: on a phone that is the user choosing an
// item in the menu that just opened, so eating it would make the
// gesture useless.
press(inner, 'pointerdown');
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(1);
});
it('stands down where the browser fires its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
// Chromium does this itself on touch; WebKit and the Android
// WebView vary, which is the whole reason both halves exist. A
// test cannot dispatch a *trusted* event, which is why the module
// tells its own apart by identity rather than by `isTrusted`.
inner.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
composed: true,
cancelable: true,
}),
);
await wait(HELD);
// One menu: the browser's. Not two.
expect(seen).toHaveLength(1);
});
});
@@ -99,6 +99,25 @@ describe('<search-trigger>', () => {
}
});
it('meets the touch floor it was shipped four pixels under', async () => {
stubPhone(true);
// #57 created this as the phone's replacement for the header search
// box, so it exists *only* where there is a thumb -- and it shipped
// at 40x40 under a comment calling that "the smallest a touch
// target should be", which was the app's own 44px floor (#56)
// restated short rather than a second opinion about it. #186.
const el = await fixture('search-trigger');
const button = shadow<HTMLButtonElement>(el, '[data-testid="search-trigger"]');
expect(button).not.toBeNull();
const box = button!.getBoundingClientRect();
expect(Math.round(box.width)).toBeGreaterThanOrEqual(44);
expect(Math.round(box.height)).toBeGreaterThanOrEqual(44);
});
it('names what the button will search', async () => {
stubPhone(true);
@@ -0,0 +1,425 @@
/**
* Every control in Settings is at least 44px (#186, second pass).
*
* The header pass covered the five controls a user meets on every
* screen. Settings is the other half and is much the larger one: swept
* on the reference device (TLP301, 424x439) with all eleven
* `config-section`s expanded, **120 controls** were under the floor,
* not the 93 the issue's first table implies, and `config-field` — the
* row shape the issue names — is eight of them. The bulk is behind the
* disclosures:
*
* | control | size | count |
* |---|---|---|
* | `.column-arrow-btn` | **16x14** | 36 |
* | `.column-toggle` | 16x16 | 29 |
* | `shortcut-capture` button | 80x**25** | 26 |
* | download format checkbox | 16x16 | 8 |
* | `config-field` select | 335x**30** | 7 |
* | `wa-input` / `wa-button` | 204x**20**, 185x**21** | 6 |
* | `library-filter` select | 120x**32** | 1 |
* | `.overflow-btn` | 31x31 | 1 |
*
* **This tier can measure it, unlike #187's seek bar**, for the reason
* the header pass gives: the controls are real elements and the rules
* are min-sizes, so a real Chromium rendering a real component gives
* the actual answer at any width. And unlike the header there is no
* overflow fit on this page, so nothing here needs the negative-margin
* treatment — height is free and the two square controls can simply be
* square.
*
* **What the sweep cannot see is written down here as a test rather
* than as a comment**, because it is the trap this whole issue keeps
* setting. Two controls are invisible to a walk of `button, select,
* input`: `config-field`'s toggle, whose `<input>` is
* `opacity: 0; width: 0; height: 0` so the thing a finger hits is the
* `<label>` around it (34x19, smaller than anything in either of the
* issue's tables), and `shortcut-capture`'s reset button, which renders
* only for a shortcut somebody has already rebound. Both are asserted
* by name below.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import '@components/config-page/config-field';
import '@components/config-page/config-page';
import '@components/config-page/download-clients';
import '@components/config-page/shortcut-capture';
import '@components/library-filter/library-filter';
import { flush, stub } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
/** The app's touch floor, from #56. */
const FLOOR = 44;
/**
* Everything a finger can hit, through every shadow root under `root`.
*
* It descends rather than querying one root because Settings is a tree
* of components — `config-page` renders `config-section`s holding
* `config-field`s and `shortcut-capture`s — and the defect was
* distributed across all of them. This is the device sweep, run here.
*/
function controlsUnder(root: Document | ShadowRoot | Element): { name: string; el: HTMLElement }[] {
const SELECTOR = 'button, select, input, [role="button"], [role="tab"], [role="switch"]';
const found: { name: string; el: HTMLElement }[] = [];
const seen = new Set<Element>();
const walk = (node: ParentNode, depth: number): void => {
if (depth > 20) return;
for (const el of Array.from(node.querySelectorAll('*'))) {
if (el.matches(SELECTOR) && !seen.has(el)) {
seen.add(el);
const box = el.getBoundingClientRect();
const style = getComputedStyle(el);
const rendered =
(box.width > 0 || box.height > 0) &&
style.visibility !== 'hidden' &&
style.display !== 'none';
if (rendered) {
const host = (el.getRootNode() as ShadowRoot).host;
found.push({
name: `${host ? host.tagName.toLowerCase() : 'root'} ${
(typeof el.className === 'string' && el.className) || el.tagName.toLowerCase()
}`,
el: el as HTMLElement,
});
}
}
if (el.shadowRoot) walk(el.shadowRoot, depth + 1);
}
};
walk(root as ParentNode, 0);
return found;
}
/**
* What a finger actually hits for `el`.
*
* For everything in this app that is one element, that is the element.
* A native checkbox is the exception and is why this function exists:
* it cannot grow its hit area without growing its paint, and a 44px
* checkbox is not what anyone wants — so a checkbox that has a label
* is targeted *through* the label, which is the fix the column lists
* and the download formats both use.
*
* The fallback is the checkbox itself, deliberately: a checkbox with
* no label is a 16px target and this must still say so.
*/
function hitTarget(el: HTMLElement): HTMLElement {
const input = el as HTMLInputElement;
if (input.type !== 'checkbox' && input.type !== 'radio') return el;
const wrapping = el.closest('label');
const root = el.getRootNode() as ShadowRoot | Document;
const associated = input.id
? root.querySelector<HTMLLabelElement>(`label[for="${CSS.escape(input.id)}"]`)
: null;
return wrapping ?? associated ?? el;
}
/** The ones that miss the floor, reported with the numbers. */
function tooSmall(controls: { name: string; el: HTMLElement }[]): string[] {
return controls
.map(({ name, el }) => {
const box = hitTarget(el).getBoundingClientRect();
return { name, w: Math.round(box.width), h: Math.round(box.height) };
})
.filter((c) => c.w < FLOOR || c.h < FLOOR)
.map((c) => `${c.name} ${c.w}x${c.h}`);
}
/**
* Open every disclosure under `host`, so the sweep can see the page.
*
* A collapsed `config-section` renders its body with `hidden`, so its
* controls measure 0x0 — which is exactly why the issue's first table
* lists seven Settings controls and the real count is 120.
*/
async function expandEverySection(host: HTMLElement & { updateComplete: Promise<unknown> }) {
const sections = shadowAll<HTMLElement>(host, 'config-section');
expect(sections.length, 'the page renders disclosures to open').toBeGreaterThan(0);
for (const section of sections) {
section.shadowRoot
?.querySelector<HTMLButtonElement>('button[aria-expanded="false"]')
?.click();
}
await flush();
await host.updateComplete;
for (const section of sections) {
await (section as HTMLElement & { updateComplete?: Promise<unknown> }).updateComplete;
}
return sections;
}
/** A control's own box, named so a failure says which and how small. */
function boxOf(el: Element | null | undefined): string {
if (!el) return 'missing';
const box = el.getBoundingClientRect();
return `${Math.round(box.width)}x${Math.round(box.height)}`;
}
function meetsFloor(el: Element | null | undefined): boolean {
if (!el) return false;
const box = el.getBoundingClientRect();
return Math.round(box.width) >= FLOOR && Math.round(box.height) >= FLOOR;
}
describe('a config field is the shape every Settings row uses', () => {
it.each(['text', 'number', 'select', 'directory', 'color'] as const)(
'a %s field meets the floor',
async (type) => {
const el = await fixture('config-field', {
schema: {
key: 'k',
label: 'Music folder',
type,
options: [{ value: 'dark', label: 'Dark' }],
},
value: type === 'color' ? '#ffd43b' : '',
});
const controls = controlsUnder(el.shadowRoot!);
// A sweep that found nothing passes vacuously.
expect(controls.length).toBeGreaterThan(0);
expect(tooSmall(controls)).toEqual([]);
},
);
it('grows the toggle, which no sweep of inputs can see', async () => {
// The `<input>` is opacity: 0; width: 0; height: 0, so the walk
// above skips it as a zero-sized node -- and the thing a finger
// hits is the styling <label> around it, which measured 34x19 on
// the device. It is absent from #186's tables for exactly that
// reason, and it is smaller than everything in them.
const el = await fixture('config-field', {
schema: { key: 'x', label: 'Scan on startup', type: 'toggle' },
value: true,
});
const target = shadow(el, '.toggle-switch');
expect(boxOf(target)).toBe('44x44');
});
it('keeps the toggle painted at its old size, in its old place', async () => {
// A 44px pill is not what a switch should look like. The box is
// 44px and the paint is not: the slider is a child centred in it,
// and negative inline margins hand the extra width back so the
// pill stays flush with the inputs in the rows above.
const el = await fixture('config-field', {
schema: { key: 'x', label: 'Scan on startup', type: 'toggle' },
value: true,
});
const target = shadow(el, '.toggle-switch') as HTMLElement;
const slider = shadow(el, '.toggle-slider');
expect(slider!.getBoundingClientRect().height).toBeLessThan(FLOOR);
const style = getComputedStyle(target);
const handedBack =
parseFloat(style.marginInlineStart) + parseFloat(style.marginInlineEnd);
expect(handedBack).toBeLessThan(0);
});
});
describe('the shortcut editor', () => {
it('meets the floor', async () => {
const el = await fixture('shortcut-capture', {
action: 'play.toggle',
label: 'Play/pause',
currentKey: 'Space',
defaultKey: 'Space',
});
expect(meetsFloor(shadow(el, 'button'))).toBe(true);
});
it('grows the reset button, which only a rebound shortcut renders', async () => {
// Not in either of #186's tables, and it cannot be: a sweep of a
// freshly-installed app never sees it. It appears the moment
// anybody uses the feature.
const el = await fixture('shortcut-capture', {
action: 'play.toggle',
label: 'Play/pause',
currentKey: 'K',
defaultKey: 'Space',
});
const reset = shadow(el, '.reset-btn');
expect(reset, 'a rebound shortcut renders a reset button').toBeTruthy();
expect(meetsFloor(reset)).toBe(true);
});
});
describe('the library filter', () => {
it('meets the floor in both of its placements', async () => {
// One component, two mount points since #57 -- the desktop top bar
// and Settings -> Libraries -- so it reaches the floor once.
const el = await fixture('library-filter');
const select = shadow(el, 'select');
expect(select, 'the filter renders a select').toBeTruthy();
expect(Math.round(select!.getBoundingClientRect().height)).toBeGreaterThanOrEqual(FLOOR);
});
});
describe('download clients', () => {
beforeEach(() => {
stub('download.Service.ListProviders', []);
stub('download.Service.ProviderKinds', []);
stub('config.Config.GetDownloadPreferences', {});
});
it('gives Web Awesome form controls the floor through the library API', async () => {
// wa-input's control is inside somebody else's shadow root, so the
// height comes from --wa-form-control-height rather than from a
// rule of ours reaching in. A custom property inherits through a
// shadow boundary, which is what makes a :host declaration reach
// it -- and what makes it measurable here.
const el = await fixture('download-clients');
await flush();
await expandEverySection(el);
expect(getComputedStyle(el).getPropertyValue('--wa-form-control-height').trim()).toBe(
'44px',
);
// And the outcome, not only the mechanism. A sweep of `input`
// reports a wa-input at 204x**42** even when this is right,
// because the inner input sits *inside* the control's own 1px
// border -- Web Awesome sizes it
// `calc(--wa-form-control-height - border-width * 2)`. What a
// finger hits is `part=base`, measured at 238x44 on the device.
//
// This reaches into another library's shadow root, which
// `name-dialog.ts` only permits where the failure is bounded. It
// is bounded the other way here: this is a test, so a renamed
// part fails loudly rather than silently passing, which is the
// direction that costs nobody a device session.
const input = shadowAll<HTMLElement>(el, 'wa-input').find(
(w) => w.getBoundingClientRect().height > 0,
);
expect(input, 'the add form renders a wa-input').toBeTruthy();
const base = input!.shadowRoot?.querySelector('[part~="base"]');
expect(base, 'wa-input still calls its control box "base"').toBeTruthy();
expect(Math.round(base!.getBoundingClientRect().height)).toBeGreaterThanOrEqual(FLOOR);
});
it('makes each allowed-format checkbox label a target', async () => {
const el = await fixture('download-clients');
await flush();
await el.updateComplete;
// Every section starts collapsed, and a collapsed body is `hidden`
// — so its controls measure 0x0 and a sweep of an unexpanded page
// reports them all as fine. That is how the issue's first table
// came to list seven Settings controls when there are 120.
await expandEverySection(el);
const options = shadowAll<HTMLElement>(el, '.format-option');
expect(options.length).toBeGreaterThan(0);
const short = options
.map((o) => ({ label: o.textContent?.trim(), h: Math.round(o.getBoundingClientRect().height) }))
.filter((o) => o.h < FLOOR);
expect(short).toEqual([]);
});
});
describe('the whole Settings page', () => {
beforeEach(() => {
for (const path of [
'library.Library.GetAllLibrariesWithTrackCounts',
'jobs.Service.GetJobs',
'download.Service.ListProviders',
'download.Service.ProviderKinds',
]) {
stub(path, []);
}
stub('config.Config.GetShortcuts', {});
stub('config.Config.GetDownloadPreferences', {});
stub('config.Config.GetThemeAccentColor', '#ffd43b');
stub('config.Config.GetThemeBackgroundShade', 'dark');
});
it('has no control under the floor with every section expanded', async () => {
// The device sweep, run here: eleven collapsed sections is what
// made the first table look like seven controls. The density is
// behind the disclosures.
const el = await fixture('config-page');
await flush();
await el.updateComplete;
await expandEverySection(el);
const controls = controlsUnder(el.shadowRoot!);
expect(controls.length).toBeGreaterThan(0);
expect(tooSmall(controls)).toEqual([]);
});
it('makes a column row a target by naming it, not by growing the checkbox', async () => {
// A native checkbox cannot grow its hit area without growing its
// paint. The label is the target instead -- which is also the
// argument config-field already makes for its own labels, and it
// is behaviour rather than annotation: the column's name is now a
// click target for its checkbox.
const el = await fixture('config-page');
await flush();
await el.updateComplete;
await expandEverySection(el);
const labels = shadowAll<HTMLLabelElement>(el, 'label.column-label');
expect(labels.length).toBeGreaterThan(0);
for (const label of labels) {
const target = label.htmlFor
? el.shadowRoot!.getElementById(label.htmlFor)
: null;
expect(
(target as HTMLInputElement | null)?.type,
`${label.textContent?.trim()} names its checkbox`,
).toBe('checkbox');
expect(
Math.round(label.getBoundingClientRect().height),
`${label.textContent?.trim()} is a target`,
).toBeGreaterThanOrEqual(FLOOR);
}
});
});
@@ -0,0 +1,440 @@
/**
* The touch gestures (plan 019, #63; long-press from plan 016 B2).
*
* These run in a real browser with real event dispatch, which is the
* only place the two things that make this hard are true: the synthetic
* event has to cross a shadow boundary to reach the listener a
* component actually bound, and the suppressors have to tell a trusted
* event from ours at document capture without eating the one they exist
* to deliver.
*
* The timings are real rather than faked, because the thing under test
* *is* a timing, and 600 ms twice is cheaper than a fake-timer harness
* that would also have to fake the pointer events.
*/
import { describe, expect, it, afterEach, beforeEach } from 'vitest';
import {
installTouchGestures,
LONG_PRESS_MS,
MOVE_TOLERANCE_PX,
} from '@utils/touch-gestures';
/** A press that has certainly resolved, either way. */
const HELD = LONG_PRESS_MS + 120;
/** A press that has certainly not. */
const BRIEF = Math.round(LONG_PRESS_MS / 4);
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
let uninstall: (() => void) | null = null;
let host: HTMLElement;
let inner: HTMLElement;
/** A row inside a shadow root, which is where every menu in this app
* is bound — an element in the light DOM would pass a weaker test. */
function mountRow(): { host: HTMLElement; inner: HTMLElement } {
const el = document.createElement('div');
const root = el.attachShadow({ mode: 'open' });
const row = document.createElement('div');
row.textContent = 'a track';
root.append(row);
document.body.append(el);
return { host: el, inner: row };
}
function press(
el: EventTarget,
type: string,
init: PointerEventInit = {},
): void {
el.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: 40,
clientY: 60,
...init,
}),
);
}
/**
* Record every `contextmenu` that reaches the listener, *as the
* listener sees it*.
*
* `target` is retargeted for the scope reading it, so an assertion made
* after dispatch has finished reports the shadow host however the event
* was dispatched - which is the same answer a broken implementation
* gives. It has to be read from inside the handler, where the component
* reads it.
*/
function recordMenus(el: EventTarget): { event: MouseEvent; target: EventTarget | null }[] {
const seen: { event: MouseEvent; target: EventTarget | null }[] = [];
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
// Every real handler does this; the gesture must work anyway.
e.stopPropagation();
seen.push({ event: e as MouseEvent, target: e.target });
});
return seen;
}
describe('an unclaimed long press is still a context menu', () => {
beforeEach(() => {
uninstall = installTouchGestures();
({ host, inner } = mountRow());
});
afterEach(() => {
uninstall?.();
uninstall = null;
host.remove();
});
it('dispatches one at the touch point, on the element touched', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(HELD);
expect(seen).toHaveLength(1);
expect(seen[0]?.event.clientX).toBe(40);
expect(seen[0]?.event.clientY).toBe(60);
// Dispatched on the row itself, not on its shadow host - which is
// the difference between a per-row handler firing and only a
// delegated one firing.
expect(seen[0]?.target).toBe(inner);
});
it('is cancelled by a press that moves', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', {
clientX: 40 + MOVE_TOLERANCE_PX + 5,
clientY: 60,
});
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('tolerates the jitter a finger cannot help', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', { clientX: 43, clientY: 62 });
await wait(HELD);
expect(seen).toHaveLength(1);
});
it('is cancelled by lifting early, and by a scroll', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
await wait(HELD);
expect(seen).toHaveLength(0);
press(inner, 'pointerdown');
press(inner, 'pointercancel');
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('ignores a mouse, which has a right button of its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown', { pointerType: 'mouse' });
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('swallows the click that ends the gesture, and only that one', async () => {
let clicks = 0;
inner.addEventListener('click', () => {
clicks += 1;
});
press(inner, 'pointerdown');
await wait(HELD);
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(0);
// The next tap is a tap: on a phone that is the user choosing an
// item in the menu that just opened, so eating it would make the
// gesture useless.
press(inner, 'pointerdown');
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(1);
});
it('stands down where the browser fires its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
// Chromium does this itself on touch; WebKit and the Android
// WebView vary, which is the whole reason both halves exist. A
// test cannot dispatch a *trusted* event, which is why the module
// tells its own apart by identity rather than by `isTrusted`.
inner.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
composed: true,
cancelable: true,
}),
);
await wait(HELD);
// One menu: the browser's. Not two.
expect(seen).toHaveLength(1);
});
});
/**
* What plan 019 adds on top, and the one property that protects
* everything downstream: a long press nobody claims is unchanged.
*/
describe('a gesture is announced before it is acted on', () => {
beforeEach(() => {
uninstall = installTouchGestures();
({ host, inner } = mountRow());
});
afterEach(() => {
uninstall?.();
uninstall = null;
host.remove();
});
it('does not synthesise a menu when the long press is claimed', async () => {
// This is the whole reason #63 could reassign the hold without
// touching one of the fourteen context menus: the lists that want
// selection mode claim it, and nothing else changes.
const menus = recordMenus(inner);
const presses: unknown[] = [];
inner.addEventListener('yj-long-press', (e) => {
presses.push(e);
e.preventDefault();
});
press(inner, 'pointerdown');
await wait(HELD);
expect(presses).toHaveLength(1);
expect(menus).toHaveLength(0);
});
it('announces a tap on the element touched, at the touch point', async () => {
const taps: { target: EventTarget | null; x: number; y: number }[] = [];
inner.addEventListener('yj-tap', (e) => {
taps.push({ target: e.target, x: e.detail.x, y: e.detail.y });
});
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
expect(taps).toHaveLength(1);
// The row, not its shadow host -- the difference between a
// delegated handler firing and a per-row one never firing.
expect(taps[0]?.target).toBe(inner);
expect([taps[0]?.x, taps[0]?.y]).toEqual([40, 60]);
});
it('lets an unclaimed tap through as an ordinary click', async () => {
// Every button, link and checkbox in the app depends on this. Only
// a *claimed* tap has its click swallowed.
let clicks = 0;
inner.addEventListener('yj-tap', () => {
/* seen, not claimed */
});
inner.addEventListener('click', () => {
clicks += 1;
});
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(1);
});
it('swallows the click behind a claimed tap', async () => {
// Or playing a track would also select it, and the row would end
// up in both states at once.
let clicks = 0;
inner.addEventListener('yj-tap', (e) => e.preventDefault());
inner.addEventListener('click', () => {
clicks += 1;
});
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(0);
});
it('does not announce a tap for a press that became a long press', async () => {
// A hold is one gesture, not a hold and then a tap on release.
const taps: unknown[] = [];
inner.addEventListener('yj-tap', (e) => taps.push(e));
press(inner, 'pointerdown');
await wait(HELD);
press(inner, 'pointerup');
expect(taps).toHaveLength(0);
});
it('does not announce a tap for a press that drifted', async () => {
// A drifted press is a scroll, and the virtualizer's -- not a tap
// that happened to move. This is the one that would make a list
// unscrollable if it were wrong.
const taps: unknown[] = [];
inner.addEventListener('yj-tap', (e) => taps.push(e));
press(inner, 'pointerdown');
press(inner, 'pointermove', {
clientX: 40,
clientY: 60 + MOVE_TOLERANCE_PX + 20,
});
press(inner, 'pointerup');
expect(taps).toHaveLength(0);
});
it('ignores a mouse entirely, for both gestures', async () => {
// plan 019 decision 1: the predicate is the pointer, per event. A
// mouse on a touchscreen keeps click-selects / double-click-plays
// on the very same row, which no viewport width can express.
const seen: unknown[] = [];
inner.addEventListener('yj-tap', (e) => seen.push(e));
inner.addEventListener('yj-long-press', (e) => seen.push(e));
press(inner, 'pointerdown', { pointerType: 'mouse' });
await wait(BRIEF);
press(inner, 'pointerup', { pointerType: 'mouse' });
press(inner, 'pointerdown', { pointerType: 'mouse' });
await wait(HELD);
expect(seen).toHaveLength(0);
});
});
/**
* The browser's own long press is a trigger, not a competitor.
*
* This is a device-only defect made checkable here. `long-press.ts`
* stood down when a trusted `contextmenu` arrived mid-press, which was
* right while both paths ended in a context menu. Once a hold can mean
* *selection mode*, standing down means the gesture silently does the
* old thing — and Chrome 113's Android WebView does fire its own, so
* on the reference device `yj-long-press` was never announced at all
* while every test in this tier passed.
*
* A test cannot dispatch a *trusted* event, which is exactly why the
* module tells its own apart by identity rather than by `isTrusted`:
* an untrusted one dispatched from here takes the same path the
* browser's does.
*/
describe("the browser's own long press", () => {
beforeEach(() => {
uninstall = installTouchGestures();
({ host, inner } = mountRow());
});
afterEach(() => {
uninstall?.();
uninstall = null;
host.remove();
});
/** Stand in for the browser recognising the hold itself. */
function browserContextMenu(el: EventTarget): MouseEvent {
const e = new MouseEvent('contextmenu', {
bubbles: true,
composed: true,
cancelable: true,
});
el.dispatchEvent(e);
return e;
}
it('announces the gesture rather than standing down', async () => {
const presses: unknown[] = [];
inner.addEventListener('yj-long-press', (e) => {
presses.push(e);
e.preventDefault();
});
press(inner, 'pointerdown');
await wait(BRIEF);
browserContextMenu(inner);
await wait(HELD);
expect(presses, 'the hold reached the component').toHaveLength(1);
});
it('suppresses its menu when a component claims the gesture', async () => {
const menus = recordMenus(inner);
inner.addEventListener('yj-long-press', (e) => e.preventDefault());
press(inner, 'pointerdown');
await wait(BRIEF);
browserContextMenu(inner);
await wait(HELD);
// The component wants selection mode, so the menu must not also
// open -- otherwise the device shows both at once.
expect(menus).toHaveLength(0);
});
it('still opens exactly one menu when nobody claims it', async () => {
// The old behaviour, reached by asking instead of assuming. This
// is what leaves the card grids, Explore and the playlist rows
// untouched by #63.
const menus = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
browserContextMenu(inner);
await wait(HELD);
expect(menus).toHaveLength(1);
});
});
@@ -0,0 +1,279 @@
/**
* What a finger does to a track list (plan 019, #63).
*
* The desktop semantics being diverged from are real and stay: a click
* selects, a double-click plays. A finger has no second button and no
* modifier keys, so the primary action has to be the primary gesture —
* and the inversion is decided **per event**, off `pointerType`, not
* off a viewport width or a platform flag (plan 019, decision 1).
*
* That is what these assert: the same row, in the same component, at
* the same width, answering a mouse one way and a finger the other.
*
* There is deliberately no double-tap. Measured on the reference
* device, playing a track is ~100ms end to end, and a double-tap
* discriminator has to hold every tap for the app's own
* `DOUBLE_CLICK_GRACE_MS` of 250 before it can act — 3.5x the primary
* interaction, to reach a menu a long press already reaches.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import '@components/track-list/track-list';
import '@components/selection-bar/selection-bar';
import { calls, flush, resetHarness, stub } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
import { installTouchGestures, LONG_PRESS_MS } from '@utils/touch-gestures';
const HELD = LONG_PRESS_MS + 120;
const BRIEF = Math.round(LONG_PRESS_MS / 4);
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
let uninstall: (() => void) | null = null;
function track(n: number) {
return {
FilePath: `/music/track-${n}.mp3`,
TrackName: `Track ${n}`,
ArtistName: 'An Artist',
Album: 'An Album',
Duration: 100 + n,
ID: n,
};
}
const TRACKS = [track(1), track(2), track(3), track(4)];
/** Dispatch a pointer event as a finger would produce it. */
function press(el: EventTarget, type: string, init: PointerEventInit = {}) {
el.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: 40,
clientY: 60,
...init,
}),
);
}
/** A whole finger tap: down, a moment, up. */
async function tap(el: EventTarget) {
press(el, 'pointerdown');
await wait(BRIEF);
press(el, 'pointerup');
await wait(0);
}
/** A finger held still until the gesture resolves. */
async function hold(el: EventTarget) {
press(el, 'pointerdown');
await wait(HELD);
press(el, 'pointerup');
await wait(0);
}
async function mountList() {
const el = await fixture('track-list');
(el as unknown as { tracks: unknown[] }).tracks = TRACKS;
await flush();
await el.updateComplete;
await wait(60);
await el.updateComplete;
return el;
}
function rows(el: HTMLElement): HTMLElement[] {
return shadowAll<HTMLElement>(el, '.track-row');
}
describe('a finger on a track row', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetTracks', TRACKS);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('config.Config.GetShortcuts', {});
stub('queue.Queue.SetQueue', null);
stub('queue.Queue.AddTracksToQueue', null);
uninstall = installTouchGestures();
});
afterEach(() => {
uninstall?.();
uninstall = null;
vi.restoreAllMocks();
});
it('plays the row it taps, rather than selecting it', async () => {
const el = await mountList();
const row = rows(el)[1];
expect(row, 'the list rendered rows').toBeTruthy();
await tap(row!);
await flush();
const queued = calls('queue.Queue.SetQueue');
expect(queued.length, 'a tap plays').toBe(1);
// From that row, in the list as displayed -- not a queue of one
// that stops when the song ends.
expect(queued[0]?.args[1]).toBe(1);
expect((queued[0]?.args[0] as string[]).length).toBe(TRACKS.length);
});
it('leaves the same row to a mouse, which still selects', async () => {
// The inversion is per event, so one component answers both
// pointers at the same width. A viewport rule cannot say this.
const el = await mountList();
const row = rows(el)[1];
row!.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
await el.updateComplete;
expect(calls('queue.Queue.SetQueue').length, 'a click does not play').toBe(0);
expect(rows(el)[1]?.getAttribute('aria-selected')).toBe('true');
});
it('enters selection mode on a long press, with that row selected', async () => {
const el = await mountList();
await hold(rows(el)[2]!);
await el.updateComplete;
const bar = shadow(el, 'selection-bar');
expect(bar, 'the action bar appears').toBeTruthy();
expect((bar as unknown as { count: number }).count).toBe(1);
expect(rows(el)[2]?.getAttribute('aria-selected')).toBe('true');
});
it('does not open a context menu when it enters the mode', async () => {
// The gesture is claimed, so the layer must not fall through to
// the synthetic `contextmenu` that every other surface still gets.
const el = await mountList();
await hold(rows(el)[0]!);
await el.updateComplete;
const menu = shadow(el, 'menu-surface');
expect((menu as unknown as { active?: boolean } | null)?.active ?? false).toBe(
false,
);
});
it('toggles rows while the mode is on, instead of playing them', async () => {
const el = await mountList();
await hold(rows(el)[0]!);
await el.updateComplete;
await tap(rows(el)[2]!);
await el.updateComplete;
expect(calls('queue.Queue.SetQueue').length, 'no track was played').toBe(0);
expect(
(shadow(el, 'selection-bar') as unknown as { count: number }).count,
).toBe(2);
});
it('leaves the mode when the last row is deselected', async () => {
// Android's own lists do this, and here it matters more than
// convention: the mode changes what a tap means, so a mode holding
// nothing is a list where tapping does nothing and the bar that
// would explain it is showing a count of zero.
const el = await mountList();
await hold(rows(el)[0]!);
await el.updateComplete;
await tap(rows(el)[0]!);
await el.updateComplete;
expect(shadow(el, 'selection-bar')).toBeFalsy();
});
it('keeps the favourite icon a favourite icon', async () => {
// It is inside a row whose tap now plays, and it has been a 44px
// target since #56 -- so without the "a control inside the row
// owns its own tap" rule, that target silently becomes a second
// play button.
const el = await mountList();
const fav = rows(el)[1]?.querySelector('.fav-icon');
expect(fav, 'a row renders a favourite control').toBeTruthy();
await tap(fav!);
await flush();
expect(calls('queue.Queue.SetQueue').length, 'tapping it does not play').toBe(
0,
);
});
it('does not play a row the finger scrolled from', async () => {
// The failure this exists for makes the list unusable rather than
// merely wrong: every flick to scroll would start a track.
const el = await mountList();
const row = rows(el)[1];
press(row!, 'pointerdown');
press(row!, 'pointermove', { clientX: 40, clientY: 200 });
press(row!, 'pointerup');
await flush();
expect(calls('queue.Queue.SetQueue').length).toBe(0);
});
});
describe('<selection-bar>', () => {
it('renders nothing with nothing selected', async () => {
const el = await fixture('selection-bar', { count: 0 });
expect(shadow(el, '.bar')).toBeFalsy();
});
it('announces the count, which changes under the finger', async () => {
const el = await fixture('selection-bar', { count: 3, actions: [] });
const live = shadow(el, '[aria-live="polite"]');
expect(live?.textContent?.trim()).toContain('3 tracks selected');
});
it('names one track in the singular', async () => {
const el = await fixture('selection-bar', { count: 1, actions: [] });
expect(shadow(el, '[aria-live="polite"]')?.textContent?.trim()).toContain(
'1 track selected',
);
});
it('keeps every control at the touch floor', async () => {
// #56 and #186. A bar a thumb uses, in the one mode that only a
// thumb can enter.
const el = await fixture('selection-bar', {
count: 2,
actions: [{ id: 'play', label: 'Play', icon: 'play' }],
});
const buttons = shadowAll<HTMLElement>(el, 'button');
expect(buttons.length).toBeGreaterThan(0);
for (const button of buttons) {
const box = button.getBoundingClientRect();
expect(
Math.min(Math.round(box.width), Math.round(box.height)),
button.getAttribute('aria-label') ?? '',
).toBeGreaterThanOrEqual(44);
}
});
});
@@ -0,0 +1,162 @@
/**
* Every control a finger meets is at least 44px (#186).
*
* #56 sized the playback transport for a thumb and named 44px; the
* queue header keeps it; nothing else was resized. So the controls a
* user meets on *every* screen — the sort control, its direction
* button, the page actions, the overflow trigger and the phone's search
* button — sat between a third and two thirds of the app's own floor.
* Measured on the reference device (TLP301, 424x439): `page-sort` 99x23,
* `page-sort-direction` **28x21**, `page-actions-more` 38x27,
* `search-trigger` 40x40.
*
* Unlike the seek bar's target (#187), this one can be measured here
* rather than inferred from the stylesheet. There the painted track had
* to stay thin, so the hit area was grown past its own box and only a
* phone-width layout of a third-party slider could show it. Here the
* control *is* the target, so a real Chromium rendering a real
* `page-header` gives the actual answer — and because it is a `min-size`
* rather than a media query, the answer is the same at every width,
* which is what makes it checkable in this tier at all.
*
* That is also why there is no phone branch to test: a 44px control on
* a desktop is merely large, and a second declaration of what a phone
* shows is a second thing to keep in step.
*/
import { describe, expect, it } from 'vitest';
import type { PageAction, PageHeader } from '@components/page-header/page-header';
import '@components/page-header/page-header';
import { fixture, shadowAll } from '@test/support/render';
/** The app's touch floor, from #56. */
const FLOOR = 44;
const SORTS = [
{ id: 'name', label: 'Name' },
{ id: 'tracks', label: 'Tracks' },
];
function actions(): PageAction[] {
return [
{ id: 'import', label: 'Import', icon: 'file-import', priority: 0, onSelect: () => {} },
{ id: 'new', label: 'New Playlist', icon: 'plus', priority: 2, onSelect: () => {} },
];
}
/** Every visible control in the header's own shadow root. */
function controlsOf(el: PageHeader): { name: string; el: HTMLElement }[] {
return shadowAll<HTMLElement>(el, 'button, select')
.filter((c) => !(c as HTMLButtonElement).hidden)
.map((c) => ({
name: c.dataset.testid ?? (c.className || c.tagName.toLowerCase()),
el: c,
}));
}
function tooSmall(controls: { name: string; el: HTMLElement }[]): string[] {
return controls
.map(({ name, el }) => {
const b = el.getBoundingClientRect();
return { name, w: Math.round(b.width), h: Math.round(b.height) };
})
.filter((c) => c.w < FLOOR || c.h < FLOOR)
.map((c) => `${c.name} ${c.w}x${c.h}`);
}
describe("the page header's controls", () => {
it('all meet the touch floor', async () => {
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
count: 50,
countNoun: 'playlist',
sortOptions: SORTS,
sortField: 'name',
sortDirection: 'asc',
actions: actions(),
});
const controls = controlsOf(el);
// A sweep that found no controls passes vacuously — the same first
// assertion icon-language.test.ts makes, for the same reason.
expect(controls.length).toBeGreaterThan(0);
// The two that were smallest, named so a regression says which.
expect(controls.map((c) => c.name)).toContain('page-sort-direction');
expect(controls.map((c) => c.name)).toContain('page-sort');
expect(tooSmall(controls)).toEqual([]);
});
it('grows the target without growing the box, so the overflow fit is untouched', async () => {
// The regression this exists for, and it was a real one: growing
// the two square controls to 44px *wide* added 22px to the header,
// which fit at every width Chromium was checked at and clipped the
// overflow trigger at 320x600 in WebKit -- the engine closest to
// what ships, and the one no machine here can run. #69's fit pass
// measures inline size, so a taller control is free and a wider one
// is not.
//
// Negative inline margins are what keep the box out of it: the
// padding makes the target, and the margin gives the space back.
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
sortOptions: SORTS,
sortField: 'name',
actions: actions(),
});
el.style.width = '320px';
for (let frame = 0; frame < 3; frame += 1) {
await new Promise((r) => requestAnimationFrame(r));
await el.updateComplete;
}
for (const selector of ['.sort-dir', '.more-button']) {
const control = shadowAll<HTMLElement>(el, selector).filter(
(c) => !(c as HTMLButtonElement).hidden,
)[0];
expect(control, selector).toBeTruthy();
const style = getComputedStyle(control!);
const added =
parseFloat(style.marginInlineStart) + parseFloat(style.marginInlineEnd);
expect(added, `${selector} gives its extra width back`).toBeLessThan(0);
}
});
it('includes the overflow trigger, which is the route to the rest', async () => {
// At 320px the fit pass collapses actions into the menu, so the
// trigger is rendered — and it is then the only way to reach them,
// which makes it the last control that should be hard to hit.
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
sortOptions: SORTS,
sortField: 'name',
actions: actions(),
});
el.style.width = '320px';
for (let frame = 0; frame < 3; frame += 1) {
await new Promise((r) => requestAnimationFrame(r));
await el.updateComplete;
}
const more = shadowAll<HTMLButtonElement>(el, '.more-button').filter(
(b) => !b.hidden,
);
expect(more.length).toBe(1);
const box = more[0]!.getBoundingClientRect();
expect(Math.round(box.width)).toBeGreaterThanOrEqual(FLOOR);
expect(Math.round(box.height)).toBeGreaterThanOrEqual(FLOOR);
});
});