@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.
- package/dist/app/action-form-body.js +10 -2
- package/dist/app/action-form-support.d.ts +1 -0
- package/dist/app/action-form-support.js +1 -1
- package/dist/app/config-edit-body.js +6 -2
- package/dist/app/entity-edit-prefill.d.ts +11 -0
- package/dist/app/entity-edit-prefill.js +9 -4
- package/dist/app/extension-sections.d.ts +5 -3
- package/dist/app/form-schema.js +1 -1
- package/dist/app/header-actions-bar.js +1 -1
- package/dist/components/edit-mounts.d.ts +2 -2
- package/dist/components/embedded-list-field.js +1 -1
- package/dist/components/related-list-section.js +14 -1
- package/dist/components/render-edit-action-button.d.ts +1 -3
- package/dist/components/render-edit-action-button.js +2 -2
- package/dist/components/render-edit.js +11 -8
- package/dist/components/render-field.js +5 -2
- package/dist/components/render-list.js +31 -18
- package/dist/flow-diagram/render.js +9 -1
- package/dist/headless/form/form-controller.js +8 -0
- package/dist/headless/form/types.d.ts +1 -0
- package/dist/headless/format/index.js +89 -64
- package/dist/headless/view-model/edit.js +2 -0
- package/dist/headless/view-model/list.js +184 -149
- package/dist/headless/view-model/types.d.ts +3 -0
- package/dist/hooks/live-refetch-scheduler.js +4 -0
- package/dist/hooks/use-reference-lookup.d.ts +9 -1
- package/dist/hooks/use-reference-lookup.js +17 -3
- package/dist/i18n-defaults.js +6 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/primitives.d.ts +19 -0
- package/dist/web/app/create-app.js +8 -3
- package/dist/web/app/dashboard-body.js +17 -1
- package/dist/web/app/dashboard-panels.js +12 -2
- package/dist/web/index.d.ts +1 -1
- package/dist/web/index.js +1 -1
- package/dist/web/layout/shell-header.js +2 -1
- package/dist/web/layout/uniform-bar-controls.d.ts +1 -0
- package/dist/web/layout/uniform-bar-controls.js +4 -0
- package/dist/web/lib/file-ref-download.js +4 -1
- package/dist/web/primitives/action-menu.js +1 -1
- package/dist/web/primitives/code-input.d.ts +6 -0
- package/dist/web/primitives/code-input.js +31 -0
- package/dist/web/primitives/data-table.js +4 -3
- package/dist/web/primitives/facet-filter.js +1 -1
- package/dist/web/primitives/file-thumbnail.d.ts +3 -0
- package/dist/web/primitives/file-thumbnail.js +5 -0
- package/dist/web/primitives/input.js +6 -1
- package/dist/web/primitives/layout-elements.js +6 -4
- package/dist/web/primitives/page-header.js +3 -1
- package/dist/web/primitives/row-actions.js +3 -3
- package/dist/web/primitives/use-focus-on-mount.d.ts +2 -0
- package/dist/web/primitives/use-focus-on-mount.js +9 -0
- package/dist/web/widgets/bounded-load-cache.d.ts +1 -1
- package/dist/web/widgets/bounded-load-cache.js +10 -4
- package/dist/web/widgets/choice-cards.d.ts +17 -0
- package/dist/web/widgets/choice-cards.js +12 -0
- package/dist/web/widgets/collapsible-section.js +1 -1
- package/dist/web/widgets/detail-list.d.ts +5 -1
- package/dist/web/widgets/detail-list.js +5 -1
- package/dist/web/widgets/index.d.ts +1 -0
- package/dist/web/widgets/index.js +1 -0
- package/dist/web/widgets/pdf-preview.js +1 -1
- package/dist/web/widgets/plan-card.d.ts +2 -0
- package/dist/web/widgets/plan-card.js +1 -1
- package/dist/web/widgets/upload-zone.js +13 -5
- package/package.json +5 -5
- 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";
|
package/dist/primitives.d.ts
CHANGED
|
@@ -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 () =>
|
|
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:
|
|
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((
|
|
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((
|
|
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 }));
|
package/dist/web/index.d.ts
CHANGED
|
@@ -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:
|
|
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,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
|
|
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 && "
|
|
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-
|
|
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
|
-
|
|
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)]",
|
|
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-
|
|
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-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
}
|