@aiaiai-pt/design-system 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,9 +6,15 @@
6
6
  import Alert from "./Alert.svelte";
7
7
  import CodeBlock from "./CodeBlock.svelte";
8
8
  import Combobox from "./Combobox.svelte";
9
+ import DatePicker from "./DatePicker.svelte";
9
10
  import DateTimePicker from "./DateTimePicker.svelte";
10
11
  import Input from "./Input.svelte";
12
+ import JsonEditor from "./JsonEditor.svelte";
13
+ import Textarea from "./Textarea.svelte";
14
+ import Toggle from "./Toggle.svelte";
11
15
  import MapPicker from "./MapPicker.svelte";
16
+ import MoneyInput from "./MoneyInput.svelte";
17
+ import MultiSelectCombobox from "./MultiSelectCombobox.svelte";
12
18
  import FileUpload from "./FileUpload.svelte";
13
19
  import FileUploadItem from "./FileUploadItem.svelte";
14
20
  import Select from "./Select.svelte";
@@ -16,6 +22,8 @@
16
22
  import type { Snippet } from "svelte";
17
23
  import {
18
24
  buildActionPayload,
25
+ isPayloadIncluded,
26
+ isReadonlyParam,
19
27
  placementConsequenceRows as buildPlacementConsequenceRows,
20
28
  type RendererMode,
21
29
  } from "./action-form-renderer-payload";
@@ -29,6 +37,26 @@
29
37
  // predicate (pure client evaluation over the value bag; the server owns
30
38
  // enforcement).
31
39
  import { sectionVisible } from "./action-form-visibility";
40
+ // #34 (atelier#669 V1, D6) — cardinality-aware relationship picking over the
41
+ // two rel param shapes (form-surface.v1 `relationship` summaries and
42
+ // derived-action `object_reference` + `multiple` params).
43
+ import {
44
+ isMultiRelationship,
45
+ isRelationshipParam,
46
+ normalizeRelIds,
47
+ relationshipTypeCode,
48
+ } from "./action-form-renderer-relationships";
49
+ // #36/#37 — widget-first dispatch (field summaries carry render intent in
50
+ // `widget`; derived-action params in `ui_schema.widget`; legacy params fall
51
+ // back to their `type`) + the date-only wire serialization.
52
+ import {
53
+ dateOnlyToDate,
54
+ dateToDateOnly,
55
+ geoJsonPointToLonLat,
56
+ lonLatToGeoJsonPoint,
57
+ storedFileDescriptor,
58
+ widgetKind,
59
+ } from "./action-form-renderer-widgets";
32
60
 
33
61
  /**
34
62
  * The renderer treats every parameter/action/placement as a loose record
@@ -94,6 +122,20 @@
94
122
  query: string,
95
123
  filter?: Record<string, string>,
96
124
  ) => Promise<SelectOption[]>;
125
+ /** Edit-time label hydration for relationship params (#34): receives the
126
+ * target entity type code and the currently-linked ids, returns
127
+ * `{value, label}` items so the picker renders labels, never raw ids.
128
+ * Same philosophy as searchEntities — the CONSUMER owns transport/auth;
129
+ * omitted → the picker falls back to showing the ids. Best-effort: a
130
+ * failed hydration never blocks the form. */
131
+ hydrateEntities?: (
132
+ typeCode: string,
133
+ ids: string[],
134
+ ) => Promise<SelectOption[]>;
135
+ /** #41 — per-field server-error binding: `{param_key: message}` rendered
136
+ * as each field's inline error (the V2a adapter re-binds proxy 4xx field
137
+ * errors here). Display-only — the HOST owns when errors set and clear. */
138
+ fieldErrors?: Record<string, string>;
97
139
  /** Environment-specific captcha (e.g. Turnstile), rendered above the button
98
140
  * in submit modes. */
99
141
  captcha?: Snippet;
@@ -138,6 +180,8 @@
138
180
  onApply = undefined,
139
181
  uploadFile = undefined,
140
182
  searchEntities = undefined,
183
+ hydrateEntities = undefined,
184
+ fieldErrors = undefined,
141
185
  captcha = undefined,
142
186
  submitLabel = "Submit",
