Files
yellowjacket/frontend/src/components/config-page/shortcut-capture.ts
T
logan f00d0c4655 fix(a11y): name every form control in Settings
Measured with Accessibility.getFullAXTree against the running app with
all seven sections expanded: 24 of 93 controls computed an empty name.
Every config-field select and toggle, and all eighteen track-list
column checkboxes, had a <label> sitting right beside them with nothing
associating the two. Now 0 of 93.

Not in the audit, and a11y.6 says why in its own line: it scanned every
<button>, and none of these is one. Same shape as the count that sent
Phase 1 looking for an unnamed sort control — the claim was answering a
narrower question than it reads as.

The fields use `for`/`id` rather than aria-label, for what it buys
beyond the name: the label text becomes a click target for the control.
A fixed id is safe only because each config-field is its own shadow
root.

Two more are named but identify nothing, which is a11y.32's complaint
one page over: three shortcut buttons announced themselves as "S", and
thirty-six column arrows as "Move up".
2026-08-13 01:52:08 -04:00

181 lines
5.3 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);
}
`;
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;
}
}