@cosmicdrift/kumiko-renderer 0.220.1 → 0.222.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.
@@ -64,7 +64,7 @@ const FORM_DRAFT_KEY_MAX_LENGTH = 256;
64
64
  // (the #1888 derived-field shape) — 500ms collapses a typing burst into one
65
65
  // save instead of one per keystroke, while still saving well before a user
66
66
  // abandons the tab.
67
- const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
67
+ export const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
68
68
 
69
69
  type FormDraftBlob = {
70
70
  readonly values: Record<string, unknown>;
@@ -190,10 +190,6 @@ function ExtensionSectionMount({
190
190
  );
191
191
  }
192
192
 
193
- // One header action + its own busy/confirm state — same pattern as
194
- // render-list.tsx's ToolbarActionView (each RenderEditAction is
195
- // independently bound by the caller, there is no shared trigger pipeline
196
- // to hook into like the built-in onDelete/onSubmit paths have).
197
193
  export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
198
194
  props: RenderEditProps<TValues, TCtx>,
199
195
  ): ReactNode {
@@ -281,9 +277,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
281
277
  // never enters `fields`/`controller.getSnapshot().values` — there is no
282
278
  // draft-blob-covered state left for persistExtensions() to compete over.
283
279
  const [extensionDirty, setExtensionDirty] = useState(false);
280
+ const [hasExtensionRegistrations, setHasExtensionRegistrations] = useState(false);
284
281
  const [extensionErrorKey, setExtensionErrorKey] = useState<string | null>(null);
285
- const { registry: extensionFormRegistry, runAll: runExtensionSubmits } =
286
- useExtensionFormHost(setExtensionDirty);
282
+ const { registry: extensionFormRegistry, runAll: runExtensionSubmits } = useExtensionFormHost(
283
+ ({ anyDirty, hasRegistrations }) => {
284
+ setExtensionDirty(anyDirty);
285
+ setHasExtensionRegistrations(hasRegistrations);
286
+ },
287
+ );
287
288
  const {
288
289
  Button,
289
290
  Banner,
@@ -301,9 +302,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
301
302
  const fields = useMemo(() => deriveFormFields<TValues, TCtx>(screen), [screen]);
302
303
 
303
304
  // Must be computed before submitConfig/useForm bakes it in (the controller
304
- // freezes it on first render) safe because a section's field-name set is
305
- // value-independent, so it matches filterEditSections(vm.sections, fieldsFilter).
306
- // undefined = unscoped, since scoping would silently drop root-level .refine() issues.
305
+ // freezes it on first render). This is the unfiltered field-name set from
306
+ // `fieldsFilter` `fields` not the value-dependent `filteredSections`
307
+ // (which also drops `section.visible === false`). Hidden fields still fall
308
+ // out via form-controller's `hiddenFields` path. undefined = unscoped, since
309
+ // scoping would silently drop root-level .refine() issues.
307
310
  // A `fieldsFilter` that matches nothing in `fields` (typo, renamed field)
308
311
  // must fall back to unscoped too — an empty scope array would filter out
309
312
  // ALL validation issues (kumiko-framework#1907), letting submit() through
@@ -361,11 +364,6 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
361
364
  // re-subscribes with a "new" onChange → refires.
362
365
  const onChangeRef = useRef(onChange);
363
366
  onChangeRef.current = onChange;
364
- // `schema` is typically an inline caller prop (`schema={z.object({...})}`)
365
- // with unstable identity across renders — as an effect dep it would refire
366
- // on every parent render, not just on a snapshot change, risking a loop if
367
- // the caller's onChange triggers a parent re-render. Held in a ref like
368
- // onChangeRef so only a real snapshot mutation retriggers this effect.
369
367
  // Guards against redelivering to the SAME callback identity on every
370
368
  // render (an inline-arrow onControlsReady would otherwise refire the
371
369
  // effect below on every render since the callback itself is now a dep).
@@ -373,6 +371,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
373
371
  // real handler after mount (e.g. `onControlsReady={ready ? cb : undefined}`)
374
372
  // gets delivered to for THIS mount instead of never (fw#1899).
375
373
  const deliveredControlsToRef = useRef<typeof onControlsReady>(undefined);
374
+ const onControlsReadyRef = useRef(onControlsReady);
375
+ onControlsReadyRef.current = onControlsReady;
376
+ const hasNavigatedRef = useRef(false);
377
+ const submittedRef = useRef(false);
378
+ const isSubmittingRef = useRef(false);
376
379
  const scopeFieldNamesRef = useRef(scopeFieldNames);
377
380
  scopeFieldNamesRef.current = scopeFieldNames;
378
381
  const scopedValidate = useCallback(
@@ -416,14 +419,15 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
416
419
  const patchAndScheduleDraftSave = useCallback(
417
420
  (partial: Partial<TValues>) => {
418
421
  controller.setValues(partial);
419
- if (!draftEnabled || dispatcher === undefined) return;
422
+ if (!draftEnabled || dispatcher === undefined || disabled || submittedRef.current) return;
420
423
  if (draftSaveTimerRef.current !== null) clearTimeout(draftSaveTimerRef.current);
421
424
  draftSaveTimerRef.current = setTimeout(() => {
422
425
  draftSaveTimerRef.current = null;
426
+ if (disabled || submittedRef.current) return;
423
427
  saveDraftRef.current(currentStepRef.current);
424
428
  }, PATCH_DRAFT_SAVE_DEBOUNCE_MS);
425
429
  },
426
- [controller, draftEnabled, dispatcher],
430
+ [controller, draftEnabled, dispatcher, disabled],
427
431
  );
428
432
 
429
433
  // Runs before the onChange effect below (declaration order = React
@@ -434,23 +438,27 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
434
438
  // value that should derive dependent fields on mount needs controls.patch
435
439
  // available on the very first onChange call, not just from the second
436
440
  // keystroke onward.
441
+ const hasControlsReady = onControlsReady !== undefined;
437
442
  useEffect(() => {
438
- if (onControlsReady === undefined) return;
439
- if (deliveredControlsToRef.current === onControlsReady) return;
440
- deliveredControlsToRef.current = onControlsReady;
441
- onControlsReady({
443
+ if (!hasControlsReady) return;
444
+ const ready = onControlsReadyRef.current;
445
+ if (ready === undefined) return;
446
+ if (deliveredControlsToRef.current === ready) return;
447
+ deliveredControlsToRef.current = ready;
448
+ ready({
442
449
  patch: patchAndScheduleDraftSave,
443
450
  validate: scopedValidate,
444
451
  getValues: () => controller.getSnapshot().values,
445
452
  submit: () => handleSubmitRef.current(),
446
453
  });
447
- // controller is mount-lifetime-stable (see useForm's comment on its own
448
- // useMemo), same for patchAndScheduleDraftSave/scopedValidate (both
449
- // useCallback over mount-stable deps) — onControlsReady is the only dep
450
- // that can legitimately change post-mount, and the guard above stops an
451
- // unstable inline-arrow identity from redelivering on every render.
452
- }, [onControlsReady, controller, scopedValidate, patchAndScheduleDraftSave]);
454
+ // Dep on boolean presence only inline-arrow identity must not redeliver.
455
+ }, [hasControlsReady, controller, scopedValidate, patchAndScheduleDraftSave]);
453
456
 
457
+ // `schema` is typically an inline caller prop (`schema={z.object({...})}`)
458
+ // with unstable identity across renders — as an effect dep it would refire
459
+ // on every parent render, not just on a snapshot change, risking a loop if
460
+ // the caller's onChange triggers a parent re-render. Held in a ref like
461
+ // onChangeRef so only a real snapshot mutation retriggers this effect.
454
462
  const schemaRef = useRef(schema);
455
463
  schemaRef.current = schema;
456
464
  // Controls are guaranteed ready by the time this fires (see the
@@ -489,23 +497,28 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
489
497
  if (draftId !== null && draftId === mintedDraftIdRef.current) return;
490
498
  let cancelled = false;
491
499
  void (async () => {
492
- const result = await dispatcher.query<{ readonly draft: FormDraftBlob | null }>(
493
- FORM_DRAFT_GET,
494
- { draftKey },
495
- );
496
- // skip: superseded by a newer mount, or the draft lookup failed — an
497
- // unreachable draft store must not break the form itself.
498
- if (cancelled || !result.isSuccess) return;
499
- const draft = result.data?.draft ?? null;
500
- // skip: nothing saved yet for this key.
501
- if (draft === null) return;
502
- // skip: the user already typed while the lookup was in flight — their
503
- // input wins over the stored draft.
504
- if (controller.getSnapshot().isDirty) return;
505
- // @cast-boundary form-draft blob: `values` round-trips through an opaque
506
- // jsonb column and comes back untyped.
507
- controller.setValues(draft.values as Partial<TValues>);
508
- setRawStep(Math.max(draft.stepIndex, 0));
500
+ try {
501
+ const result = await dispatcher.query<{ readonly draft: FormDraftBlob | null }>(
502
+ FORM_DRAFT_GET,
503
+ { draftKey },
504
+ );
505
+ // skip: superseded by a newer mount, or the draft lookup failed — an
506
+ // unreachable draft store must not break the form itself.
507
+ if (cancelled || !result.isSuccess) return;
508
+ const draft = result.data?.draft ?? null;
509
+ // skip: nothing saved yet for this key.
510
+ if (draft === null) return;
511
+ // skip: the user already typed or wizard-navigated while the lookup
512
+ // was in flight — their input wins over the stored draft.
513
+ if (controller.getSnapshot().isDirty || hasNavigatedRef.current) return;
514
+ // @cast-boundary form-draft blob: `values` round-trips through an opaque
515
+ // jsonb column and comes back untyped.
516
+ controller.setValues(draft.values as Partial<TValues>);
517
+ setRawStep(Math.max(draft.stepIndex, 0));
518
+ } catch (err: unknown) {
519
+ // biome-ignore lint/suspicious/noConsole: draft restore must not red-screen the form
520
+ console.warn("render-edit: draft restore query failed", err);
521
+ }
509
522
  })();
510
523
  return () => {
511
524
  cancelled = true;
@@ -533,24 +546,29 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
533
546
  dispatcher === undefined
534
547
  )
535
548
  return;
536
- didListRef.current = true;
537
549
  let cancelled = false;
538
550
  void (async () => {
539
- const result = await dispatcher.query<{ readonly drafts: readonly DraftCandidate[] }>(
540
- FORM_DRAFT_LIST,
541
- { screenId: screen.id },
542
- );
543
- // skip: superseded by a newer mount, or the lookup failed — an
544
- // unreachable draft store must not break a fresh create.
545
- if (cancelled || !result.isSuccess) return;
546
- const prefix = newDraftPrefix(screen.id);
547
- // list's LIKE-prefix scan also matches edit-mode drafts
548
- // (`${screenId}:${entityId}`) keep only create-mode ones.
549
- const candidates = (result.data?.drafts ?? []).filter((d) => d.draftKey.startsWith(prefix));
550
- // skip: no open drafts for this screen stay null, saveDraft mints a
551
- // fresh draftId on the first step change same as any other create.
552
- if (candidates.length === 0) return;
553
- setDraftCandidates(candidates);
551
+ try {
552
+ const result = await dispatcher.query<{ readonly drafts: readonly DraftCandidate[] }>(
553
+ FORM_DRAFT_LIST,
554
+ { screenId: screen.id },
555
+ );
556
+ // skip: superseded by a newer mount, or the lookup failed — an
557
+ // unreachable draft store must not break a fresh create.
558
+ if (cancelled || !result.isSuccess) return;
559
+ didListRef.current = true;
560
+ const prefix = newDraftPrefix(screen.id);
561
+ // list's LIKE-prefix scan also matches edit-mode drafts
562
+ // (`${screenId}:${entityId}`)keep only create-mode ones.
563
+ const candidates = (result.data?.drafts ?? []).filter((d) => d.draftKey.startsWith(prefix));
564
+ // skip: no open drafts for this screen — stay null, saveDraft mints a
565
+ // fresh draftId on the first step change same as any other create.
566
+ if (candidates.length === 0) return;
567
+ setDraftCandidates(candidates);
568
+ } catch (err: unknown) {
569
+ // biome-ignore lint/suspicious/noConsole: draft lookup must not red-screen the form
570
+ console.warn("render-edit: draft list query failed", err);
571
+ }
554
572
  })();
555
573
  return () => {
556
574
  cancelled = true;
@@ -671,11 +689,16 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
671
689
  );
672
690
  return;
673
691
  }
674
- void dispatcher.write(FORM_DRAFT_SAVE, {
675
- draftKey: key,
676
- values: controller.getSnapshot().values,
677
- stepIndex,
678
- });
692
+ void dispatcher
693
+ .write(FORM_DRAFT_SAVE, {
694
+ draftKey: key,
695
+ values: controller.getSnapshot().values,
696
+ stepIndex,
697
+ })
698
+ .catch((err: unknown) => {
699
+ // biome-ignore lint/suspicious/noConsole: fire-and-forget draft save must not red-screen
700
+ console.warn("render-edit: draft save failed", err);
701
+ });
679
702
  }
680
703
 
681
704
  // Next: scoped validate() on the current step's fields — errors stay
@@ -684,6 +707,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
684
707
  // controls.validate() API), so no call, no clearing of unrelated errors.
685
708
  function handleWizardNext(): void {
686
709
  const next = Math.min(currentStep + 1, lastStepIndex);
710
+ hasNavigatedRef.current = true;
687
711
  // Locked state (#1896): `disabled` means "no input/no write", not "no
688
712
  // navigation" — a disabled wizard must still be steppable so Back/Next
689
713
  // reach every step. But neither validate() (would block on an empty
@@ -703,6 +727,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
703
727
 
704
728
  function handleWizardBack(): void {
705
729
  const previous = Math.max(currentStep - 1, 0);
730
+ hasNavigatedRef.current = true;
731
+ if (disabled) {
732
+ setRawStep(previous);
733
+ return;
734
+ }
706
735
  setRawStep(previous);
707
736
  saveDraft(previous);
708
737
  }
@@ -770,6 +799,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
770
799
  // native form submit (Enter key) reaches this handler regardless of the
771
800
  // button's disabled attribute — block it here too, not just in the UI.
772
801
  if (disabled) return;
802
+ // Sync guard — React state `isSubmitting` is too late for double-clicks
803
+ // in the same tick (customSubmit has no submitInFlight of its own).
804
+ if (isSubmittingRef.current) return;
805
+ isSubmittingRef.current = true;
773
806
  setIsSubmitting(true);
774
807
  setExtensionErrorKey(null);
775
808
  try {
@@ -779,7 +812,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
779
812
  if (snapshot.isUnchanged && extensionDirty) {
780
813
  // Same discard as the main path: an extension-only save is still a
781
814
  // successful submit, so the draft must not survive it.
782
- if (await persistExtensions()) await discardDraft();
815
+ if (await persistExtensions()) {
816
+ submittedRef.current = true;
817
+ await discardDraft();
818
+ }
783
819
  return;
784
820
  }
785
821
  let result: SubmitResult<unknown>;
@@ -829,7 +865,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
829
865
  // `onSubmit` typically navigates away and unmounts this form, which
830
866
  // would abort an in-flight discard and leave the draft behind after
831
867
  // a successful submit.
832
- if (result.isNoOp !== true) await discardDraft();
868
+ if (result.isNoOp !== true) {
869
+ submittedRef.current = true;
870
+ await discardDraft();
871
+ }
833
872
  extensionsPersisted = await persistExtensions();
834
873
  } else if (result.validationBlocked) {
835
874
  // Root-level `.refine()`/cross-field issues from controller.validate()
@@ -867,6 +906,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
867
906
  // and would unmount it before the user sees the failure).
868
907
  if (shouldNotifyCaller(result, extensionsPersisted)) onSubmit?.(result);
869
908
  } finally {
909
+ isSubmittingRef.current = false;
870
910
  setIsSubmitting(false);
871
911
  }
872
912
  }
@@ -936,7 +976,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
936
976
  {translate("kumiko.actions.next")}
937
977
  </Button>
938
978
  )}
