feat(ui): focus trap for consent dialog + recording-state live region (T7.5, FR-UX-4)
Traps focus in the recording-consent overlay the same way as Settings. Adds a visually-hidden aria-live region announcing "Recording started/paused/stopped" — previously nothing in the header was a live region, so a non-visual user had no way to learn recording had begun.
This commit is contained in:
+38
-1
@@ -7,6 +7,7 @@
|
||||
import Settings from "./lib/views/Settings.svelte";
|
||||
import ConsentNotice from "./lib/components/ConsentNotice.svelte";
|
||||
import LevelMeter from "./lib/components/LevelMeter.svelte";
|
||||
import { trapFocus } from "./lib/actions/trapFocus";
|
||||
import { recording } from "./lib/stores/recording.svelte";
|
||||
import { settings } from "./lib/stores/settings.svelte";
|
||||
import { meetings } from "./lib/stores/meetings.svelte";
|
||||
@@ -64,6 +65,23 @@
|
||||
);
|
||||
}
|
||||
|
||||
// Screen-reader announcement for recording state changes (T7.5, FR-UX-4) —
|
||||
// nothing else in the header is a live region, so this is the only way a
|
||||
// non-visual user learns recording started/stopped. Tracked via an effect
|
||||
// (not a plain $derived) so "stopped" fires only after having recorded,
|
||||
// never on initial idle load.
|
||||
let recordingAnnouncement = $state("");
|
||||
let previousRecordingState = recording.state;
|
||||
$effect(() => {
|
||||
const current = recording.state;
|
||||
if (current !== previousRecordingState) {
|
||||
if (current === "recording") recordingAnnouncement = "Recording started";
|
||||
else if (current === "paused") recordingAnnouncement = "Recording paused";
|
||||
else if (previousRecordingState !== "idle") recordingAnnouncement = "Recording stopped";
|
||||
previousRecordingState = current;
|
||||
}
|
||||
});
|
||||
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
if (showConsent) showConsent = false;
|
||||
@@ -100,6 +118,7 @@
|
||||
</script>
|
||||
|
||||
<div class="app" data-theme={resolvedTheme}>
|
||||
<div class="sr-only" role="status" aria-live="polite">{recordingAnnouncement}</div>
|
||||
<header class="bar">
|
||||
<strong>WhispAssist</strong>
|
||||
<span class="muted">local · private</span>
|
||||
@@ -152,7 +171,14 @@
|
||||
</header>
|
||||
|
||||
{#if showConsent}
|
||||
<div class="consent-overlay" role="dialog" aria-modal="true" aria-label="Recording consent">
|
||||
<div
|
||||
class="consent-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Recording consent"
|
||||
tabindex="-1"
|
||||
use:trapFocus
|
||||
>
|
||||
<ConsentNotice onAccept={acceptConsentAndRecord} onCancel={() => (showConsent = false)} />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -169,6 +195,17 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
:global(:root) {
|
||||
--bg: #ffffff;
|
||||
--fg: #1a1a1a;
|
||||
|
||||
Reference in New Issue
Block a user