@cosmicdrift/kumiko-renderer 0.363.0 → 0.365.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.
@@ -1,4 +1,4 @@
1
- import { evalFieldCondition, findUnavailableSelectOptions, NO_WIDGET_FIELD_TYPES, } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import { evalFieldCondition, findUnavailableSelectOptions, isUnchangedValue, NO_WIDGET_FIELD_TYPES, } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
3
3
  import * as z from "zod";
4
4
  import { layoutEditFields } from "./layout-fields.js";
@@ -34,8 +34,7 @@ function isRequiredValueMissing(spec, field, record, unchangedFrom) {
34
34
  return false;
35
35
  if (isPresent(record[spec.field]))
36
36
  return false;
37
- return !(unchangedFrom !== undefined &&
38
- JSON.stringify(record[spec.field]) === JSON.stringify(unchangedFrom[spec.field]));
37
+ return !(unchangedFrom !== undefined && isUnchangedValue(record[spec.field], unchangedFrom[spec.field]));
39
38
  }
40
39
  // Client-side presence validation for the auto-wired entityEdit path —
41
40
  // checks that every rendered required field HAS a value, not that the
@@ -79,17 +78,24 @@ export function buildFormSchema(entity, screen, verb, unchangedFrom) {
79
78
  // @cast-boundary form-values
80
79
  const record = values;
81
80
  const selectFieldsToCheck = {};
81
+ // The server validates the merged row, so a locked select still fails there; the
82
+ // client reports it on the condition field the user can actually change.
83
+ const lockedSelectFields = {};
82
84
  for (const spec of fields) {
83
85
  const field = entity.fields[spec.field];
84
86
  if (!field)
85
87
  continue;
86
88
  // Hidden on create, read-only on update: the user cannot resolve a presence error.
87
- if (excludedFromWrite.has(spec.field))
89
+ if (excludedFromWrite.has(spec.field)) {
90
+ lockedSelectFields[spec.field] = field;
88
91
  continue;
92
+ }
89
93
  // Not operable by the user — a presence error would be unresolvable,
90
94
  // same reason as the NO_WIDGET_FIELD_TYPES check below.
91
- if (spec.readOnly !== undefined && evalFieldCondition(spec.readOnly, record))
95
+ if (spec.readOnly !== undefined && evalFieldCondition(spec.readOnly, record)) {
96
+ lockedSelectFields[spec.field] = field;
92
97
  continue;
98
+ }
93
99
  selectFieldsToCheck[spec.field] = field;
94
100
  if (!isRequiredValueMissing(spec, field, record, unchangedFrom))
95
101
  continue;
@@ -103,11 +109,15 @@ export function buildFormSchema(entity, screen, verb, unchangedFrom) {
103
109
  }
104
110
  // Against `unchangedFrom` the helper still checks a stored value whose
105
111
  // discriminator changed — the server validates the merged row.
106
- for (const issue of findUnavailableSelectOptions(selectFieldsToCheck, record, unchangedFrom)) {
112
+ for (const issue of findUnavailableSelectOptions({ ...lockedSelectFields, ...selectFieldsToCheck }, record, unchangedFrom)) {
113
+ const lockedDef = lockedSelectFields[issue.field];
114
+ const blamedField = lockedDef !== undefined && lockedDef.type === "select"
115
+ ? (lockedDef.conditionalOptions?.find((rule) => rule.options.includes(issue.value) && !evalFieldCondition(rule.when, record))?.when.field ?? issue.field)
116
+ : issue.field;
107
117
  ctx.addIssue({
108
118
  code: "custom",
109
- path: [issue.field],
110
- message: `"${issue.field}" is not available for the current selection.`,
119
+ path: [blamedField],
120
+ message: `"${blamedField}" is not available for the current selection.`,
111
121
  params: { [I18N_KEY_PARAM]: OPTION_NOT_AVAILABLE_I18N_KEY, allowed: issue.allowed },
112
122
  });
113
123
  }
@@ -51,7 +51,7 @@ import { findEditScreenFor, navigateTargetAllows, screenAccessAllows } from "./s
51
51
  import { SecretMintBody } from "./secret-mint-body.js";
52
52
  import { SecretsEditBody } from "./secrets-edit-body.js";
53
53
  import { useEmbeddedScreen } from "./use-embedded-screen.js";
54
- import { evalVisibleWhen } from "./visible-when.js";
54
+ import { evalVisibleWhenAfterShown } from "./visible-when.js";
55
55
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error.js";
56
56
  export { qualifyScreenId };
57
57
  /** Mirrors qualifyScreenId for nav QNs (registry form `<feature>:nav:<short-id>`).
@@ -96,9 +96,13 @@ export function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCo
96
96
  function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, children, }) {
97
97
  const { Banner, PageHeader } = usePrimitives();
98
98
  const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
99
- const t = useTranslation();
100
99
  const result = useQuery(visibleWhen.query, {}, { live: true });
101
- const verdict = evalVisibleWhen(visibleWhen, result);
100
+ const wasShown = useRef(false);
101
+ const verdict = evalVisibleWhenAfterShown(visibleWhen, result, wasShown.current);
102
+ useEffect(() => {
103
+ if (verdict === "visible")
104
+ wasShown.current = true;
105
+ }, [verdict]);
102
106
  if (verdict === "visible")
103
107
  return children;
104
108
  if (verdict === "loading") {
@@ -106,14 +110,19 @@ function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, c
106
110
  }
107
111
  // The route still names the locked screen; its breadcrumb must not label what stands in for it.
108
112
  const hideLockedBreadcrumb = PageHeader !== undefined && pageHeaderSlotAvailable ? _jsx(PageHeader, { hideBreadcrumb: true }) : null;
109
- return (_jsxs(_Fragment, { children: [hideLockedBreadcrumb, fallback !== undefined ? (_jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate })) : (_jsx(Banner, { padded: true, variant: "info", testId: "kumiko-screen-unavailable", children: t("kumiko.screen.unavailable") }))] }));
113
+ return (_jsxs(_Fragment, { children: [hideLockedBreadcrumb, fallback !== undefined ? (_jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate })) : (_jsx(ScreenUnavailableBanner, {}))] }));
114
+ }
115
+ function ScreenUnavailableBanner() {
116
+ const { Banner } = usePrimitives();
117
+ const t = useTranslation();
118
+ return (_jsx(Banner, { padded: true, variant: "info", testId: "kumiko-screen-unavailable", children: t("kumiko.screen.unavailable") }));
110
119
  }
111
120
  function FallbackScreen({ featureName, fallback, translate, }) {
112
121
  const target = useEmbeddedScreen(featureName, fallback);
113
- const { Banner, Text } = usePrimitives();
114
- if (target === undefined) {
115
- return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-not-found", children: ["Screen not found: ", _jsx(Text, { variant: "code", children: fallback })] }));
116
- }
122
+ // Also undefined when the user lacks access to the fallback screen, which is
123
+ // not a framework error.
124
+ if (target === undefined)
125
+ return _jsx(ScreenUnavailableBanner, {});
117
126
  return _jsx(KumikoScreen, { schema: target.schema, qn: target.qn, translate: translate });
118
127
  }
119
128
  function renderScreenBody({ schema, screen, translate, entityId, onRowClick, onCopyLink, }) {
@@ -480,7 +489,11 @@ function isRepresentableAtScale(value, scale) {
480
489
  const tolerance = Math.abs(scaled) * 8 * Number.EPSILON + Number.EPSILON;
481
490
  return Math.abs(scaled - Math.round(scaled)) <= tolerance;
482
491
  }
483
- function isIsoInstant(value) {
492
+ // Server schema (`z.iso.datetime()`) accepts only a `Z` suffix, while
493
+ // Temporal.Instant.from also takes numeric offsets.
494
+ function isUtcIsoInstant(value) {
495
+ if (!value.endsWith("Z"))
496
+ return false;
484
497
  try {
485
498
  Temporal.Instant.from(value);
486
499
  return true;
@@ -490,6 +503,21 @@ function isIsoInstant(value) {
490
503
  }
491
504
  }
492
505
  const EMBEDDED_LIST_PREFILL_ROW_LIMIT = 500;
506
+ // The server schema (`z.string().date()`) rejects calendar-invalid days like 2020-02-31.
507
+ function isValidIsoDate(value) {
508
+ if (!ISO_DATE_PATTERN.test(value))
509
+ return false;
510
+ try {
511
+ Temporal.PlainDate.from(value, { overflow: "reject" });
512
+ return true;
513
+ }
514
+ catch {
515
+ return false;
516
+ }
517
+ }
518
+ function isDecimalWithinSafeRange(value, scale) {
519
+ return scale === undefined || Math.abs(value) <= Number.MAX_SAFE_INTEGER / 10 ** scale;
520
+ }
493
521
  function coerceEmbeddedCell(value, cell) {
494
522
  switch (cell.type) {
495
523
  case "number":
@@ -497,13 +525,15 @@ function coerceEmbeddedCell(value, cell) {
497
525
  case "decimal":
498
526
  return typeof value === "number" &&
499
527
  Number.isFinite(value) &&
500
- (cell.scale === undefined || isRepresentableAtScale(value, cell.scale))
528
+ (cell.scale === undefined ||
529
+ (isRepresentableAtScale(value, cell.scale) &&
530
+ isDecimalWithinSafeRange(value, cell.scale)))
501
531
  ? value
502
532
  : undefined;
503
533
  case "date":
504
- return typeof value === "string" && ISO_DATE_PATTERN.test(value) ? value : undefined;
534
+ return typeof value === "string" && isValidIsoDate(value) ? value : undefined;
505
535
  case "timestamp":
506
- return typeof value === "string" && isIsoInstant(value) ? value : undefined;
536
+ return typeof value === "string" && isUtcIsoInstant(value) ? value : undefined;
507
537
  case "money":
508
538
  // Row cells are signed minor units; the currency lives on the head, so a
509
539
  // top-level `{amount, currency}` in major units would land 100x off.
@@ -552,6 +582,8 @@ function coerceEmbeddedListRows(raw, fieldName, schema, maxItems) {
552
582
  }
553
583
  function coercePrefillString(raw, name, shape, fallback, defaultCurrency, moneyCurrencyOverrides) {
554
584
  if (shape.type === "number") {
585
+ if (raw.trim() === "")
586
+ return fallback;
555
587
  const parsed = Number(raw);
556
588
  return Number.isFinite(parsed) ? parsed : fallback;
557
589
  }
@@ -2198,8 +2230,12 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2198
2230
  (screen.detailFor !== undefined
2199
2231
  ? findEntityListScreen(schema, screen.detailFor)?.id
2200
2232
  : undefined);
2201
- return (_jsx(RecordNotFoundState, { translate: effectiveTranslate, ...(backListScreen !== undefined && {
2202
- onBack: () => nav.navigate({ screenId: lastSegment(backListScreen) }),
2233
+ return (_jsx(RecordNotFoundState, { translate: effectiveTranslate, ...((returnTarget !== undefined || backListScreen !== undefined) && {
2234
+ onBack: () => navigateToReturnOr(nav, returnTarget, () => {
2235
+ if (backListScreen !== undefined) {
2236
+ nav.navigate({ screenId: lastSegment(backListScreen) });
2237
+ }
2238
+ }),
2203
2239
  }) }));
2204
2240
  }
2205
2241
  // Grouped into the head Card alongside title/status/metrics (fw#2713):
@@ -217,16 +217,20 @@ export function clampDateRange(current, bound, value) {
217
217
  // starts at that day's first instant, "to the 14th" includes everything up to
218
218
  // its last. Going through the next day's start keeps both ends right across a
219
219
  // DST boundary, where the day is not 24h long.
220
- function dayBoundInstant(date, bound, timeZone) {
221
- let plainDate;
220
+ function parsePlainDate(date) {
222
221
  try {
223
- plainDate = Temporal.PlainDate.from(date);
222
+ return Temporal.PlainDate.from(date);
224
223
  }
225
224
  catch {
226
- // Half-typed or hand-crafted URL value — drop the bound rather than send
227
- // something the handler's ISO-datetime schema would reject.
228
225
  return null;
229
226
  }
227
+ }
228
+ function dayBoundInstant(date, bound, timeZone) {
229
+ // Half-typed or hand-crafted URL value — drop the bound rather than send
230
+ // something the handler's ISO-datetime schema would reject.
231
+ const plainDate = parsePlainDate(date);
232
+ if (plainDate === null)
233
+ return null;
230
234
  const start = plainDate.toZonedDateTime(timeZone).toInstant();
231
235
  return bound === "from"
232
236
  ? start.toString()
@@ -242,7 +246,10 @@ function dayBoundInstant(date, bound, timeZone) {
242
246
  // A shared or hand-built URL can carry from > to; the handler's `from <= to`
243
247
  // refine would answer 422, so the bounds are put in order before conversion.
244
248
  function swapInvertedRange(range) {
245
- const inverted = range.from !== "" && range.to !== "" && range.from > range.to;
249
+ // Comparing raw strings would swap a valid bound against garbage and flip the filter's meaning.
250
+ const from = parsePlainDate(range.from);
251
+ const to = parsePlainDate(range.to);
252
+ const inverted = from !== null && to !== null && Temporal.PlainDate.compare(from, to) > 0;
246
253
  return inverted ? { from: range.to, to: range.from } : range;
247
254
  }
248
255
  export function buildDateRangePayload(specs, urlFilters, timeZone) {
package/dist/app/nav.d.ts CHANGED
@@ -18,7 +18,9 @@ export type ObjectTarget = {
18
18
  export type NavTarget = ScreenTarget | ObjectTarget;
19
19
  export type NavigateOptions = {
20
20
  /** Search params of the target route, set in the same history entry as
21
- * the path. Without them the target starts with an empty query. */
21
+ * the path. Without them a different target starts with an empty query;
22
+ * navigating to the current path keeps the current query (pass
23
+ * `searchParams: {}` to clear it). */
22
24
  readonly searchParams?: Readonly<Record<string, string>>;
23
25
  };
24
26
  export type NavApi = {
@@ -2,3 +2,4 @@ import type { DashboardPanelVisibility } from "@cosmicdrift/kumiko-framework/ui-
2
2
  import type { UseQueryResult } from "../hooks/use-query.js";
3
3
  export type VisibleWhenVerdict = "loading" | "visible" | "hidden" | "error";
4
4
  export declare function evalVisibleWhen(visibleWhen: DashboardPanelVisibility, result: UseQueryResult<Readonly<Record<string, unknown>>> | undefined): VisibleWhenVerdict;
5
+ export declare function evalVisibleWhenAfterShown(visibleWhen: DashboardPanelVisibility, result: UseQueryResult<Readonly<Record<string, unknown>>> | undefined, wasShown: boolean): VisibleWhenVerdict;
@@ -9,3 +9,10 @@ export function evalVisibleWhen(visibleWhen, result) {
9
9
  return result.loading ? "loading" : "hidden";
10
10
  return result.data[visibleWhen.field] === visibleWhen.eq ? "visible" : "hidden";
11
11
  }
12
+ // Once the content was shown, a failed refetch must not unmount it (and drop unsaved
13
+ // form input) while the last good data still matches; the first evaluation stays fail-closed.
14
+ export function evalVisibleWhenAfterShown(visibleWhen, result, wasShown) {
15
+ const verdict = evalVisibleWhen(visibleWhen, result);
16
+ const lastGoodDataMatches = result?.data?.[visibleWhen.field] === visibleWhen.eq;
17
+ return verdict === "error" && wasShown && lastGoodDataMatches ? "visible" : verdict;
18
+ }
@@ -1,6 +1,6 @@
1
1
  import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
2
2
  import type { OptionsQueryPayload } from "@cosmicdrift/kumiko-types/fields";
3
- import type { ReactNode } from "react";
3
+ import { type ReactNode } from "react";
4
4
  export type SelectOptionEntry = {
5
5
  readonly value: string;
6
6
  readonly label: string;
@@ -9,14 +9,14 @@ export type SelectOptionEntry = {
9
9
  };
10
10
  export declare function staticSelectOptions(field: EditFieldViewModel): readonly SelectOptionEntry[];
11
11
  export declare function mergeOptionAvailability(options: readonly SelectOptionEntry[], rows: readonly unknown[], current: string): readonly SelectOptionEntry[];
12
- export declare function filterUnavailableOptions(options: readonly SelectOptionEntry[], rows: readonly unknown[], currentValues: readonly string[]): readonly SelectOptionEntry[];
12
+ export declare function filterUnavailableOptions(options: readonly SelectOptionEntry[], rows: readonly unknown[], heldValues: ReadonlySet<string>): readonly SelectOptionEntry[];
13
13
  type AvailabilityOptionsMultiSelectProps = {
14
14
  readonly field: EditFieldViewModel;
15
15
  readonly query: string;
16
- readonly currentValues: readonly string[];
16
+ readonly initialValues: readonly string[];
17
17
  readonly children: (options: readonly SelectOptionEntry[]) => ReactNode;
18
18
  };
19
- export declare function AvailabilityOptionsMultiSelect({ field, query, currentValues, children, }: AvailabilityOptionsMultiSelectProps): ReactNode;
19
+ export declare function AvailabilityOptionsMultiSelect({ field, query, initialValues, children, }: AvailabilityOptionsMultiSelectProps): ReactNode;
20
20
  type AvailabilityOptionsSelectProps = {
21
21
  readonly field: EditFieldViewModel;
22
22
  readonly query: string;
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useRef } from "react";
2
3
  import { useQuery } from "../hooks/use-query.js";
3
4
  import { usePrimitives } from "../primitives.js";
4
5
  import { resolveOptionsQueryPayload } from "./query-options-select.js";
@@ -36,21 +37,27 @@ export function mergeOptionAvailability(options, rows, current) {
36
37
  };
37
38
  });
38
39
  }
39
- // Combobox primitives cannot disable a single option, so unavailable ones are
40
- // dropped; an already selected value stays listed so it can still be removed.
41
- export function filterUnavailableOptions(options, rows, currentValues) {
40
+ // Unavailable options are dropped unless the target already held them when the
41
+ // form mounted; those stay listed but disabled, because the write path keeps
42
+ // values the caller cannot grant and an unchecked one could never be re-checked.
43
+ export function filterUnavailableOptions(options, rows, heldValues) {
42
44
  const unavailable = new Set();
43
45
  for (const row of rows) {
44
46
  if (isAvailabilityRow(row) && row.disabled === true)
45
47
  unavailable.add(row.value);
46
48
  }
47
- return options.filter((option) => !unavailable.has(option.value) || currentValues.includes(option.value));
49
+ return options.flatMap((option) => {
50
+ if (!unavailable.has(option.value))
51
+ return [option];
52
+ return heldValues.has(option.value) ? [{ ...option, disabled: true }] : [];
53
+ });
48
54
  }
49
55
  // Until the result arrives, and when the query fails, every static option is
50
56
  // shown; the write path enforces the rule either way.
51
- export function AvailabilityOptionsMultiSelect({ field, query, currentValues, children, }) {
57
+ export function AvailabilityOptionsMultiSelect({ field, query, initialValues, children, }) {
52
58
  const { data } = useQuery(query, {});
53
- return children(filterUnavailableOptions(staticSelectOptions(field), data?.rows ?? [], currentValues));
59
+ const heldAtMount = useRef(new Set(initialValues));
60
+ return children(filterUnavailableOptions(staticSelectOptions(field), data?.rows ?? [], heldAtMount.current));
54
61
  }
55
62
  // Select with static options whose availability (SelectFieldDef.optionsAvailabilityQuery)
56
63
  // comes from a query, so it has to be a mounted component for useQuery. Until the
@@ -13,6 +13,8 @@ export function FileListField({ field, id, kind, onChange, hasError, }) {
13
13
  const { Input, Button, Grid, GridCell, Text } = usePrimitives();
14
14
  const t = useTranslation();
15
15
  const fileIds = readFileIds(field.value);
16
+ // The server accepts repeated ids, so the id alone is not a unique key.
17
+ const keys = fileIds.map((fileId, index) => `${fileId}#${fileIds.slice(0, index).filter((x) => x === fileId).length}`);
16
18
  const disabled = field.readOnly;
17
19
  const canAdd = !disabled && (field.maxCount === undefined || fileIds.length < field.maxCount);
18
20
  const inputProps = {
@@ -42,7 +44,7 @@ export function FileListField({ field, id, kind, onChange, hasError, }) {
42
44
  onChange(fileIds.filter((_, i) => i !== index));
43
45
  else
44
46
  onChange(fileIds.map((existing, i) => (i === index ? next : existing)));
45
- } }), !disabled && (_jsxs(Grid, { columns: "auto", children: [index > 0 && (_jsx(Button, { variant: "ghost", size: "icon", icon: "chevron-up", ariaLabel: t("kumiko.field.file-list.move-up"), testId: `${id}-${index}-move-up`, onClick: () => move(index, index - 1) })), index < fileIds.length - 1 && (_jsx(Button, { variant: "ghost", size: "icon", icon: "chevron-down", ariaLabel: t("kumiko.field.file-list.move-down"), testId: `${id}-${index}-move-down`, onClick: () => move(index, index + 1) }))] }))] }, fileId))), canAdd && (_jsxs(GridCell, { children: [_jsx(Text, { variant: "muted", children: t("kumiko.field.file-list.add") }), _jsx(Input, { ...inputProps, id: `${id}-add`, value: null, onChange: (next) => {
47
+ } }), !disabled && (_jsxs(Grid, { columns: "auto", children: [index > 0 && (_jsx(Button, { variant: "ghost", size: "icon", icon: "chevron-up", ariaLabel: t("kumiko.field.file-list.move-up"), testId: `${id}-${index}-move-up`, onClick: () => move(index, index - 1) })), index < fileIds.length - 1 && (_jsx(Button, { variant: "ghost", size: "icon", icon: "chevron-down", ariaLabel: t("kumiko.field.file-list.move-down"), testId: `${id}-${index}-move-down`, onClick: () => move(index, index + 1) }))] }))] }, keys[index]))), canAdd && (_jsxs(GridCell, { children: [_jsx(Text, { variant: "muted", children: t("kumiko.field.file-list.add") }), _jsx(Input, { ...inputProps, id: `${id}-add`, value: null, onChange: (next) => {
46
48
  if (next !== null)
47
49
  onChange([...fileIds, next]);
48
50
  } })] }, "add"))] }));
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
3
3
  export type MultiSelectCheckboxOption = {
4
4
  readonly value: string;
5
5
  readonly label: string;
6
+ readonly disabled?: boolean;
6
7
  };
