Skip to content

fix(app): destructive-action confirm — focus trap + SharesPage Unpublish modal#371

Merged
graydawnc merged 1 commit into
mainfrom
feat/renderer-destructive-confirm
Jun 8, 2026
Merged

fix(app): destructive-action confirm — focus trap + SharesPage Unpublish modal#371
graydawnc merged 1 commit into
mainfrom
feat/renderer-destructive-confirm

Conversation

@graydawnc

Copy link
Copy Markdown
Collaborator

What

Two renderer-side a11y/UX fixes from the post-launch audit.

useFocusTrap — Tab/Shift+Tab containment on destructive modals

aria-modal="true" on the confirm dialogs is a hint to assistive tech but doesn't actually contain keyboard focus — Tab/Shift+Tab could escape into the underlying page. On a destructive confirmation surface (Unpublish / Schedule deletion) this is a real footgun: the next Tab might land on a destructive control the user never saw.

New hook wires into both UnpublishConfirmModal and DeleteAccountConfirmModal:

  • Initial focus lands on Cancel (safe default — pressing Enter immediately on open doesn't commit the action)
  • Tab/Shift+Tab cycles inside the modal
  • Focus restores to the previously-active element on close
  • Focusable detection uses offsetWidth > 0 && offsetHeight > 0 (NOT offsetParent, which returns null for position: fixed overlays) AND guards against aria-hidden tab-stops

SharesPage Unpublish — confirm modal instead of single-click revoke

The Share popover's unpublish already routes through UnpublishConfirmModal. The Published-list Unpublish action did not — clicking the EyeOff icon on a row immediately fired spoolShare.revoke(). Inconsistent with the destructive-action discipline elsewhere in the app.

stateDiagram-v2
    [*] --> Idle
    Idle --> Confirming: click EyeOff on row A
    Confirming --> Idle: cancel / Esc / outside-click
    Confirming --> Revoking: confirm
    Revoking --> Idle: 200 + cache.markRevoked + refresh
    Revoking --> Confirming: error (inline + toast)

    state Confirming {
        [*] --> RowsLocked
        RowsLocked --> RowsLocked: other rows<br/>disabled (locked prop)
    }
    state Revoking {
        [*] --> ActiveRowBusy
        ActiveRowBusy --> ActiveRowBusy: spinner on row A<br/>others stay locked
    }
Loading

While a revoke is in flight, every row's Unpublish button is locked so clicking another row can't silently no-op (the row-level busy spinner shows on the active row; other rows show as disabled — the user gets unambiguous feedback that the system is busy).

Verification

  • pnpm --filter @spool/app exec tsc --noEmit clean
  • pnpm --filter @spool/app test → 433 unit pass (the 1 fail is the pre-existing sessionResume.test.ts:38 macOS path-slug flake, unrelated)

Risk

  • Focus trap: changes initial focus + Tab cycling behaviour on the two confirm modals. No existing tests assert on focus.
  • SharesPage Unpublish flow: replaces single-click revoke with confirm modal. The Published-list surface is gated by VITE_FEATURE_SHAREPUBLISH which CI doesn't set, so e2e doesn't exercise this path — covered by manual smoke.
  • locked prop on PublishedRow is optional with default false; existing callers (none external) unaffected.

Submitted by @graydawnc.

…ish modal

Two renderer-side a11y/UX fixes from the post-launch audit.

useFocusTrap (new hook):
  aria-modal="true" on the confirm dialogs is a hint to assistive
  tech but doesn't actually contain keyboard focus — Tab/Shift+Tab
  could escape into the underlying page. On a destructive
  confirmation surface (Unpublish / Schedule deletion) this is a real
  footgun: the next Tab might land on a destructive control the user
  never saw.

  Wires into both UnpublishConfirmModal and DeleteAccountConfirmModal.
  Initial focus lands on Cancel (safe default — pressing Enter
  immediately on open doesn't commit the action), Tab/Shift+Tab cycles
  inside the modal, focus restores to the previously-active element
  on close. Focusable detection uses offsetWidth/offsetHeight (not
  offsetParent, which returns null for position:fixed modals) AND
  guards against aria-hidden tab-stops.

SharesPage Unpublish — confirm modal instead of single-click revoke:
  The Share popover's unpublish already routes through
  UnpublishConfirmModal. The Published-list Unpublish action did
  not — clicking the EyeOff icon on a row immediately fired
  spoolShare.revoke(). Inconsistent with the destructive-action
  discipline elsewhere in the app.

  PublishedList now opens UnpublishConfirmModal on click; the existing
  optimistic-revoke flow (markRevoked / noteLocalMutation / refresh)
  moves into the confirm callback. While a revoke is in flight,
  every row's Unpublish button is `locked` so clicking another row
  can't silently no-op (the row-level `busy` spinner shows on the
  active row; other rows show as disabled).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@graydawnc
graydawnc added this pull request to the merge queue Jun 8, 2026
Merged via the queue into main with commit faa2930 Jun 8, 2026
6 checks passed
@graydawnc
graydawnc deleted the feat/renderer-destructive-confirm branch June 8, 2026 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant