@cosmicdrift/kumiko-renderer 0.387.0 → 0.388.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.
Files changed (68) hide show
  1. package/dist/app/action-form-body.js +10 -2
  2. package/dist/app/action-form-support.d.ts +1 -0
  3. package/dist/app/action-form-support.js +1 -1
  4. package/dist/app/config-edit-body.js +6 -2
  5. package/dist/app/entity-edit-prefill.d.ts +11 -0
  6. package/dist/app/entity-edit-prefill.js +9 -4
  7. package/dist/app/extension-sections.d.ts +5 -3
  8. package/dist/app/form-schema.js +1 -1
  9. package/dist/app/header-actions-bar.js +1 -1
  10. package/dist/components/edit-mounts.d.ts +2 -2
  11. package/dist/components/embedded-list-field.js +1 -1
  12. package/dist/components/related-list-section.js +14 -1
  13. package/dist/components/render-edit-action-button.d.ts +1 -3
  14. package/dist/components/render-edit-action-button.js +2 -2
  15. package/dist/components/render-edit.js +11 -8
  16. package/dist/components/render-field.js +5 -2
  17. package/dist/components/render-list.js +31 -18
  18. package/dist/flow-diagram/render.js +9 -1
  19. package/dist/headless/form/form-controller.js +8 -0
  20. package/dist/headless/form/types.d.ts +1 -0
  21. package/dist/headless/format/index.js +89 -64
  22. package/dist/headless/view-model/edit.js +2 -0
  23. package/dist/headless/view-model/list.js +184 -149
  24. package/dist/headless/view-model/types.d.ts +3 -0
  25. package/dist/hooks/live-refetch-scheduler.js +4 -0
  26. package/dist/hooks/use-reference-lookup.d.ts +9 -1
  27. package/dist/hooks/use-reference-lookup.js +17 -3
  28. package/dist/i18n-defaults.js +6 -2
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +1 -0
  31. package/dist/primitives.d.ts +19 -0
  32. package/dist/web/app/create-app.js +8 -3
  33. package/dist/web/app/dashboard-body.js +17 -1
  34. package/dist/web/app/dashboard-panels.js +12 -2
  35. package/dist/web/index.d.ts +1 -1
  36. package/dist/web/index.js +1 -1
  37. package/dist/web/layout/shell-header.js +2 -1
  38. package/dist/web/layout/uniform-bar-controls.d.ts +1 -0
  39. package/dist/web/layout/uniform-bar-controls.js +4 -0
  40. package/dist/web/lib/file-ref-download.js +4 -1
  41. package/dist/web/primitives/action-menu.js +1 -1
  42. package/dist/web/primitives/code-input.d.ts +6 -0
  43. package/dist/web/primitives/code-input.js +31 -0
  44. package/dist/web/primitives/data-table.js +4 -3
  45. package/dist/web/primitives/facet-filter.js +1 -1
  46. package/dist/web/primitives/file-thumbnail.d.ts +3 -0
  47. package/dist/web/primitives/file-thumbnail.js +5 -0
  48. package/dist/web/primitives/input.js +6 -1
  49. package/dist/web/primitives/layout-elements.js +6 -4
  50. package/dist/web/primitives/page-header.js +3 -1
  51. package/dist/web/primitives/row-actions.js +3 -3
  52. package/dist/web/primitives/use-focus-on-mount.d.ts +2 -0
  53. package/dist/web/primitives/use-focus-on-mount.js +9 -0
  54. package/dist/web/widgets/bounded-load-cache.d.ts +1 -1
  55. package/dist/web/widgets/bounded-load-cache.js +10 -4
  56. package/dist/web/widgets/choice-cards.d.ts +17 -0
  57. package/dist/web/widgets/choice-cards.js +12 -0
  58. package/dist/web/widgets/collapsible-section.js +1 -1
  59. package/dist/web/widgets/detail-list.d.ts +5 -1
  60. package/dist/web/widgets/detail-list.js +5 -1
  61. package/dist/web/widgets/index.d.ts +1 -0
  62. package/dist/web/widgets/index.js +1 -0
  63. package/dist/web/widgets/pdf-preview.js +1 -1
  64. package/dist/web/widgets/plan-card.d.ts +2 -0
  65. package/dist/web/widgets/plan-card.js +1 -1
  66. package/dist/web/widgets/upload-zone.js +13 -5
  67. package/package.json +5 -5
  68. package/src/changes.json +49 -0
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export type { CustomScreensMap, CustomScreensProviderProps } from "./app/custom-
14
14
  export { CustomScreensProvider, useCustomScreenComponent } from "./app/custom-screens.tsx";
15
15
  export type { DashboardBodyProps, DashboardBodyProviderProps } from "./app/dashboard-body.tsx";
16
16
  export { DashboardBodyProvider, useDashboardBody } from "./app/dashboard-body.tsx";
17
+ export { type TenantCurrencyResolution, useTenantCurrency } from "./app/entity-edit-prefill.ts";
17
18
  export { type ErrorNotice, ErrorReference, errorNoticeOf } from "./app/error-reference.tsx";
18
19
  export type { ExtensionFormRegistry, ExtensionFormSubmitHandler, ExtensionSubmitContext, ExtensionSubmitResult, } from "./app/extension-form-submit.tsx";
