Three controls are revealed by :hover and are the only route to their action on a device that has none. #68 hid the home card's play button on touch, which was right because tapping the card does the same thing; these are the opposite case, so hiding them removes the action outright and leaving them costs the same long-press flash #68 was filed for -- they are visibility:hidden / opacity:0, so on touch they are invisible controls that still take taps. track-details' cover-art overlay and remove, and shortcut-capture's reset, are always visible under `@media not all and (hover: hover)`. The queue row's remove is the third case the report names and takes the other treatment, because #60 has since landed: the row's context menu is a bottom sheet carrying "Remove from Queue", so the action is one long-press away and an always-visible X would spend part of a 424px row on something already reachable. It is display:none outside `(hover: hover) and (pointer: fine)` rather than visibility:hidden, which would leave a button holding its hit area and its place in the accessibility tree -- the trap this issue is about. The rule is not extracted into styles/ yet: that leaves two call sites of the always-visible form, under the four the report names. No tier here can render as a touch device, so the tests read the parsed stylesheet the way #68's does and say so; the touch and hover renderings were measured against the running app in a hasTouch context instead. Closes #137
192 lines
5.7 KiB
TypeScript
192 lines
5.7 KiB
TypeScript
import { LitElement, html, css } from 'lit';
|
|
import { customElement, property, state } from 'lit/decorators.js';
|
|
import { buildKeyString } from '../../services/keyboard-shortcut-service';
|
|
|
|
@customElement('shortcut-capture')
|
|
export class ShortcutCapture extends LitElement {
|
|
@property() action = '';
|
|
@property() currentKey = '';
|
|
@property() defaultKey = '';
|
|
|
|
/**
|
|
* What the binding does, for the name.
|
|
*
|
|
* The button's text is the *key* — so the shortcuts list rendered
|
|
* three buttons called "S", two called "Down" and one called "?",
|
|
* beside a visible label that named none of them (it is a sibling,
|
|
* in another shadow root, and nothing associated the two). The
|
|
* label is what a control is for; the key is its value.
|
|
*/
|
|
@property() label = '';
|
|
|
|
@state() private recording = false;
|
|
|
|
static override styles = css`
|
|
:host {
|
|
display: inline-block;
|
|
}
|
|
button {
|
|
font-family: inherit;
|
|
font-size: var(--yj-text-sm, 13px);
|
|
padding: 4px 12px;
|
|
border-radius: 4px;
|
|
border: 1px solid var(--yj-border, #555);
|
|
background: var(--yj-bg-input, #333);
|
|
color: var(--yj-text-primary, #eee);
|
|
cursor: pointer;
|
|
min-width: 80px;
|
|
text-align: center;
|
|
transition:
|
|
border-color 0.15s,
|
|
background 0.15s;
|
|
}
|
|
button:hover {
|
|
border-color: var(--yj-accent, #ffd43b);
|
|
}
|
|
button.recording {
|
|
border-color: var(--yj-accent, #ffd43b);
|
|
background: var(--yj-bg-active, #444);
|
|
animation: pulse 1.2s ease-in-out infinite;
|
|
}
|
|
button.not-set {
|
|
color: var(--yj-text-tertiary, #888);
|
|
font-style: italic;
|
|
}
|
|
@keyframes pulse {
|
|
0%,
|
|
100% {
|
|
opacity: 1;
|
|
}
|
|
50% {
|
|
opacity: 0.7;
|
|
}
|
|
}
|
|
.reset-btn {
|
|
font-size: var(--yj-text-xs, 11px);
|
|
padding: 2px 6px;
|
|
margin-left: 4px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--yj-text-tertiary, #888);
|
|
cursor: pointer;
|
|
min-width: auto;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
}
|
|
:host(:hover) .reset-btn {
|
|
opacity: 1;
|
|
}
|
|
.reset-btn:hover {
|
|
color: var(--yj-accent-text, #ffd43b);
|
|
}
|
|
/*
|
|
* Reset is the only way to put a rebound shortcut back, so where
|
|
* the device has no hover it is always visible rather than an
|
|
* invisible button holding its hit area. The inverse of #68's
|
|
* rule, which applies where the hover control is redundant.
|
|
*/
|
|
@media not all and (hover: hover) {
|
|
.reset-btn {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
`;
|
|
|
|
private handleClick = () => {
|
|
this.recording = true;
|
|
// Focus self so keydown events arrive
|
|
this.shadowRoot?.querySelector('button')?.focus();
|
|
};
|
|
|
|
private handleKeydown = (e: KeyboardEvent) => {
|
|
if (!this.recording) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const keyStr = buildKeyString(e);
|
|
if (!keyStr) return; // bare modifier press — keep recording
|
|
|
|
if (keyStr === 'Escape') {
|
|
this.recording = false;
|
|
return;
|
|
}
|
|
|
|
this.recording = false;
|
|
|
|
this.dispatchEvent(
|
|
new CustomEvent('shortcut-change', {
|
|
detail: { action: this.action, key: keyStr },
|
|
bubbles: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
};
|
|
|
|
private handleBlur = () => {
|
|
// Cancel recording if focus leaves
|
|
if (this.recording) {
|
|
this.recording = false;
|
|
}
|
|
};
|
|
|
|
private handleReset = (e: Event) => {
|
|
e.stopPropagation();
|
|
if (this.defaultKey && this.currentKey !== this.defaultKey) {
|
|
this.dispatchEvent(
|
|
new CustomEvent('shortcut-change', {
|
|
detail: {
|
|
action: this.action,
|
|
key: this.defaultKey,
|
|
},
|
|
bubbles: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
}
|
|
};
|
|
|
|
override render() {
|
|
const showReset =
|
|
this.defaultKey && this.currentKey !== this.defaultKey;
|
|
return html`
|
|
<button
|
|
class=${this.recording
|
|
? 'recording'
|
|
: this.currentKey
|
|
? ''
|
|
: 'not-set'}
|
|
aria-label=${this.label
|
|
? `${this.label} shortcut: ${this.currentKey || 'not set'}`
|
|
: ''}
|
|
@click=${this.handleClick}
|
|
@keydown=${this.handleKeydown}
|
|
@blur=${this.handleBlur}
|
|
>
|
|
${this.recording
|
|
? 'Press a key combo\u2026'
|
|
: this.currentKey || 'Not set'}
|
|
</button>
|
|
${showReset
|
|
? html`
|
|
<button
|
|
class="reset-btn"
|
|
@click=${this.handleReset}
|
|
title="Reset to default (${this.defaultKey})"
|
|
aria-label="Reset ${this.label ||
|
|
this.action} to ${this.defaultKey}"
|
|
>
|
|
\u21BA
|
|
</button>
|
|
`
|
|
: ''}
|
|
`;
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'shortcut-capture': ShortcutCapture;
|
|
}
|
|
}
|