Skip to content

Commit

Permalink
feat: focus trap with exit option
Browse files Browse the repository at this point in the history
  • Loading branch information
lublagg committed Nov 21, 2024
1 parent 6211831 commit 87b2aef
Show file tree
Hide file tree
Showing 12 changed files with 153 additions and 119 deletions.
24 changes: 22 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@types/jest": "^29.5.1",
"@types/node": "^22.9.1",
"@types/react": "^18.2.7",
"@types/react-dom": "^18.2.4",
"@typescript-eslint/eslint-plugin": "^5.59.7",
Expand Down
1 change: 1 addition & 0 deletions src/components/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
color: rgb(110,109,95);
text-align: center;
padding: 10px;
height: 100%;
&.isActive {
background-color: #fee9ff;
}
Expand Down
3 changes: 1 addition & 2 deletions src/components/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import { App } from "./App";
import { render, screen } from "@testing-library/react";

describe("test load app", () => {
const mockHandleSetActiveTrap = jest.fn();
it("renders without crashing", async () => {
render(<App activeTrap={false} handleSetActiveTrap={mockHandleSetActiveTrap}/>);
render(<App />);
expect(screen.getByText("(Data Analysis through Voice and Artificial Intelligence)")).toBeDefined();
});
});
81 changes: 55 additions & 26 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useRef } from "react";
import FocusTrap from "focus-trap-react";
import { initializePlugin } from "@concord-consortium/codap-plugin-api";
import { ReadAloudMenu } from "./readaloud-menu";
import { ChatInputComponent } from "./chat-input";
Expand All @@ -24,19 +25,20 @@ const mockAiResponse = (): ChatMessage => {
return response;
};

interface IAppProps {
activeTrap: boolean;
handleSetActiveTrap: (active: boolean) => void;
}

export const App = ({activeTrap, handleSetActiveTrap}: IAppProps) => {
export const App = () => {
const greeting = "Hello! I'm DAVAI, your Data Analysis through Voice and Artificial Intelligence partner.";
const [chatTranscript, setChatTranscript] = useState<ChatTranscript>({messages: [{speaker: "DAVAI", content: greeting, timestamp: timeStamp()}]});
const [readAloudEnabled, setReadAloudEnabled] = useState(false);
const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [activeTrap, setActiveTrap] = useState(false);

const readAloudMenuRef = useRef(null);
const chatInputRef = useRef(null);
const chatTranscriptRef = useRef(null);

useEffect(() => {
initializePlugin({pluginName: kPluginName, version: kVersion, dimensions: kInitialDimensions});
setActiveTrap(true);
}, []);

const handleSetReadAloudEnabled = () => {
Expand All @@ -59,26 +61,53 @@ export const App = ({activeTrap, handleSetActiveTrap}: IAppProps) => {
}, 1000);
};

// focus-trap-react does not support JSdom, so we need to disable it in test environment.
// see: https://github.com/focus-trap/focus-trap-react?tab=readme-ov-file#testing-in-jsdom
const isTestEnv = process.env.NODE_ENV === "test";

return (
<div
onFocus={() => handleSetActiveTrap(true)}
role="main"
className={`App ${activeTrap && "isActive"}`}
<FocusTrap
active={activeTrap}
focusTrapOptions={{
allowOutsideClick: true,
escapeDeactivates: true,
initialFocus: "#chat-input",
onActivate: () => setActiveTrap(true),
onDeactivate: () => setActiveTrap(false),
returnFocusOnDeactivate: false,
tabbableOptions: {
displayCheck: isTestEnv ? "none" : "full"
}
}}
>
<h1>
DAVAI
<span>(Data Analysis through Voice and Artificial Intelligence)</span>
</h1>
<ChatTranscriptComponent chatTranscript={chatTranscript} />
<ChatInputComponent onSubmit={handleChatInputSubmit} />
<ReadAloudMenu
enabled={readAloudEnabled}
onToggle={handleSetReadAloudEnabled}
playbackSpeed={playbackSpeed}
onPlaybackSpeedSelect={handleSetPlaybackSpeed}
/>
<button onClick={() => handleSetActiveTrap(true)}>activate focus trap</button>
<button onClick={() => handleSetActiveTrap(false)}>exit focus trap</button>
</div>
<div
onFocus={() => setActiveTrap(true)}
role="main"
className={`App ${activeTrap && "isActive"}`}
>
<h1>
DAVAI
<span>(Data Analysis through Voice and Artificial Intelligence)</span>
</h1>
<ChatTranscriptComponent
ref={chatTranscriptRef}
chatTranscript={chatTranscript}
/>
<ChatInputComponent
ref={chatInputRef}
onSubmit={handleChatInputSubmit}
/>
<ReadAloudMenu
ref={readAloudMenuRef}
enabled={readAloudEnabled}
onToggle={handleSetReadAloudEnabled}
playbackSpeed={playbackSpeed}
onPlaybackSpeedSelect={handleSetPlaybackSpeed}
/>
<button onClick={() => setActiveTrap(false)}>
exit focus trap and browse CODAP
</button>
</div>
</FocusTrap>
);
};
12 changes: 7 additions & 5 deletions src/components/chat-input.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import React, { FormEvent, useRef, useState } from "react";
import React, { FormEvent, forwardRef, useRef, useState } from "react";

import "./chat-input.scss";

