fix(frontend): make overlays accessible and wake word opt-in

This commit is contained in:
2026-10-07 09:32:53 -03:00
parent 0c57f7c76d
commit a56e1082e5
12 changed files with 285 additions and 138 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ function Bubble({ message, userName }: { message: Message; userName: string }) {
clearTimeout(timer);
};
}, [message.text, message.partial]);
if (expiredText === message.text) {
if (!message.partial && expiredText === message.text) {
return null;
}
return (
@@ -36,7 +36,7 @@ function Bubble({ message, userName }: { message: Message; userName: string }) {
}
export const Bubbles = memo(function Bubbles({ messages, userName }: { messages: Message[]; userName: string }) {
return (
<div className="bubble-stack" aria-hidden="true">
<div className="bubble-stack" aria-hidden="true" inert>
{messages.slice(-3).map((message) => (
<Bubble key={message.id} message={message} userName={userName} />
))}
+6 -2
View File
@@ -1,5 +1,5 @@
import { X } from "lucide-react";
import { useEffect, useId, useRef, type ReactNode } from "react";
import { useLayoutEffect, useId, useRef, type ReactNode } from "react";
interface Props {
title: string;
@@ -10,11 +10,15 @@ interface Props {
export function Dialog({ title, children, onClose, className = "" }: Props) {
const ref = useRef<HTMLDialogElement>(null);
const titleId = useId();
useEffect(() => {
useLayoutEffect(() => {
const dialog = ref.current;
const opener = document.activeElement;
dialog?.showModal();
return () => {
dialog?.close();
if (opener instanceof HTMLElement && opener.isConnected) {
opener.focus({ preventScroll: true });
}
};
}, []);
return (
@@ -0,0 +1,54 @@
import type { Reports } from "../lib/contracts";
import { reportSuggestions } from "../lib/reports";
import { Dialog } from "./Dialog";
function ReportContent({ reports }: { reports: Reports }) {
return (
<div className="reports-content">
<div className="reports-hint">
<p>{reports.count} reportes disponibles</p>
<ul>
{reports.hints.map((hint, index) => (
<li key={index}>{hint}</li>
))}
</ul>
{reports.note && <p>{reports.note}</p>}
</div>
<div className="reports-suggestions">
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
<ul className="reports-suggestions-list">
{reportSuggestions(reports).map((suggestion) => (
<li key={suggestion}>{suggestion}</li>
))}
</ul>
</div>
</div>
);
}
export function ReportsPanel({
reports,
desktop,
open,
onClose,
}: {
reports: Reports;
desktop: boolean;
open: boolean;
onClose: () => void;
}) {
if (desktop) {
return (
<aside id="reports-sidebar" className="reports-panel" aria-label="Reportes programados">
<h2>Reportes</h2>
<ReportContent reports={reports} />
</aside>
);
}
return open ? (
<Dialog title="Reportes" className="reports-drawer" onClose={onClose}>
<div id="reports-sidebar">
<ReportContent reports={reports} />
</div>
</Dialog>
) : null;
}
@@ -0,0 +1,49 @@
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useSyncExternalStore } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { VoiceController } from "../realtime/controller";
import { WakeWordControl } from "./WakeWordControl";
class Recognition {
static instances: Recognition[] = [];
lang = "";
continuous = false;
interimResults = false;
onstart: (() => void) | null = null;
onend: (() => void) | null = null;
onerror = null;
onresult = null;
start = vi.fn(() => {
this.onstart?.();
});
abort = vi.fn();
constructor() {
Recognition.instances.push(this);
}
}
function Control({ controller }: { controller: VoiceController }) {
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot);
return <WakeWordControl enabled={state.wakeWordEnabled} controller={controller} />;
}
afterEach(() => {
vi.unstubAllGlobals();
});
it("requires opt-in, displays actual listening, and releases recognition on Stop", async () => {
Recognition.instances = [];
vi.stubGlobal("SpeechRecognition", Recognition);
const controller = new VoiceController();
const user = userEvent.setup();
render(<Control controller={controller} />);
expect(Recognition.instances).toHaveLength(0);
await user.click(screen.getByRole("checkbox"));
expect(Recognition.instances).toHaveLength(1);
expect(screen.getByRole("status")).toHaveTextContent("Micrófono activo");
await act(async () => {
await controller.stop();
});
expect(Recognition.instances[0]?.abort).toHaveBeenCalledOnce();
expect(screen.getByRole("checkbox")).not.toBeChecked();
expect(Recognition.instances).toHaveLength(1);
});
@@ -0,0 +1,32 @@
import { useWakeWord } from "../hooks/use-wake-word";
import type { VoiceController } from "../realtime/controller";
export function WakeWordControl({ enabled, controller }: { enabled: boolean; controller: VoiceController }) {
const status = useWakeWord(enabled, controller);
if (status === "unsupported") {
return null;
}
return (
<div className="wake-word-control">
<label>
<input
type="checkbox"
checked={enabled}
onChange={(event) => {
controller.setWakeWordEnabled(event.target.checked);
}}
/>
Activar con «hola vocero»
</label>
<span role="status">
{status === "listening"
? "Micrófono activo: escuchando la frase de activación."
: status === "blocked"
? "No se pudo activar. Revisa el permiso del micrófono y vuelve a marcar la opción."
: status === "starting"
? "Preparando activación por voz…"
: "Opcional. Se desactiva al detener la conversación."}
</span>
</div>
);
}