19
20
  export { ExtensionFormRegistryProvider, useExtensionFormSubmit, } from "./app/extension-form-submit.tsx";
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ export { CONTENT_EDITOR_ELEMENT_ID, ContentEditorsProvider, TextareaContentEdito
17
17
  export { ContentPreview, substituteVariables } from "./app/content-preview.js";
18
18
  export { CustomScreensProvider, useCustomScreenComponent } from "./app/custom-screens.js";
19
19
  export { DashboardBodyProvider, useDashboardBody } from "./app/dashboard-body.js";
20
+ export { useTenantCurrency } from "./app/entity-edit-prefill.js";
20
21
  export { ErrorReference, errorNoticeOf } from "./app/error-reference.js";
21
22
  export { ExtensionFormRegistryProvider, useExtensionFormSubmit, } from "./app/extension-form-submit.js";
22
23
  export { ExtensionSectionsProvider, extensionSectionName, useExtensionSectionComponent, } from "./app/extension-sections.js";
@@ -203,6 +203,7 @@ export type InputProps = {
203
203
  readonly name: string;
204
204
  readonly value: string;
205
205
  readonly onChange: (v: string) => void;
206
+ readonly autoFocus?: boolean;
206
207
  readonly disabled?: boolean;
207
208
  readonly required?: boolean;
208
209
  readonly hasError?: boolean;
@@ -228,6 +229,24 @@ export type InputProps = {
228
229
  /** Raw keydown access (e.g. Enter-to-submit at the field instead of
229
230
  * the surrounding Form). Forwarded to the element. */
230
231
  readonly onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
232
+ } | {
233
+ /** Fixed-length numeric one-time code. `onComplete` fires once when input,
234
+ * paste or autofill brings the value to full length; the value holds
235
+ * digits only. */
236
+ readonly kind: "code";
237
+ readonly id: string;
238
+ readonly name: string;
239
+ readonly value: string;
240
+ readonly onChange: (v: string) => void;
241
+ readonly length: number;
242
+ readonly onComplete?: (code: string) => void;
243
+ readonly autoFocus?: boolean;
244
+ readonly disabled?: boolean;
245
+ readonly required?: boolean;
246
+ readonly hasError?: boolean;
247
+ readonly testId?: string;
248
+ /** Default "one-time-code". */
249
+ readonly autoComplete?: string;
231
250
  } | {
232
251
  readonly kind: "email";
233
252
  readonly id: string;
@@ -6,6 +6,7 @@ import { rendererError, rendererWarn } from "../../dev-console.js";
6
6
  import { AppFeaturesProvider, ClientActionsProvider, ColumnRenderersProvider, ContentEditorsProvider, CustomScreensProvider, createSessionEndedSignal, DashboardBodyProvider, DispatcherProvider, DraftStorageProvider, ErrorBoundary, ExtensionSectionsProvider, hasDetailScreen, KumikoScreen, kumikoDefaultTranslations, LeaveGuardDialog, LiveEventsProvider, LocaleProvider, mergeTranslations, NavProvider, navigateWithReturnTo, PrimitivesProvider, qualifyScreenId, ScreenMissingState, SessionEndedSignalProvider, TokensProvider, toAppSchema, translationsByLocaleFromKeys, useNav, usePrimitives, useTranslation, withSessionEndedDetection, } from "../../index.js";
7
7
  import { createLiveDispatcher } from "../../live/index.js";
8
8
  import { lastSegment } from "../layout/nav-tree.js";
9
+ import { clearFileMetaCache } from "../primitives/file-thumbnail.js";
9
10
  import { defaultPrimitives, ScreenWidthProvider } from "../primitives/index.js";
10
11
  import { ToastProvider } from "../primitives/toast.js";
11
12
  import { createEventSourceLiveEvents } from "../sse/live-events.js";
@@ -255,9 +256,13 @@ function AppSchemaBoundary({ app, screenQn, schemaUrl, navAdapter, onAppLoaded,
255
256
  onUnmountResetSchemaRef.current = onUnmountResetSchema;
256
257
  // A real unmount (the gate withdrawing children, e.g. on logout) resets
257
258
  // the schema so the next mount re-fetches instead of reusing this
258
- // session's — fetch-mode only, see isFetchMode in KumikoAppRoot.
259
+ // session's — fetch-mode only, see isFetchMode in KumikoAppRoot. The file
260
+ // meta cache is dropped in every mode.
259
261
  useEffect(() => {
260
- return () => onUnmountResetSchemaRef.current?.();
262
+ return () => {
263
+ clearFileMetaCache();
264
+ onUnmountResetSchemaRef.current?.();
265
+ };
261
266
  }, []);
262
267
  if (app === undefined) {
263
268
  return (_jsx(AppSchemaFetchBoot, { onLoaded: onAppLoaded, ...(schemaUrl !== undefined && { schemaUrl }) }));
@@ -498,7 +503,7 @@ function RoutedScreen({ app, fallbackQn, translate, onRowClick, }) {
498
503
  }, [nav]);
499
504
  if (feature === undefined || activeScreen === undefined) {
500
505
  const landingExists = findOwnerFeature(app.features, fallbackQn) !== undefined;
501
- return (_jsx(ScreenMissingState, { ...(landingExists && { homeScreenId: lastSegment(fallbackQn) }), screenQn: qn }));
506
+ return (_jsx(ScreenMissingState, { ...(landingExists && { homeScreenId: lastSegment(fallbackQn) }), screenQn: nav.route?.screenId ?? qn }));
502
507
  }
503
508
  // KumikoScreen takes a single-feature schema: the feature owning the active screen.
504
509
  const schemaForScreen = feature;
@@ -140,6 +140,20 @@ function useVisibilityGateResults() {
140
140
  }, []);
141
141
  return { results, report, forget };
142
142
  }
143
+ const NOOP_REFETCH = async () => { };
144
+ // A gate reports through an effect, so for its first commit (and after a payload change) no
145
+ // result exists yet; without a placeholder that reads as "hidden" and flashes the fallback.
146
+ function withPendingGates(results, gateKeys) {
147
+ if (gateKeys.every((key) => results.has(key)))
148
+ return results;
149
+ const merged = new Map(results);
150
+ for (const key of gateKeys) {
151
+ if (!merged.has(key)) {
152
+ merged.set(key, { data: null, error: null, loading: true, refetch: NOOP_REFETCH });
153
+ }
154
+ }
155
+ return merged;
156
+ }
143
157
  function distinctVisibilityGates(panels, screenParams) {
144
158
  const gates = new Map();
145
159
  for (const panel of panels) {
@@ -227,6 +241,8 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
227
241
  const { loadedAtMs, reportLoaded } = useLoadedAt(screenParams);
228
242
  const visibilityGates = distinctVisibilityGates(screen.panels, screenParams);
229
243
  const gateResults = useVisibilityGateResults();
244
+ const gateKeysSignature = visibilityGates.map((gate) => gate.key).join("\n");
245
+ const gateResultsWithPending = useMemo(() => withPendingGates(gateResults.results, gateKeysSignature === "" ? [] : gateKeysSignature.split("\n")), [gateResults.results, gateKeysSignature]);
230
246
  // The record query is not a panel query: it needs the interval before the
231
247
  // registry context below exists, and `refresh.field` reads from its own row.
232
248
  const [refreshIntervalMs, setRefreshIntervalMs] = useState(() => resolveRefreshIntervalMs(screen.refresh, null));
@@ -271,7 +287,7 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
271
287
  // The shell header owns the title; the badge and the actions sit in it there.
272
288
  const badgeInHeader = scopeBadge !== null && usesPageHeaderSlot;
273
289
  const showPageHeader = usesPageHeaderSlot && (badgeInHeader || recordTitle !== undefined || headerActionsBar !== null);
274
- return (_jsx(PanelLoadedContext.Provider, { value: reportLoaded, children: _jsx(DashboardQueryContext.Provider, { value: queryRuntime, children: _jsxs(PageSection, { className: "flex flex-col gap-4", testId: `dashboard-${screen.id}`, children: [showPageHeader && (_jsx(PageHeader, { ...(recordTitle !== undefined && { title: recordTitle }), ...(badgeInHeader && { status: scopeBadge }), ...(headerActionsBar !== null && { actions: headerActionsBar }) })), _jsxs("div", { className: "flex flex-wrap items-end justify-between gap-3", children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-2", children: [!usesPageHeaderSlot && recordTitle !== undefined && (_jsx(Heading, { variant: "page", testId: `dashboard-${screen.id}-record-title`, children: recordTitle })), !badgeInHeader && scopeBadge, picker] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [!usesPageHeaderSlot && headerActionsBar, screen.showUpdatedAt !== false && (_jsx("span", { "data-testid": `dashboard-${screen.id}-updated-at`, className: "text-xs tabular-nums text-muted-foreground", children: t("kumiko.dashboard.updated-at", { time: formatDateTime(loadedAtMs) }) })), rangeControl] })] }), actionError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-action-error", children: [actionError.message, _jsx(ErrorReference, { requestId: actionError.requestId })] })), recordQuery.error !== null && recordRow === null && (_jsxs("div", { role: "alert", "data-testid": `dashboard-${screen.id}-record-error`, className: "flex flex-col items-start gap-2 text-sm", children: [_jsx("span", { className: "text-status-bad", children: dispatcherErrorText(recordQuery.error, t) }), _jsx(Button, { variant: "secondary", onClick: () => void refetchRecord(), children: t("kumiko.dashboard.panel.error.retry") })] })), description !== undefined && (_jsx(Text, { variant: "muted", testId: `dashboard-${screen.id}-description`, children: description })), scope?.notice !== undefined && (_jsx(Banner, { variant: "info", testId: `dashboard-${screen.id}-scope-notice`, children: effectiveTranslate(scope.notice) })), _jsxs(VisibilityQueryContext.Provider, { value: gateResults.results, children: [_jsx(VisibilityGateQueries, { gates: visibilityGates, onResult: gateResults.report, onForget: gateResults.forget }), _jsx("div", { className: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4", children: screen.panels.map((panel) => {
290
+ return (_jsx(PanelLoadedContext.Provider, { value: reportLoaded, children: _jsx(DashboardQueryContext.Provider, { value: queryRuntime, children: _jsxs(PageSection, { className: "flex flex-col gap-4", testId: `dashboard-${screen.id}`, children: [showPageHeader && (_jsx(PageHeader, { ...(recordTitle !== undefined && { title: recordTitle }), ...(badgeInHeader && { status: scopeBadge }), ...(headerActionsBar !== null && { actions: headerActionsBar }) })), _jsxs("div", { className: "flex flex-wrap items-end justify-between gap-3", children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-2", children: [!usesPageHeaderSlot && recordTitle !== undefined && (_jsx(Heading, { variant: "page", testId: `dashboard-${screen.id}-record-title`, children: recordTitle })), !badgeInHeader && scopeBadge, picker] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [!usesPageHeaderSlot && headerActionsBar, screen.showUpdatedAt !== false && (_jsx("span", { "data-testid": `dashboard-${screen.id}-updated-at`, className: "text-xs tabular-nums text-muted-foreground", children: t("kumiko.dashboard.updated-at", { time: formatDateTime(loadedAtMs) }) })), rangeControl] })] }), actionError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-action-error", children: [actionError.message, _jsx(ErrorReference, { requestId: actionError.requestId })] })), recordQuery.error !== null && recordRow === null && (_jsxs("div", { role: "alert", "data-testid": `dashboard-${screen.id}-record-error`, className: "flex flex-col items-start gap-2 text-sm", children: [_jsx("span", { className: "text-status-bad", children: dispatcherErrorText(recordQuery.error, t) }), _jsx(Button, { variant: "secondary", onClick: () => void refetchRecord(), children: t("kumiko.dashboard.panel.error.retry") })] })), description !== undefined && (_jsx(Text, { variant: "muted", testId: `dashboard-${screen.id}-description`, children: description })), scope?.notice !== undefined && (_jsx(Banner, { variant: "info", testId: `dashboard-${screen.id}-scope-notice`, children: effectiveTranslate(scope.notice) })), _jsxs(VisibilityQueryContext.Provider, { value: gateResultsWithPending, children: [_jsx(VisibilityGateQueries, { gates: visibilityGates, onResult: gateResults.report, onForget: gateResults.forget }), _jsx("div", { className: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4", children: screen.panels.map((panel) => {
275
291
  if (panel.kind === "screen") {
276
292
  return (_jsx(ScreenPanelTile, { panel: panel, featureName: featureName, translate: effectiveTranslate }, panel.id));
277
293
  }
@@ -311,7 +311,7 @@ function TextBlockView({ block, translate, }) {
311
311
  ? "text-sm font-medium text-foreground"
312
312
  : "text-sm text-muted-foreground", children: translate(block.text) }));
313
313
  case "list":
314
- return (_jsx("ul", { "data-block": "list", className: "list-disc space-y-1 pl-5 text-sm text-muted-foreground", children: block.items.map((item) => (_jsx("li", { children: translate(item) }, item))) }));
314
+ return (_jsx("ul", { "data-block": "list", className: "list-disc space-y-1 pl-5 text-sm text-muted-foreground", children: withOccurrenceKeys(block.items, (item) => item).map(({ entry, key }) => (_jsx("li", { children: translate(entry) }, key))) }));
315
315
  case "code":
316
316
  return (_jsx("pre", { "data-block": "code", className: "overflow-x-auto rounded-md bg-muted p-3 text-xs", children: _jsx("code", { children: block.code }) }));
317
317
  case "hint":
@@ -330,6 +330,16 @@ function TextBlockLink({ block, translate, }) {
330
330
  const target = { screenId: lastSegment(block.screen) };
331
331
  return (_jsx(Link, { href: nav.hrefFor(target), onPress: () => nav.navigate(target), testId: `dashboard-text-link-${target.screenId}`, children: translate(block.text) }));
332
332
  }
333
+ // Content-derived keys collide for identical entries; the occurrence count keeps them unique.
334
+ function withOccurrenceKeys(entries, contentKey) {
335
+ const seen = new Map();
336
+ return entries.map((entry) => {
337
+ const content = contentKey(entry);
338
+ const occurrence = seen.get(content) ?? 0;
339
+ seen.set(content, occurrence + 1);
340
+ return { entry, key: `${content}#${occurrence}` };
341
+ });
342
+ }
333
343
  function textBlockKey(block) {
334
344
  switch (block.kind) {
335
345
  case "list":
@@ -341,7 +351,7 @@ function textBlockKey(block) {
341
351
  }
342
352
  }
343
353
  export function TextPanelBody({ panel, label, translate, }) {
344
- const blocks = (_jsx("div", { className: "flex flex-col gap-3", children: panel.blocks.map((block) => (_jsx(TextBlockView, { block: block, translate: translate }, textBlockKey(block)))) }));
354
+ const blocks = (_jsx("div", { className: "flex flex-col gap-3", children: withOccurrenceKeys(panel.blocks, textBlockKey).map(({ entry, key }) => (_jsx(TextBlockView, { block: entry, translate: translate }, key))) }));
345
355
  const testId = `dashboard-panel-${panel.id}`;
346
356
  if (label !== undefined) {
347
357
  return (_jsx(PanelShell, { testId: testId, title: label, children: blocks }));
@@ -75,4 +75,4 @@ export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from
75
75
  export type { KumikoBuild } from "./version/update-checker.tsx";
76
76
  export { readLoadedBuild } from "./version/update-checker.tsx";
77
77
  export type { AiTextAreaProps, AiTextFieldProps, BooleanFieldProps, ComparisonMetric, DateFieldProps, DrawerProps, FeedRow, FileFieldProps, FloatingPanelGeometry, FloatingPanelProps, InfinityListProps, InfinityListSelection, ModeSwitchVariant, NumberFieldProps, PdfPreviewSource, PhotoSlotSpec, PhotoSlotsProps, PlanCardActionSlot, PlanCardLinkSlot, PlanCardPrice, PlanCardProps, PlanGridProps, ProgressListRow, PublicShellProps, PublicShellVariant, QueryTableColumn, QueryTableProps, RangeFieldProps, ResultColumn, SelectFieldProps, SideBySideTableCell, SideBySideTableColumn, SideBySideTableRow, StatDelta, StatTone, StatusBadgeTone, StatusBarEntry, StatusTone, TextareaFieldProps, TextFieldProps, TimeseriesHeight, TimeseriesPoint, TimeseriesReferenceLine, TimeseriesXAxis, TimeseriesYAxis, UploadZoneProps, } from "./widgets/index.ts";
78
- export { AiTextArea, AiTextField, BooleanField, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PdfPreview, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.ts";
78
+ export { AiTextArea, AiTextField, BooleanField, ChoiceCards, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PdfPreview, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.ts";
package/dist/web/index.js CHANGED
@@ -50,4 +50,4 @@ export { applyTokensToCssVars, defaultTokens, lightTokens, useBrowserTokensApi,
50
50
  export { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable.js";
51
51
  export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar.js";
52
52
  export { readLoadedBuild } from "./version/update-checker.js";
53
- export { AiTextArea, AiTextField, BooleanField, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PdfPreview, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.js";
53
+ export { AiTextArea, AiTextField, BooleanField, ChoiceCards, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PdfPreview, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.js";
@@ -19,6 +19,7 @@ import { HeaderOverflowMenuContext, headerOverflowMenuItemClass } from "./header
19
19
  import { buildNavRegistrySliceForApp, lastSegment } from "./nav-tree.js";
20
20
  import { usePageHeaderSlot } from "./page-header-slot.js";
21
21
  import { resolveDetailBreadcrumb, screenTitleKey, } from "./shell-breadcrumb.js";
22
+ import { UNIFORM_BAR_CONTROLS_CLASSES } from "./uniform-bar-controls.js";
22
23
  export function ShellHeader({ schema, user, headerActions, }) {
23
24
  const nav = useNav();
24
25
  const t = useTranslation();
@@ -68,7 +69,7 @@ export function ShellHeader({ schema, user, headerActions, }) {
68
69
  e.preventDefault();
69
70
  nav.navigate({ screenId });
70
71
  }, children: crumb.label })) : isLast ? (_jsx("h1", { "aria-current": "page", title: titleOverride ?? crumb.label, className: "truncate text-lg font-semibold text-foreground", children: titleOverride ?? crumb.label })) : (_jsx(BreadcrumbPage, { className: "whitespace-nowrap text-foreground-secondary", children: crumb.label })) })] }, screenId ?? crumb.label));
71
- }) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn("flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
72
+ }) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn(UNIFORM_BAR_CONTROLS_CLASSES, "flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
72
73
  }
73
74
  // A hand-rolled menu instead of Radix DropdownMenu: app headerActions are opaque
74
75
  // nodes with their own popovers, and the panel stays mounted while closed so their
@@ -0,0 +1 @@
1
+ export declare const UNIFORM_BAR_CONTROLS_CLASSES = "[&_[data-slot=button]:not([data-size^=icon])]:h-9 [&_[data-slot=input]]:h-9";
@@ -0,0 +1,4 @@
1
+ // Header and toolbar controls share one height whatever size a screen, app or
2
+ // primitive asked for: the descendant selector outranks the button's own
3
+ // size utilities. Icon-only buttons keep their square size.
4
+ export const UNIFORM_BAR_CONTROLS_CLASSES = "[&_[data-slot=button]:not([data-size^=icon])]:h-9 [&_[data-slot=input]]:h-9";
@@ -37,6 +37,9 @@ export async function fetchFileRefBlob(fileRefId) {
37
37
  fileName: fileNameFromContentDisposition(res.headers.get("content-disposition")),
38
38
  };
39
39
  }
40
+ // The browser starts the download after click() returns; Safari and some
41
+ // Firefox versions fail it when the blob URL is already revoked.
42
+ const OBJECT_URL_REVOKE_DELAY_MS = 40_000;
40
43
  export function saveBlobAsFile(fileName, blob, options) {
41
44
  const payload = options?.bom === true ? new Blob([UTF8_BOM, blob], { type: blob.type }) : blob;
42
45
  const url = URL.createObjectURL(payload);
@@ -53,7 +56,7 @@ export function saveBlobAsFile(fileName, blob, options) {
53
56
  }
54
57
  }
55
58
  finally {
56
- URL.revokeObjectURL(url);
59
+ setTimeout(() => URL.revokeObjectURL(url), OBJECT_URL_REVOKE_DELAY_MS);
57
60
  }
58
61
  }
59
62
  export async function downloadFileRef(fileRefId, options) {
@@ -23,7 +23,7 @@ export function ActionMenu({ trigger, triggerLabel, items, align = "end", minWid
23
23
  return _jsx(DropdownMenuSeparator, {}, `sep-${idx}`);
24
24
  }
25
25
  if (item.kind === "label") {
26
- // biome-ignore lint/suspicious/noArrayIndexKey: Separators have no ID, so their position provides identity
26
+ // biome-ignore lint/suspicious/noArrayIndexKey: labels have no ID either, so their position provides identity
27
27
  return _jsx(DropdownMenuLabel, { children: item.label }, `label-${idx}`);
28
28
  }
29
29
  return (_jsxs(DropdownMenuItem, { onSelect: item.onSelect, disabled: item.disabled === true, "data-testid": `action-menu-item-${item.id}`, className: item.variant === "danger" ? "text-destructive focus:text-destructive" : undefined, children: [item.icon !== undefined && _jsx("span", { className: "size-4 shrink-0", children: item.icon }), _jsx("span", { className: "flex-1", children: item.label }), item.shortcut !== undefined && (_jsx("span", { className: "ml-auto text-xs text-muted-foreground tracking-wider font-mono", children: item.shortcut }))] }, item.id));
@@ -0,0 +1,6 @@
1
+ import { type ReactNode } from "react";
2
+ import type { InputProps } from "../../index.ts";
3
+ export type CodeInputProps = Extract<InputProps, {
4
+ readonly kind: "code";
5
+ }>;
6
+ export declare function CodeInput(props: CodeInputProps): ReactNode;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useContext } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+ import { FieldDescriptionContext } from "./contexts.js";
5
+ import { useFocusOnMount } from "./use-focus-on-mount.js";
6
+ const MIN_LENGTH_FOR_SEPARATOR = 6;
7
+ export function CodeInput(props) {
8
+ const { length, value, hasError, disabled } = props;
9
+ const describedBy = useContext(FieldDescriptionContext);
10
+ const inputRef = useFocusOnMount(props.autoFocus);
11
+ const activeIndex = Math.min(value.length, length - 1);
12
+ const hasSeparator = length >= MIN_LENGTH_FOR_SEPARATOR && length % 2 === 0;
13
+ const handleChange = (e) => {
14
+ const next = e.target.value.replace(/\D/g, "").slice(0, length);
15
+ props.onChange(next);
16
+ if (next.length === length && next !== value)
17
+ props.onComplete?.(next);
18
+ };
19
+ const cells = [];
20
+ for (let index = 0; index < length; index++) {
21
+ const isActive = index === activeIndex;
22
+ const char = value[index];
23
+ if (hasSeparator && index === length / 2) {
24
+ cells.push(_jsx("span", { "aria-hidden": "true", className: "shrink-0 text-muted-foreground", children: "\u2013" }, "separator"));
25
+ }
26
+ cells.push(_jsxs("div", { "data-active": isActive, "aria-hidden": "true", className: cn("flex h-12 min-w-0 flex-1 items-center justify-center rounded-md border border-input bg-transparent text-xl font-semibold tabular-nums shadow-xs transition-[color,box-shadow] dark:bg-input/30", hasError === true && "border-destructive", "data-[active=true]:group-focus-within:ring-[3px]", hasError === true
27
+ ? "data-[active=true]:group-focus-within:border-destructive data-[active=true]:group-focus-within:ring-destructive/20 dark:data-[active=true]:group-focus-within:ring-destructive/40"
28
+ : "data-[active=true]:group-focus-within:border-ring data-[active=true]:group-focus-within:ring-ring/50"), children: [char, isActive && char === undefined ? (_jsx("span", { className: "hidden h-6 w-px animate-pulse bg-foreground group-focus-within:block" })) : null] }, index));
29
+ }
30
+ return (_jsxs("div", { className: cn("group relative flex w-full items-center gap-2", disabled === true && "cursor-not-allowed opacity-50"), children: [cells, _jsx("input", { ref: inputRef, type: "text", id: props.id, name: props.name, value: value, onChange: handleChange, disabled: disabled, inputMode: "numeric", autoComplete: props.autoComplete ?? "one-time-code", spellCheck: false, "data-testid": props.testId, "aria-describedby": describedBy, "aria-required": props.required, "aria-invalid": hasError === true ? true : undefined, className: "absolute inset-0 h-full w-full bg-transparent text-transparent caret-transparent outline-none selection:bg-transparent" })] }));
31
+ }
@@ -3,6 +3,7 @@ import { cardColumnRoles } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, Search, SlidersHorizontal, X, } from "lucide-react";
4
4
  import { Fragment, useId, useState, } from "react";
5
5
  import { useOptionalLocale, useOptionalTranslation } from "../../index.js";
6
+ import { UNIFORM_BAR_CONTROLS_CLASSES } from "../layout/uniform-bar-controls.js";
6
7
  import { cn } from "../lib/cn.js";
7
8
  import { Button as UiButton } from "../ui/button.js";
8
9
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table.js";
@@ -268,11 +269,11 @@ export function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange
268
269
  ? screenPaddingClassName
269
270
  : screenPadding === false
270
271
  ? undefined
271
- : "p-6")), children: [hasToolbar && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-toolbar` : "render-list-toolbar", className: cn("flex flex-wrap items-center", fillsHeight
272
+ : "p-6")), children: [hasToolbar && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-toolbar` : "render-list-toolbar", className: cn("flex flex-wrap items-center", UNIFORM_BAR_CONTROLS_CLASSES, fillsHeight
272
273
  ? isNarrow
273
274
  ? "shrink-0 gap-2 p-3"
274
275
  : "min-h-13 shrink-0 gap-2 px-6 py-2 md:py-0"
275
276
  : "gap-3"), children: [toolbarStart !== undefined && (_jsxs("div", { className: cn(fillsHeight
276
- ? "relative w-full md:w-[300px] max-md:flex-1 [&_input]:border-border-strong [&_input]:pl-8 [&_input]:max-md:h-11 md:[&_input]:h-8"
277
- : "flex-1 min-w-48 max-w-sm"), children: [fillsHeight && (_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 z-10 size-4 -translate-y-1/2 text-muted-foreground", "aria-hidden": "true" })), toolbarStart] })), collapsesFacetsBehindButton ? (_jsxs(_Fragment, { children: [_jsx(UiButton, { type: "button", variant: "outline", size: "icon", className: "size-11", "aria-label": tableTranslate?.("kumiko.list.filter.toggle") ?? "Filter", "aria-expanded": facetsOpenNarrow, "data-testid": testId !== undefined ? `${testId}-filter-toggle` : "filter-toggle", onClick: () => setFacetsOpenNarrow((open) => !open), children: _jsx(SlidersHorizontal, { className: "size-4", "aria-hidden": "true" }) }), facetsOpenNarrow && _jsx("div", { className: "w-full", children: facetCluster })] })) : (facetCluster), toolbarEnd !== undefined && (_jsx("div", { className: "flex flex-wrap items-center gap-2 ml-auto", children: toolbarEnd }))] })), toolbarDescription !== undefined && (_jsx("p", { "data-testid": testId !== undefined ? `${testId}-description` : "render-list-description", className: cn("m-0 pb-2 text-sm text-muted-foreground", fillsHeight && (isNarrow ? "px-3" : "px-6")), children: toolbarDescription })), content] }));
277
+ ? "relative w-full md:w-[300px] max-md:flex-1 [&_input]:border-border-strong [&_input]:pl-8 [&_input]:max-md:h-11"
278
+ : "flex-1 min-w-48 max-w-sm"), children: [fillsHeight && (_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 z-10 size-4 -translate-y-1/2 text-muted-foreground", "aria-hidden": "true" })), toolbarStart] })), collapsesFacetsBehindButton ? (_jsxs(_Fragment, { children: [_jsx(UiButton, { type: "button", variant: "outline", size: "icon", className: "size-11", "aria-label": tableTranslate?.("kumiko.list.filter.toggle") ?? "Filter", "aria-expanded": facetsOpenNarrow, "data-testid": testId !== undefined ? `${testId}-filter-toggle` : "filter-toggle", onClick: () => setFacetsOpenNarrow((open) => !open), children: _jsx(SlidersHorizontal, { className: "size-4", "aria-hidden": "true" }) }), facetsOpenNarrow && _jsx("div", { className: "w-full", children: facetCluster })] })) : (facetCluster), toolbarEnd !== undefined && (_jsx("div", { className: "flex flex-wrap items-center gap-2 ml-auto", children: toolbarEnd }))] })), toolbarDescription !== undefined && (_jsx("p", { "data-testid": testId !== undefined ? `${testId}-description` : "render-list-description", className: cn("m-0 pb-2 text-sm text-muted-foreground", fillsHeight && (isNarrow ? "px-3" : "px-6"), fillsHeight && !hasToolbar && "pt-4"), children: toolbarDescription })), content] }));
278
279
  }
@@ -26,7 +26,7 @@ export function FacetFilter({ facet, selected, onChange, dense = false, }) {
26
26
  if (facet.chips !== undefined) {
27
27
  return _jsx(FacetChips, { facet: facet, selected: selected, onChange: onChange });
28
28
  }
29
- return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(UiButton, { variant: "outline", size: "sm", className: cn("h-9", dense && "h-8 border-border-strong px-2.5 max-md:h-11"), "data-testid": `facet-${facet.field}`, children: [facet.label, selected.length > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-1 rounded-sm px-1 font-normal tabular-nums", children: selected.length })), _jsx(ChevronDown, { className: "text-muted-foreground" })] }) }), _jsx(DropdownMenuContent, { align: "start", className: "max-h-[var(--radix-dropdown-menu-content-available-height)] w-44 overflow-y-auto", children: facet.options.map((opt) => (_jsx(DropdownMenuCheckboxItem, { checked: selected.includes(opt.value), onCheckedChange: (checked) => toggle(opt.value, checked), onSelect: (e) => e.preventDefault(), "data-testid": `facet-${facet.field}-${opt.value}`, children: opt.label }, opt.value))) })] }));
29
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(UiButton, { variant: "outline", size: "sm", className: cn("h-9", dense && "border-border-strong px-2.5"), "data-testid": `facet-${facet.field}`, children: [facet.label, selected.length > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-1 rounded-sm px-1 font-normal tabular-nums", children: selected.length })), _jsx(ChevronDown, { className: "text-muted-foreground" })] }) }), _jsx(DropdownMenuContent, { align: "start", className: "max-h-[var(--radix-dropdown-menu-content-available-height)] w-44 overflow-y-auto", children: facet.options.map((opt) => (_jsx(DropdownMenuCheckboxItem, { checked: selected.includes(opt.value), onCheckedChange: (checked) => toggle(opt.value, checked), onSelect: (e) => e.preventDefault(), "data-testid": `facet-${facet.field}-${opt.value}`, children: opt.label }, opt.value))) })] }));
30
30
  }
31
31
  // Time-range filter: two native <input type="date"> instead of a
32
32
  // dropdown — the browser supplies the calendar, the locale and the keyboard
@@ -12,6 +12,9 @@ type FileMeta = {
12
12
  readonly fileName: string;
13
13
  readonly mimeType: string | undefined;
14
14
  };
15
+ /** File names are per-user data and the server re-checks access per request, so
16
+ * the cache must not outlive the session that filled it. */
17
+ export declare function clearFileMetaCache(): void;
15
18
  export declare function useFileMeta(fileId: string): FileMeta | null;
16
19
  export declare function FileNameLink({ fileId, fallbackLabel, className, }: {
17
20
  readonly fileId: string;
@@ -46,6 +46,11 @@ const META_BATCH_SIZE = 100;
46
46
  const META_CACHE_LIMIT = 500;
47
47
  const metaCache = new Map();
48
48
  let pendingMetaIds = new Map();
49
+ /** File names are per-user data and the server re-checks access per request, so
50
+ * the cache must not outlive the session that filled it. */
51
+ export function clearFileMetaCache() {
52
+ metaCache.clear();
53
+ }
49
54
  function parseBatchMeta(json) {
50
55
  const metaById = new Map();
51
56
  if (typeof json !== "object" || json === null || !("files" in json))
@@ -5,6 +5,7 @@ import { Checkbox } from "../ui/checkbox.js";
5
5
  import { Input as UiInput } from "../ui/input.js";
6
6
  import { Switch } from "../ui/switch.js";
7
7
  import { Textarea } from "../ui/textarea.js";
8
+ import { CodeInput } from "./code-input.js";
8
9
  import { ComboboxInput } from "./combobox.js";
9
10
  import { DateInput } from "./date-input.js";
10
11
  import { FileUploadInput } from "./file-upload.js";
@@ -15,6 +16,7 @@ import { NumberInput } from "./number-input.js";
15
16
  import { hasDescriptionOrGroup } from "./option-groups.js";
16
17
  import { TimestampInput } from "./timestamp-input.js";
17
18
  import { TzInput } from "./tz-input.js";
19
+ import { useFocusOnMount } from "./use-focus-on-mount.js";
18
20
  import "../react-css-custom-properties.js";
19
21
  import { FieldDescriptionContext, FieldLayoutContext } from "./contexts.js";
20
22
  import { defaultSelectPresentation, FIELD_ICON_CLASS, fieldIconFor, RadioListSelect, SegmentedSelect, textareaMinHeight, unitSuffixId, withFieldIcon, withInlineDisabledHint, withInputAddons, withoutSegmentedSelectPlaceholder, withTrailingAction, } from "./field.js";
@@ -23,6 +25,7 @@ export function DefaultInput(props) {
23
25
  // themselves, no manual border-destructive needed anymore.
24
26
  const booleanLayout = useContext(FieldLayoutContext);
25
27
  const describedBy = useContext(FieldDescriptionContext);
28
+ const textInputRef = useFocusOnMount(props.kind === "text" ? props.autoFocus : undefined);
26
29
  const common = {
27
30
  id: props.id,
28
31
  name: props.name,
@@ -33,7 +36,9 @@ export function DefaultInput(props) {
33
36
  };
34
37
  switch (props.kind) {
35
38
  case "text":
36
- return withInputAddons(props, withFieldIcon(props.icon, _jsx(UiInput, { type: "text", ...common, ...props.dataAttributes, "data-testid": props.testId, readOnly: props.readOnly, value: props.value, onChange: (e) => props.onChange(e.target.value), onKeyDown: props.onKeyDown, ...(props.placeholder !== undefined && { placeholder: props.placeholder }), ...(props.autoComplete !== undefined && { autoComplete: props.autoComplete }), className: cn(fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined) })));
39
+ return withInputAddons(props, withFieldIcon(props.icon, _jsx(UiInput, { type: "text", ref: textInputRef, ...common, ...props.dataAttributes, "data-testid": props.testId, readOnly: props.readOnly, value: props.value, onChange: (e) => props.onChange(e.target.value), onKeyDown: props.onKeyDown, ...(props.placeholder !== undefined && { placeholder: props.placeholder }), ...(props.autoComplete !== undefined && { autoComplete: props.autoComplete }), className: cn(fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined) })));
40
+ case "code":
41
+ return _jsx(CodeInput, { ...props });
37
42
  case "email":
38
43
  return (_jsx(UiInput, { type: "email", ...common, "data-testid": props.testId, value: props.value, onChange: (e) => props.onChange(e.target.value), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), autoComplete: props.autoComplete ?? "email" }));
39
44
  case "month":
@@ -61,7 +61,7 @@ const FIELD_CELL_WIDTH_CLASS = {
61
61
  money: "w-40 shrink-0 [&_label]:whitespace-nowrap",
62
62
  date: "w-full sm:w-[200px]",
63
63
  timestamp: "w-full sm:w-[328px]",
64
- select: "w-full sm:w-auto sm:min-w-[200px] sm:has-[[data-radio-list]]:w-full",
64
+ select: "w-full sm:w-60 sm:has-[[data-radio-list]]:w-full",
65
65
  full: "w-full",
66
66
  // self-start: the label shares the top line with neighbouring labels. The
67
67
  // switch (1.15rem) gets (h-9 input − switch) / 2 margin to sit on the input line.
@@ -75,7 +75,8 @@ export function DefaultGrid({ columns, children, testId, maxRows, flow, list, ar
75
75
  if (list === true) {
76
76
  return (_jsx("div", { ...groupProps, "data-testid": testId, className: "flex flex-col divide-y divide-border", children: children }));
77
77
  }
78
- if (flow === true) {
78
+ const declaresColumns = typeof columns === "number" && columns >= 2;
79
+ if (flow === true && (insideDrawerBody || !declaresColumns)) {
79
80
  return (_jsx("div", { ...groupProps, "data-testid": testId, className: cn("flex gap-4", insideDrawerBody
80
81
  ? "flex-col items-start"
81
82
  : // items-end aligns inputs when labels wrap to different heights; a
@@ -116,12 +117,13 @@ export function DefaultGrid({ columns, children, testId, maxRows, flow, list, ar
116
117
  overflowY: "auto",
117
118
  }),
118
119
  };
119
- return (_jsx("div", { ...groupProps, "data-testid": testId, className: "grid gap-[var(--kumiko-grid-gap,1rem)] grid-cols-1 sm:[grid-template-columns:var(--grid-cols)]", style: style, children: children }));
120
+ return (_jsx("div", { ...groupProps, "data-testid": testId, className: cn("grid gap-[var(--kumiko-grid-gap,1rem)] grid-cols-1 sm:[grid-template-columns:var(--grid-cols)]", flow === true &&
121
+ "items-end has-[[data-appendix-below]]:items-start has-[[role=alert]]:items-start"), style: style, children: children }));
120
122
  }
121
123
  export function DefaultGridCell({ span, width, children }) {
122
124
  const insideDrawerBody = useContext(DrawerBodyContext);
123
125
  if (width !== undefined) {
124
- return (_jsx("div", { className: cn("min-w-0 max-w-full", insideDrawerBody
126
+ return (_jsx("div", { className: cn("min-w-0 max-w-full", width === "full" && "sm:[grid-column:1/-1]", insideDrawerBody
125
127
  ? (DRAWER_FIELD_CELL_WIDTH_CLASS[width] ?? FIELD_CELL_WIDTH_CLASS[width])
126
128
  : FIELD_CELL_WIDTH_CLASS[width]), children: children }));
127
129
  }
@@ -2,6 +2,8 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useEffect } from "react";
3
3
  import { createPortal } from "react-dom";
4
4
  import { usePageHeaderSlot } from "../layout/page-header-slot.js";
5
+ import { UNIFORM_BAR_CONTROLS_CLASSES } from "../layout/uniform-bar-controls.js";
6
+ import { cn } from "../lib/cn.js";
5
7
  export function DefaultPageHeader({ title, recordTitle, status, actions, overflowItems, hideBreadcrumb, breadcrumbScreenId, }) {
6
8
  const slot = usePageHeaderSlot();
7
9
  const setOverflowItems = slot?.setOverflowItems;
@@ -42,7 +44,7 @@ export function DefaultPageHeader({ title, recordTitle, status, actions, overflo
42
44
  return () => setOverflowItems(undefined);
43
45
  }, [setOverflowItems, overflowItems]);
44
46
  if (slot === null) {
45
- return (_jsxs("div", { "data-kumiko-layout": "page-header-inline", className: "flex items-center gap-2 px-4 py-2", children: [status, _jsx("div", { className: "ml-auto flex items-center gap-2", children: actions })] }));
47
+ return (_jsxs("div", { "data-kumiko-layout": "page-header-inline", className: "flex items-center gap-2 px-4 py-2", children: [status, _jsx("div", { className: cn("ml-auto flex items-center gap-2", UNIFORM_BAR_CONTROLS_CLASSES), children: actions })] }));
46
48
  }
47
49
  return (_jsxs(_Fragment, { children: [status !== undefined &&
48
50
  slot.statusElement !== null &&
@@ -37,19 +37,19 @@ export function RowActionsCell({ row, actions, mode = "adaptive", rowIsLink = fa
37
37
  // otherwise "inline" is exactly the wall-to-wall text-button problem
38
38
  // this feature exists to fix. An author-set `display` takes that choice over.
39
39
  const iconOnly = !hasExplicitDisplay && shouldRenderActionsIconOnly(visible);
40
- return (_jsx("div", { className: "flex w-full items-center gap-1 justify-start", children: visible.map((a) => (_jsx(RowActionButton, { row: row, action: a, iconOnly: iconOnly, compact: compact }, a.id))) }));
40
+ return (_jsx("div", { className: "flex w-full flex-nowrap items-center gap-1 justify-start whitespace-nowrap", children: visible.map((a) => (_jsx(RowActionButton, { row: row, action: a, iconOnly: iconOnly, compact: compact }, a.id))) }));
41
41
  }
42
42
  if (hasExplicitDisplay) {
43
43
  const inline = visible.filter((a) => a.display !== undefined);
44
44
  const rest = visible.filter((a) => a.display === undefined);
45
- return (_jsxs("div", { className: "inline-flex flex-wrap items-center gap-1 justify-end", children: [inline.map((a) => (_jsx(RowActionButton, { row: row, action: a, compact: compact }, a.id))), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
45
+ return (_jsxs("div", { className: "inline-flex flex-nowrap items-center gap-1 justify-end whitespace-nowrap", children: [inline.map((a) => (_jsx(RowActionButton, { row: row, action: a, compact: compact }, a.id))), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
46
46
  }
47
47
  if (rowIsLink) {
48
48
  return (_jsx("div", { className: "inline-flex items-center justify-end", children: _jsx(RowActionsKebab, { row: row, actions: visible }) }));
49
49
  }
50
50
  const primary = primaryRowAction(visible);
51
51
  const rest = visible.filter((a) => a.id !== primary?.id);
52
- return (_jsxs("div", { className: "inline-flex flex-wrap items-center gap-1 justify-end", children: [primary !== undefined && _jsx(RowActionButton, { row: row, action: primary, asLink: true }), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
52
+ return (_jsxs("div", { className: "inline-flex flex-nowrap items-center gap-1 justify-end whitespace-nowrap", children: [primary !== undefined && _jsx(RowActionButton, { row: row, action: primary, asLink: true }), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
53
53
  }
54
54
  // Shared trigger state between inline button + kebab item: busy flag
55
55
  // (while the async onTrigger runs) + confirm-pending action. Both
@@ -0,0 +1,2 @@
1
+ import { type RefObject } from "react";
2
+ export declare function useFocusOnMount(enabled: boolean | undefined): RefObject<HTMLInputElement | null>;
@@ -0,0 +1,9 @@
1
+ import { useEffect, useRef } from "react";
2
+ export function useFocusOnMount(enabled) {
3
+ const ref = useRef(null);
4
+ useEffect(() => {
5
+ if (enabled === true)
6
+ ref.current?.focus();
7
+ }, [enabled]);
8
+ return ref;
9
+ }
@@ -1 +1 @@
1
- export declare function createBoundedLoadCache<T>(max: number, dispose: (value: T) => void): (key: string, load: () => Promise<T>) => Promise<T>;
1
+ export declare function createBoundedLoadCache<T>(max: number, dispose: (value: T) => void, shouldKeep?: (value: T) => boolean): (key: string, load: () => Promise<T>) => Promise<T>;
@@ -1,6 +1,8 @@
1
1
  // Evicts the oldest entry once `max` is exceeded and disposes only that one,
2
- // so several documents on one page keep their own loading tasks alive.
3
- export function createBoundedLoadCache(max, dispose) {
2
+ // so several documents on one page keep their own loading tasks alive. A
3
+ // resolved value that `shouldKeep` rejects (a handled failure) is dropped so
4
+ // the next mount retries.
5
+ export function createBoundedLoadCache(max, dispose, shouldKeep = () => true) {
4
6
  const entries = new Map();
5
7
  return (key, load) => {
6
8
  const cached = entries.get(key);
@@ -9,10 +11,14 @@ export function createBoundedLoadCache(max, dispose) {
9
11
  const loading = load();
10
12
  entries.set(key, loading);
11
13
  // A failed load must not stick: the next mount retries instead of replaying the rejection.
12
- loading.catch(() => {
14
+ const forget = () => {
13
15
  if (entries.get(key) === loading)
14
16
  entries.delete(key);
15
- });
17
+ };
18
+ loading.then((value) => {
19
+ if (!shouldKeep(value))
20
+ forget();
21
+ }, forget);
16
22
  if (entries.size > max) {
17
23
  const [oldestKey, oldest] = entries.entries().next().value ?? [];
18
24
  if (oldestKey !== undefined && oldest !== undefined) {
@@ -0,0 +1,17 @@
1
+ import { type ReactNode } from "react";
2
+ /** Card-styled radio group for mutually exclusive choices that need a description or price line. */
3
+ export declare function ChoiceCards<T extends string>({ value, options, onChange, ariaLabel, testId, }: {
4
+ /** `null`: nothing selected. */
5
+ readonly value: T | null;
6
+ readonly options: readonly {
7
+ readonly value: T;
8
+ readonly title: string;
9
+ readonly description?: string;
10
+ /** Right-aligned next to the title, e.g. a price. */
11
+ readonly aside?: string;
12
+ readonly disabled?: boolean;
13
+ }[];
14
+ readonly onChange: (value: T) => void;
15
+ readonly ariaLabel?: string;
16
+ readonly testId?: string;
17
+ }): ReactNode;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Check } from "lucide-react";
3
+ import { useId } from "react";
4
+ import { cn } from "../lib/cn.js";
5
+ /** Card-styled radio group for mutually exclusive choices that need a description or price line. */
6
+ export function ChoiceCards({ value, options, onChange, ariaLabel, testId, }) {
7
+ const groupName = useId();
8
+ return (_jsx("div", { role: "radiogroup", "aria-label": ariaLabel, "data-testid": testId, className: "grid grid-cols-1 gap-2 sm:grid-cols-2", children: options.map((option) => {
9
+ const selected = option.value === value;
10
+ return (_jsxs("label", { "data-testid": testId !== undefined ? `${testId}-${option.value}` : undefined, className: cn("relative flex cursor-pointer flex-col gap-1 rounded-lg border bg-card p-4 transition-colors", "hover:bg-muted/50 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring", selected ? "border-primary ring-1 ring-primary" : "border-border", option.disabled && "cursor-not-allowed opacity-50"), children: [_jsx("input", { type: "radio", className: "sr-only", name: groupName, value: option.value, checked: selected, disabled: option.disabled, onChange: () => onChange(option.value) }), _jsxs("span", { className: "flex items-start justify-between gap-2", children: [_jsx("span", { className: "font-semibold", children: option.title }), _jsxs("span", { className: "flex items-center gap-2", children: [option.aside !== undefined && (_jsx("span", { className: "font-semibold tabular-nums", children: option.aside })), _jsx("span", { "aria-hidden": "true", className: cn("flex size-5 shrink-0 items-center justify-center rounded-full border", selected ? "border-primary bg-primary text-primary-foreground" : "border-input"), children: selected && _jsx(Check, { className: "size-3" }) })] })] }), option.description !== undefined && (_jsx("span", { className: "text-sm text-muted-foreground", children: option.description }))] }, option.value));
11
+ }) }));
12
+ }
@@ -13,5 +13,5 @@ export function CollapsibleSection({ title, defaultOpen = false, children, testI
13
13
  if (defaultOpen)
14
14
  setOpen(true);
15
15
  }, [defaultOpen]);
16
- return (_jsxs("details", { "data-testid": testId, className: "group rounded-lg border bg-card", open: open, onToggle: (e) => setOpen(e.currentTarget.open), children: [_jsxs("summary", { className: "flex cursor-pointer select-none items-center justify-between gap-2 rounded-lg px-4 py-2.5 text-sm font-medium hover:bg-muted/50 [&::-webkit-details-marker]:hidden", children: [_jsx("span", { children: title }), _jsx(ChevronRight, { "aria-hidden": "true", className: "size-4 text-muted-foreground transition-transform group-open:rotate-90" })] }), _jsx("div", { className: "border-t px-4 py-3", children: children })] }));
16
+ return (_jsxs("details", { "data-testid": testId, className: "group rounded-lg border bg-card", open: open, onToggle: (e) => setOpen(e.currentTarget.open), children: [_jsxs("summary", { className: "flex h-9 cursor-pointer select-none items-center justify-between gap-2 rounded-lg px-4 text-sm font-medium hover:bg-muted/50 [&::-webkit-details-marker]:hidden", children: [_jsx("span", { children: title }), _jsx(ChevronRight, { "aria-hidden": "true", className: "size-4 text-muted-foreground transition-transform group-open:rotate-90" })] }), _jsx("div", { className: "border-t px-4 py-3", children: children })] }));
17
17
  }
@@ -1,5 +1,9 @@
1
1
  import type { ReactNode } from "react";
2
- /** Read-only key-value list for detail views (dimmed label on the left, value on the right). The value is a ReactNode, so badges and chips are supported. `emphasize` highlights a row, such as the final result in a calculator. */
2
+ /**
3
+ * Read-only key-value list for detail views (dimmed label on the left, value on the right). The
4
+ * value is a ReactNode, so badges and chips are supported. `emphasize` highlights a row, such as
5
+ * the final result in a calculator.
6
+ */
3
7
  export declare function DetailList({ rows, testId, }: {
4
8
  readonly rows: readonly {
5
9
  /** Optional stable key -- falls back to the label when omitted (fine as
@@ -1,6 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cn } from "../lib/cn.js";
3
- /** Read-only key-value list for detail views (dimmed label on the left, value on the right). The value is a ReactNode, so badges and chips are supported. `emphasize` highlights a row, such as the final result in a calculator. */
3
+ /**
4
+ * Read-only key-value list for detail views (dimmed label on the left, value on the right). The
5
+ * value is a ReactNode, so badges and chips are supported. `emphasize` highlights a row, such as
6
+ * the final result in a calculator.
7
+ */
4
8
  export function DetailList({ rows, testId, }) {
5
9
  return (_jsx("dl", { "data-testid": testId, className: "@container flex flex-col divide-y", children: rows.map((row) => (_jsxs("div", { className: "grid grid-cols-1 gap-0.5 py-2.5 @md:grid-cols-[200px_1fr] @md:gap-4", children: [_jsx("dt", { className: cn("text-sm text-muted-foreground", row.emphasize === true && "font-semibold text-foreground"), children: row.label }), _jsx("dd", { className: cn("text-sm font-medium", row.emphasize === true && "font-semibold"), children: row.value })] }, row.id ?? row.label))) }));
6
10
  }