fix(frontend): make overlays accessible and wake word opt-in
This commit is contained in:
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user