143
187
  boundary = undefined,
@@ -235,6 +279,8 @@
235
279
  // parameter must have a non-empty value. Server-side submission criteria are
236
280
  // enforced by the BFF on submit and surfaced via `submitError`.
237
281
  function isEmpty(value: unknown): boolean {
282
+ // #34 — an empty ID list (required m2m with zero picks) must block submit.
283
+ if (Array.isArray(value)) return value.length === 0;
238
284
  return value === undefined || value === null || value === "";
239
285
  }
240
286
  // #634 S4 — the gate counts only parameters whose section is LIVE-visible:
@@ -243,13 +289,15 @@
243
289
  // to exactly `visibleParameters`.
244
290
  const gateParameters = $derived(liveSections.flatMap((section) => section.items));
245
291
  const canSubmit = $derived(
246
- gateParameters
247
- .filter((parameter) => parameter.required)
248
- .every((parameter) =>
249
- String(parameter.type ?? "") === "file"
250
- ? (fileUploads[parameterKey(parameter)] ?? []).length > 0
251
- : !isEmpty(values[parameterKey(parameter)]),
252
- ),
292
+ // #38 — any invalid json draft blocks submit, required or not.
293
+ !Object.values(jsonInvalid).some((message) => message !== null) &&
294
+ gateParameters
295
+ .filter((parameter) => parameter.required)
296
+ .every((parameter) =>
297
+ String(parameter.type ?? "") === "file"
298
+ ? (fileUploads[parameterKey(parameter)] ?? []).length > 0
299
+ : !isEmpty(values[parameterKey(parameter)]),
300
+ ),
253
301
  );
254
302
 
255
303
  async function handleSubmit(): Promise<void> {
@@ -388,6 +436,11 @@
388
436
  }
389
437
 
390
438
  function initialValue(parameter: Entity): unknown {
439
+ // #34 — a multi-value rel param holds an ID LIST in the bag (the m2m wire
440
+ // shape), normalized even when the default arrives as a scalar.
441
+ if (isRelationshipParam(parameter) && isMultiRelationship(parameter)) {
442
+ return normalizeRelIds(parameter.default_value);
443
+ }
391
444
  if (parameter.default_value !== null && parameter.default_value !== undefined) {
392
445
  return parameter.default_value;
393
446
  }
@@ -395,6 +448,12 @@
395
448
  if (type === "bool" || type === "boolean") return false;
396
449
  if (type === "number" || type === "integer") return "";
397
450
  if (type === "enum" || type === "select") return enumOptions(parameter)[0]?.value ?? "";
451
+ // #34 — an unset single `relationship` summary is null on the wire
452
+ // (string|null contract). Legacy `object_reference` keeps its "" seed.
453
+ if (type === "relationship") return null;
454
+ // #38 — an unset json field is null (empty editor), never the ""
455
+ // string (which would render as a literal '""' draft).
456
+ if (type === "json" || widgetKind(parameter) === "json") return null;
398
457
  return "";
399
458
  }
400
459
 
@@ -407,6 +466,10 @@
407
466
  for (const parameter of orderedParameters) {
408
467
  const key = parameterKey(parameter);
409
468
  if (!key) continue;
469
+ // #41 / D3 — payload include|exclude: excluded params render but never
470
+ // reach the wire (readonly defaults OUT on the form-surface.v1 lane;
471
+ // `payload: "include"` declares prefill-and-send).
472
+ if (!isPayloadIncluded(parameter)) continue;
410
473
  bag[key] = values[key] ?? initialValue(parameter);
411
474
  }
412
475
  return bag;
@@ -434,13 +497,17 @@
434
497
  : {};
435
498
  }
436
499
 
