clqms-shadcn-v1/src/lib/components/reusable/form/dictionary-form-renderer.svelte
faiztyanirh 1389eac272 25/03/2026
multiple fix/add :
change testdefsite to use isBoolean
add toggle components in renderer
change payload agestart ageend to days
initial edit form
2026-03-25 16:12:16 +07:00

928 lines
29 KiB
Svelte

<script>
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import * as Popover from "$lib/components/ui/popover/index.js";
import { Toggle } from '$lib/components/ui/toggle/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Spinner } from '$lib/components/ui/spinner/index.js';
import ReusableCalendar from '$lib/components/reusable/reusable-calendar.svelte';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as InputGroup from '$lib/components/ui/input-group/index.js';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import MoveLeftIcon from '@lucide/svelte/icons/move-left';
import MoveRightIcon from '@lucide/svelte/icons/move-right';
import BrushCleaningIcon from '@lucide/svelte/icons/brush-cleaning';
import DeleteIcon from '@lucide/svelte/icons/delete';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import PlusIcon from '@lucide/svelte/icons/plus';
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left';
import CheckIcon from "@lucide/svelte/icons/check";
import XIcon from "@lucide/svelte/icons/x";
let {
formState,
formFields,
mode = 'create',
disabledResultTypes = [],
disabledReferenceTypes = [],
disabledSign = false,
disabledFlag = false,
joinFields = $bindable(),
hiddenFields,
handleTestTypeChange,
handleResultTypeChange,
handleRefTypeChange,
onAddMember,
onRemoveMember,
//starthere
tokens = [],
cursorIndex = 0,
onUnselectAll,
onGetErrorStatus,
onAddLiteral,
onAddNewline,
onSetCursor,
operators,
logicalop,
comparisonop,
onMoveCursorLeft,
onMoveCursorRight,
onDeleteChar,
onClearExpression,
onAddOperator,
onAddValue,
} = $props();
let searchQuery = $state({});
let dropdownOpen = $state({});
let numberPopoverOpen = $state(false);
let stringPopoverOpen = $state(false);
let stringLiteralInput = $state('');
let numberLiteralInput = $state(null);
function getFilteredOptions(key) {
const query = searchQuery[key] || '';
if (!query) return formState.selectOptions?.[key] ?? [];
return (formState.selectOptions?.[key] ?? []).filter((opt) =>
opt.label.toLowerCase().includes(query.toLowerCase())
);
}
async function initializeDefaultValues() {
for (const group of formFields) {
for (const row of group.rows) {
for (const col of row.columns) {
if (col.type === 'group') {
for (const child of col.columns) {
await handleDefaultValue(child);
}
} else {
await handleDefaultValue(col);
}
}
}
}
}
async function handleDefaultValue(field) {
if (!field.defaultValue || !field.optionsEndpoint) return;
await formState.fetchOptions(field, formState.form);
if (!formState.form[field.key]) {
formState.form[field.key] = field.defaultValue;
}
}
$effect(() => {
initializeDefaultValues();
});
const expressionString = $derived(
tokens.map(t => t.type === 'newline' ? '\n' : t.value).join(' ').replace(/ \n /g, '\n')
);
</script>
{#snippet Fieldset({
key,
label,
required,
type,
optionsEndpoint,
options,
optionsToggle,
validateOn,
dependsOn,
endpointParamKey,
valueKey,
labelKey,
txtKey
})}
<div class="flex w-full flex-col gap-1.5">
<div class="flex justify-between items-center w-full">
<Label>{label}</Label>
{#if required}
<span class="text-destructive text-xl leading-none h-3.5">*</span>
{/if}
{#if key === 'FormulaCode' && formState.form.FormulaInput?.length}
{@const inputStatus = onGetErrorStatus?.()}
<div class="flex items-center gap-2 text-sm text-destructive">
<span>Must included :</span>
<div class="flex gap-1 flex-wrap">
{#each inputStatus as item (item.value)}
<Badge class="px-1 text-[10px]" variant={item.done ? 'default' : 'destructive'}>
{item.value}
</Badge>
{/each}
</div>
</div>
{/if}
</div>
<div class="relative flex flex-col items-center w-full">
{#if type === 'text'}
<Input
type="text"
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes('blur')) {
validateFieldAsync(key, mode, originalData?.[key]);
}
}}
readonly={key === 'NumRefType' || key === 'TxtRefType' || key === 'Level'}
disabled={key === 'Flag' && disabledFlag}
/>
{:else if type === 'email'}
<Input
type="email"
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes('blur')) {
formState.validateField(key, formState.form[key], false);
}
}}
/>
{:else if type === 'number'}
<Input
type="number"
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes('blur')) {
formState.validateField(key, formState.form[key], false);
}
}}
onkeydown={(e) => ['e', 'E', '+', '-'].includes(e.key) && e.preventDefault()}
/>
{:else if type === 'textarea'}
<Textarea
class="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes('blur')) {
formState.validateField(key, formState.form[key], false);
}
}}
bind:value={formState.form[key]}
/>
{:else if type === 'select'}
{@const selectedLabel =
formState.selectOptions?.[key]?.find((opt) => opt.value === formState.form[key])?.label ||
'Choose'}
{@const filteredOptions = getFilteredOptions(key)}
<Select.Root
type="single"
bind:value={formState.form[key]}
onValueChange={(val) => {
formState.form[key] = val;
if (validateOn?.includes('input')) {
formState.validateField?.(key, formState.form[key], false);
}
if (key === 'TestType') {
handleTestTypeChange(val);
}
if (key === 'Province') {
formState.form.City = '';
if (formState.selectOptions) {
formState.selectOptions.City = [];
}
if (formState.lastFetched) {
formState.lastFetched.City = null;
}
}
if (key === 'ResultType') {
handleResultTypeChange(val);
}
if (key === 'RefType') {
handleRefTypeChange(val);
}
if (key === 'HostType') {
formState.form.HostID = '';
formState.form.HostTestCode = '';
formState.form.HostTestName = '';
formState.selectOptions.HostTestCode = [];
formState.selectOptions.HostTestName = [];
}
if (key === 'HostID') {
formState.form.HostTestCode = '';
formState.form.HostTestName = '';
formState.selectOptions.HostTestCode = [];
formState.selectOptions.HostTestName = [];
}
if (key === 'ClientType') {
formState.form.ClientID = '';
formState.form.ClientTestCode = '';
formState.form.ClientTestName = '';
formState.selectOptions.ClientTestCode = [];
formState.selectOptions.ClientTestName = [];
}
if (key === 'ClientID') {
formState.form.ClientTestCode = '';
formState.form.ClientTestName = '';
formState.selectOptions.ClientTestCode = [];
formState.selectOptions.ClientTestName = [];
}
}}
onOpenChange={(open) => {
if (open) {
if (options && options.length > 0) {
if (formState.selectOptions) {
formState.selectOptions[key] = options;
}
} else if (optionsEndpoint) {
formState.fetchOptions?.(
{ key, optionsEndpoint, dependsOn, endpointParamKey, valueKey, labelKey },
formState.form
);
}
}
}}
>
<Select.Trigger class="w-full truncate">
{selectedLabel}
</Select.Trigger>
<Select.Content>
<div class="p-2">
<input
type="text"
placeholder="Search..."
class="w-full border rounded px-2 py-1 text-sm"
bind:value={searchQuery[key]}
/>
</div>
{#if formState.loadingOptions?.[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each filteredOptions as option}
<Select.Item
value={option.value}
disabled={(key === 'ResultType' && disabledResultTypes.includes(option.value)) ||
(key === 'RefType' && disabledReferenceTypes.includes(option.value))}
>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
{:else if type === 'selectmultiple'}
{@const filteredOptions = getFilteredOptions(key)}
{@const currentValues = Array.isArray(formState.form[key]) ? formState.form[key] : []}
<Select.Root
type="multiple"
value={currentValues.map((item) => item.value)}
onValueChange={(val) => {
const selectedObjects = (formState.selectOptions?.[key] ?? []).filter((opt) =>
val.includes(opt.value)
);
formState.form[key] = selectedObjects;
if (validateOn?.includes('input')) {
formState.validateField?.(key, selectedObjects, false);
formState.validateField?.('FormulaCode', formState.form.FormulaCode ?? '', false);
}
}}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions?.(
{ key, optionsEndpoint, dependsOn, endpointParamKey, valueKey, labelKey },
formState.form
);
}
}}
>
<Select.Trigger class="w-full">
{currentValues.length
? (formState.selectOptions?.[key] ?? [])
.filter((o) => currentValues.some((f) => f.value === o.value))
.map((o) => o.label)
.join(', ')
: 'Choose'}
</Select.Trigger>
<Select.Content>
<div class="p-2">
<input
type="text"
placeholder="Search..."
class="w-full border rounded px-2 py-1 text-sm"
bind:value={searchQuery[key]}
/>
</div>
<Select.Group>
{#if formState.loadingOptions?.[key]}
<div class="p-2 text-sm text-muted-foreground">Loading...</div>
{:else}
{#if currentValues.length > 0}
<Select.Separator />
<button
class="w-full px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
onclick={() => onUnselectAll?.(key)}
>
Unselect All
</button>
{/if}
{#each filteredOptions as opt (opt.value)}
<Select.Item value={opt.value} label={opt.label}>
{opt.label}
</Select.Item>
{/each}
{/if}
</Select.Group>
</Select.Content>
</Select.Root>
{:else if type === 'date'}
<ReusableCalendar
bind:value={formState.form[key]}
parentFunction={(dateStr) => {
formState.form[key] = dateStr;
if (validateOn?.includes('input')) {
formState.validateField(key, dateStr, false);
}
}}
/>
{:else if type === 'signvalue'}
<InputGroup.Root>
<InputGroup.Input
placeholder="Type here"
bind:value={formState.form[txtKey]}
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField('Low', formState.form[txtKey]);
formState.validateField('LowSign');
formState.validateField('High', formState.form[txtKey]);
formState.validateField('HighSign');
}
}}
/>
<InputGroup.Addon align="inline-start">
<DropdownMenu.Root
onOpenChange={(open) => {
dropdownOpen[key] = open;
if (open && optionsEndpoint) {
formState.fetchOptions?.(
{ key, optionsEndpoint, valueKey, labelKey },
formState.form
);
}
}}
>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<InputGroup.Button {...props} variant="ghost" disabled={disabledSign}>
{formState.selectOptions?.[key]?.find(
(opt) => opt.value === formState.form[key]
)?.label || 'Choose'}
<ChevronDownIcon />
</InputGroup.Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start">
{#if formState.loadingOptions?.[key]}
<DropdownMenu.Item disabled>Loading...</DropdownMenu.Item>
{:else}
<DropdownMenu.Item
onSelect={() => {
formState.form[key] = '';
dropdownOpen[key] = false;
formState.validateField('LowSign');
formState.validateField('HighSign');
}}
>
- None -
</DropdownMenu.Item>
{#each formState.selectOptions?.[key] || [] as option}
<DropdownMenu.Item
onclick={() => {
formState.form[key] = option.value;
dropdownOpen[key] = false;
}}
onSelect={() => {
formState.form[key] = option.value;
formState.validateField('LowSign');
formState.validateField('HighSign');
}}
>
{option.label}
</DropdownMenu.Item>
{/each}
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</InputGroup.Addon>
</InputGroup.Root>
{:else if type === 'agejoin'}
<div class="flex items-center gap-2 w-full">
<InputGroup.Root>
<InputGroup.Input
type="number"
bind:value={joinFields[key].YY}
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField('AgeStart');
formState.validateField('AgeEnd');
}
}}
/>
<InputGroup.Addon align="inline-end">
<InputGroup.Text>Year</InputGroup.Text>
</InputGroup.Addon>
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Input
type="number"
bind:value={joinFields[key].MM}
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField('AgeStart');
formState.validateField('AgeEnd');
}
}}
/>
<InputGroup.Addon align="inline-end">
<InputGroup.Text>Month</InputGroup.Text>
</InputGroup.Addon>
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Input
type="number"
bind:value={joinFields[key].DD}
oninput={() => {
if (validateOn?.includes('input')) {
formState.validateField('AgeStart');
formState.validateField('AgeEnd');
}
}}
/>
<InputGroup.Addon align="inline-end">
<InputGroup.Text>Day</InputGroup.Text>
</InputGroup.Addon>
</InputGroup.Root>
</div>
{:else if type === 'formulabuilder'}
<div class="flex flex-col gap-8 w-full">
<div class="flex gap-1 w-full">
<Button type="button" variant="outline" size="icon" onclick={onMoveCursorLeft}>
<MoveLeftIcon class="w-4 h-4" />
</Button>
<div
class="relative flex-1 min-h-[2rem] rounded-md border bg-background px-3 py-2 font-mono text-sm cursor-text focus-within:ring-1 focus-within:ring-ring"
role="textbox"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Backspace') { e.preventDefault(); onDeleteChar(); }
if (e.key === 'ArrowLeft') onMoveCursorLeft();
if (e.key === 'ArrowRight') onMoveCursorRight();
}}
>
{#if tokens.length === 0}
<span class="text-muted-foreground text-xs italic">Select test then click buttons below to build formula</span>
{:else}
{@const lines = (() => {
const result = [[]];
tokens.forEach((tok, idx) => {
if (tok.type === 'newline') result.push([]);
else result[result.length - 1].push({ tok, idx });
});
return result;
})()}
{#each lines as line, lineIdx}
<div class="flex flex-wrap items-center gap-1 min-h-[28px] {lineIdx > 0 ? 'mt-1 pt-1 border-t border-dashed border-border' : ''}">
{#each line as { tok, idx }}
{#if cursorIndex === idx}
<span class="animate-cursor inline-block h-5 w-0.5 bg-primary rounded"></span>
{/if}
<button
type="button"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold border transition-colors"
onclick={() => onSetCursor(idx + 1)}
>
{tok.value}
</button>
{/each}
{#if line.length > 0 && cursorIndex === line[line.length - 1].idx + 1}
<span class="animate-cursor inline-block h-5 w-0.5 bg-primary rounded"></span>
{:else if line.length === 0 && lineIdx === lines.length - 1}
<span class="animate-cursor inline-block h-5 w-0.5 bg-primary rounded"></span>
{/if}
</div>
{/each}
{/if}
</div>
<Button type="button" variant="outline" size="icon" onclick={onMoveCursorRight}>
<MoveRightIcon class="w-4 h-4" />
</Button>
<Button type="button" variant="outline" size="icon" onclick={onDeleteChar}>
<DeleteIcon class="w-4 h-4" />
</Button>
<Button type="button" variant="outline" size="icon" onclick={onClearExpression}>
<BrushCleaningIcon class="w-4 h-4" />
</Button>
<Button type="button" variant="outline" size="icon" onclick={onAddNewline} title="New line">
<CornerDownLeftIcon class="w-4 h-4" />
</Button>
</div>
<div class="flex flex-col gap-4">
{#if formState.form.FormulaInput?.length > 0}
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<Label class="uppercase tracking-wide">Selected Tests</Label>
<div class="flex flex-wrap gap-2">
{#each formState.form.FormulaInput as item (item)}
<Button
type="button"
variant="outline"
class="h-auto w-auto p-2"
onclick={() => onAddValue?.(item.value)}
>
{item.value}
</Button>
{/each}
</div>
</div>
</div>
{/if}
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<Label class="uppercase tracking-wide">Custom Values</Label>
<div class="flex flex-wrap gap-2">
<Popover.Root bind:open={stringPopoverOpen}>
<Popover.Trigger>
{#snippet child({ props: triggerProps })}
<Button
{...triggerProps}
type="button"
variant="outline"
class="h-auto w-auto p-2"
>
"abc"
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-64" side="bottom" align="start">
<div>
<div class="flex flex-col gap-3">
<p class="text-sm font-semibold">Enter Text Value</p>
<Input
type="text"
placeholder='e.g. F, POS, NEG'
bind:value={stringLiteralInput}
onkeydown={(e) => {
if (e.key === 'Enter' && stringLiteralInput.trim()) {
onAddLiteral(`"${stringLiteralInput.trim()}"`);
stringLiteralInput = '';
}
}}
/>
<div class="flex justify-end gap-2">
<Button
type="button"
size="sm"
disabled={!stringLiteralInput.trim()}
onclick={() => {
onAddLiteral(`"${stringLiteralInput.trim()}"`);
stringLiteralInput = '';
}}
>
Add
</Button>
</div>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Popover.Root bind:open={numberPopoverOpen}>
<Popover.Trigger>
{#snippet child({ props: triggerProps })}
<Button
{...triggerProps}
type="button"
variant="outline"
class="h-auto w-auto p-2"
>
123
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-64" side="bottom" align="start">
<div>
<div class="flex flex-col gap-3">
<p class="text-sm font-semibold">Enter Number Value</p>
<Input
type="number"
placeholder='e.g. 142'
bind:value={numberLiteralInput}
onkeydown={(e) => {
if (e.key === 'Enter' && numberLiteralInput != null && !isNaN(numberLiteralInput)) {
onAddLiteral(String(numberLiteralInput));
numberLiteralInput = null;
}
}}
/>
<div class="flex justify-end gap-2">
<Button
type="button"
size="sm"
disabled={numberLiteralInput == null || isNaN(numberLiteralInput)}
onclick={() => {
onAddLiteral(String(numberLiteralInput));
numberLiteralInput = null;
numberPopoverOpen = false;
}}
>
Add
</Button>
</div>
</div>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
<div class="flex flex-col gap-2">
<Label class="uppercase tracking-wide">Logical Operators</Label>
<div class="flex flex-wrap gap-2">
{#each logicalop as op}
<Button
type="button"
variant="outline"
class="h-auto w-auto p-2"
onclick={() => onAddOperator?.(op)}
>
{op}
</Button>
{/each}
</div>
</div>
<div class="flex flex-col gap-2">
<Label class="uppercase tracking-wide">Comparison Operators</Label>
<div class="flex flex-wrap gap-2">
{#each comparisonop as op}
<Button
type="button"
variant="outline"
size="icon"
onclick={() => onAddOperator?.(op)}
>
{op}
</Button>
{/each}
</div>
</div>
<div class="flex flex-col gap-2">
<Label class="uppercase tracking-wide">Math Operators</Label>
<div class="flex flex-wrap gap-2">
{#each operators as op}
<Button
type="button"
variant="outline"
size="icon"
onclick={() => onAddOperator?.(op)}
>
{op}
</Button>
{/each}
</div>
</div>
</div>
</div>
{#if tokens.length > 0}
<div class="flex flex-col gap-2">
<Label class="uppercase tracking-wide">Preview</Label>
<div class="border-2 border-dashed border-muted-foreground/30 rounded-lg">
<pre class="font-mono text-sm bg-muted/50 p-2 rounded">{expressionString}</pre>
</div>
</div>
{/if}
</div>
{:else if type === 'members'}
{@const filteredOptions = getFilteredOptions(key)}
<div class="flex flex-col gap-2 w-full">
{#each formState.form.Members as member, index (member.id)}
{@const selectedLabel =
formState.selectOptions?.[key]?.find((opt) => opt.value === member.value)?.label ||
'Choose'}
<div class="flex gap-1 w-full">
<Button type="button" variant="outline" size="icon" disabled>
{index + 1}
</Button>
<div class="flex-1">
<Select.Root
type="single"
bind:value={member.value}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions?.(
{ key, optionsEndpoint, dependsOn, endpointParamKey, valueKey, labelKey },
formState.form
);
}
}}
onValueChange={() => {
if (validateOn?.includes('input')) {
formState.validateField?.(key, formState.form[key], false);
}
}}
>
<Select.Trigger class="w-full">
{selectedLabel}
</Select.Trigger>
<Select.Content>
<div class="p-2">
<input
type="text"
placeholder="Search..."
class="w-full border rounded px-2 py-1 text-sm"
bind:value={searchQuery[key]}
/>
</div>
{#if formState.loadingOptions?.[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each filteredOptions as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
</div>
<Button
type="button"
variant="outline"
size="icon"
onclick={() => onRemoveMember(member.id)}
>
<Trash2Icon class="size-4" />
</Button>
</div>
{/each}
<Button variant="outline" onclick={onAddMember}>
<PlusIcon class="size-4" />
Add Test
</Button>
</div>
{:else if type === "toggle"}
{@const toggleOff = optionsToggle?.[0] ?? { value: false, label: 'Off' }}
{@const toggleOn = optionsToggle?.[1] ?? { value: true, label: 'On' }}
{@const isOn = String(formState.form[key]) === String(toggleOn.value)}
<div class="flex items-center w-full">
<Toggle
aria-label="Toggle"
variant="outline"
class="w-full transition-all data-[state=on]:text-primary"
pressed={isOn}
onPressedChange={(pressed) => {
formState.form[key] = pressed ? toggleOn.value : toggleOff.value;
}}
>
{#if isOn}
<CheckIcon class="mr-2 h-4 w-4" />
{:else}
<XIcon class="mr-2 h-4 w-4" />
{/if}
{isOn ? toggleOn.label : toggleOff.label}
</Toggle>
</div>
{:else}
<Input
type="text"
bind:value={formState.form[key]}
placeholder="Custom field type: {type}"
/>
{/if}
<div class={`absolute min-h-[1rem] w-full ${key === 'Members' ? 'right-0 -top-5 text-right' : 'top-10'}`}>
{#if key !== 'FormulaCode' && (formState.errors[key] || formState.errors[txtKey])}
{@const errorMessage = formState.errors[key] ?? formState.errors[txtKey]}
<div class="text-sm text-destructive">
{errorMessage}
</div>
{/if}
</div>
</div>
</div>
{/snippet}
<div class="p-2 space-y-6">
{#each formFields as group}
<div class="space-y-6">
{#if group.title}
<div class="text-md 2xl:text-lg font-semibold italic">
<span class="border-b-2 border-primary">{group.title}</span>
</div>
{/if}
{#each group.rows as row}
{@const visibleColumns = row.columns.filter((col) => !hiddenFields?.includes(col.key))}
{#if visibleColumns.length > 0}
<div
class="grid grid-cols-1 space-y-2 gap-6 md:gap-4"
class:md:grid-cols-1={visibleColumns.length === 1 &&
visibleColumns[0].fullWidth !== false}
class:md:grid-cols-2={visibleColumns.length === 2 ||
(visibleColumns.length === 1 && visibleColumns[0].fullWidth === false)}
class:md:grid-cols-3={visibleColumns.length === 3}
>
{#each visibleColumns as col}
{#if col.type === 'group'}
{@const visibleChildColumns = col.columns.filter(
(child) => !hiddenFields?.includes(child.key)
)}
{#if visibleChildColumns.length > 0}
<div
class="grid grid-cols-1 gap-6 md:gap-2"
class:md:grid-cols-1={visibleChildColumns.length === 1 &&
visibleChildColumns[0].fullWidth !== false}
class:md:grid-cols-2={visibleChildColumns.length === 2 ||
(visibleChildColumns.length === 1 &&
visibleChildColumns[0].fullWidth === false)}
class:md:grid-cols-3={visibleChildColumns.length === 3}
>
{#each visibleChildColumns as child}
{@render Fieldset(child)}
{/each}
</div>
{/if}
{:else}
{@render Fieldset(col)}
{/if}
{/each}
</div>
{/if}
{/each}
</div>
{/each}
</div>
<style>
@keyframes cursor-blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
.animate-cursor {
animation: cursor-blink 1s infinite;
}
</style>