Files
dm-companion/src/components/DocumentList.tsx

141 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as Icons from "@/components/Icons.tsx";
import type { AnyDocument, DocumentId } from "@/lib/types";
import {
Dialog,
DialogPanel,
Transition,
TransitionChild,
} from "@headlessui/react";
import { Fragment, useCallback, useState } from "react";
type Props<T extends AnyDocument> = {
title?: React.ReactNode;
error?: React.ReactNode;
items: T[];
renderRow: (item: T) => React.ReactNode;
newItemForm: (onSubmit: () => void) => React.ReactNode;
removeItem: (itemId: DocumentId) => void;
};
/**
* DocumentList is a generic list component for displaying document items with a dialog for adding new items.
*
* @param title - The title displayed above the list (left-aligned)
* @param items - The array of document items to display
* @param renderRow - Function to render each row's content
* @param newItemForm - Function that renders a form for creating a new item; receives an onSubmit callback
*/
export function DocumentList<T extends AnyDocument>({
title,
error,
items,
renderRow,
newItemForm,
removeItem,
}: Props<T>) {
const [open, setOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const toggleEditMode = useCallback(
() => setIsEditing((x) => !x),
[setIsEditing],
);
// Handles closing the dialog after form submission
const handleFormSubmit = (): void => {
setOpen(false);
};
return (
<section className="w-full">
<div className="flex items-center justify-between">
{title && <h2 className="text-xl font-bold text-slate-100">{title}</h2>}
<div className="flex gap-2">
{isEditing && (
<button
type="button"
className="inline-flex items-center justify-center rounded-full bg-violet-600 hover:bg-violet-700 text-white w-8 h-8 focus:outline-none focus:ring-2 focus:ring-violet-400"
aria-label="Add new item"
onClick={() => setOpen(true)}
>
<Icons.Cross />
</button>
)}
<button
type="button"
className="inline-flex items-center justify-center rounded-full bg-violet-600 hover:bg-violet-700 text-white w-8 h-8 focus:outline-none focus:ring-2 focus:ring-violet-400"
aria-label={isEditing ? "Exit edit mode" : "Enter edit mode"}
onClick={toggleEditMode}
>
{isEditing ? <Icons.Done /> : <Icons.Edit />}
</button>
</div>
</div>
{error && (
<div className="bg-red-900 rounded p-4 text-slate-100">{error}</div>
)}
<ul className="flex flex-col space-y-2">
{items.map((item) => (
<li
key={item.id}
className="p-2 m-0 border-b-1 last:border-0 border-slate-700 flex flex-row justify-between items-center"
>
{renderRow(item)}
{isEditing && (
<div>
<button
type="button"
className="inline-flex items-center justify-center rounded-full bg-violet-600 hover:bg-violet-700 text-white w-8 h-8 focus:outline-none focus:ring-2 focus:ring-violet-400"
aria-label="Remove item"
onClick={() => removeItem(item.id)}
>
<Icons.Remove />
</button>
</div>
)}
</li>
))}
</ul>
<Transition show={open} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={setOpen}>
<TransitionChild
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-50 transition-opacity" />
</TransitionChild>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="bg-slate-900 rounded-lg shadow-xl max-w-md w-full p-6 border border-slate-700 relative">
{newItemForm(handleFormSubmit)}
<button
type="button"
className="absolute top-3 right-3 text-slate-400 hover:text-red-400 focus:outline-none"
aria-label="Close dialog"
onClick={() => setOpen(false)}
>
<span aria-hidden="true">×</span>
</button>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
</section>
);
}