fix(a11y): make the five hand-rolled dialogs real dialogs
Four autotag dialogs and the remove-library confirmation rendered a plain overlay div: no role, no aria-modal, no focus trap, no focus restore. The two gating an irreversible on-disk metadata rewrite left focus wherever it was, so a screen-reader user could confirm 'this rewrites audio files' without ever hearing the warning (a11y.4, a11y.16). Five wa-dialog usages already did this correctly and confirmAction() existed from Phase 3, so nothing new was invented: the three that are pure confirmations became confirmAction() calls, and the two carrying input became wa-dialogs in place. Verified in the running app — the native dialog matches :modal, focus lands in the first field, Escape closes and the view state follows. autotag-view's last document keydown listener goes with them. It existed only because its dialogs could not close themselves.
This commit is contained in:
@@ -26,6 +26,8 @@ import { libraryStore } from '../../store/library-store';
|
|||||||
import { notificationStore } from '../../store/notification-store';
|
import { notificationStore } from '../../store/notification-store';
|
||||||
import { describeError, explainError } from '../../utils/describe-error';
|
import { describeError, explainError } from '../../utils/describe-error';
|
||||||
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
||||||
|
import { confirmAction } from '../confirm-dialog/confirm-dialog';
|
||||||
|
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||||
|
|
||||||
type PendingItem = autotagservice.PendingItem;
|
type PendingItem = autotagservice.PendingItem;
|
||||||
type ScoreView = autotagservice.ScoreView;
|
type ScoreView = autotagservice.ScoreView;
|
||||||
@@ -1026,44 +1028,19 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
|||||||
border: 1px solid currentColor;
|
border: 1px solid currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Dialogs (carried over) ── */
|
/* ── Dialogs ──
|
||||||
|
The frame, the backdrop, the focus trap and the Escape
|
||||||
|
handling belong to wa-dialog; what is left here is the
|
||||||
|
content these two put inside it. */
|
||||||
|
|
||||||
.dialog-overlay {
|
wa-dialog::part(dialog) {
|
||||||
position: fixed;
|
background: var(--yj-bg-surface, #212529);
|
||||||
inset: 0;
|
color: var(--yj-text-primary, #fff);
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog {
|
wa-dialog p { margin: 0 0 1rem 0; font-size: 0.9rem; }
|
||||||
background: var(--yj-bg-surface, #222);
|
|
||||||
padding: 1.25rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
min-width: 420px;
|
|
||||||
max-width: 560px;
|
|
||||||
border: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.1));
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-header { display: flex; align-items: center; margin-bottom: 0.75rem; }
|
.url-input {
|
||||||
.dialog-header h3 { margin: 0; }
|
|
||||||
|
|
||||||
.dialog-close {
|
|
||||||
margin-left: auto;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--yj-text-secondary, #b3b3b3);
|
|
||||||
border: 0;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-close:hover { color: var(--yj-text-primary, #fff); }
|
|
||||||
.dialog p { margin: 0 0 1rem 0; font-size: 0.9rem; }
|
|
||||||
|
|
||||||
.dialog input[type="url"] {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -1074,16 +1051,15 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog .row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── In-app search dialog ── */
|
/* ── In-app search dialog ── */
|
||||||
|
|
||||||
.search-dialog { min-width: 480px; }
|
.search-dialog::part(dialog) { min-width: 480px; }
|
||||||
|
|
||||||
.search-kind {
|
.search-kind {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1119,8 +1095,15 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-result {
|
.search-result {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
padding: 0.45rem 0.5rem;
|
padding: 0.45rem 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
border-bottom: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.05));
|
border-bottom: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.05));
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
@@ -1166,7 +1149,10 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
|||||||
@state() private loading = false;
|
@state() private loading = false;
|
||||||
@state() private foldersLoading = false;
|
@state() private foldersLoading = false;
|
||||||
@state() private errorMessage = '';
|
@state() private errorMessage = '';
|
||||||
@state() private dialog: 'none' | 'paste' | 'warning' | 'leave' | 'search' = 'none';
|
// Two of the four dialogs this view used to hand-roll were plain
|
||||||
|
// confirmations and are `confirmAction()` calls now; the two that
|
||||||
|
// remain carry input and so are `<wa-dialog>`s in the template.
|
||||||
|
@state() private dialog: 'none' | 'paste' | 'search' = 'none';
|
||||||
@state() private pasteURL = '';
|
@state() private pasteURL = '';
|
||||||
|
|
||||||
// In-app MusicBrainz search ("suggest a candidate") dialog state.
|
// In-app MusicBrainz search ("suggest a candidate") dialog state.
|
||||||
@@ -1175,6 +1161,9 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
|||||||
@state() private searchArtist = '';
|
@state() private searchArtist = '';
|
||||||
@state() private searchResults: SearchHitView[] = [];
|
@state() private searchResults: SearchHitView[] = [];
|
||||||
@state() private searchLoading = false;
|
@state() private searchLoading = false;
|
||||||
|
/** Whether a search has actually been run, as opposed to the fields
|
||||||
|
* merely being seeded from the current folder. */
|
||||||
|
@state() private searchRan = false;
|
||||||
@state() private searchError = '';
|
@state() private searchError = '';
|
||||||
@state() private queueMenuOpen = false;
|
@state() private queueMenuOpen = false;
|
||||||
// Collapsible sidebar sections. Pending stays expanded so the
|
// Collapsible sidebar sections. Pending stays expanded so the
|
||||||
@@ -1197,7 +1186,11 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
|||||||
* when it is disconnected — which never happens, because the view
|
* when it is disconnected — which never happens, because the view
|
||||||
* is cached (see utils/view-lifecycle.ts). */
|
* is cached (see utils/view-lifecycle.ts). */
|
||||||
protected override onViewActivate(): void {
|
protected override onViewActivate(): void {
|
||||||
this.listenWhileActive(document, 'keydown', this.onKeydown as EventListener);
|
// This view owned one last document keydown listener, for Escape,
|
||||||
|
// because its dialogs were hand-rolled and nothing else would
|
||||||
|
// close them. They are `wa-dialog`s now and close themselves, so
|
||||||
|
// the shortcut service is the only thing on this page deciding
|
||||||
|
// what a key means — which is what Phase 1 was for.
|
||||||
this.listenWhileActive(
|
this.listenWhileActive(
|
||||||
document,
|
document,
|
||||||
'mousedown',
|
'mousedown',
|
||||||
@@ -1391,49 +1384,42 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
|||||||
private onPasteKeydown = (e: KeyboardEvent) => {
|
private onPasteKeydown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Enter') this.onPasteSubmit();
|
if (e.key === 'Enter') this.onPasteSubmit();
|
||||||
};
|
};
|
||||||
private onWarningCancel = () => { this.dialog = 'none'; };
|
/** Acknowledge the once-per-library "this rewrites files" warning,
|
||||||
private onWarningContinue = async () => {
|
* then apply. Returns without applying if the user backed out. */
|
||||||
if (!this.current) return;
|
private async confirmWarningThenApply(): Promise<void> {
|
||||||
|
const current = this.current;
|
||||||
|
|
||||||
this.markWarningAcked(this.current.libraryId);
|
if (!current) return;
|
||||||
|
|
||||||
// Unwrapped, a rejection here meant the lines after it — the
|
const library = current.libraryName || `library #${current.libraryId}`;
|
||||||
// one that closes the dialog — never ran, and the dialog sat
|
const ok = await confirmAction({
|
||||||
// there forever (errors.m6).
|
title: 'Heads up: this rewrites audio files',
|
||||||
|
message: `Applying autotag writes new metadata directly to every track in ${current.albumName}. `
|
||||||
|
+ 'The change lands on disk and is not automatically reversible. '
|
||||||
|
+ 'Files outside this library are not touched.',
|
||||||
|
impact: `This warning shows once per library. Continue to acknowledge for ${library}.`,
|
||||||
|
confirmLabel: 'Continue',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
this.markWarningAcked(current.libraryId);
|
||||||
|
|
||||||
|
// Unwrapped, a rejection here meant the lines after it never ran
|
||||||
|
// and the dialog sat there forever (errors.m6). The dialog closes
|
||||||
|
// itself now, but the apply still has to happen either way.
|
||||||
try {
|
try {
|
||||||
await AckLibraryWarning(this.current.libraryId);
|
await AckLibraryWarning(current.libraryId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('autotag: could not record the warning ack', err);
|
console.error('autotag: could not record the warning ack', err);
|
||||||
this.errorMessage = describeError(
|
this.errorMessage = describeError(
|
||||||
err,
|
err,
|
||||||
'That acknowledgement could not be saved.',
|
'That acknowledgement could not be saved.',
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
this.dialog = 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.executeApply();
|
await this.executeApply();
|
||||||
};
|
}
|
||||||
private onLeaveCancel = () => { this.dialog = 'none'; };
|
|
||||||
private onLeaveConfirm = async () => {
|
|
||||||
if (!this.current) return;
|
|
||||||
|
|
||||||
this.dialog = 'none';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await LeaveAsIs(this.current.groupKey);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('autotag: leave-as-is failed', err);
|
|
||||||
this.errorMessage = describeError(
|
|
||||||
err,
|
|
||||||
'That folder could not be left as it is.',
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.refreshAfterAction();
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Open the in-app MB search dialog, seeding the query fields from
|
/** Open the in-app MB search dialog, seeding the query fields from
|
||||||
* the current folder's album/artist so the common case is one
|
* the current folder's album/artist so the common case is one
|
||||||
@@ -1444,6 +1430,10 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
|||||||
this.searchArtist = this.current?.albumArtist ?? '';
|
this.searchArtist = this.current?.albumArtist ?? '';
|
||||||
this.searchResults = [];
|
this.searchResults = [];
|
||||||
this.searchError = '';
|
this.searchError = '';
|
||||||
|
// The query is seeded from the folder, so "no results" was true of
|
||||||
|
// a search nobody had run yet — visible the moment the dialog
|
||||||
|
// opens, under the fields still being filled in.
|
||||||
|
this.searchRan = false;
|
||||||
this.dialog = 'search';
|
this.dialog = 'search';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1454,6 +1444,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
|||||||
if (!query) return;
|
if (!query) return;
|
||||||
this.searchLoading = true;
|
this.searchLoading = true;
|
||||||
this.searchError = '';
|
this.searchError = '';
|
||||||
|
this.searchRan = true;
|
||||||
try {
|
try {
|
||||||
this.searchResults = await SearchCandidates(
|
this.searchResults = await SearchCandidates(
|
||||||
this.searchKind, query, this.searchArtist.trim(),
|
this.searchKind, query, this.searchArtist.trim(),
|
||||||
@@ -1734,9 +1725,11 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
|||||||
private async onApply(): Promise<void> {
|
private async onApply(): Promise<void> {
|
||||||
if (!this.current || !this.score || this.score.candidates.length === 0) return;
|
if (!this.current || !this.score || this.score.candidates.length === 0) return;
|
||||||
if (!this.hasLibraryWarningBeenAcked(this.current.libraryId)) {
|
if (!this.hasLibraryWarningBeenAcked(this.current.libraryId)) {
|
||||||
this.dialog = 'warning';
|
await this.confirmWarningThenApply();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.executeApply();
|
await this.executeApply();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1803,14 +1796,27 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
|||||||
private async onLeave(): Promise<void> {
|
private async onLeave(): Promise<void> {
|
||||||
if (!this.current) return;
|
if (!this.current) return;
|
||||||
|
|
||||||
if (this.topScore() < CONFIDENT_SCORE) {
|
const groupKey = this.current.groupKey;
|
||||||
this.dialog = 'leave';
|
|
||||||
|
|
||||||
return;
|
if (this.topScore() < CONFIDENT_SCORE) {
|
||||||
|
const top = Math.round(this.topScore() * 100);
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: 'Keep the current tags?',
|
||||||
|
message: `No candidate scored high enough to trust automatically (top is ${top}%). `
|
||||||
|
+ `“Leave as-is” marks the local tags on ${this.current.albumName} as correct `
|
||||||
|
+ 'and removes this folder from the review queue.',
|
||||||
|
impact: 'If the local tags are wrong, prefer Skip (S) instead — '
|
||||||
|
+ 'that leaves the folder pending for later review.',
|
||||||
|
confirmLabel: 'Mark as correct',
|
||||||
|
});
|
||||||
|
|
||||||
|
// The confirmation is modal, but it is still an await: the
|
||||||
|
// folder we asked about has to be the folder we act on.
|
||||||
|
if (!ok || this.current?.groupKey !== groupKey) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await LeaveAsIs(this.current.groupKey);
|
await LeaveAsIs(groupKey);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('autotag: leave-as-is failed', err);
|
console.error('autotag: leave-as-is failed', err);
|
||||||
this.errorMessage = describeError(
|
this.errorMessage = describeError(
|
||||||
@@ -1858,18 +1864,6 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Escape closes this view's hand-rolled dialogs. Everything else
|
|
||||||
* the page binds is a registered shortcut in the `autotag` panel
|
|
||||||
* scope, so the shortcut service is the only thing deciding what a
|
|
||||||
* key means — including on the pages this view is not on. */
|
|
||||||
private onKeydown = (e: KeyboardEvent): void => {
|
|
||||||
if (e.key !== 'Escape' || this.dialog === 'none') return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
this.dialog = 'none';
|
|
||||||
this.pasteURL = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ── Version clustering ── */
|
/* ── Version clustering ── */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2948,112 +2942,41 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
|||||||
|
|
||||||
private renderPasteDialog() {
|
private renderPasteDialog() {
|
||||||
return html`
|
return html`
|
||||||
<div class="dialog-overlay" @click=${(e: MouseEvent) => {
|
<wa-dialog
|
||||||
if (e.target === e.currentTarget) this.onPasteCancel();
|
label="Paste MusicBrainz URL"
|
||||||
}}>
|
data-testid="autotag-paste-dialog"
|
||||||
<div class="dialog">
|
?open=${this.dialog === 'paste'}
|
||||||
<div class="dialog-header">
|
@wa-hide=${this.onPasteCancel}
|
||||||
<h3>Paste MusicBrainz URL</h3>
|
>
|
||||||
<button class="dialog-close" aria-label="Close"
|
<p>
|
||||||
@click=${this.onPasteCancel}>×</button>
|
Paste a MusicBrainz release or release-group URL.
|
||||||
</div>
|
Tracks are aligned automatically when it loads.
|
||||||
<p>
|
</p>
|
||||||
Paste a MusicBrainz release or release-group URL.
|
<input type="url"
|
||||||
Tracks are aligned automatically when it loads.
|
class="url-input"
|
||||||
</p>
|
aria-label="MusicBrainz release URL"
|
||||||
<input type="url"
|
placeholder="https://musicbrainz.org/release/..."
|
||||||
placeholder="https://musicbrainz.org/release/..."
|
.value=${this.pasteURL}
|
||||||
.value=${this.pasteURL}
|
@input=${this.onPasteInput}
|
||||||
@input=${this.onPasteInput}
|
@keydown=${this.onPasteKeydown}
|
||||||
@keydown=${this.onPasteKeydown}
|
autofocus>
|
||||||
autofocus>
|
<div class="row" slot="footer">
|
||||||
<div class="row">
|
<button class="secondary" @click=${this.onPasteCancel}>Cancel</button>
|
||||||
<button class="secondary" @click=${this.onPasteCancel}>Cancel</button>
|
<button @click=${this.onPasteSubmit}>Load</button>
|
||||||
<button @click=${this.onPasteSubmit}>Load</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</wa-dialog>
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderWarningDialog() {
|
|
||||||
if (!this.current) return nothing;
|
|
||||||
return html`
|
|
||||||
<div class="dialog-overlay" @click=${(e: MouseEvent) => {
|
|
||||||
if (e.target === e.currentTarget) this.onWarningCancel();
|
|
||||||
}}>
|
|
||||||
<div class="dialog">
|
|
||||||
<div class="dialog-header">
|
|
||||||
<h3>Heads up: this rewrites audio files</h3>
|
|
||||||
<button class="dialog-close" aria-label="Close"
|
|
||||||
@click=${this.onWarningCancel}>×</button>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
Applying autotag writes new metadata directly to every
|
|
||||||
track in <strong>${this.current.albumName}</strong>.
|
|
||||||
The change lands on disk and is not automatically
|
|
||||||
reversible. Files outside this library are not
|
|
||||||
touched.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
This warning shows once per library. Click Continue to
|
|
||||||
acknowledge for
|
|
||||||
<strong>${this.current.libraryName || `library #${this.current.libraryId}`}</strong>.
|
|
||||||
</p>
|
|
||||||
<div class="row">
|
|
||||||
<button class="secondary" @click=${this.onWarningCancel}>Cancel</button>
|
|
||||||
<button @click=${this.onWarningContinue}>Continue</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderLeaveDialog() {
|
|
||||||
if (!this.current) return nothing;
|
|
||||||
const top = this.score?.candidates[0];
|
|
||||||
const topPct = top ? Math.round(top.score * 100) : 0;
|
|
||||||
return html`
|
|
||||||
<div class="dialog-overlay" @click=${(e: MouseEvent) => {
|
|
||||||
if (e.target === e.currentTarget) this.onLeaveCancel();
|
|
||||||
}}>
|
|
||||||
<div class="dialog">
|
|
||||||
<div class="dialog-header">
|
|
||||||
<h3>Keep the current tags?</h3>
|
|
||||||
<button class="dialog-close" aria-label="Close"
|
|
||||||
@click=${this.onLeaveCancel}>×</button>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
No candidate scored high enough to trust automatically
|
|
||||||
(top is ${topPct}%). "Leave as-is" marks the local
|
|
||||||
tags on <strong>${this.current.albumName}</strong> as
|
|
||||||
correct and removes this folder from the review queue.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If the local tags are wrong, prefer <kbd>S</kbd> (Skip)
|
|
||||||
instead — that leaves the folder pending for later
|
|
||||||
review.
|
|
||||||
</p>
|
|
||||||
<div class="row">
|
|
||||||
<button class="secondary" @click=${this.onLeaveCancel}>Cancel</button>
|
|
||||||
<button @click=${this.onLeaveConfirm}>Mark as correct</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderSearchDialog() {
|
private renderSearchDialog() {
|
||||||
return html`
|
return html`
|
||||||
<div class="dialog-overlay" @click=${(e: MouseEvent) => {
|
<wa-dialog
|
||||||
if (e.target === e.currentTarget) this.onSearchCancel();
|
label="Search MusicBrainz"
|
||||||
}}>
|
class="search-dialog"
|
||||||
<div class="dialog search-dialog">
|
data-testid="autotag-search-dialog"
|
||||||
<div class="dialog-header">
|
?open=${this.dialog === 'search'}
|
||||||
<h3>Search MusicBrainz</h3>
|
@wa-hide=${this.onSearchCancel}
|
||||||
<button class="dialog-close" aria-label="Close"
|
>
|
||||||
@click=${this.onSearchCancel}>×</button>
|
|
||||||
</div>
|
|
||||||
<div class="search-kind">
|
<div class="search-kind">
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="searchKind" value="releasegroup"
|
<input type="radio" name="searchKind" value="releasegroup"
|
||||||
@@ -3069,55 +2992,54 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" class="search-input"
|
<input type="text" class="search-input"
|
||||||
|
aria-label=${this.searchKind === 'recording' ? 'Track title' : 'Album name'}
|
||||||
placeholder=${this.searchKind === 'recording' ? 'Track title' : 'Album name'}
|
placeholder=${this.searchKind === 'recording' ? 'Track title' : 'Album name'}
|
||||||
.value=${this.searchQuery}
|
.value=${this.searchQuery}
|
||||||
@input=${(e: Event) => { this.searchQuery = (e.target as HTMLInputElement).value; }}
|
@input=${(e: Event) => { this.searchQuery = (e.target as HTMLInputElement).value; }}
|
||||||
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') void this.runSearch(); }}
|
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') void this.runSearch(); }}
|
||||||
autofocus>
|
autofocus>
|
||||||
<input type="text" class="search-input"
|
<input type="text" class="search-input"
|
||||||
|
aria-label="Artist (optional)"
|
||||||
placeholder="Artist (optional)"
|
placeholder="Artist (optional)"
|
||||||
.value=${this.searchArtist}
|
.value=${this.searchArtist}
|
||||||
@input=${(e: Event) => { this.searchArtist = (e.target as HTMLInputElement).value; }}
|
@input=${(e: Event) => { this.searchArtist = (e.target as HTMLInputElement).value; }}
|
||||||
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') void this.runSearch(); }}>
|
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') void this.runSearch(); }}>
|
||||||
<div class="row">
|
|
||||||
<button class="secondary" @click=${this.onSearchCancel}>Cancel</button>
|
|
||||||
<button @click=${() => { void this.runSearch(); }}
|
|
||||||
?disabled=${this.searchLoading || this.searchQuery.trim() === ''}>
|
|
||||||
${this.searchLoading ? 'Searching…' : 'Search'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
${this.searchError
|
${this.searchError
|
||||||
? html`<div class="error" style="margin-top:0.75rem;">${this.searchError}</div>`
|
? html`<div class="error" style="margin-top:0.75rem;">${this.searchError}</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
${this.searchResults.length > 0 ? html`
|
${this.searchResults.length > 0 ? html`
|
||||||
<div class="search-results">
|
<div class="search-results" role="list">
|
||||||
${this.searchResults.map((hit) => html`
|
${this.searchResults.map((hit) => html`
|
||||||
<div class="search-result"
|
<button type="button" class="search-result" role="listitem"
|
||||||
@click=${() => { void this.pickSearchResult(hit); }}>
|
@click=${() => { void this.pickSearchResult(hit); }}>
|
||||||
<div class="sr-title">${hit.title}</div>
|
<div class="sr-title">${hit.title}</div>
|
||||||
<div class="sr-sub">
|
<div class="sr-sub">
|
||||||
<span>${hit.artist || '—'}</span>
|
<span>${hit.artist || '—'}</span>
|
||||||
${hit.detail ? html`<span class="sr-detail">${hit.detail}</span>` : nothing}
|
${hit.detail ? html`<span class="sr-detail">${hit.detail}</span>` : nothing}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
`)}
|
`)}
|
||||||
</div>
|
</div>
|
||||||
` : this.searchLoading || this.searchError || this.searchQuery.trim() === ''
|
` : this.searchRan && !this.searchLoading && !this.searchError
|
||||||
? nothing
|
? html`<div class="search-empty">No results — try dropping the artist or switching Album/Track.</div>`
|
||||||
: html`<div class="search-empty">No results — try dropping the artist or switching Album/Track.</div>`}
|
: nothing}
|
||||||
</div>
|
<div class="row" slot="footer">
|
||||||
</div>
|
<button class="secondary" @click=${this.onSearchCancel}>Cancel</button>
|
||||||
|
<button @click=${() => { void this.runSearch(); }}
|
||||||
|
?disabled=${this.searchLoading || this.searchQuery.trim() === ''}>
|
||||||
|
${this.searchLoading ? 'Searching…' : 'Search'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</wa-dialog>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both dialogs render unconditionally so `wa-dialog` owns opening and
|
||||||
|
// closing (and therefore the focus trap and the focus restore); the
|
||||||
|
// `open` property is what says which — mounting one on demand would
|
||||||
|
// put the element and its `showModal()` in the same update.
|
||||||
private renderDialog() {
|
private renderDialog() {
|
||||||
switch (this.dialog) {
|
return html`${this.renderPasteDialog()}${this.renderSearchDialog()}`;
|
||||||
case 'paste': return this.renderPasteDialog();
|
|
||||||
case 'warning': return this.renderWarningDialog();
|
|
||||||
case 'leave': return this.renderLeaveDialog();
|
|
||||||
case 'search': return this.renderSearchDialog();
|
|
||||||
default: return nothing;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import './config-field';
|
|||||||
import './config-section';
|
import './config-section';
|
||||||
import './download-clients';
|
import './download-clients';
|
||||||
import './shortcut-capture';
|
import './shortcut-capture';
|
||||||
|
import { confirmAction } from '../confirm-dialog/confirm-dialog';
|
||||||
import { shortcutsStore } from '../../store/shortcuts-store';
|
import { shortcutsStore } from '../../store/shortcuts-store';
|
||||||
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
|
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
|
||||||
|
|
||||||
@@ -222,9 +223,10 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
@state() private libraries: library.Info[] = [];
|
@state() private libraries: library.Info[] = [];
|
||||||
@state() private editingLibraryId: number | null = null;
|
@state() private editingLibraryId: number | null = null;
|
||||||
@state() private editingName = '';
|
@state() private editingName = '';
|
||||||
|
/** The library a removal is in flight for. The impact is computed
|
||||||
|
* before the confirmation rather than held here, so this is now
|
||||||
|
* "which row is busy" and nothing else. */
|
||||||
@state() private removingLibraryId: number | null = null;
|
@state() private removingLibraryId: number | null = null;
|
||||||
@state() private removalImpact: library.RemovalImpact | null = null;
|
|
||||||
@state() private isRemoving = false;
|
|
||||||
@state() private activeMenuId: number | null = null;
|
@state() private activeMenuId: number | null = null;
|
||||||
@state() private concurrencyMode = 'auto';
|
@state() private concurrencyMode = 'auto';
|
||||||
@state() private indexStatus: explore.IndexStatus | null = null;
|
@state() private indexStatus: explore.IndexStatus | null = null;
|
||||||
@@ -629,51 +631,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cancel confirmation dialog */
|
|
||||||
.cancel-dialog-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-dialog {
|
|
||||||
background: var(
|
|
||||||
--yj-bg-surface,
|
|
||||||
#2a2a2a
|
|
||||||
);
|
|
||||||
border: 1px solid
|
|
||||||
var(--yj-border, #444);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 24px;
|
|
||||||
max-width: 420px;
|
|
||||||
width: 90%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-dialog-title {
|
|
||||||
font-size: var(--yj-text-lg, 18px);
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-dialog-message {
|
|
||||||
font-size: var(--yj-text-sm, 14px);
|
|
||||||
color: var(
|
|
||||||
--yj-text-secondary,
|
|
||||||
#aaa
|
|
||||||
);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-dialog-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(
|
background: var(
|
||||||
--yj-accent,
|
--yj-accent,
|
||||||
@@ -1158,34 +1115,58 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
this.editingName = (e.target as HTMLInputElement).value;
|
this.editingName = (e.target as HTMLInputElement).value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Say what will happen, then ask — through the one confirmation the
|
||||||
|
* app has, which is a `wa-dialog` and so brings the focus trap, the
|
||||||
|
* Escape handler and the focus restore the hand-rolled overlay this
|
||||||
|
* replaces had none of (a11y.16).
|
||||||
|
*/
|
||||||
private handleRemoveClick = async (id: number): Promise<void> => {
|
private handleRemoveClick = async (id: number): Promise<void> => {
|
||||||
this.activeMenuId = null;
|
this.activeMenuId = null;
|
||||||
|
|
||||||
try {
|
|
||||||
const impact = await GetRemovalImpact(id);
|
|
||||||
|
|
||||||
this.removalImpact = impact;
|
|
||||||
this.removingLibraryId = id;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to get removal impact:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleConfirmRemove = async (): Promise<void> => {
|
|
||||||
if (this.removingLibraryId === null) return;
|
|
||||||
|
|
||||||
const id = this.removingLibraryId;
|
|
||||||
const lib = this.libraries.find((l) => l.id === id);
|
const lib = this.libraries.find((l) => l.id === id);
|
||||||
const libName = lib?.name ?? 'Library';
|
const libName = lib?.name ?? 'Library';
|
||||||
|
let impact: library.RemovalImpact | null = null;
|
||||||
|
|
||||||
this.isRemoving = true;
|
try {
|
||||||
|
impact = await GetRemovalImpact(id);
|
||||||
|
} catch (err) {
|
||||||
|
// Asking without the impact is worse than not asking at all,
|
||||||
|
// so this is a failure the user has to see rather than a
|
||||||
|
// confirmation with a blank consequence.
|
||||||
|
console.error('Failed to get removal impact:', err);
|
||||||
|
notificationStore.persistent({
|
||||||
|
key: 'library-remove',
|
||||||
|
title: 'Library not removed',
|
||||||
|
text: `Could not work out what removing “${libName}” would delete. ${describeError(err)}`,
|
||||||
|
detail: String(err),
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: 'Remove library',
|
||||||
|
message: `Remove “${libName}”?`,
|
||||||
|
impact: `This deletes ${impact.trackCount} tracks, affects `
|
||||||
|
+ `${impact.playlistsAffected} playlists and removes `
|
||||||
|
+ `${impact.queueItemCount} queue items.`,
|
||||||
|
confirmLabel: 'Remove',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
await this.removeLibrary(id, libName);
|
||||||
|
};
|
||||||
|
|
||||||
|
private async removeLibrary(id: number, libName: string): Promise<void> {
|
||||||
|
this.removingLibraryId = id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const summary = await RemoveLibrary(id);
|
const summary = await RemoveLibrary(id);
|
||||||
|
|
||||||
this.removingLibraryId = null;
|
this.removingLibraryId = null;
|
||||||
this.removalImpact = null;
|
|
||||||
this.isRemoving = false;
|
|
||||||
notificationStore.transient({
|
notificationStore.transient({
|
||||||
tone: 'success',
|
tone: 'success',
|
||||||
key: 'library-remove',
|
key: 'library-remove',
|
||||||
@@ -1193,9 +1174,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
});
|
});
|
||||||
void this.loadLibraries();
|
void this.loadLibraries();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.isRemoving = false;
|
|
||||||
this.removingLibraryId = null;
|
this.removingLibraryId = null;
|
||||||
this.removalImpact = null;
|
|
||||||
console.error('Failed to remove library:', err);
|
console.error('Failed to remove library:', err);
|
||||||
notificationStore.persistent({
|
notificationStore.persistent({
|
||||||
key: 'library-remove',
|
key: 'library-remove',
|
||||||
@@ -1204,13 +1183,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
detail: String(err),
|
detail: String(err),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
private handleCancelRemove = (): void => {
|
|
||||||
this.removingLibraryId = null;
|
|
||||||
this.removalImpact = null;
|
|
||||||
this.isRemoving = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
private toggleOverflowMenu = (id: number, e: Event): void => {
|
private toggleOverflowMenu = (id: number, e: Event): void => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -2057,10 +2030,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
// --- Library section ---
|
// --- Library section ---
|
||||||
|
|
||||||
private renderLibrarySection() {
|
private renderLibrarySection() {
|
||||||
const removingLib = this.libraries.find(
|
|
||||||
(l) => l.id === this.removingLibraryId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<config-section
|
<config-section
|
||||||
heading="Libraries"
|
heading="Libraries"
|
||||||
@@ -2109,11 +2078,15 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
`}
|
`}
|
||||||
<span class="library-path">${lib.path}</span>
|
<span class="library-path">${lib.path}</span>
|
||||||
<span class="library-count">
|
<span class="library-count">
|
||||||
${lib.trackCount} tracks
|
${this.removingLibraryId === lib.id
|
||||||
|
? 'Removing…'
|
||||||
|
: html`${lib.trackCount} tracks`}
|
||||||
</span>
|
</span>
|
||||||
<div class="overflow-wrapper">
|
<div class="overflow-wrapper">
|
||||||
<button
|
<button
|
||||||
class="overflow-btn"
|
class="overflow-btn"
|
||||||
|
aria-label=${`Actions for ${lib.name}`}
|
||||||
|
?disabled=${this.removingLibraryId === lib.id}
|
||||||
@click=${(e: Event) => this.toggleOverflowMenu(lib.id, e)}
|
@click=${(e: Event) => this.toggleOverflowMenu(lib.id, e)}
|
||||||
>
|
>
|
||||||
\u22EF
|
\u22EF
|
||||||
@@ -2173,51 +2146,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
@config-change=${this.handleConcurrencyChange}
|
@config-change=${this.handleConcurrencyChange}
|
||||||
></config-field>
|
></config-field>
|
||||||
|
|
||||||
${this.removingLibraryId !== null && this.removalImpact
|
|
||||||
? html`
|
|
||||||
<div
|
|
||||||
class="cancel-dialog-overlay"
|
|
||||||
@click=${this.handleCancelRemove}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="cancel-dialog"
|
|
||||||
@click=${(e: Event) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div class="cancel-dialog-title">
|
|
||||||
Remove Library
|
|
||||||
</div>
|
|
||||||
<div class="cancel-dialog-message">
|
|
||||||
Remove '${removingLib?.name}'?
|
|
||||||
This will delete
|
|
||||||
${this.removalImpact.trackCount}
|
|
||||||
tracks, affect
|
|
||||||
${this.removalImpact.playlistsAffected}
|
|
||||||
playlists, and remove
|
|
||||||
${this.removalImpact.queueItemCount}
|
|
||||||
queue items.
|
|
||||||
</div>
|
|
||||||
<div class="cancel-dialog-actions">
|
|
||||||
<button
|
|
||||||
class="btn-ghost"
|
|
||||||
?disabled=${this.isRemoving}
|
|
||||||
@click=${this.handleCancelRemove}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-danger"
|
|
||||||
?disabled=${this.isRemoving}
|
|
||||||
@click=${this.handleConfirmRemove}
|
|
||||||
>
|
|
||||||
${this.isRemoving
|
|
||||||
? html`<span class="spinner"></span> Removing\u2026`
|
|
||||||
: 'Remove'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: nothing}
|
|
||||||
</config-section>
|
</config-section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user