audio streaming app plyr.fm
37
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(confirm-dialog): use native <dialog> + showModal for top-layer stacking (#1330)

the component used a CSS overlay with `z-index: 1000` and manual ESC /
focus-trap plumbing. while every other sheet / modal in the codebase
(AudioRevisionsSheet, LikersSheet, LogoutModal, SearchModal,
PdsMigrationModal, FeedbackModal, Toast, TermsOverlay) uses
`z-index: 9999`. opening a confirm from *inside* one of those sheets —
specifically "restore" inside the audio version-history sheet —
rendered the confirm behind the sheet, forcing the user to dismiss the
sheet before they could click confirm.

bumping the z-index to 10000 would have been whack-a-mole. using the
native <dialog> element with `.showModal()` puts the dialog in the
browser's top layer, which stacks above every other element on the
page regardless of z-index. by construction, nested modals work.

secondary benefits from switching to the platform primitive:
- focus trap, aria-modal, ESC handling all native — removed our
reimplementations
- ::backdrop pseudo-element for backdrop styling
- role="alertdialog" for semantic correctness on confirmation prompts
- oncancel handler blocks ESC-dismiss while an async confirm is in
flight (pending=true), so the user can't dismiss a pending operation
mid-run and leave parent state inconsistent with UI state

public API of the component is unchanged — both existing callsites
(replace-audio confirm + restore-revision confirm in portal/+page.svelte)
continue to pass `open={...}` one-way and manage close via `onCancel`.

Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4 (1M context)
and committed by
GitHub
0b2504e7 8f20e8e4

+64 -61
+64 -61
frontend/src/lib/components/ConfirmDialog.svelte
··· 27 27 28 28 const titleId = `confirm-dialog-title-${Math.random().toString(36).slice(2, 10)}`; 29 29 30 - function close() { 31 - if (pending) return; 32 - if (onCancel) { 33 - onCancel(); 34 - } else { 35 - open = false; 30 + // ref to the native <dialog> so we can call showModal() / close(). 31 + // using native <dialog>+showModal() puts this in the browser's top layer, 32 + // which stacks above every other element on the page regardless of z-index. 33 + // that's what makes nested modals work correctly (e.g. confirming a restore 34 + // from inside the version-history sheet). 35 + let dialogEl = $state<HTMLDialogElement>(); 36 + 37 + // sync the `open` prop ↔ the dialog's modal state 38 + $effect(() => { 39 + if (!dialogEl) return; 40 + if (open && !dialogEl.open) { 41 + dialogEl.showModal(); 42 + } else if (!open && dialogEl.open) { 43 + dialogEl.close(); 36 44 } 45 + }); 46 + 47 + function requestClose() { 48 + if (pending) return; 49 + if (!open) return; 50 + if (onCancel) onCancel(); 51 + else open = false; 37 52 } 38 53 39 54 function handleBackdropClick(event: MouseEvent) { 40 - if (event.target === event.currentTarget) close(); 55 + // <dialog>.showModal() renders a ::backdrop pseudo-element; clicking it 56 + // dispatches a click whose target === the <dialog> itself (not a child). 57 + // use that to close on backdrop click. keep inner clicks scoped. 58 + if (event.target === dialogEl) requestClose(); 41 59 } 42 60 43 - function handleKeydown(event: KeyboardEvent) { 44 - if (event.key === 'Escape') { 45 - event.preventDefault(); 46 - close(); 47 - } 61 + function handleCancel(event: Event) { 62 + // the native `cancel` event fires when the user presses ESC, before the 63 + // dialog actually closes. block it while an async confirm is in flight 64 + // so the user can't dismiss a pending operation mid-run. 65 + if (pending) event.preventDefault(); 48 66 } 49 67 50 68 async function handleConfirm() { ··· 52 70 } 53 71 </script> 54 72 55 - {#if open} 56 - <div 57 - class="modal-overlay" 58 - role="presentation" 59 - onclick={handleBackdropClick} 60 - onkeydown={handleKeydown} 61 - > 62 - <div 63 - class="modal" 64 - role="alertdialog" 65 - aria-modal="true" 66 - aria-labelledby={titleId} 67 - tabindex="-1" 73 + <dialog 74 + bind:this={dialogEl} 75 + class="confirm-dialog" 76 + role="alertdialog" 77 + aria-labelledby={titleId} 78 + oncancel={handleCancel} 79 + onclose={requestClose} 80 + onclick={handleBackdropClick} 81 + > 82 + <div class="modal-header"> 83 + <h3 id={titleId}>{title}</h3> 84 + </div> 85 + <div class="modal-body"> 86 + <p>{body}</p> 87 + </div> 88 + <div class="modal-footer"> 89 + <button class="cancel-btn" onclick={requestClose} disabled={pending}> 90 + {cancelText} 91 + </button> 92 + <button 93 + class="confirm-btn" 94 + class:danger={variant === 'danger'} 95 + onclick={handleConfirm} 96 + disabled={pending} 68 97 > 69 - <div class="modal-header"> 70 - <h3 id={titleId}>{title}</h3> 71 - </div> 72 - <div class="modal-body"> 73 - <p>{body}</p> 74 - </div> 75 - <div class="modal-footer"> 76 - <button class="cancel-btn" onclick={close} disabled={pending}> 77 - {cancelText} 78 - </button> 79 - <button 80 - class="confirm-btn" 81 - class:danger={variant === 'danger'} 82 - onclick={handleConfirm} 83 - disabled={pending} 84 - > 85 - {pending && pendingText ? pendingText : confirmText} 86 - </button> 87 - </div> 88 - </div> 98 + {pending && pendingText ? pendingText : confirmText} 99 + </button> 89 100 </div> 90 - {/if} 101 + </dialog> 91 102 92 103 <style> 93 - .modal-overlay { 94 - position: fixed; 95 - top: 0; 96 - left: 0; 97 - right: 0; 98 - bottom: 0; 99 - background: rgba(0, 0, 0, 0.5); 100 - display: flex; 101 - align-items: center; 102 - justify-content: center; 103 - z-index: 1000; 104 - padding: 1rem; 105 - } 106 - 107 - .modal { 104 + .confirm-dialog { 108 105 background: var(--bg-primary); 106 + color: inherit; 109 107 border: 1px solid var(--border-default); 110 108 border-radius: var(--radius-xl); 109 + padding: 0; 111 110 width: 100%; 112 111 max-width: 400px; 113 112 box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); 113 + } 114 + 115 + .confirm-dialog::backdrop { 116 + background: rgba(0, 0, 0, 0.5); 114 117 } 115 118 116 119 .modal-header {