@cosmicdrift/kumiko-renderer 0.220.1 → 0.221.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.
- package/package.json +4 -4
- package/src/__tests__/merge-search-params-into-initial.test.ts +22 -1
- package/src/app/__tests__/content-editors.test.tsx +2 -1
- package/src/app/__tests__/content-preview.test.tsx +22 -0
- package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +208 -1
- package/src/app/__tests__/entity-list-row-action-refetch.test.tsx +187 -1
- package/src/app/__tests__/form-schema.test.ts +9 -0
- package/src/app/__tests__/list-filter-facets.test.tsx +7 -4
- package/src/app/content-editors.tsx +3 -2
- package/src/app/content-preview.tsx +11 -3
- package/src/app/form-schema.ts +4 -6
- package/src/app/kumiko-screen.tsx +107 -51
- package/src/components/__tests__/render-edit-action-button.test.tsx +4 -0
- package/src/components/__tests__/render-edit-submit-actions.test.tsx +52 -1
- package/src/components/render-edit-action-button.tsx +4 -0
- package/src/components/render-edit.tsx +100 -65
- package/src/hooks/__tests__/use-form.test.tsx +8 -4
- package/src/i18n.tsx +42 -0
- package/src/index.ts +2 -0
- package/src/primitives.tsx +5 -7
|
@@ -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 {
|
|
@@ -301,9 +297,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
301
297
|
const fields = useMemo(() => deriveFormFields<TValues, TCtx>(screen), [screen]);
|
|
302
298
|
|
|
303
299
|
// Must be computed before submitConfig/useForm bakes it in (the controller
|
|
304
|
-
// freezes it on first render)
|
|
305
|
-
//
|
|
306
|
-
//
|
|
300
|
+
// freezes it on first render). This is the unfiltered field-name set from
|
|
301
|
+
// `fieldsFilter` ∩ `fields` — not the value-dependent `filteredSections`
|
|
302
|
+
// (which also drops `section.visible === false`). Hidden fields still fall
|
|
303
|
+
// out via form-controller's `hiddenFields` path. undefined = unscoped, since
|
|
304
|
+
// scoping would silently drop root-level .refine() issues.
|
|
307
305
|
// A `fieldsFilter` that matches nothing in `fields` (typo, renamed field)
|
|
308
306
|
// must fall back to unscoped too — an empty scope array would filter out
|
|
309
307
|
// ALL validation issues (kumiko-framework#1907), letting submit() through
|
|
@@ -361,11 +359,6 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
361
359
|
// re-subscribes with a "new" onChange → refires.
|
|
362
360
|
const onChangeRef = useRef(onChange);
|
|
363
361
|
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
362
|
// Guards against redelivering to the SAME callback identity on every
|
|
370
363
|
// render (an inline-arrow onControlsReady would otherwise refire the
|
|
371
364
|
// effect below on every render since the callback itself is now a dep).
|
|
@@ -373,6 +366,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
373
366
|
// real handler after mount (e.g. `onControlsReady={ready ? cb : undefined}`)
|
|
374
367
|
// gets delivered to for THIS mount instead of never (fw#1899).
|
|
375
368
|
const deliveredControlsToRef = useRef<typeof onControlsReady>(undefined);
|
|
369
|
+
const onControlsReadyRef = useRef(onControlsReady);
|
|
370
|
+
onControlsReadyRef.current = onControlsReady;
|
|
371
|
+
const hasNavigatedRef = useRef(false);
|
|
372
|
+
const submittedRef = useRef(false);
|
|
373
|
+
const isSubmittingRef = useRef(false);
|
|
376
374
|
const scopeFieldNamesRef = useRef(scopeFieldNames);
|
|
377
375
|
scopeFieldNamesRef.current = scopeFieldNames;
|
|
378
376
|
const scopedValidate = useCallback(
|
|
@@ -416,14 +414,15 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
416
414
|
const patchAndScheduleDraftSave = useCallback(
|
|
417
415
|
(partial: Partial<TValues>) => {
|
|
418
416
|
controller.setValues(partial);
|
|
419
|
-
if (!draftEnabled || dispatcher === undefined) return;
|
|
417
|
+
if (!draftEnabled || dispatcher === undefined || disabled || submittedRef.current) return;
|
|
420
418
|
if (draftSaveTimerRef.current !== null) clearTimeout(draftSaveTimerRef.current);
|
|
421
419
|
draftSaveTimerRef.current = setTimeout(() => {
|
|
422
420
|
draftSaveTimerRef.current = null;
|
|
421
|
+
if (disabled || submittedRef.current) return;
|
|
423
422
|
saveDraftRef.current(currentStepRef.current);
|
|
424
423
|
}, PATCH_DRAFT_SAVE_DEBOUNCE_MS);
|
|
425
424
|
},
|
|
426
|
-
[controller, draftEnabled, dispatcher],
|
|
425
|
+
[controller, draftEnabled, dispatcher, disabled],
|
|
427
426
|
);
|
|
428
427
|
|
|
429
428
|
// Runs before the onChange effect below (declaration order = React
|
|
@@ -434,23 +433,27 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
434
433
|
// value that should derive dependent fields on mount needs controls.patch
|
|
435
434
|
// available on the very first onChange call, not just from the second
|
|
436
435
|
// keystroke onward.
|
|
436
|
+
const hasControlsReady = onControlsReady !== undefined;
|
|
437
437
|
useEffect(() => {
|
|
438
|
-
if (
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
438
|
+
if (!hasControlsReady) return;
|
|
439
|
+
const ready = onControlsReadyRef.current;
|
|
440
|
+
if (ready === undefined) return;
|
|
441
|
+
if (deliveredControlsToRef.current === ready) return;
|
|
442
|
+
deliveredControlsToRef.current = ready;
|
|
443
|
+
ready({
|
|
442
444
|
patch: patchAndScheduleDraftSave,
|
|
443
445
|
validate: scopedValidate,
|
|
444
446
|
getValues: () => controller.getSnapshot().values,
|
|
445
447
|
submit: () => handleSubmitRef.current(),
|
|
446
448
|
});
|
|
447
|
-
//
|
|
448
|
-
|
|
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]);
|
|
449
|
+
// Dep on boolean presence only — inline-arrow identity must not redeliver.
|
|
450
|
+
}, [hasControlsReady, controller, scopedValidate, patchAndScheduleDraftSave]);
|
|
453
451
|
|
|
452
|
+
// `schema` is typically an inline caller prop (`schema={z.object({...})}`)
|
|
453
|
+
// with unstable identity across renders — as an effect dep it would refire
|
|
454
|
+
// on every parent render, not just on a snapshot change, risking a loop if
|
|
455
|
+
// the caller's onChange triggers a parent re-render. Held in a ref like
|
|
456
|
+
// onChangeRef so only a real snapshot mutation retriggers this effect.
|
|
454
457
|
const schemaRef = useRef(schema);
|
|
455
458
|
schemaRef.current = schema;
|
|
456
459
|
// Controls are guaranteed ready by the time this fires (see the
|
|
@@ -489,23 +492,28 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
489
492
|
if (draftId !== null && draftId === mintedDraftIdRef.current) return;
|
|
490
493
|
let cancelled = false;
|
|
491
494
|
void (async () => {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
495
|
+
try {
|
|
496
|
+
const result = await dispatcher.query<{ readonly draft: FormDraftBlob | null }>(
|
|
497
|
+
FORM_DRAFT_GET,
|
|
498
|
+
{ draftKey },
|
|
499
|
+
);
|
|
500
|
+
// skip: superseded by a newer mount, or the draft lookup failed — an
|
|
501
|
+
// unreachable draft store must not break the form itself.
|
|
502
|
+
if (cancelled || !result.isSuccess) return;
|
|
503
|
+
const draft = result.data?.draft ?? null;
|
|
504
|
+
// skip: nothing saved yet for this key.
|
|
505
|
+
if (draft === null) return;
|
|
506
|
+
// skip: the user already typed or wizard-navigated while the lookup
|
|
507
|
+
// was in flight — their input wins over the stored draft.
|
|
508
|
+
if (controller.getSnapshot().isDirty || hasNavigatedRef.current) return;
|
|
509
|
+
// @cast-boundary form-draft blob: `values` round-trips through an opaque
|
|
510
|
+
// jsonb column and comes back untyped.
|
|
511
|
+
controller.setValues(draft.values as Partial<TValues>);
|
|
512
|
+
setRawStep(Math.max(draft.stepIndex, 0));
|
|
513
|
+
} catch (err: unknown) {
|
|
514
|
+
// biome-ignore lint/suspicious/noConsole: draft restore must not red-screen the form
|
|
515
|
+
console.warn("render-edit: draft restore query failed", err);
|
|
516
|
+
}
|
|
509
517
|
})();
|
|
510
518
|
return () => {
|
|
511
519
|
cancelled = true;
|
|
@@ -533,24 +541,29 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
533
541
|
dispatcher === undefined
|
|
534
542
|
)
|
|
535
543
|
return;
|
|
536
|
-
didListRef.current = true;
|
|
537
544
|
let cancelled = false;
|
|
538
545
|
void (async () => {
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
546
|
+
try {
|
|
547
|
+
const result = await dispatcher.query<{ readonly drafts: readonly DraftCandidate[] }>(
|
|
548
|
+
FORM_DRAFT_LIST,
|
|
549
|
+
{ screenId: screen.id },
|
|
550
|
+
);
|
|
551
|
+
// skip: superseded by a newer mount, or the lookup failed — an
|
|
552
|
+
// unreachable draft store must not break a fresh create.
|
|
553
|
+
if (cancelled || !result.isSuccess) return;
|
|
554
|
+
didListRef.current = true;
|
|
555
|
+
const prefix = newDraftPrefix(screen.id);
|
|
556
|
+
// list's LIKE-prefix scan also matches edit-mode drafts
|
|
557
|
+
// (`${screenId}:${entityId}`) — keep only create-mode ones.
|
|
558
|
+
const candidates = (result.data?.drafts ?? []).filter((d) => d.draftKey.startsWith(prefix));
|
|
559
|
+
// skip: no open drafts for this screen — stay null, saveDraft mints a
|
|
560
|
+
// fresh draftId on the first step change same as any other create.
|
|
561
|
+
if (candidates.length === 0) return;
|
|
562
|
+
setDraftCandidates(candidates);
|
|
563
|
+
} catch (err: unknown) {
|
|
564
|
+
// biome-ignore lint/suspicious/noConsole: draft lookup must not red-screen the form
|
|
565
|
+
console.warn("render-edit: draft list query failed", err);
|
|
566
|
+
}
|
|
554
567
|
})();
|
|
555
568
|
return () => {
|
|
556
569
|
cancelled = true;
|
|
@@ -671,11 +684,16 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
671
684
|
);
|
|
672
685
|
return;
|
|
673
686
|
}
|
|
674
|
-
void dispatcher
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
687
|
+
void dispatcher
|
|
688
|
+
.write(FORM_DRAFT_SAVE, {
|
|
689
|
+
draftKey: key,
|
|
690
|
+
values: controller.getSnapshot().values,
|
|
691
|
+
stepIndex,
|
|
692
|
+
})
|
|
693
|
+
.catch((err: unknown) => {
|
|
694
|
+
// biome-ignore lint/suspicious/noConsole: fire-and-forget draft save must not red-screen
|
|
695
|
+
console.warn("render-edit: draft save failed", err);
|
|
696
|
+
});
|
|
679
697
|
}
|
|
680
698
|
|
|
681
699
|
// Next: scoped validate() on the current step's fields — errors stay
|
|
@@ -684,6 +702,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
684
702
|
// controls.validate() API), so no call, no clearing of unrelated errors.
|
|
685
703
|
function handleWizardNext(): void {
|
|
686
704
|
const next = Math.min(currentStep + 1, lastStepIndex);
|
|
705
|
+
hasNavigatedRef.current = true;
|
|
687
706
|
// Locked state (#1896): `disabled` means "no input/no write", not "no
|
|
688
707
|
// navigation" — a disabled wizard must still be steppable so Back/Next
|
|
689
708
|
// reach every step. But neither validate() (would block on an empty
|
|
@@ -703,6 +722,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
703
722
|
|
|
704
723
|
function handleWizardBack(): void {
|
|
705
724
|
const previous = Math.max(currentStep - 1, 0);
|
|
725
|
+
hasNavigatedRef.current = true;
|
|
726
|
+
if (disabled) {
|
|
727
|
+
setRawStep(previous);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
706
730
|
setRawStep(previous);
|
|
707
731
|
saveDraft(previous);
|
|
708
732
|
}
|
|
@@ -770,6 +794,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
770
794
|
// native form submit (Enter key) reaches this handler regardless of the
|
|
771
795
|
// button's disabled attribute — block it here too, not just in the UI.
|
|
772
796
|
if (disabled) return;
|
|
797
|
+
// Sync guard — React state `isSubmitting` is too late for double-clicks
|
|
798
|
+
// in the same tick (customSubmit has no submitInFlight of its own).
|
|
799
|
+
if (isSubmittingRef.current) return;
|
|
800
|
+
isSubmittingRef.current = true;
|
|
773
801
|
setIsSubmitting(true);
|
|
774
802
|
setExtensionErrorKey(null);
|
|
775
803
|
try {
|
|
@@ -779,7 +807,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
779
807
|
if (snapshot.isUnchanged && extensionDirty) {
|
|
780
808
|
// Same discard as the main path: an extension-only save is still a
|
|
781
809
|
// successful submit, so the draft must not survive it.
|
|
782
|
-
if (await persistExtensions())
|
|
810
|
+
if (await persistExtensions()) {
|
|
811
|
+
submittedRef.current = true;
|
|
812
|
+
await discardDraft();
|
|
813
|
+
}
|
|
783
814
|
return;
|
|
784
815
|
}
|
|
785
816
|
let result: SubmitResult<unknown>;
|
|
@@ -829,7 +860,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
829
860
|
// `onSubmit` typically navigates away and unmounts this form, which
|
|
830
861
|
// would abort an in-flight discard and leave the draft behind after
|
|
831
862
|
// a successful submit.
|
|
832
|
-
if (result.isNoOp !== true)
|
|
863
|
+
if (result.isNoOp !== true) {
|
|
864
|
+
submittedRef.current = true;
|
|
865
|
+
await discardDraft();
|
|
866
|
+
}
|
|
833
867
|
extensionsPersisted = await persistExtensions();
|
|
834
868
|
} else if (result.validationBlocked) {
|
|
835
869
|
// Root-level `.refine()`/cross-field issues from controller.validate()
|
|
@@ -867,6 +901,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
867
901
|
// and would unmount it before the user sees the failure).
|
|
868
902
|
if (shouldNotifyCaller(result, extensionsPersisted)) onSubmit?.(result);
|
|
869
903
|
} finally {
|
|
904
|
+
isSubmittingRef.current = false;
|
|
870
905
|
setIsSubmitting(false);
|
|
871
906
|
}
|
|
872
907
|
}
|
|
@@ -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
|
|
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
|
|
42
|
-
|
|
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
package/src/primitives.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
183
|
-
|
|
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
|
-
/**
|
|
227
|
-
|
|
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";
|