Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 75 additions & 14 deletions src/components/FileTransfer/Controls.css
Original file line number Diff line number Diff line change
@@ -1,17 +1,78 @@
.file-transfer-controls {
display: flex;
gap: var(--space-3);
margin-bottom: var(--space-4);
}

.file-transfer-controls input[type="file"],
.file-transfer-controls input[type="text"] {
flex-grow: 1;
}

.file-transfer-controls button {
background: var(--gradient-success);
color: #ffffff;
.file-transfer-controls {
display: flex;
gap: var(--space-3);
margin-bottom: var(--space-4);
align-items: flex-start;
}

.file-transfer-controls input[type="file"],
.file-transfer-controls input[type="text"] {
flex-grow: 1;
}

.file-transfer-recipient {
flex: 1 1 12rem;
min-width: 10rem;
position: relative;
}

.file-transfer-recipient input[type="text"] {
box-sizing: border-box;
width: 100%;
}

.file-transfer-recipient-list {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
left: 0;
list-style: none;
margin: var(--space-1) 0 0;
max-height: 12rem;
min-width: 100%;
overflow-y: auto;
padding: var(--space-1);
position: absolute;
right: 0;
z-index: 20;
}

.file-transfer-recipient-option,
.file-transfer-recipient-empty {
align-items: center;
border-radius: var(--radius-sm);
color: var(--color-text);
display: flex;
font-size: var(--font-size-sm);
gap: var(--space-2);
justify-content: space-between;
min-height: 2rem;
padding: var(--space-2) var(--space-3);
white-space: nowrap;
}

.file-transfer-recipient-option {
cursor: pointer;
}

.file-transfer-recipient-option.active,
.file-transfer-recipient-option:hover {
background: var(--color-bg-hover);
}

.file-transfer-recipient-status,
.file-transfer-recipient-empty {
color: var(--color-text-muted);
}

.file-transfer-recipient-status {
font-size: var(--font-size-xs);
}

.file-transfer-controls button {
background: var(--gradient-success);
color: #ffffff;
border: none;
padding: var(--space-3) var(--space-4);
text-align: center;
Expand Down
74 changes: 74 additions & 0 deletions src/components/FileTransfer/Controls.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React, { useState } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { TransferPeer } from "../../fileTransferPeers";
import Controls from "./Controls";

const recipients: TransferPeer[] = [
{
id: "#100",
label: "Quinn",
transferAddress: "Quinn",
away: false,
idle: false,
},
{
id: "#200",
label: "Riley",
transferAddress: "Riley",
away: true,
idle: false,
},
];

const selectedFile = new File(["hello"], "hello.txt", {
type: "text/plain",
});

function renderControls(onSendFile = vi.fn()) {
function Harness() {
const [selectedRecipient, setSelectedRecipient] =
useState<TransferPeer | null>(null);

return (
<Controls
onFileChange={vi.fn()}
onRecipientChange={setSelectedRecipient}
onSendFile={onSendFile}
selectedFile={selectedFile}
selectedRecipient={selectedRecipient}
recipients={recipients}
/>
);
}

render(<Harness />);
return { onSendFile };
}

describe("FileTransfer Controls", () => {
it("keeps Send disabled for arbitrary typed recipients", () => {
renderControls();

fireEvent.change(screen.getByLabelText("Recipient"), {
target: { value: "Not Connected" },
});

expect(
(screen.getByRole("button", { name: "Send File" }) as HTMLButtonElement)
.disabled
).toBe(true);
});

it("enables Send after selecting a connected player", () => {
renderControls();

fireEvent.focus(screen.getByLabelText("Recipient"));
fireEvent.mouseDown(screen.getByRole("option", { name: "Quinn" }));

expect(
(screen.getByRole("button", { name: "Send File" }) as HTMLButtonElement)
.disabled
).toBe(false);
});
});
170 changes: 155 additions & 15 deletions src/components/FileTransfer/Controls.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,185 @@
import React from "react";
import React, { useEffect, useMemo, useState } from "react";
import type { TransferPeer } from "../../fileTransferPeers";
import "./Controls.css";

interface FileTransferControlsProps {
onFileChange: (file: File) => void;
onRecipientChange: (recipient: string) => void;
onRecipientChange: (recipient: TransferPeer | null) => void;
onSendFile: () => void;
selectedFile: File | null;
recipient: string;
selectedRecipient: TransferPeer | null;
recipients: TransferPeer[];
}

const Controls: React.FC<FileTransferControlsProps> = ({
onFileChange,
onRecipientChange,
onSendFile,
selectedFile,
recipient,
selectedRecipient,
recipients,
}) => {
const recipientInputId = React.useId();
const recipientListId = React.useId();
const [recipientQuery, setRecipientQuery] = useState("");
const [isRecipientListOpen, setIsRecipientListOpen] = useState(false);
const [activeRecipientIndex, setActiveRecipientIndex] = useState(0);

const matchingRecipients = useMemo(() => {
const normalizedQuery = recipientQuery.trim().toLowerCase();
if (!normalizedQuery) {
return recipients;
}

return recipients.filter((recipient) =>
recipient.label.toLowerCase().includes(normalizedQuery)
);
}, [recipientQuery, recipients]);

useEffect(() => {
setRecipientQuery(selectedRecipient?.label ?? "");
}, [selectedRecipient]);

useEffect(() => {
if (activeRecipientIndex >= matchingRecipients.length) {
setActiveRecipientIndex(0);
}
}, [activeRecipientIndex, matchingRecipients.length]);

const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
onFileChange(event.target.files[0]);
}
};

const handleRecipientChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onRecipientChange(event.target.value);
const selectRecipient = (recipient: TransferPeer) => {
onRecipientChange(recipient);
setRecipientQuery(recipient.label);
setIsRecipientListOpen(false);
};

const handleRecipientChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
const nextQuery = event.target.value;
const exactMatch =
recipients.find(
(recipient) =>
recipient.label.toLowerCase() === nextQuery.trim().toLowerCase()
) ?? null;

setRecipientQuery(nextQuery);
onRecipientChange(exactMatch);
setIsRecipientListOpen(true);
setActiveRecipientIndex(0);
};

const handleRecipientKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>
) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setIsRecipientListOpen(true);
setActiveRecipientIndex((index) =>
matchingRecipients.length === 0
? 0
: (index + 1) % matchingRecipients.length
);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setIsRecipientListOpen(true);
setActiveRecipientIndex((index) =>
matchingRecipients.length === 0
? 0
: (index - 1 + matchingRecipients.length) %
matchingRecipients.length
);
} else if (event.key === "Enter" && isRecipientListOpen) {
const activeRecipient = matchingRecipients[activeRecipientIndex];
if (activeRecipient) {
event.preventDefault();
selectRecipient(activeRecipient);
}
} else if (event.key === "Escape") {
setIsRecipientListOpen(false);
}
};

return (
<div className="file-transfer-controls">
<input type="file" onChange={handleFileChange} />
<input
type="text"
placeholder="Recipient"
value={recipient}
onChange={handleRecipientChange}
/>
<button onClick={onSendFile} disabled={!selectedFile || !recipient}>
<div className="file-transfer-recipient">
<label htmlFor={recipientInputId} className="sr-only">
Recipient
</label>
<input
id={recipientInputId}
type="text"
role="combobox"
aria-autocomplete="list"
aria-expanded={isRecipientListOpen}
aria-controls={recipientListId}
aria-activedescendant={
isRecipientListOpen && matchingRecipients[activeRecipientIndex]
? `${recipientListId}-${matchingRecipients[activeRecipientIndex].id}`
: undefined
}
placeholder="Recipient"
value={recipientQuery}
onChange={handleRecipientChange}
onFocus={() => setIsRecipientListOpen(true)}
onBlur={() => setIsRecipientListOpen(false)}
onKeyDown={handleRecipientKeyDown}
/>
{isRecipientListOpen && (
<ul
id={recipientListId}
className="file-transfer-recipient-list"
role="listbox"
>
{matchingRecipients.length === 0 ? (
<li className="file-transfer-recipient-empty">
No connected players
</li>
) : (
matchingRecipients.map((recipient, index) => (
<li
key={recipient.id}
id={`${recipientListId}-${recipient.id}`}
className={
index === activeRecipientIndex
? "file-transfer-recipient-option active"
: "file-transfer-recipient-option"
}
role="option"
aria-selected={selectedRecipient?.id === recipient.id}
onMouseDown={(event) => {
event.preventDefault();
selectRecipient(recipient);
}}
>
<span>{recipient.label}</span>
{(recipient.away || recipient.idle) && (
<span className="file-transfer-recipient-status">
{[recipient.away ? "away" : "", recipient.idle ? "idle" : ""]
.filter(Boolean)
.join(", ")}
</span>
)}
</li>
))
)}
</ul>
)}
</div>
<button
onClick={onSendFile}
disabled={!selectedFile || !selectedRecipient}
>
Send File
</button>
</div>
);
};

export default Controls;
export default Controls;
Loading
Loading