939
- {isFormEditable && (!isWizard || isLastWizardStep) && (
979
+ {(isFormEditable || hasExtensionRegistrations) && (!isWizard || isLastWizardStep) && (
940
980
  <Button
941
981
  type="submit"
942
982
  disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting || disabled}
@@ -1068,6 +1108,19 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1068
1108
  // navigating past its step — otherwise Finish only ran the last
1069
1109
  // mounted step's handler and silently dropped earlier steps' writes.
1070
1110
  const stepHidden = isWizard && sectionIndex !== currentStep;
1111
+ const wrapWizardStep = (key: string, el: ReactNode): ReactNode => {
1112
+ if (!isWizard) return el;
1113
+ if (WizardStepGroup === undefined) {
1114
+ throw new Error(
1115
+ "RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
1116
+ );
1117
+ }
1118
+ return (
1119
+ <WizardStepGroup key={key} hidden={stepHidden}>
1120
+ {el}
1121
+ </WizardStepGroup>
1122
+ );
1123
+ };
1071
1124
  // Off-screen wizard steps stay mounted (see comment above) but must
1072
1125
  // not participate in native constraint validation, or the Next
1073
1126
  // button's `type="submit"` triggers the browser's full-form check
@@ -1094,18 +1147,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1094
1147
  validate={scopedValidate}
1095
1148
  />
1096
1149
  );
1097
- if (!isWizard) return mount;
1098
- if (WizardStepGroup === undefined) {
1099
- // Both silent fallbacks are unsafe here — render-visible-all-steps or unmount-drops-registry.
1100
- throw new Error(
1101
- "RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
1102
- );
1103
- }
1104
- return (
1105
- <WizardStepGroup key={section.title} hidden={stepHidden}>
1106
- {mount}
1107
- </WizardStepGroup>
1108
- );
1150
+ return wrapWizardStep(section.title, mount);
1109
1151
  }
1110
1152
  if (section.kind === "relatedList") {
1111
1153
  // parentId is the displayed record's id — without it there's no
@@ -1163,17 +1205,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1163
1205
  </Grid>
1164
1206
  </Section>
1165
1207
  );
1166
- if (!isWizard) return sectionEl;
1167
- if (WizardStepGroup === undefined) {
1168
- throw new Error(
1169
- "RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
1170
- );
1171
- }
1172
- return (
1173
- <WizardStepGroup key={sectionKey} hidden={stepHidden}>
1174
- {sectionEl}
1175
- </WizardStepGroup>
1176
- );
1208
+ return wrapWizardStep(sectionKey, sectionEl);
1177
1209
  })}
