267 lines
9.6 KiB
TypeScript
267 lines
9.6 KiB
TypeScript
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);
|
|
|
|
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];
|
|
|
|
if (!(neighbor && current)) {
|
|
return;
|
|
}
|
|
const next = [...fields];
|
|
const index = fields.indexOf(current);
|
|
const other = fields.indexOf(neighbor);
|
|
|
|
next[index] = neighbor;
|
|
next[other] = current;
|
|
onChange(next);
|
|
};
|
|
|
|
if (!(sectionId || grouped.length || (addAction && !sections.length))) {
|
|
return null;
|
|
}
|
|
|
|
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})`}
|
|
/>
|
|
<span
|
|
aria-hidden="true"
|
|
className="inline-flex h-7 min-w-9 shrink-0 items-center justify-center rounded-md border bg-muted/30 px-1.5 text-xs font-medium text-muted-foreground tabular-nums"
|
|
>
|
|
{index + 1}
|
|
</span>
|
|
{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>
|
|
);
|
|
}
|