Skip to content

Commit

Permalink
feat: basic chat UI
Browse files Browse the repository at this point in the history
  • Loading branch information
emcelroy committed Nov 22, 2024
1 parent 4b3bc21 commit 27d29fd
Show file tree
Hide file tree
Showing 12 changed files with 169 additions and 65 deletions.
4 changes: 2 additions & 2 deletions src/components/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
padding: 10px;

h1 {
font-size: 12px;
font-size: .75rem;
font-weight: bold;
margin: 10px;

span {
display: block;
font-size: 10px;
font-size: .625rem;
}
}
.buttons {
Expand Down
38 changes: 22 additions & 16 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { initializePlugin } from "@concord-consortium/codap-plugin-api";
import { ReadAloudMenu } from "./readaloud-menu";
import { initializePlugin, selectSelf } from "@concord-consortium/codap-plugin-api";
// import { ReadAloudMenu } from "./readaloud-menu";
import { ChatInputComponent } from "./chat-input";
import { ChatTranscriptComponent } from "./chat-transcript";
import { ChatTranscript, ChatMessage } from "../types";
Expand All @@ -27,20 +27,24 @@ const mockAiResponse = (): ChatMessage => {
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 [readAloudEnabled, setReadAloudEnabled] = useState(false);
// const [playbackSpeed, setPlaybackSpeed] = useState(1);

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

const handleSetReadAloudEnabled = () => {
setReadAloudEnabled(!readAloudEnabled);
const handleFocusShortcut = () => {
selectSelf();
};

const handleSetPlaybackSpeed = (speed: number) => {
setPlaybackSpeed(speed);
};
// const handleSetReadAloudEnabled = () => {
// setReadAloudEnabled(!readAloudEnabled);
// };

// const handleSetPlaybackSpeed = (speed: number) => {
// setPlaybackSpeed(speed);
// };

const handleChatInputSubmit = (messageText: string) => {
setChatTranscript(prevTranscript => ({
Expand All @@ -56,18 +60,20 @@ export const App = () => {

return (
<div className="App">
<h1>
DAVAI
<span>(Data Analysis through Voice and Artificial Intelligence)</span>
</h1>
<header>
<h1>
<abbr title="Data Analysis through Voice and Artificial Intelligence">DAVAI</abbr>
<span>(Data Analysis through Voice and Artificial Intelligence)</span>
</h1>
</header>
<ChatTranscriptComponent chatTranscript={chatTranscript} />
<ChatInputComponent onSubmit={handleChatInputSubmit} />
<ReadAloudMenu
<ChatInputComponent onSubmit={handleChatInputSubmit} onKeyboardShortcut={handleFocusShortcut} />
{/* <ReadAloudMenu
enabled={readAloudEnabled}
onToggle={handleSetReadAloudEnabled}
playbackSpeed={playbackSpeed}
onPlaybackSpeedSelect={handleSetPlaybackSpeed}
/>
/> */}
</div>
);
};
8 changes: 8 additions & 0 deletions src/components/chat-input.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

.chat-input {
margin-top: 10px;

fieldset {
border: none;
display: flex;
Expand All @@ -18,6 +19,13 @@
padding: 8px 10px;
}

.error {
color: #d00;
font-size: .75rem;
margin: 0;
padding: 0;
}

.buttons-container {
align-items: center;
display: flex;
Expand Down
15 changes: 13 additions & 2 deletions src/components/chat-input.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import React from "react";
import { act, render, screen, within } from "@testing-library/react";
import { act, fireEvent, render, screen, within } from "@testing-library/react";

import { ChatInputComponent } from "./chat-input";

describe("test chat input component", () => {
const mockHandleSubmit = jest.fn();

it("renders a textarea and submit button that lets user send chat messages", () => {
render(<ChatInputComponent onSubmit={mockHandleSubmit} />);
render(<ChatInputComponent onSubmit={mockHandleSubmit} onKeyboardShortcut={jest.fn()} />);

const chatInput = screen.getByTestId("chat-input");
expect(chatInput).toBeInTheDocument();
Expand All @@ -19,7 +19,18 @@ describe("test chat input component", () => {
expect(chatInputTextarea).toBeInTheDocument();
const chatInputSend = within(chatInput).getByTestId("chat-input-send");
expect(chatInputSend).toBeInTheDocument();
// If no message is entered, an error message should appear.
act(() => chatInputSend.click());
const inputError = within(chatInput).getByTestId("input-error");
expect(inputError).toBeInTheDocument();
expect(inputError).toHaveAttribute("aria-live", "assertive");
expect(inputError).toHaveTextContent("Please enter a message before sending.");
expect(mockHandleSubmit).not.toHaveBeenCalled();
// If message is entered, no error should appear and the message should be submitted.
chatInputTextarea.focus();
fireEvent.change(chatInputTextarea, {target: {value: "Hello!"}})

Check warning on line 31 in src/components/chat-input.test.tsx

View workflow job for this annotation

GitHub Actions / Build and Run Jest Tests

Missing semicolon

Check warning on line 31 in src/components/chat-input.test.tsx

View workflow job for this annotation

GitHub Actions / S3 Deploy

Missing semicolon
act(() => chatInputSend.click());
expect(inputError).not.toBeInTheDocument();
expect(mockHandleSubmit).toHaveBeenCalled();
});
});
76 changes: 70 additions & 6 deletions src/components/chat-input.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,36 @@
import React, { FormEvent, useRef, useState } from "react";
import React, { FormEvent, useEffect, useRef, useState } from "react";

import { isInputElement } from "../utils";

import "./chat-input.scss";

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

export const ChatInputComponent = ({onSubmit}: IProps) => {
export const ChatInputComponent = ({onKeyboardShortcut, onSubmit}: IProps) => {
const textAreaRef = useRef<HTMLTextAreaElement>(null);
// const [browserSupportsDictation, setBrowserSupportsDictation] = useState(false);
// const [dictationEnabled, setDictationEnabled] = useState(false);
const [inputValue, setInputValue] = useState("");
const [showPlaceholder, setShowPlaceholder] = useState(true);
const [showError, setShowError] = useState(false);

const handleSubmit = (event?: FormEvent) => {
event?.preventDefault();
// setDictationEnabled(false);
onSubmit(inputValue);
setInputValue("");
setShowPlaceholder(false);
textAreaRef.current?.focus();

if (!inputValue || inputValue.trim() === "") {
setShowError(true);
textAreaRef.current?.focus();
} else {
onSubmit(inputValue);
setInputValue("");
setShowPlaceholder(false);
textAreaRef.current?.focus();
setShowError(false);
}
};

const handleKeyUp = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
Expand Down Expand Up @@ -77,6 +88,46 @@ export const ChatInputComponent = ({onSubmit}: IProps) => {
// setDictationEnabled(!dictationEnabled);
// };

useEffect(() => {
// Add a keyboard shortcut for placing focus on the chat input when the user's focus is outside the iframe.
const keys = {
d: false,
a: false
};

if (window.parent !== window) {
window.parent.document.addEventListener("keydown", (event) => {
if (event.key === "d") keys.d = true;
if (event.key === "a") keys.a = true;

if (keys.d && keys.a) {

// Check if the focused element is an input, textarea, or content-editable
const activeElement = window.parent.document.activeElement;
if (isInputElement(activeElement)) {
keys.d = false;
keys.a = false;
return;
}

const iframe = window.frameElement;
if (iframe) {
if (textAreaRef.current) {
textAreaRef.current.focus();
onKeyboardShortcut();
// Reset key states after shortcut is triggered. Note: a complimentary `keyup` listener for
// handling this won't work reliably in this context.
keys.d = false;
keys.a = false;
} else {
console.warn("Target input not found inside the iframe.");
}
}
}
});
}
}, [onKeyboardShortcut]);

return (
<div className="chat-input" data-testid="chat-input">
<form onSubmit={handleSubmit}>
Expand All @@ -85,6 +136,8 @@ export const ChatInputComponent = ({onSubmit}: IProps) => {
Chat Input
</label>
<textarea
aria-describedby={showError ? "input-error" : undefined}
aria-invalid={showError}
data-testid="chat-input-textarea"
id="chat-input"
placeholder={showPlaceholder ? "Ask DAVAI about the data" : ""}
Expand All @@ -93,6 +146,17 @@ export const ChatInputComponent = ({onSubmit}: IProps) => {
onChange={(e) => setInputValue(e.target.value)}
onKeyUp={handleKeyUp}
/>
{showError &&
<div
aria-live="assertive"
className="error"
data-testid="input-error"
id="input-error"
role="alert"
>
Please enter a message before sending.
</div>
}
<div className="buttons-container">
<button
className="send"
Expand Down
6 changes: 3 additions & 3 deletions src/components/chat-transcript.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@use "vars" as *;

.chat-transcript {
.chat-transcript__messages {
background: white;
color: $dark-gray;
list-style: none;
Expand All @@ -19,8 +19,8 @@
background: $light-teal-2;
}

h2, p {
font-size: 12px;
h3, p {
font-size: .75rem;
line-height: 17px;
margin: 0;
padding: 0;
Expand Down
8 changes: 3 additions & 5 deletions src/components/chat-transcript.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,12 @@ describe("test chat transcript component", () => {

const transcript = screen.getByTestId("chat-transcript");
expect(transcript).toBeInTheDocument();
expect(transcript).toHaveAttribute("aria-label", "DAVAI Chat Transcript");
const messagesContainer = screen.getByTestId("chat-transcript__messages");
expect(messagesContainer).toBeInTheDocument();
expect(messagesContainer).toHaveAttribute("aria-live", "assertive");
const messages = within(transcript).getAllByTestId("chat-message");
expect(messages).toHaveLength(2);

// note: messages from AI should be assertive, while messages from user will not have aria-live
expect(messages[0]).toHaveAttribute("aria-live", "assertive");
expect(messages[1]).not.toHaveAttribute("aria-live");

messages.forEach((message: HTMLElement, index: number) => {
const labelContent = `${chatTranscript.messages[index].speaker} at ${chatTranscript.messages[index].timestamp}`;
expect(message).toHaveAttribute("aria-label", labelContent);
Expand Down
56 changes: 29 additions & 27 deletions src/components/chat-transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,32 +19,34 @@ 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>
<section id="chat-transcript" className="chat-transcript" data-testid="chat-transcript" role="group">
<h2 className="visually-hidden">DAVAI Chat Transcript</h2>
<section
// For now we are using "assertive". This may change as we refine the experience.
aria-live="assertive"
className="chat-transcript__messages"
data-testid="chat-transcript__messages"
role="list"
>
{chatTranscript.messages.map((message: ChatMessage) => {
return (
<section
aria-label={`${message.speaker} at ${message.timestamp}`}
className="chat-transcript__message"
data-testid="chat-message"
key={message.timestamp}
role="listitem"
>
<h3 aria-label="speaker" data-testid="chat-message-speaker">
{message.speaker}
</h3>
<p aria-label="message" data-testid="chat-message-content">
{message.content}
</p>
</section>
);
})}
</section>
</section>
);
};
2 changes: 1 addition & 1 deletion src/components/readaloud-menu.scss
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
border-radius: 6px;
text-align: center;
font-family: 'Montserrat', sans-serif;
font-size: 12px;
font-size: .75rem;

option {
color: $dark-gray;
Expand Down
4 changes: 2 additions & 2 deletions src/index.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!DOCTYPE html lang="us-EN">
<html>
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="UTF-8">
<title>DAVAI</title>
Expand Down
2 changes: 1 addition & 1 deletion src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ html, body {

body {
font-family: "Montserrat", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 100%;
user-select: none;
}
Loading

0 comments on commit 27d29fd

Please sign in to comment.