7
8
  export type MultiSelectCheckboxesProps = {
8
9
  readonly field: EditFieldViewModel;
@@ -14,8 +14,11 @@ export function MultiSelectCheckboxes({ field, id, options, value, onChange, })
14
14
  const allSelected = options.length > 0 && options.every((opt) => selected.has(opt.value));
15
15
  // Always emits in `field.options` order (MultiSelectFieldDef's documented
16
16
  // ordering guarantee), never in click/selection order.
17
+ // Disabled options are pinned: they keep their current state whatever is toggled.
17
18
  const emitOrdered = (next) => {
18
- onChange(options.filter((opt) => next.has(opt.value)).map((opt) => opt.value));
19
+ onChange(options
20
+ .filter((opt) => (opt.disabled === true ? selected.has(opt.value) : next.has(opt.value)))
21
+ .map((opt) => opt.value));
19
22
  };
20
23
  const toggleOption = (optionValue, checked) => {
21
24
  const next = new Set(selected);
@@ -35,5 +38,5 @@ export function MultiSelectCheckboxes({ field, id, options, value, onChange, })
35
38
  : t("kumiko.field.multiSelect.select-all") }), _jsx(Grid, { columns: field.columns ?? 2, testId: `${id}-checkboxes`,
36
39
  // Field's label targets `id`, which no checkbox carries here; point the
37
40
  // group at Field's `${id}-label` element instead.
38
- ariaLabelledBy: `${id}-label`, ...(field.maxRows !== undefined && { maxRows: field.maxRows }), children: options.map((opt) => (_jsx(GridCell, { children: _jsx(Field, { id: `${id}-${opt.value}`, label: opt.label, layout: "inline", children: _jsx(Input, { kind: "boolean", id: `${id}-${opt.value}`, name: `${id}-${opt.value}`, value: selected.has(opt.value), onChange: (checked) => toggleOption(opt.value, checked), disabled: disabled }) }) }, opt.value))) })] }));
41
+ ariaLabelledBy: `${id}-label`, ...(field.maxRows !== undefined && { maxRows: field.maxRows }), children: options.map((opt) => (_jsx(GridCell, { children: _jsx(Field, { id: `${id}-${opt.value}`, label: opt.label, layout: "inline", children: _jsx(Input, { kind: "boolean", id: `${id}-${opt.value}`, name: `${id}-${opt.value}`, value: selected.has(opt.value), onChange: (checked) => toggleOption(opt.value, checked), disabled: disabled || opt.disabled === true }) }) }, opt.value))) })] }));
39
42
  }