1178
1210
  {formError !== null && (
1179
1211
  <Banner
@@ -359,12 +359,18 @@ function FieldRendererOutput({
359
359
  // App locale as default when the FormatSpec declares none of its own —
360
360
  // otherwise locale-sensitive formats (timestamp/date/number/decimal/
361
361
  // bigInt/unit) fell back to Intl's runtime default instead of the app
362
- // language chosen via LocaleProvider (fw#2187). An explicit
363
- // `renderer.locale` still wins, same pattern as dateLocale vs. appLocale
364
- // further below in readOnlyDisplayText.
362
+ // language chosen via LocaleProvider (fw#2187). Prefer renderer.locale
363
+ // when set; coalesce undefined (spread override) back to appLocale (#2332).
365
364
  return (
366
365
  <Text testId={`field-value-${field.field}`}>
367
- {applyFormatSpec({ locale: appLocale, ...renderer }, field.value, t)}
366
+ {applyFormatSpec(
367
+ {
368
+ ...renderer,
369
+ locale: (renderer as { locale?: string }).locale ?? appLocale,
370
+ },
371
+ field.value,
372
+ t,
373
+ )}
368
374
  </Text>
369
375
  );
370
376
  }
@@ -520,7 +526,7 @@ function renderInput({
520
526
  );
521
527
  }
522
528
  case "money": {
523
- const currency = field.currency ?? "EUR";
529
+ const currency = resolveMoneyCurrency(field.value, field.currency);
524
530
  return (
525
531
  <Input
526
532
  kind="money"
@@ -692,6 +698,17 @@ function numberValue(v: unknown): number | "" {
692
698
  // stored-config coercion) is MAJOR units too — every producer in this repo
693
699
  // hands rehydrateMoney's `{amount,…}` shape or a raw major-unit number, never
694
700
  // pre-scaled minor units.
701
+ function resolveMoneyCurrency(value: unknown, fieldCurrency: string | undefined): string {
702
+ if (
703
+ typeof value === "object" &&
704
+ value !== null &&
705
+ typeof (value as { currency?: unknown }).currency === "string"
706
+ ) {
707
+ return (value as { currency: string }).currency;
708
+ }
709
+ return fieldCurrency ?? "EUR";
710
+ }
711
+
695
712
  function moneyMinorValue(v: unknown, currency: string): number | "" {
696
713
  if (v === undefined || v === null || v === "") return "";
697
714
  if (typeof v === "number") return Math.round(v * 10 ** currencyDecimals(currency));
@@ -756,7 +773,7 @@ function readOnlyDisplayText(field: EditFieldViewModel, appLocale: string): stri
756
773
  return n === "" ? "—" : new Intl.NumberFormat(appLocale).format(n);
757
774
  }
758
775
  case "money": {
759
- const currency = field.currency ?? "EUR";
776
+ const currency = resolveMoneyCurrency(value, field.currency);
760
777
  const minor = moneyMinorValue(value, currency);
761
778
  if (minor === "") return "—";
762
779
  const major = minor / 10 ** currencyDecimals(currency);
@@ -33,14 +33,18 @@ describe("useForm — mounted without a DispatcherProvider", () => {
33
33
  expect(result.current.snapshot.values.title).toBe("");
34
34
  });
35
35
 
36
- test("submit() without an explicit dispatcher rejects instead of crashing the hook", async () => {
36
+ test("submit() without an explicit dispatcher returns isSuccess:false instead of crashing the hook", async () => {
37
37
  const { result } = renderHook(() =>
38
38
  useForm({ initial: { title: "hello" }, submit: { type: "app:write:task:create" } }),
39
39
  );
40
40
 
41
- await expect(result.current.controller.submit()).rejects.toThrow(
42
- /submit\(\) called without a dispatcher/,
43
- );
41
+ const submitResult = await result.current.controller.submit();
42
+ expect(submitResult.validationBlocked).toBe(false);
43
+ expect(submitResult.isSuccess).toBe(false);
44
+ if (submitResult.isSuccess || submitResult.validationBlocked) {
45
+ throw new Error("expected write failure");
46
+ }
47
+ expect(submitResult.error.message).toMatch(/submit\(\) called without a dispatcher/);
44
48
  });
45
49
 
46
50
  test("submit() with an explicit dispatcher still works without a provider", async () => {
package/src/i18n.tsx CHANGED
@@ -193,6 +193,48 @@ function interpolate(template: string, params?: Readonly<Record<string, unknown>
193
193
  });
194
194
  }
195
195
 
196
+ /** Non-throwing LocaleContext read — undefined outside LocaleProvider.
197
+ * Used by DataTable FormatCell so plain tables without a provider do not crash. */
198
+ export function useOptionalLocale(): string | undefined {
199
+ const ctx = useContext(LocaleContext);
200
+ useSyncExternalStore(
201
+ (onStoreChange) => (ctx ? ctx.resolver.subscribe(onStoreChange) : () => {}),
202
+ () => (ctx ? ctx.resolver.locale() : "en"),
203
+ () => "en",
204
+ );
205
+ return ctx === undefined ? undefined : ctx.resolver.locale();
206
+ }
207
+
208
+ /** Non-throwing translate — undefined outside LocaleProvider. */
209
+ export function useOptionalTranslation():
210
+ | ((key: string, params?: Readonly<Record<string, unknown>>) => string)
211
+ | undefined {
212
+ const ctx = useContext(LocaleContext);
213
+ const locale = useSyncExternalStore(
214
+ (onStoreChange) => (ctx ? ctx.resolver.subscribe(onStoreChange) : () => {}),
215
+ () => (ctx ? ctx.resolver.locale() : "en"),
216
+ () => "en",
217
+ );
218
+ const t = useCallback(
219
+ (key: string, params?: Readonly<Record<string, unknown>>): string => {
220
+ if (ctx === undefined) return key;
221
+ const resolved = ctx.resolver.translate(key, params);
222
+ if (resolved !== key) return resolved;
223
+ const languageRoot = locale.split("-")[0] ?? locale;
224
+ const localesToTry = [locale, languageRoot, ctx.fallbackLocale];
225
+ for (const bundle of ctx.fallbackBundles) {
226
+ for (const localeToTry of localesToTry) {
227
+ const value = bundle[localeToTry]?.[key];
228
+ if (value !== undefined) return interpolate(value, params);
229
+ }
230
+ }
231
+ return key;
232
+ },
233
+ [ctx, locale],
234
+ );
235
+ return ctx === undefined ? undefined : t;
236
+ }
237
+
196
238
  /** Default-Resolver für Apps ohne eigene i18n-Schicht. Gibt jeden Key
197
239
  * unverändert zurück — die Plugin-Fallback-Bundles erledigen dann die
198
240
  * echte Übersetzung. Nützlich auch als Basis für Tests. */
package/src/index.ts CHANGED
@@ -158,6 +158,8 @@ export {
158
158
  mergeTranslations,
159
159
  translationsByLocaleFromKeys,
160
160
  useLocale,
161
+ useOptionalLocale,
162
+ useOptionalTranslation,
161
163
  useTranslation,
162
164
  } from "./i18n";
163
165
  export { kumikoDefaultTranslations } from "./i18n-defaults";
@@ -35,7 +35,7 @@ import type {
35
35
  ConfigScope,
36
36
  ConfigValueSource,
37
37
  } from "@cosmicdrift/kumiko-framework/engine";
38
- import type { FormWidth } from "@cosmicdrift/kumiko-framework/ui-types";
38
+ import type { FieldIconKey, FormWidth } from "@cosmicdrift/kumiko-framework/ui-types";
39
39
  import type {
40
40
  FieldIssue,
41
41
  ListColumnViewModel,
@@ -179,9 +179,8 @@ export type InputProps =
179
179
  /** Read-only Input (z.B. gewürfelter Free-Tier-Slug). Nicht `disabled`
180
180
  * — bleibt fokussier-/kopierbar. */
181
181
  readonly readOnly?: boolean;
182
- /** Symbolic icon key (FIELD_ICONS registry, renderer-web) — renders
183
- * as a prefix on the input. Unknown key → no icon (no boot-fail). */
184
- readonly icon?: string;
182
+ /** Closed FieldIconKey vocabulary (FIELD_ICONS registry, renderer-web). */
183
+ readonly icon?: FieldIconKey;
185
184
  }
186
185
  | {
187
186
  readonly kind: "email";
@@ -223,9 +222,8 @@ export type InputProps =
223
222
  readonly required?: boolean;
224
223
  readonly hasError?: boolean;
225
224
  readonly testId?: string;
226
- /** Symbolic icon key (FIELD_ICONS registry, renderer-web) — renders
227
- * as a prefix on the input. Unknown key → no icon (no boot-fail). */
228
- readonly icon?: string;
225
+ /** Closed FieldIconKey vocabulary (FIELD_ICONS registry, renderer-web). */
226
+ readonly icon?: FieldIconKey;
229
227
  /** `<input step>`. "any" disables the native stepMismatch constraint
230
228
  * (needed for decimal fields — integer fields leave this unset). */
231
229
  readonly step?: number | "any";