141 lines
4.9 KiB
TypeScript
141 lines
4.9 KiB
TypeScript
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>
|
||
);
|
||
}
|