@@ -352,9 +352,9 @@ function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner,
352
352
  return (_jsx(Input, { kind: "tz", ...common, value: stringValue(field.value), onChange: (v) => onChange(v) }));
353
353
  case "multiSelect": {
354
354
  const arrayValue = stringArrayOf(field.value);
355
- const renderMultiSelect = (options) => field.display === "checkboxes" ? (_jsx(MultiSelectCheckboxes, { field: field, id: id, options: options, value: arrayValue, onChange: (v) => onChange(v) })) : (_jsx(Input, { kind: "combobox", ...common, multiple: true, value: arrayValue, onChange: (v) => onChange(v), options: options }));
355
+ const renderMultiSelect = (options) => field.display === "checkboxes" ? (_jsx(MultiSelectCheckboxes, { field: field, id: id, options: options, value: arrayValue, onChange: (v) => onChange(v) })) : (_jsx(Input, { kind: "combobox", ...common, multiple: true, value: arrayValue, onChange: (v) => onChange(withPinnedValues(v, options, arrayValue)), options: options }));
356
356
  if (field.selectOptionsAvailabilityQuery !== undefined && field.readOnly !== true) {
357
- return (_jsx(AvailabilityOptionsMultiSelect, { field: field, query: field.selectOptionsAvailabilityQuery.query, currentValues: arrayValue, children: renderMultiSelect }));
357
+ return (_jsx(AvailabilityOptionsMultiSelect, { field: field, query: field.selectOptionsAvailabilityQuery.query, initialValues: arrayValue, children: renderMultiSelect }));
358
358
  }
359
359
  return renderMultiSelect(staticSelectOptions(field));
360
360
  }
