metis-desk/packages/rdx-ui/src/components/template-section-fields.tsx

261 lines
9.3 KiB
TypeScript
Raw Normal View History

import { Button } from "@repo/shadcn-ui/components/button";
import { useId } from "react";
import { useDragReorder } from "../hooks/use-drag-reorder";
import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from "../lib/icons";
import type { FieldSection } from "./field-sections";
import { FormLabel } from "./form-label";
import { FormSectionTitle } from "./form-section-title";
import { ReorderDropIndicator } from "./reorder-drop-indicator";
import { ReorderHandle } from "./reorder-handle";
import type { SelectAction } from "./select-with-action";
import { SelectWithAction } from "./select-with-action";
import { SwitchField } from "./switch-field";
import type { SectionBinding, SectionEditorLabels } from "./template-sections-editor";
export function TemplateSectionFields({
sectionId,
sections,
fields,
fieldNames,
fieldNotes,
labels,
disabled,
onChange,
addAction,
layout = "cards",
}: {
layout?: "cards" | "compact";
sectionId?: string | undefined;
sections: readonly FieldSection[];
fields: readonly SectionBinding[];
fieldNames: Readonly<Record<string, string>>;
fieldNotes?: Readonly<Record<string, string>> | undefined;
labels: SectionEditorLabels;
disabled: boolean;
addAction?: SelectAction | undefined;
onChange: (fields: SectionBinding[]) => void;
}) {
const prefix = useId();
const grouped = fields.filter((field) => field.sectionId === sectionId);
2026-10-10 10:01:58 +00:00
const { getHandleProps, getItemProps, getDropPlacement } = useDragReorder({
items: grouped,
getId: (field) => field.fieldId,
disabled,
onChange: (next) => {
const reordered = next.values();
onChange(
fields.map((field) =>
field.sectionId === sectionId ? (reordered.next().value ?? field) : field
)
);
},
});
const move = (fieldId: string, offset: number) => {
const position = grouped.findIndex((field) => field.fieldId === fieldId);
const neighbor = grouped[position + offset];
const current = grouped[position];
2026-10-10 10:01:58 +00:00
if (!(neighbor && current)) {
return;
}
const next = [...fields];
const index = fields.indexOf(current);
const other = fields.indexOf(neighbor);
2026-10-10 10:01:58 +00:00
next[index] = neighbor;
next[other] = current;
onChange(next);
};
2026-10-10 10:01:58 +00:00
if (!(sectionId || grouped.length || (addAction && !sections.length))) {
return null;
}
2026-10-10 10:01:58 +00:00
return (
<section
aria-labelledby={`${prefix}-title`}
className={
sectionId
? "@container space-y-3"
: "@container space-y-3 rounded-lg border border-dashed bg-muted/30 p-4"
}
>
<div className="flex flex-wrap items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
<FormSectionTitle as="h5" id={`${prefix}-title`} layout="inline">
{sectionId || !sections.length ? labels.fields : labels.ungrouped}
</FormSectionTitle>
<span className="shrink-0 rounded-full border bg-background px-2 py-0.5 text-xs text-muted-foreground tabular-nums">
{grouped.length}
</span>
</div>
{addAction && (
<Button
aria-haspopup="dialog"
aria-label={`${addAction.label}: ${sections.find((section) => section.id === sectionId)?.name || (sectionId ? labels.name : labels.ungrouped)}`}
className="ml-auto"
disabled={disabled || addAction.disabled}
onClick={addAction.onClick}
size="sm"
type="button"
variant="outline"
>
<PlusIcon aria-hidden="true" />
{addAction.label}
</Button>
)}
</div>
<p className="sr-only" id={`${prefix}-reorder-help`}>
{labels.reorderHelp}
</p>
{!grouped.length && (
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
{labels.empty}
</p>
)}
<div className="grid gap-3 @min-[700px]:grid-cols-2">
{grouped.map((field, index) => {
const name = fieldNames[field.fieldId] ?? field.fieldId;
const requiredSwitch = (
<SwitchField
checked={field.required}
disabled={disabled}
id={`${prefix}-${field.fieldId}-required`}
label={
<>
<span className={layout === "compact" ? "sr-only" : undefined}>
{labels.required}
</span>
<span className="sr-only">: {name}</span>
</>
}
onCheckedChange={(required) =>
onChange(
fields.map((item) =>
item.fieldId === field.fieldId ? { ...item, required } : item
)
)
}
title={`${labels.required}: ${name}`}
/>
);
return (
<div
{...getItemProps(field.fieldId)}
className={
layout === "compact"
? "relative min-w-0 rounded-md border bg-background px-2 py-1 data-[dragging=true]:opacity-50"
: "relative flex min-w-0 flex-col gap-3 rounded-md border bg-background p-3 shadow-xs data-[dragging=true]:opacity-50"
}
key={field.fieldId}
>
<ReorderDropIndicator placement={getDropPlacement(field.fieldId)} />
<div className="flex items-center justify-between gap-2">
<ReorderHandle
{...getHandleProps(field.fieldId)}
aria-describedby={`${prefix}-reorder-help`}
label={`${labels.reorder}: ${name} (${index + 1}/${grouped.length})`}
/>
{layout === "compact" && requiredSwitch}
<div className="min-w-0 flex-1">
<p
className="break-words text-sm font-medium"
title={fieldNotes?.[field.fieldId]}
>
{name}
</p>
{fieldNotes?.[field.fieldId] && (
<p
className={layout === "compact" ? "sr-only" : "text-xs text-muted-foreground"}
>
{fieldNotes[field.fieldId]}
</p>
)}
</div>
<div className="flex shrink-0 items-center">
<Button
aria-label={`${labels.moveUp}: ${name}`}
disabled={disabled || index === 0}
onClick={() => move(field.fieldId, -1)}
size="icon-sm"
type="button"
variant="ghost"
>
<ArrowUpIcon />
</Button>
<Button
aria-label={`${labels.moveDown}: ${name}`}
disabled={disabled || index === grouped.length - 1}
onClick={() => move(field.fieldId, 1)}
size="icon-sm"
type="button"
variant="ghost"
>
<ArrowDownIcon />
</Button>
<Button
aria-label={`${labels.removeField}: ${name}`}
disabled={disabled}
onClick={() =>
onChange(fields.filter((item) => item.fieldId !== field.fieldId))
}
size="icon-sm"
type="button"
variant="ghost"
>
<Trash2Icon />
</Button>
</div>
</div>
{layout === "cards" && (
<div className="flex flex-wrap items-end gap-3 border-t pt-3">
{requiredSwitch}
{sections.length > 0 && (
<div className="min-w-40 flex-1 space-y-2">
<FormLabel htmlFor={`${prefix}-${field.fieldId}-section`}>
{labels.assign}
</FormLabel>
<SelectWithAction
disabled={disabled}
id={`${prefix}-${field.fieldId}-section`}
label={`${labels.assign}: ${name}`}
onValueChange={(section) =>
onChange(
fields.map((item) =>
item.fieldId === field.fieldId
? {
fieldId: item.fieldId,
required: item.required,
...(section ? { sectionId: section } : {}),
}
: item
)
)
}
options={[
...sections.map((section) => ({
value: section.id,
label: section.name || labels.name,
})),
]}
placeholder={labels.ungrouped}
value={field.sectionId ?? ""}
/>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</section>
);
}