interface IProps {
onSubmit: (messageText: string) => void;
}

export const ChatInputComponent = ({onSubmit}: IProps) => {
export const ChatInputComponent = forwardRef<HTMLFormElement, IProps>(({onSubmit}, ref) => {
const textAreaRef = useRef<HTMLTextAreaElement>(null);
// const [browserSupportsDictation, setBrowserSupportsDictation] = useState(false);
// const [dictationEnabled, setDictationEnabled] = useState(false);
Expand Down Expand Up @@ -79,7 +79,7 @@ export const ChatInputComponent = ({onSubmit}: IProps) => {

return (
<div className="chat-input" data-testid="chat-input">
<form onSubmit={handleSubmit}>
<form ref={ref} onSubmit={handleSubmit}>
<fieldset>
<label className="visually-hidden" data-testid="chat-input-label" htmlFor="chat-input">
Chat Input
Expand All @@ -101,7 +101,7 @@ export const ChatInputComponent = ({onSubmit}: IProps) => {
>
Send
</button>
{/* {browserSupportsDictation &&
{/* {browserSupportsDictation &&
<button
aria-pressed={dictationEnabled}
className="dictate"
Expand All @@ -116,4 +116,6 @@ export const ChatInputComponent = ({onSubmit}: IProps) => {
</form>
</div>
);
};
});

ChatInputComponent.displayName = "ChatInputComponent";
75 changes: 43 additions & 32 deletions src/components/chat-transcript.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import React, { useEffect } from "react";

import { ChatTranscript, ChatMessage } from "../types";
import React, { forwardRef, useEffect } from "react";
import { ChatMessage, ChatTranscript } from "../types";

import "./chat-transcript.scss";

interface IProps {
chatTranscript: ChatTranscript;
}

export const ChatTranscriptComponent = ({chatTranscript}: IProps) => {
export const ChatTranscriptComponent = forwardRef<HTMLDivElement, IProps>(({chatTranscript}, ref) => {

useEffect(() => {
// Always scroll to the bottom of the chat transcript.
Expand All @@ -19,32 +18,44 @@ export const ChatTranscriptComponent = ({chatTranscript}: IProps) => {
});

return (
<ul
aria-label="DAVAI Chat Transcript"
className="chat-transcript"
data-testid="chat-transcript"
role="main"
>
{chatTranscript.messages.map((message: ChatMessage) => {
return (
<li
aria-label={`${message.speaker} at ${message.timestamp}`}
// For now we are using "assertive" and only applying aria-live to AI messages. This
// may change as we refine the experience.
aria-live={message.speaker === "DAVAI" ? "assertive" : undefined}
className="chat-transcript__message"
data-testid="chat-message"
key={message.timestamp}
>
<h2 aria-label="speaker" data-testid="chat-message-speaker">
{message.speaker}
</h2>
<p aria-label="message" data-testid="chat-message-content">
{message.content}
</p>
</li>
);
})}
</ul>
<div ref={ref} className="chat-transcript-container" role="main">
<p id="chat-transcript-description" className="visually-hidden">
This is a transcript of a chat with DAVAI.
</p>
<div
aria-label="DAVAI Chat Transcript"
aria-describedby="chat-transcript-description"
className="chat-transcript"
contentEditable="true"
data-testid="chat-transcript"
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
onBeforeInput={(e) => e.preventDefault()}
>
{chatTranscript.messages.map((message: ChatMessage) => {
return (
<div
aria-label={`${message.speaker} at ${message.timestamp}`}
// For now we are using "assertive" and only applying aria-live to AI messages. This
// may change as we refine the experience.
aria-live={message.speaker === "DAVAI" ? "assertive" : undefined}
className="chat-transcript__message"
data-testid="chat-message"
key={message.timestamp}
>
<h2 aria-label="speaker" data-testid="chat-message-speaker">
{message.speaker}
</h2>
<p aria-label="message" data-testid="chat-message-content">
{message.content}
</p>
</div>
);
})}
</div>
</div>
);
};
});

ChatTranscriptComponent.displayName = "ChatTranscriptComponent";

30 changes: 0 additions & 30 deletions src/components/focus-trap-wrapper.tsx

This file was deleted.

13 changes: 3 additions & 10 deletions src/components/readaloud-menu.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { act, render, screen, within } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";

import { ReadAloudMenu } from "./readaloud-menu";

Expand Down Expand Up @@ -31,14 +31,7 @@ describe("test read aloud menu component", () => {
expect(readAloudPlaybackSpeed).toBeInTheDocument();
expect(readAloudPlaybackSpeed).toHaveAttribute("id", "readaloud-playback-speed");
expect(readAloudPlaybackSpeed).toHaveValue("1");
act(() => {
readAloudPlaybackSpeed.click();
const option3 = screen.getByTestId("playback-speed-option-3") as HTMLOptionElement;
expect(option3).toHaveValue("1.5");
option3.selected = true;
readAloudPlaybackSpeed.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(mockHandleSelect).toHaveBeenCalled();
expect(readAloudPlaybackSpeed).toHaveValue("1.5");
fireEvent.change(readAloudPlaybackSpeed, { target: { value: "1.5" } });
expect(mockHandleSelect).toHaveBeenCalledWith(1.5);
});
});
Loading

0 comments on commit 87b2aef

Please sign in to comment.