Files
pi-gui/ui/src/components/SessionPicker.tsx
T

177 lines
4.9 KiB
TypeScript

import { type RefObject, useEffect, useMemo, useRef, useState } from "react";
import type { DirectorySession } from "../workspace/types";
const focusableSelector =
'button:not(:disabled), input:not(:disabled), [href], [tabindex]:not([tabindex="-1"])';
function sessionTitle(session: DirectorySession) {
return session.name ?? session.firstMessage ?? "Untitled session";
}
function messageCountLabel(count: number) {
return `${count} ${count === 1 ? "message" : "messages"}`;
}
function matchesQuery(session: DirectorySession, query: string) {
return [session.name, session.firstMessage, session.id].some((value) =>
value?.toLocaleLowerCase().includes(query),
);
}
function SessionRow({
session,
onOpen,
}: {
session: DirectorySession;
onOpen: (session: DirectorySession) => void;
}) {
const showPreview = Boolean(
session.name &&
session.firstMessage &&
session.firstMessage !== session.name,
);
return (
<li>
<button className="session-row" onClick={() => onOpen(session)}>
<span className="session-row-content">
<strong>{sessionTitle(session)}</strong>
{showPreview ? (
<span className="session-preview">{session.firstMessage}</span>
) : null}
<small className="session-metadata">
<span>{messageCountLabel(session.messageCount)}</span>
<time dateTime={session.modified}>
Modified {new Date(session.modified).toLocaleDateString()}
</time>
</small>
</span>
{session.runtimeId ? <span className="session-open">Open</span> : null}
</button>
</li>
);
}
function SessionResults({
sessions,
query,
onOpen,
}: {
sessions: DirectorySession[];
query: string;
onOpen: (session: DirectorySession) => void;
}) {
if (sessions.length === 0)
return <p className="muted">No saved sessions match “{query}”.</p>;
return (
<ul id="session-results" className="session-list">
{sessions.map((session) => (
<SessionRow key={session.path} session={session} onOpen={onOpen} />
))}
</ul>
);
}
function useDialogFocus(
dialogRef: RefObject<HTMLElement | null>,
searchRef: RefObject<HTMLInputElement | null>,
onClose: () => void,
) {
const openerRef = useRef<HTMLElement | null>(
document.activeElement as HTMLElement | null,
);
useEffect(() => {
const dialog = dialogRef.current;
const controls = () => [
...(dialog?.querySelectorAll<HTMLElement>(focusableSelector) ?? []),
];
searchRef.current?.focus();
const keydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab") return;
const focusable = controls();
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", keydown);
return () => {
window.removeEventListener("keydown", keydown);
openerRef.current?.focus();
};
}, [dialogRef, onClose, searchRef]);
}
export function SessionPicker({
sessions,
onOpen,
onClose,
}: {
sessions: DirectorySession[];
onOpen: (session: DirectorySession) => void;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const dialogRef = useRef<HTMLElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const normalizedQuery = query.trim().toLocaleLowerCase();
const filteredSessions = useMemo(
() => sessions.filter((session) => matchesQuery(session, normalizedQuery)),
[normalizedQuery, sessions],
);
useDialogFocus(dialogRef, searchRef, onClose);
const resultLabel = `${filteredSessions.length} saved session${filteredSessions.length === 1 ? "" : "s"}`;
return (
<div className="modal-backdrop">
<section
ref={dialogRef}
className="session-picker"
role="dialog"
aria-modal="true"
aria-labelledby="session-picker-title"
>
<div className="picker-heading">
<h2 id="session-picker-title">Saved sessions</h2>
<button onClick={onClose}>Close</button>
</div>
<div className="session-search">
<label className="sr-only" htmlFor="session-search">
Search saved sessions
</label>
<input
ref={searchRef}
id="session-search"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search by name, message, or ID"
aria-controls="session-results"
/>
</div>
<p className="session-result-count" role="status" aria-live="polite">
{sessions.length ? resultLabel : "No saved sessions"}
</p>
{sessions.length === 0 ? (
<p className="muted">No saved sessions for this directory.</p>
) : (
<SessionResults
sessions={filteredSessions}
query={query}
onOpen={onOpen}
/>
)}
</section>
</div>
);
}