437
- async function handleFiles(key: string, files: File[]) {
500
+ // #40 — `replace` (a stored current exists on the edit form): SINGLE slot,
501
+ // a new upload supersedes the pending one; the stored file itself is only
502
+ // superseded host-side when the new key rides the payload. Default
503
+ // (append) keeps the legacy up-to-3 behavior byte-identical.
504
+ async function handleFiles(key: string, files: File[], replace = false) {
438
505
  if (!uploadFile) return;
439
506
  fileError = { ...fileError, [key]: "" };
440
- const existing = fileUploads[key] ?? [];
441
- const room = FILE_MAX_COUNT - existing.length;
507
+ const existing = replace ? [] : (fileUploads[key] ?? []);
508
+ const room = replace ? 1 : FILE_MAX_COUNT - existing.length;
442
509
  const batch = files.slice(0, room);
443
- if (files.length > room) {
510
+ if (!replace && files.length > room) {
444
511
  fileError = { ...fileError, [key]: `Up to ${FILE_MAX_COUNT} files` };
445
512
  }
446
513
  if (batch.length === 0) return;
@@ -451,7 +518,9 @@
451
518
  if ("key" in result) {
452
519
  fileUploads = {
453
520
  ...fileUploads,
454
- [key]: [...(fileUploads[key] ?? []), { key: result.key, name: file.name }],
521
+ [key]: replace
522
+ ? [{ key: result.key, name: file.name }]
523
+ : [...(fileUploads[key] ?? []), { key: result.key, name: file.name }],
455
524
  };
456
525
  } else {
457
526
  fileError = { ...fileError, [key]: result.error };
@@ -483,8 +552,10 @@
483
552
  const gen = (referenceSearchGen[key] = (referenceSearchGen[key] ?? 0) + 1);
484
553
  referenceLoading = { ...referenceLoading, [key]: true };
485
554
  try {
555
+ // #34 — the type code comes from the relationship meta when present
556
+ // (form-surface lane), else the param's own object_type (action lane).
486
557
  const items = await searchEntities(
487
- String(parameter.object_type ?? ""),
558
+ relationshipTypeCode(parameter),
488
559
  query,
489
560
  objectFilter(parameter),
490
561
  );
@@ -498,6 +569,96 @@
498
569
  }
499
570
  }
500
571
 
572
+ // #34 — relationship label bookkeeping. `relLabels` maps, per param key, a
573
+ // linked entity id → its display label, fed by (a) edit-time hydration
574
+ // through the `hydrateEntities` seam and (b) pick-time capture from the
575
+ // search results — so a chip / selected value never degrades to a raw id
576
+ // when a later search replaces the Combobox items. (The m2m search-to-add
577
+ // draft is MultiSelectCombobox-internal — the renderer never sees it.)
578
+ let relLabels = $state<Record<string, Record<string, string>>>({});
579
+ const relHydrateRequested: Record<string, Set<string>> = {};
580
+
581
+ // #38 — per-param json parse invalidity (JsonEditor oninvalid seam). A
582
+ // non-null message anywhere blocks submit: invalid text must never reach
583
+ // the wire OR be silently dropped by submitting around it.
584
+ let jsonInvalid = $state<Record<string, string | null>>({});
585
+
586
+ $effect(() => {
587
+ if (!hydrateEntities) return;
588
+ for (const parameter of orderedParameters) {
589
+ if (!isRelationshipParam(parameter)) continue;
590
+ const key = parameterKey(parameter);
591
+ if (!key) continue;
592
+ const ids = normalizeRelIds(values[key] ?? initialValue(parameter));
593
+ const known = relLabels[key] ?? {};
594
+ const requested = (relHydrateRequested[key] ??= new Set());
595
+ const missing = ids.filter((id) => !(id in known) && !requested.has(id));
596
+ if (!missing.length) continue;
597
+ for (const id of missing) requested.add(id);
598
+ hydrateEntities(relationshipTypeCode(parameter), missing)
599
+ .then((items) => {
600
+ if (!Array.isArray(items) || !items.length) return;
601
+ const next = { ...(relLabels[key] ?? {}) };
602
+ for (const item of items) {
603
+ const value = String(item.value ?? "");
604
+ if (value) next[value] = String(item.label ?? value);
605
+ }
606
+ relLabels = { ...relLabels, [key]: next };
607
+ })
608
+ .catch(() => {
609
+ // Best-effort: the picker falls back to raw ids.
610
+ });
611
+ }
612
+ });
613
+
614
+ function relLabel(key: string, id: string): string {
615
+ return relLabels[key]?.[id] ?? id;
616
+ }
617
+
618
+ function captureRelLabel(key: string, id: string) {
619
+ const item = (referenceItems[key] ?? []).find((entry) => entry.value === id);
620
+ if (!item) return;
621
+ relLabels = {
622
+ ...relLabels,
623
+ [key]: { ...(relLabels[key] ?? {}), [id]: item.label },
624
+ };
625
+ }
626
+
627
+ /** Combobox items for a rel param. Multi: the raw search results —
628
+ * MultiSelectCombobox itself excludes already-picked ids. Single: prepends
629
+ * the current selection when a later search dropped it (label
630
+ * persistence). */
631
+ function relComboItems(parameter: Entity): SelectOption[] {
632
+ const key = parameterKey(parameter);
633
+ const searched = referenceItems[key] ?? [];
634
+ if (isMultiRelationship(parameter)) return searched;
635
+ const current = typeof values[key] === "string" ? String(values[key]) : "";
636
+ if (!current || searched.some((item) => item.value === current)) {
637
+ return searched;
638
+ }
639
+ return [{ value: current, label: relLabel(key, current) }, ...searched];
640
+ }
641
+
642
+ function pickSingleRel(key: string, id: string) {
643
+ captureRelLabel(key, id);
644
+ setValue(key, id);
645
+ }
646
+
647
+ function addRelId(key: string, id: string) {
648
+ if (!id) return;
649
+ const current = normalizeRelIds(values[key]);
650
+ if (current.includes(id)) return;
651
+ captureRelLabel(key, id);
652
+ setValue(key, [...current, id]);
653
+ }
654
+
655
+ function removeRelId(key: string, id: string) {
656
+ setValue(
657
+ key,
658
+ normalizeRelIds(values[key]).filter((value) => value !== id),
659
+ );
660
+ }
661
+
501
662
  // `datetime` params keep an ISO 8601 STRING in the value bag (the platform's
502
663
  // datetime wire vocabulary — `$now` resolves to one) so the payload stays
503
664
  // plain JSON; the DateTimePicker speaks Date on its prop edge.
@@ -565,6 +726,7 @@
565
726
  {@const parameter = rawParameter as Entity}
566
727
  {@const key = parameterKey(parameter)}
567
728
  {@const type = parameterType(parameter)}
729
+ {@const kind = widgetKind(parameter)}
568
730
  <!-- A11y (#244 C7 / Selo item 4): required fields are marked
569
731
  `aria-required` on the control itself, so a screen reader announces
570
732
  "required" on the field — not just the disconnected visual hint below.
@@ -572,17 +734,113 @@
572
734
  {@const ariaRequired = parameter.required ? "true" : undefined}
573
735
  <!-- #252 — a parameter with `editable: false` (visibility.editable === false)
574
736
  renders READ-ONLY: its value (e.g. a logged-in citizen's prefilled
575
- identity — name/email) is shown but not editable. The value still rides
576
- the payload; the field just can't be changed. Default (undefined/true) is
577
- editable, so this is purely additive. -->
578
- {@const editable = parameter.editable !== false}
579
- {#if type === "enum" || type === "select" || enumOptions(parameter).length}
737
+ identity — name/email) is shown but not editable. On the ACTION lane the
738
+ value still rides the payload; on the form-surface.v1 lane
739
+ (`visibility: "readonly"` string) it defaults OFF the wire per D3 —
740
+ see isPayloadIncluded. -->
741
+ {@const editable = !isReadonlyParam(parameter)}
742
+ <!-- #41 — first-class field states: `disabled` and `loading` are host/
743
+ contract-declared inertness (loading = options/context not resolved
744
+ yet); `fieldError` is the per-field server-error binding. -->
745
+ {@const fieldDisabled = parameter.disabled === true || parameter.loading === true}
746
+ {@const fieldError = fieldErrors?.[key]}
747
+ {#if kind === "currency"}
748
+ <!-- #35 — money: the bag carries the RAW number ("" when empty — the
749
+ number-field empty sentinel); formatting is display-only. -->
750
+ <MoneyInput
751
+ label={String(parameter.label ?? key)}
752
+ name={key}
753
+ value={typeof values[key] === "number" ? (values[key] as number) : null}
754
+ readonly={!editable}
755
+ disabled={fieldDisabled}
756
+ error={fieldError}
757
+ onchange={(amount: number | null) => setValue(key, amount === null ? "" : amount)}
758
+ aria-required={ariaRequired}
759
+ />
760
+ {:else if kind === "textarea"}
761
+ <!-- #36 — long text. Full-width-only (FULL_WIDTH_WIDGET_KINDS clamp). -->
762
+ <Textarea
763
+ label={String(parameter.label ?? key)}
764
+ name={key}
765
+ rows={4}
766
+ value={String(values[key] ?? initialValue(parameter) ?? "")}
767
+ readonly={!editable}
768
+ disabled={fieldDisabled}
769
+ error={fieldError}
770
+ oninput={(event: Event) =>
771
+ setValue(key, (event.target as HTMLTextAreaElement).value)}
772
+ aria-required={ariaRequired}
773
+ />
774
+ {:else if kind === "toggle"}
775
+ <!-- #36 — boolean as the DS switch; the bag carries a BOOLEAN, never a
776
+ "yes"/"no" string. A bare bool param WITHOUT the widget keeps the
777
+ legacy Yes/No select (byte-identical action lane). -->
778
+ <Toggle
779
+ label={String(parameter.label ?? key)}
780
+ checked={values[key] === true}
781
+ disabled={fieldDisabled || !editable}
782
+ onchange={(checked: boolean) => setValue(key, checked)}
783
+ aria-required={ariaRequired}
784
+ />
785
+ {:else if kind === "slug"}
786
+ <!-- #36 — slug input-type refinement: machine-name typing affordances;
787
+ the value stays whatever the operator types (validation is the
788
+ server's). -->
789
+ <Input
790
+ label={String(parameter.label ?? key)}
791
+ name={key}
792
+ value={String(values[key] ?? initialValue(parameter) ?? "")}
793
+ readonly={!editable}
794
+ disabled={fieldDisabled}
795
+ error={fieldError}
796
+ spellcheck="false"
797
+ autocapitalize="none"
798
+ autocorrect="off"
799
+ oninput={(event: Event) => setValue(key, (event.target as HTMLInputElement).value)}
800
+ aria-required={ariaRequired}
801
+ />
802
+ {:else if kind === "json"}
803
+ <!-- #38 — json editor with the invalid-state contract: invalid text
804
+ surfaces its parse error, emits nothing, and blocks submit via the
805
+ jsonInvalid gate. -->
806
+ <JsonEditor
807
+ label={String(parameter.label ?? key)}
808
+ value={values[key] ?? initialValue(parameter)}
809
+ readonly={!editable}
810
+ disabled={fieldDisabled}
811
+ error={fieldError}
812
+ onchange={(value: unknown) => setValue(key, value)}
813
+ oninvalid={(message: string | null) =>
814
+ (jsonInvalid = { ...jsonInvalid, [key]: message })}
815
+ />
816
+ {:else if kind === "date"}
817
+ <!-- #37 — date-only picker; the bag carries the YYYY-MM-DD wire string
818
+ (LOCAL calendar parts — no toISOString, no DST off-by-one). -->
819
+ <DatePicker
820
+ label={String(parameter.label ?? key)}
821
+ value={dateOnlyToDate(values[key])}
822
+ readonly={!editable}
823
+ disabled={fieldDisabled}
824
+ error={fieldError}
825
+ onchange={(date: Date | null) => setValue(key, date ? dateToDateOnly(date) : "")}
826
+ />
827
+ {:else if type === "enum" || type === "select" || enumOptions(parameter).length}
828
+ {@const options = enumOptions(parameter)}
829
+ <!-- #41 — empty state: an option-driven field with ZERO resolved options
830
+ is inert with an explicit hint, never a dead control. -->
580
831
  <Select
581
832
  label={String(parameter.label ?? key)}
582
833
  name={key}
583
834
  value={String(values[key] ?? initialValue(parameter) ?? "")}
584
- options={enumOptions(parameter)}
835
+ {options}
585
836
  placeholder="Select value"
837
+ disabled={fieldDisabled || !editable || options.length === 0}
838
+ error={fieldError}
839
+ help={parameter.loading === true
840
+ ? "Loading options..."
841
+ : options.length === 0
842
+ ? "No options available"
843
+ : undefined}
586
844
  onchange={(value: string) => setValue(key, value)}
587
845
  aria-required={ariaRequired}
588
846
  />
@@ -593,6 +851,8 @@
593
851
  type="number"
594
852
  value={String(values[key] ?? "")}
595
853
  readonly={!editable}
854
+ disabled={fieldDisabled}
855
+ error={fieldError}
596
856
  oninput={(event: Event) => {
597
857
  const value = (event.target as HTMLInputElement).value;
598
858
  setValue(key, value === "" ? "" : Number(value));
@@ -608,6 +868,8 @@
608
868
  { value: "true", label: "Yes" },
609
869
  { value: "false", label: "No" },
610
870
  ]}
871
+ disabled={fieldDisabled || !editable}
872
+ error={fieldError}
611
873
  onchange={(value: string) => setValue(key, value === "true")}
612
874
  aria-required={ariaRequired}
613
875
  />
@@ -619,50 +881,102 @@
619
881
  label={String(parameter.label ?? key)}
620
882
  value={datetimeValue(values[key])}
621
883
  readonly={!editable}
884
+ disabled={fieldDisabled}
885
+ error={fieldError}
622
886
  onchange={(date: Date) => setValue(key, date.toISOString())}
623
887
  />
624
- {:else if type === "object_reference"}
625
- <!-- `object_reference` parameter (#629 PR1b): lazy-search Combobox over
626
- the entity type named by `object_type`; `object_filter` rides the
627
- injected searchEntities seam. No seam disabled (the file-param
628
- convention for a missing consumer transport). -->
629
- <Combobox
630
- label={String(parameter.label ?? key)}
631
- items={referenceItems[key] ?? []}
632
- value={String(values[key] ?? "")}
633
- placeholder={typeof parameter.object_type === "string" && parameter.object_type
634
- ? `Search ${parameter.object_type}...`
635
- : "Search..."}
636
- disabled={!searchEntities || !editable}
637
- loading={referenceLoading[key] ?? false}
638
- onsearch={(query: string) => handleReferenceSearch(parameter, query)}
639
- onchange={(value: string) => setValue(key, value)}
640
- />
888
+ {:else if isRelationshipParam(parameter)}
889
+ <!-- Relationship / `object_reference` picking, cardinality-aware (#34,
890
+ atelier#669 D6). Covers the form-surface.v1 `relationship` summary
891
+ AND the derived-action `object_reference` (+ `multiple`) param.
892
+ `object_filter` rides the injected searchEntities seam; no seam →
893
+ disabled (the file-param convention for a missing transport). -->
894
+ {#if isMultiRelationship(parameter)}
895
+ <!-- many_to_many: the DS MultiSelectCombobox widget (chips +
896
+ search-to-add). The renderer contributes only SEMANTICS: the value
897
+ bag carries the ID LIST, labels come from hydration/pick capture —
898
+ a single-pick replace of an m2m value is impossible by
899
+ construction (the widget never holds the selection). -->
900
+ <MultiSelectCombobox
901
+ data-testid="afr-rel-multi"
902
+ label={String(parameter.label ?? key)}
903
+ required={parameter.required === true}
904
+ items={relComboItems(parameter)}
905
+ selected={normalizeRelIds(values[key]).map((id) => ({
906
+ value: id,
907
+ label: relLabel(key, id),
908
+ }))}
909
+ placeholder={relationshipTypeCode(parameter)
910
+ ? `Search ${relationshipTypeCode(parameter)}...`
911
+ : "Search..."}
912
+ disabled={!searchEntities || !editable || fieldDisabled}
913
+ loading={(referenceLoading[key] ?? false) || parameter.loading === true}
914
+ error={fieldError}
915
+ onsearch={(query: string) => handleReferenceSearch(parameter, query)}
916
+ onadd={(value: string) => addRelId(key, value)}
917
+ onremove={(value: string) => removeRelId(key, value)}
918
+ />
919
+ {:else}
920
+ <Combobox
921
+ label={String(parameter.label ?? key)}
922
+ items={relComboItems(parameter)}
923
+ value={String(values[key] ?? "")}
924
+ placeholder={relationshipTypeCode(parameter)
925
+ ? `Search ${relationshipTypeCode(parameter)}...`
926
+ : "Search..."}
927
+ disabled={!searchEntities || !editable || fieldDisabled}
928
+ loading={(referenceLoading[key] ?? false) || parameter.loading === true}
929
+ error={fieldError}
930
+ onsearch={(query: string) => handleReferenceSearch(parameter, query)}
931
+ onchange={(value: string) => pickSingleRel(key, value)}
932
+ />
933
+ {/if}
641
934
  {:else if type === "file"}
642
935
  <!-- `file` parameter (#75 M5 slice 4b): upload-as-you-attach. The keys
643
936
  ride payload.attachment_keys; raw_values never sees this param. -->
644
937
  <!-- A11y: the file param is a labelled group (the FileUpload's own input
645
938
  can't take a `for`/label from here), so SR users get the field name +
646
939
  required state when they enter it. -->
940
+ {@const storedFile = storedFileDescriptor(parameter.default_value)}
941
+ {@const replaceMode = storedFile !== null}
647
942
  <div class="afr-file-param" role="group" aria-labelledby={`${key}-file-label`}>
648
943
  <span id={`${key}-file-label`} class="afr-file-label"
649
944
  >{String(parameter.label ?? key)}{parameter.required
650
945
  ? " (required)"
651
946
  : ""}</span
652
947
  >
948
+ <!-- #40 — REPLACE semantics: the stored current shows until a pending
949
+ replacement exists; untouched emits NO attachment key (the host
950
+ keeps the stored file); removing the pending replacement restores
951
+ untouched. -->
952
+ {#if replaceMode && storedFile && !(fileUploads[key] ?? []).length}
953
+ <div class="afr-file-current" data-testid="afr-file-current">
954
+ {#if storedFile.url}
955
+ <a
956
+ class="afr-file-current-name"
957
+ href={storedFile.url}
958
+ target="_blank"
959
+ rel="noopener noreferrer">{storedFile.name}</a
960
+ >
961
+ {:else}
962
+ <span class="afr-file-current-name">{storedFile.name}</span>
963
+ {/if}
964
+ <span class="afr-file-current-hint">Current file — uploading replaces it</span>
965
+ </div>
966
+ {/if}
653
967
  {#each fileUploads[key] ?? [] as f (f.key)}
654
968
  <FileUploadItem
655
969
  name={f.name}
656
970
  onremove={() => removeFile(key, f.key)}
657
971
  />
658
972
  {/each}
659
- {#if (fileUploads[key] ?? []).length < FILE_MAX_COUNT}
973
+ {#if replaceMode || (fileUploads[key] ?? []).length < FILE_MAX_COUNT}
660
974
  <FileUpload
661
975
  accept={fileAccept(parameter)}
662
976
  maxSize={fileMaxBytes(parameter)}
663
- multiple
977
+ multiple={!replaceMode}
664
978
  disabled={!uploadFile || fileBusy[key]}
665
- onfiles={(files: File[]) => handleFiles(key, files)}
979
+ onfiles={(files: File[]) => handleFiles(key, files, replaceMode)}
666
980
  onreject={() => {
667
981
  fileError = {
668
982
  ...fileError,
@@ -673,8 +987,29 @@
673
987
  {/if}
674
988
  {#if fileError[key]}
675
989
  <p class="afr-file-error" role="alert">{fileError[key]}</p>
990
+ {:else if fieldError}
991
+ <p class="afr-file-error" role="alert">{fieldError}</p>
676
992
  {/if}
677
993
  </div>
994
+ {:else if kind === "geometry"}
995
+ <!-- #39 — the form-surface.v1 geometry widget: the value bag carries a
996
+ GeoJSON Point (the CRUD wire shape); the MapPicker keeps speaking
997
+ [lon, lat] at its prop edge; the pure adapters translate. A
998
+ non-Point value fails LOUD on the field instead of rendering a
999
+ silently-empty map over real data. The legacy `geo` param (bare
1000
+ tuple) is the branch below, untouched. -->
1001
+ {@const hydrated = geoJsonPointToLonLat(values[key])}
1002
+ <MapPicker
1003
+ mode="point"
1004
+ height="20rem"
1005
+ {boundary}
1006
+ {layers}
1007
+ {onoutofbounds}
1008
+ error={fieldError ?? hydrated.error ?? geoError}
1009
+ label={String(parameter.label ?? key)}
1010
+ value={hydrated.coords ?? undefined}
1011
+ onchange={(coords: [number, number]) => setValue(key, lonLatToGeoJsonPoint(coords))}
1012
+ />
678
1013
  {:else if type === "geo"}
679
1014
  <!-- A `geo` parameter renders the DS map-picker; its value is the
680
1015
  [lon, lat] the BFF's GEO_PARSE binding transform turns into a Point.
@@ -690,7 +1025,7 @@
690
1025
  {boundary}
691
1026
  {layers}
692
1027
  {onoutofbounds}
693
- error={geoError}
1028
+ error={fieldError ?? geoError}
694
1029
  label={String(parameter.label ?? key)}
695
1030
  center={Array.isArray(parameter.default_value)
696
1031
  ? (parameter.default_value as [number, number])
@@ -711,6 +1046,8 @@
711
1046
  name={key}
712
1047
  value={String(values[key] ?? initialValue(parameter) ?? "")}
713
1048
  readonly={!editable}
1049
+ disabled={fieldDisabled}
1050
+ error={fieldError}
714
1051
  oninput={(event: Event) => setValue(key, (event.target as HTMLInputElement).value)}
715
1052
  aria-required={ariaRequired}
716
1053
  />
@@ -928,6 +1265,25 @@
928
1265
  color: var(--input-error-text);
929
1266
  }
930
1267
 
1268
+ .afr-file-current {
1269
+ display: flex;
1270
+ align-items: baseline;
1271
+ gap: var(--space-sm);
1272
+ }
1273
+
1274
+ .afr-file-current-name {
1275
+ font-family: var(--input-font);
1276
+ font-size: var(--input-font-size);
1277
+ color: var(--color-text);
1278
+ }
1279
+
1280
+ .afr-file-current-hint {
1281
+ font-family: var(--input-help-font);
1282
+ font-size: var(--input-help-size);
1283
+ color: var(--input-help-color);
1284
+ }
1285
+
1286
+
931
1287
  .submit-captcha {
932
1288
  min-height: var(--space-4xl);
933
1289
  }
@@ -69,6 +69,17 @@ interface Props {
69
69
  * transport/auth/tenancy (the uploadFile seam pattern — the DS stays
70
70
  * presentation-only). Omitted → object_reference params render disabled. */
71
71
  searchEntities?: (typeCode: string, query: string, filter?: Record<string, string>) => Promise<SelectOption[]>;
72
+ /** Edit-time label hydration for relationship params (#34): receives the
73
+ * target entity type code and the currently-linked ids, returns
74
+ * `{value, label}` items so the picker renders labels, never raw ids.
75
+ * Same philosophy as searchEntities — the CONSUMER owns transport/auth;
76
+ * omitted → the picker falls back to showing the ids. Best-effort: a
77
+ * failed hydration never blocks the form. */
78
+ hydrateEntities?: (typeCode: string, ids: string[]) => Promise<SelectOption[]>;
79
+ /** #41 — per-field server-error binding: `{param_key: message}` rendered
80
+ * as each field's inline error (the V2a adapter re-binds proxy 4xx field
81
+ * errors here). Display-only — the HOST owns when errors set and clear. */
82
+ fieldErrors?: Record<string, string>;
72
83
  /** Environment-specific captcha (e.g. Turnstile), rendered above the button
73
84
  * in submit modes. */
74
85
  captcha?: Snippet;
@@ -35,7 +35,7 @@ declare const DateRangePicker: import("svelte").Component<{
35
35
  onchange?: any;
36
36
  id?: any;
37
37
  class?: string;
38
- } & Record<string, any>, {}, "start" | "end">;
38
+ } & Record<string, any>, {}, "end" | "start">;
39
39
  type $$ComponentProps = {
40
40
  start?: any;
41
41
  end?: any;