@@ -449,8 +449,11 @@ const ISO_CURRENCY_CODE = /^[A-Z]{3}$/;
449
449
  function resolveMoneyCurrency(value, fieldCurrency) {
450
450
  if (typeof value === "object" && value !== null && "currency" in value) {
451
451
  const { currency } = value;
452
- if (typeof currency === "string" && ISO_CURRENCY_CODE.test(currency))
453
- return currency;
452
+ if (typeof currency === "string") {
453
+ const normalized = currency.toUpperCase();
454
+ if (ISO_CURRENCY_CODE.test(normalized))
455
+ return normalized;
456
+ }
454
457
  }
455
458
  return fieldCurrency ?? "EUR";
456
459
  }
@@ -472,7 +475,17 @@ function resolveNumberUnit(unit, row) {
472
475
  return trimmed.length > 0 && trimmed.length <= MAX_SIBLING_UNIT_LENGTH ? trimmed : undefined;
473
476
  }
474
477
  function stringArrayOf(value) {
475
- return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : [];
478
+ return Array.isArray(value)
479
+ ? value.filter((item) => typeof item === "string")
480
+ : [];
481
+ }
482
+ // The combobox cannot disable a single option, so removals of disabled options
483
+ // the form already holds are merged back.
484
+ function withPinnedValues(next, options, current) {
485
+ const missingPinned = options
486
+ .filter((opt) => opt.disabled === true && current.includes(opt.value) && !next.includes(opt.value))
487
+ .map((opt) => opt.value);
488
+ return missingPinned.length === 0 ? next : [...next, ...missingPinned];
476
489
  }
