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:
2026-08-12 11:07:23 -04:00
parent 9f03b3ff94
commit 7912cdf23f
2 changed files with 182 additions and 332 deletions
@@ -26,6 +26,8 @@ import { libraryStore } from '../../store/library-store';
import { notificationStore } from '../../store/notification-store';
import { describeError, explainError } from '../../utils/describe-error';
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 ScoreView = autotagservice.ScoreView;
@@ -1026,44 +1028,19 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
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 {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
wa-dialog::part(dialog) {
background: var(--yj-bg-surface, #212529);
color: var(--yj-text-primary, #fff);
}
.dialog {
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));
}
wa-dialog p { margin: 0 0 1rem 0; font-size: 0.9rem; }
.dialog-header { display: flex; align-items: center; margin-bottom: 0.75rem; }
.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"] {
.url-input {
width: 100%;
padding: 0.4rem;
font: inherit;
@@ -1074,16 +1051,15 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
box-sizing: border-box;
}
.dialog .row {
.row {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
}
/* ── In-app search dialog ── */
.search-dialog { min-width: 480px; }
.search-dialog::part(dialog) { min-width: 480px; }
.search-kind {
display: flex;
@@ -1119,8 +1095,15 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
}
.search-result {
display: block;
width: 100%;
text-align: left;
background: transparent;
color: inherit;
font: inherit;
padding: 0.45rem 0.5rem;
cursor: pointer;
border: 0;
border-bottom: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.05));
border-radius: 4px;
}
@@ -1166,7 +1149,10 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
@state() private loading = false;
@state() private foldersLoading = false;
@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 = '';
// In-app MusicBrainz search ("suggest a candidate") dialog state.
@@ -1175,6 +1161,9 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
@state() private searchArtist = '';
@state() private searchResults: SearchHitView[] = [];
@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 queueMenuOpen = false;
// 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
* is cached (see utils/view-lifecycle.ts). */
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(
document,
'mousedown',
@@ -1391,49 +1384,42 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
private onPasteKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter') this.onPasteSubmit();
};
private onWarningCancel = () => { this.dialog = 'none'; };
private onWarningContinue = async () => {
if (!this.current) return;
/** Acknowledge the once-per-library "this rewrites files" warning,
* then apply. Returns without applying if the user backed out. */
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
// one that closes the dialog — never ran, and the dialog sat
// there forever (errors.m6).
const library = current.libraryName || `library #${current.libraryId}`;
const ok = await confirmAction({
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 {
await AckLibraryWarning(this.current.libraryId);
await AckLibraryWarning(current.libraryId);
} catch (err) {
console.error('autotag: could not record the warning ack', err);
this.errorMessage = describeError(
err,
'That acknowledgement could not be saved.',
);
} finally {
this.dialog = 'none';
}
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
* 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.searchResults = [];
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';
}
@@ -1454,6 +1444,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
if (!query) return;
this.searchLoading = true;
this.searchError = '';
this.searchRan = true;
try {
this.searchResults = await SearchCandidates(
this.searchKind, query, this.searchArtist.trim(),
@@ -1734,9 +1725,11 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
private async onApply(): Promise<void> {
if (!this.current || !this.score || this.score.candidates.length === 0) return;
if (!this.hasLibraryWarningBeenAcked(this.current.libraryId)) {
this.dialog = 'warning';
await this.confirmWarningThenApply();
return;
}
await this.executeApply();
}
@@ -1803,14 +1796,27 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
private async onLeave(): Promise<void> {
if (!this.current) return;
if (this.topScore() < CONFIDENT_SCORE) {
this.dialog = 'leave';
const groupKey = this.current.groupKey;
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 {
await LeaveAsIs(this.current.groupKey);
await LeaveAsIs(groupKey);
} catch (err) {
console.error('autotag: leave-as-is failed', err);
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 ── */
/**
@@ -2948,112 +2942,41 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
private renderPasteDialog() {
return html`
<div class="dialog-overlay" @click=${(e: MouseEvent) => {
if (e.target === e.currentTarget) this.onPasteCancel();
}}>
<div class="dialog">
<div class="dialog-header">
<h3>Paste MusicBrainz URL</h3>
<button class="dialog-close" aria-label="Close"
@click=${this.onPasteCancel}>×</button>
</div>
<p>
Paste a MusicBrainz release or release-group URL.
Tracks are aligned automatically when it loads.
</p>
<input type="url"
placeholder="https://musicbrainz.org/release/..."
.value=${this.pasteURL}
@input=${this.onPasteInput}
@keydown=${this.onPasteKeydown}
autofocus>
<div class="row">
<button class="secondary" @click=${this.onPasteCancel}>Cancel</button>
<button @click=${this.onPasteSubmit}>Load</button>
</div>
<wa-dialog
label="Paste MusicBrainz URL"
data-testid="autotag-paste-dialog"
?open=${this.dialog === 'paste'}
@wa-hide=${this.onPasteCancel}
>
<p>
Paste a MusicBrainz release or release-group URL.
Tracks are aligned automatically when it loads.
</p>
<input type="url"
class="url-input"
aria-label="MusicBrainz release URL"
placeholder="https://musicbrainz.org/release/..."
.value=${this.pasteURL}
@input=${this.onPasteInput}
@keydown=${this.onPasteKeydown}
autofocus>
<div class="row" slot="footer">
<button class="secondary" @click=${this.onPasteCancel}>Cancel</button>
<button @click=${this.onPasteSubmit}>Load</button>
</div>
</div>
`;
}
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>
</wa-dialog>
`;
}
private renderSearchDialog() {
return html`
<div class="dialog-overlay" @click=${(e: MouseEvent) => {
if (e.target === e.currentTarget) this.onSearchCancel();
}}>
<div class="dialog search-dialog">
<div class="dialog-header">
<h3>Search MusicBrainz</h3>
<button class="dialog-close" aria-label="Close"
@click=${this.onSearchCancel}>×</button>
</div>
<wa-dialog
label="Search MusicBrainz"
class="search-dialog"
data-testid="autotag-search-dialog"
?open=${this.dialog === 'search'}
@wa-hide=${this.onSearchCancel}
>
<div class="search-kind">
<label>
<input type="radio" name="searchKind" value="releasegroup"
@@ -3069,55 +2992,54 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
</label>
</div>
<input type="text" class="search-input"
aria-label=${this.searchKind === 'recording' ? 'Track title' : 'Album name'}
placeholder=${this.searchKind === 'recording' ? 'Track title' : 'Album name'}
.value=${this.searchQuery}
@input=${(e: Event) => { this.searchQuery = (e.target as HTMLInputElement).value; }}
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') void this.runSearch(); }}
autofocus>
<input type="text" class="search-input"
aria-label="Artist (optional)"
placeholder="Artist (optional)"
.value=${this.searchArtist}
@input=${(e: Event) => { this.searchArtist = (e.target as HTMLInputElement).value; }}
@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
? html`<div class="error" style="margin-top:0.75rem;">${this.searchError}</div>`
: nothing}
${this.searchResults.length > 0 ? html`
<div class="search-results">
<div class="search-results" role="list">
${this.searchResults.map((hit) => html`
<div class="search-result"
<button type="button" class="search-result" role="listitem"
@click=${() => { void this.pickSearchResult(hit); }}>
<div class="sr-title">${hit.title}</div>
<div class="sr-sub">
<span>${hit.artist || '—'}</span>
${hit.detail ? html`<span class="sr-detail">${hit.detail}</span>` : nothing}
</div>
</div>
</button>
`)}
</div>
` : this.searchLoading || this.searchError || this.searchQuery.trim() === ''
? nothing
: html`<div class="search-empty">No results — try dropping the artist or switching Album/Track.</div>`}
</div>
</div>
` : this.searchRan && !this.searchLoading && !this.searchError
? html`<div class="search-empty">No results — try dropping the artist or switching Album/Track.</div>`
: nothing}
<div class="row" slot="footer">
<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() {
switch (this.dialog) {
case 'paste': return this.renderPasteDialog();
case 'warning': return this.renderWarningDialog();
case 'leave': return this.renderLeaveDialog();
case 'search': return this.renderSearchDialog();
default: return nothing;
}
return html`${this.renderPasteDialog()}${this.renderSearchDialog()}`;
}
override render() {
@@ -38,6 +38,7 @@ import './config-field';
import './config-section';
import './download-clients';
import './shortcut-capture';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
import { shortcutsStore } from '../../store/shortcuts-store';
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
@@ -222,9 +223,10 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
@state() private libraries: library.Info[] = [];
@state() private editingLibraryId: number | null = null;
@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 removalImpact: library.RemovalImpact | null = null;
@state() private isRemoving = false;
@state() private activeMenuId: number | null = null;
@state() private concurrencyMode = 'auto';
@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 {
background: var(
--yj-accent,
@@ -1158,34 +1115,58 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
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> => {
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 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 {
const summary = await RemoveLibrary(id);
this.removingLibraryId = null;
this.removalImpact = null;
this.isRemoving = false;
notificationStore.transient({
tone: 'success',
key: 'library-remove',
@@ -1193,9 +1174,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
});
void this.loadLibraries();
} catch (err) {
this.isRemoving = false;
this.removingLibraryId = null;
this.removalImpact = null;
console.error('Failed to remove library:', err);
notificationStore.persistent({
key: 'library-remove',
@@ -1204,13 +1183,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
detail: String(err),
});
}
};
private handleCancelRemove = (): void => {
this.removingLibraryId = null;
this.removalImpact = null;
this.isRemoving = false;
};
}
private toggleOverflowMenu = (id: number, e: Event): void => {
e.stopPropagation();
@@ -2057,10 +2030,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
// --- Library section ---
private renderLibrarySection() {
const removingLib = this.libraries.find(
(l) => l.id === this.removingLibraryId,
);
return html`
<config-section
heading="Libraries"
@@ -2109,11 +2078,15 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
`}
<span class="library-path">${lib.path}</span>
<span class="library-count">
${lib.trackCount} tracks
${this.removingLibraryId === lib.id
? 'Removing…'
: html`${lib.trackCount} tracks`}
</span>
<div class="overflow-wrapper">
<button
class="overflow-btn"
aria-label=${`Actions for ${lib.name}`}
?disabled=${this.removingLibraryId === lib.id}
@click=${(e: Event) => this.toggleOverflowMenu(lib.id, e)}
>
\u22EF
@@ -2173,51 +2146,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
@config-change=${this.handleConcurrencyChange}
></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>
`;
}