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`
`; } } declare global { interface HTMLElementTagNameMap { 'selection-bar': SelectionBar; } }