Plan 019 phases 3 and 4, which finish #63. The queue panel and both playlist detail views get tap-to-play and hold-to-select; the playlist views get swipe-to-queue as well. Phase 3 was not the pure wiring the plan expected, in two places. A tap on a queue row plays that position. Copying track-list's tap -- which sets the queue to the list the row is in -- would rebuild the queue from the queue, discarding its source, its shuffle order and anything inserted by hand. It reads as a no-op and is not one. And the queue panel has no swipe, deliberately. A right swipe means add to the queue everywhere else it exists, and a queue row is already in the queue; the only thing it could mean there is remove, which is the same gesture with the opposite effect one screen away. Removing a queue row is on the row, on its sheet since #60, and now on its selection bar. The assertion is that its rows do not opt in. The reveal became utils/swipe-to-queue.ts rather than being copied into three lists, keyed on a data-swipe attribute so one stylesheet carries the touch-action half of the device fix to rows that are called two different things. Phase 4 was already true and is now asserted: a claimed tap has its click swallowed, so an explore-link inside a row never sees one and tap-to-play wins with no rule of its own. Its test was vacuous when written -- the tap helper sent no click, so there was nothing to swallow -- which also weakened phase 1's. It sends one now. Escape leaves selection mode, from selection-bar rather than from each of the four hosts, since that element exists only while the mode does. The platform's back gesture deliberately does not reach it: the shell owns the history stack and four lists reaching for history is four stacks. That is #200. Verified on the reference phone: a queue row taps to its own index and refuses a swipe, a playlist row queues on a swipe and plays its playlist on a tap, and a hold raises the bar without the menu. Closes #63
219 lines
8.0 KiB
TypeScript
219 lines
8.0 KiB
TypeScript
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.
|
||
*
|
||
* **Escape leaves the mode, from here rather than from each host.**
|
||
* This element exists only while the mode does, so it is the one place
|
||
* a dismissal can be attached and detached with the thing it
|
||
* dismisses. It is the same exception the overlaid queue's Escape is.
|
||
*
|
||
* **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 }),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Escape leaves the mode.
|
||
*
|
||
* A mode changes what a tap means, so it has to have an exit that
|
||
* is not "find the ×" -- and this is the documented exception to
|
||
* the app's one-keyboard-authority rule, on exactly the grounds
|
||
* the overlaid queue's Escape is: **it is a dismissal, not a
|
||
* shortcut**, so it is not a panel-scoped binding and it is
|
||
* attached only while there is something to dismiss. Putting it
|
||
* here rather than in each host is what gives all four surfaces
|
||
* the same answer, since this element exists only while the mode
|
||
* does.
|
||
*
|
||
* The platform's own back gesture is the other half of that and is
|
||
* deliberately *not* here: the shell owns the history stack
|
||
* (#6/#55), and a component reaching for `history` itself is how
|
||
* two stacks come to disagree about what one press means -- the
|
||
* fault that deleted `navStack`. See #200.
|
||
*/
|
||
private onKeydown = (e: KeyboardEvent) => {
|
||
if (e.key !== 'Escape' || this.count <= 0) return;
|
||
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
this.emit('selection-exit');
|
||
};
|
||
|
||
override connectedCallback() {
|
||
super.connectedCallback();
|
||
document.addEventListener('keydown', this.onKeydown, true);
|
||
}
|
||
|
||
override disconnectedCallback() {
|
||
super.disconnectedCallback();
|
||
document.removeEventListener('keydown', this.onKeydown, 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;
|
||
}
|
||
}
|