477
490
  function stringPropertyOf(value, key) {
478
491
  const property = propertyOf(value, key);
@@ -31,14 +31,20 @@ export function RenderList(props) {
31
31
  // mount and can flush after a keystroke landing in the same commit,
32
32
  // resetting localQ and swallowing the input.
33
33
  const [syncedSearchValue, setSyncedSearchValue] = useState(searchValue);
34
- // The parent may echo our own debounced emit back late (router/URL round
35
- // trip); adopting that stale echo would erase keys typed in between.
36
- const lastEmittedRef = useRef(undefined);
34
+ // The parent may echo our own debounced emits back late (router/URL round
35
+ // trip), possibly after a newer emit already went out; adopting such a stale
36
+ // echo would erase keys typed in between. All unechoed emits are kept, and a
37
+ // matching echo drops itself and every older one.
38
+ const pendingEmitsRef = useRef([]);
37
39
  if (searchValue !== syncedSearchValue) {
38
40
  setSyncedSearchValue(searchValue);
39
- if (searchValue !== lastEmittedRef.current) {
41
+ const echoIndex = pendingEmitsRef.current.indexOf(searchValue ?? "");
42
+ if (echoIndex >= 0) {
43
+ pendingEmitsRef.current.splice(0, echoIndex + 1);
44
+ }
45
+ else {
40
46
  setLocalQ(searchValue ?? "");
41
- lastEmittedRef.current = undefined;
47
+ pendingEmitsRef.current = [];
42
48
  }
43
49
  }
44
50
  useEffect(() => {
@@ -47,7 +53,7 @@ export function RenderList(props) {
47
53
  if (localQ === (searchValue ?? ""))
48
54
  return;
49
55
  const timer = setTimeout(() => {
50
- lastEmittedRef.current = localQ;
56
+ pendingEmitsRef.current.push(localQ);
51
57
  onSearchChange(localQ);
52
58
  }, SEARCH_DEBOUNCE_MS);
53
59
  return () => clearTimeout(timer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.363.0",
3
+ "version": "0.365.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.363.0",
19
- "@cosmicdrift/kumiko-headless": "0.363.0",
20
- "@cosmicdrift/kumiko-types": "0.363.0",
18
+ "@cosmicdrift/kumiko-framework": "0.365.0",
19
+ "@cosmicdrift/kumiko-headless": "0.365.0",
20
+ "@cosmicdrift/kumiko-types": "0.365.0",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.363.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.365.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,28 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "List search keeps the newest typed text; file lists tolerate duplicate ids; date ranges and locked selects validate correctly",
6
+ "detail": "A late echo of an older debounced search emit no longer overwrites newer typing. Duplicate file ids no longer collide as React keys. An inverted date range is swapped only when both bounds parse. Select rules on writeExcluded or readOnly fields are now checked by the form schema."
7
+ },
8
+ {
9
+ "version": "0.364.0",
10
+ "type": "fix",
11
+ "title": "A failed refetch of a visibleWhen gate no longer unmounts a screen that is already showing",
12
+ "detail": "ScreenVisibilityGate used to fail closed on every error, so one failed live refetch (SSE reconnect, a short 5xx) removed the content and reset unsaved form input. Only the first evaluation fails closed now; after the content was shown, an error keeps it mounted as long as the last good data still matches the condition. The dashboard panel retry path is unchanged."
13
+ },
14
+ {
15
+ "version": "0.364.0",
16
+ "type": "fix",
17
+ "title": "A held multiSelect value that optionsAvailabilityQuery marks unavailable stays listed but cannot be removed",
18
+ "detail": "The keep-listed set is taken from the values present when the field mounted, not from the live value. In the checkbox grid the entry is rendered disabled and select-all/deselect-all leave it as is; in the combobox a removal is merged back into the value. Previously unchecking such a role hid it for good while the write path kept it, so the form reported a removal that never happened. A stored lower-case currency code such as usd is normalised to upper case instead of falling back to the field currency, and array values with a non-string element keep their string entries instead of being treated as empty."
19
+ },
20
+ {
21
+ "version": "0.364.0",
22
+ "type": "fix",
23
+ "title": "Screen fallback access, projectionDetail back navigation and stricter prefill",
24
+ "detail": "A visibleWhen fallback screen the user may not access now shows the standard unavailable notice instead of a \"Screen not found\" error. The not-found state of a projectionDetail honours returnTo for its back button. Embedded-list prefill rejects calendar-invalid dates, timestamps with numeric offsets and decimals beyond the safe range, and an empty number param keeps the field default instead of becoming 0."
25
+ },
2
26
  {
3
27
  "version": "0.363.0",
4
28
  "type": "improvement",