@cosmicdrift/kumiko-renderer 0.377.0 → 0.379.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-shim.js +1 -2
- package/dist/app/action-form-support.js +16 -4
- package/dist/app/kumiko-screen.js +51 -38
- package/dist/app/list-empty-content.d.ts +1 -0
- package/dist/app/list-empty-content.js +5 -0
- package/dist/app/row-actions.d.ts +7 -1
- package/dist/app/row-actions.js +6 -1
- package/dist/app/secret-mint-body.js +3 -2
- package/dist/components/embedded-list-field.js +16 -7
- package/dist/components/file-list-field.js +4 -0
- package/dist/components/form-derivation-patch.d.ts +8 -0
- package/dist/components/form-derivation-patch.js +24 -0
- package/dist/components/form-screen-layout.d.ts +13 -0
- package/dist/components/form-screen-layout.js +13 -0
- package/dist/components/grid-cell-for-field.d.ts +4 -1
- package/dist/components/grid-cell-for-field.js +6 -2
- package/dist/components/list-empty-state.d.ts +4 -1
- package/dist/components/list-empty-state.js +4 -1
- package/dist/components/reference-create-dialog.js +1 -1
- package/dist/components/related-list-section.js +4 -1
- package/dist/components/render-edit-logic.d.ts +1 -1
- package/dist/components/render-edit-logic.js +8 -2
- package/dist/components/render-edit-types.d.ts +7 -1
- package/dist/components/render-edit.js +38 -6
- package/dist/components/render-field.d.ts +3 -1
- package/dist/components/render-field.js +27 -8
- package/dist/components/render-list.d.ts +3 -1
- package/dist/components/render-list.js +29 -8
- package/dist/components/write-form-section.js +4 -1
- package/dist/field-issue-text.d.ts +4 -0
- package/dist/field-issue-text.js +14 -0
- package/dist/file-ids-of.d.ts +1 -0
- package/dist/file-ids-of.js +7 -0
- package/dist/flow-diagram/icons.d.ts +3 -0
- package/dist/flow-diagram/icons.js +14 -0
- package/dist/flow-diagram/index.d.ts +2 -0
- package/dist/flow-diagram/index.js +1 -0
- package/dist/flow-diagram/render.d.ts +3 -0
- package/dist/flow-diagram/render.js +234 -0
- package/dist/flow-diagram/types.d.ts +18 -0
- package/dist/flow-diagram/types.js +1 -0
- package/dist/headless/dispatcher/index.d.ts +1 -0
- package/dist/headless/dispatcher/index.js +1 -0
- package/dist/headless/dispatcher/request-id.d.ts +1 -0
- package/dist/headless/dispatcher/request-id.js +15 -0
- package/dist/headless/form/form-controller.js +16 -1
- package/dist/headless/form/zod-bridge.js +7 -0
- package/dist/headless/index.d.ts +2 -1
- package/dist/headless/index.js +2 -1
- package/dist/headless/plural-key.d.ts +1 -0
- package/dist/headless/plural-key.js +5 -0
- package/dist/headless/view-model/edit.d.ts +8 -1
- package/dist/headless/view-model/edit.js +23 -7
- package/dist/headless/view-model/types.d.ts +11 -3
- package/dist/hooks/live-refetch-scheduler.d.ts +6 -0
- package/dist/hooks/live-refetch-scheduler.js +45 -0
- package/dist/hooks/use-query.js +8 -4
- package/dist/hooks/use-reference-lookup.d.ts +1 -0
- package/dist/hooks/use-reference-lookup.js +6 -3
- package/dist/i18n-defaults.js +28 -3
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -1
- package/dist/leave-guard-dialog.d.ts +2 -0
- package/dist/leave-guard-dialog.js +28 -0
- package/dist/leave-guard.d.ts +15 -0
- package/dist/leave-guard.js +39 -0
- package/dist/live/dispatcher-live.js +1 -16
- package/dist/primitives.d.ts +39 -0
- package/dist/sse/live-events.d.ts +3 -0
- package/dist/sse/live-events.js +3 -0
- package/dist/web/app/create-app.js +2 -2
- package/dist/web/app/dashboard-body.js +13 -1
- package/dist/web/app/nav.js +51 -9
- package/dist/web/index.d.ts +2 -2
- package/dist/web/index.js +1 -1
- package/dist/web/layout/nav-tree.js +13 -3
- package/dist/web/layout/shell-header.js +3 -3
- package/dist/web/lib/file-upload-form-data.d.ts +2 -0
- package/dist/web/lib/file-upload-form-data.js +2 -0
- package/dist/web/lib/upload-error-key.d.ts +7 -0
- package/dist/web/lib/upload-error-key.js +23 -0
- package/dist/web/lib/upload-file-ref.d.ts +1 -1
- package/dist/web/primitives/embedded-list-input.js +5 -4
- package/dist/web/primitives/file-thumbnail.d.ts +6 -0
- package/dist/web/primitives/file-thumbnail.js +19 -19
- package/dist/web/primitives/file-upload.d.ts +2 -1
- package/dist/web/primitives/file-upload.js +11 -7
- package/dist/web/primitives/file-value-list.d.ts +3 -0
- package/dist/web/primitives/file-value-list.js +22 -0
- package/dist/web/primitives/files-dropzone.d.ts +16 -0
- package/dist/web/primitives/files-dropzone.js +52 -0
- package/dist/web/primitives/index.js +65 -30
- package/dist/web/primitives/segmented-select.js +8 -2
- package/dist/web/sse/live-events.d.ts +13 -0
- package/dist/web/sse/live-events.js +88 -23
- package/dist/web/widgets/bounded-load-cache.d.ts +1 -0
- package/dist/web/widgets/bounded-load-cache.js +25 -0
- package/dist/web/widgets/drawer.js +37 -2
- package/dist/web/widgets/index.d.ts +1 -0
- package/dist/web/widgets/index.js +1 -0
- package/dist/web/widgets/pdf-preview.d.ts +10 -0
- package/dist/web/widgets/pdf-preview.js +140 -0
- package/dist/web/widgets/upload-zone.d.ts +11 -2
- package/dist/web/widgets/upload-zone.js +30 -7
- package/package.json +14 -5
- package/src/changes.json +143 -0
- package/src/web/styles.css +13 -9
|
@@ -8,6 +8,7 @@ import { ArrowDown, ArrowUp, ArrowUpDown, Building, CalendarDays, Check, Chevron
|
|
|
8
8
|
// package.json#browser (Metro), hence the explicit browser subpath.
|
|
9
9
|
import QRCode from "qrcode/lib/browser.js";
|
|
10
10
|
import { Children, createContext, Fragment, isValidElement, useContext, useEffect, useId, useRef, useState, } from "react";
|
|
11
|
+
import { fieldIssueText } from "../../field-issue-text.js";
|
|
11
12
|
import { applyFormatSpec, EMPTY_VALUE_PLACEHOLDER, isSafeHref } from "../../headless/index.js";
|
|
12
13
|
import { InsideDrawerProvider, needsActionConfirm, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, useColumnRenderer, useInsideDrawer, useOptionalLocale, useOptionalPrimitives, useOptionalTranslation, useTranslation, WriteFailedError, } from "../../index.js";
|
|
13
14
|
import { Icon, NAV_ICONS } from "../icons.js";
|
|
@@ -32,6 +33,8 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMe
|
|
|
32
33
|
import { EmbeddedListInput } from "./embedded-list-input.js";
|
|
33
34
|
import { CardLeadingImage, FileDisplayCell } from "./file-thumbnail.js";
|
|
34
35
|
import { FileUploadInput } from "./file-upload.js";
|
|
36
|
+
import { FileValueList } from "./file-value-list.js";
|
|
37
|
+
import { FilesDropzoneInput } from "./files-dropzone.js";
|
|
35
38
|
import { DefaultJsonView } from "./json-view.js";
|
|
36
39
|
import { screenFormColumnWidthClassName, screenPaddingClassName, screenWidthClassName, } from "./layout.js";
|
|
37
40
|
import { DefaultLightbox } from "./lightbox.js";
|
|
@@ -169,14 +172,23 @@ const FieldDescriptionContext = createContext(undefined);
|
|
|
169
172
|
function fieldDescriptionId(id) {
|
|
170
173
|
return `${id}-description`;
|
|
171
174
|
}
|
|
175
|
+
function fieldErrorId(id) {
|
|
176
|
+
return `${id}-error`;
|
|
177
|
+
}
|
|
172
178
|
function DefaultField(props) {
|
|
173
|
-
|
|
179
|
+
const describedBy = [
|
|
180
|
+
props.description !== undefined ? fieldDescriptionId(props.id) : undefined,
|
|
181
|
+
props.issues !== undefined && props.issues.length > 0 ? fieldErrorId(props.id) : undefined,
|
|
182
|
+
]
|
|
183
|
+
.filter((id) => id !== undefined)
|
|
184
|
+
.join(" ");
|
|
185
|
+
return (_jsx(FieldLabelledContext.Provider, { value: props.label !== "", children: _jsx(FieldDescriptionContext.Provider, { value: describedBy === "" ? undefined : describedBy, children: _jsx(DefaultFieldBody, { ...props }) }) }));
|
|
174
186
|
}
|
|
175
187
|
function DefaultFieldBody({ id, label, required, issues, labelAppendix, fieldAppendix, appendixPlacement, description, status, children, layout, accent, hideLabel, testId, changed, }) {
|
|
176
188
|
const t = useTranslation();
|
|
177
189
|
const hasError = issues !== undefined && issues.length > 0;
|
|
178
190
|
const labelEl = (_jsxs(UiLabel, { id: fieldLabelId(id), htmlFor: id, className: cn("min-w-0 gap-0 text-[13px]", hasError ? "text-destructive" : "text-foreground", hideLabel === true && "sr-only"), children: [_jsx("span", { className: layout === "inline" ? undefined : "truncate", title: typeof label === "string" ? label : undefined, children: label }), required === true && (_jsx("span", { "data-required": true, className: "ml-0.5 shrink-0 text-destructive", children: "*" }))] }));
|
|
179
|
-
const errorsEl = hasError ? (_jsx("div", { role: "alert", "data-testid": testId !== undefined ? `${testId}-errors` : undefined, className: "text-xs text-destructive", children: issues.map((issue) => (_jsx("div", { children: t
|
|
191
|
+
const errorsEl = hasError ? (_jsx("div", { id: fieldErrorId(id), role: "alert", "data-testid": testId !== undefined ? `${testId}-errors` : undefined, className: "text-xs text-destructive", children: issues.map((issue) => (_jsx("div", { children: fieldIssueText(t, issue) }, `${issue.path}:${issue.code}`))) })) : null;
|
|
180
192
|
// Inline (boolean/checkbox): Control links, Label rechts — shadcn-Muster.
|
|
181
193
|
if (layout === "inline") {
|
|
182
194
|
return (_jsxs("div", { "data-testid": testId, className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(FieldLayoutContext.Provider, { value: "inline", children: children }), labelEl, labelAppendix !== undefined && labelAppendix] }), errorsEl] }));
|
|
@@ -352,7 +364,13 @@ function DefaultInput(props) {
|
|
|
352
364
|
return booleanLayout === "inline" ? (_jsx(Checkbox, { id: props.id, name: props.name, disabled: props.disabled, "aria-required": props.required, "aria-invalid": props.hasError === true ? true : undefined, checked: props.value, onCheckedChange: (checked) => props.onChange(checked === true) })) : (_jsx(Switch, { id: props.id, name: props.name, disabled: props.disabled, "aria-required": props.required, "aria-invalid": props.hasError === true ? true : undefined, checked: props.value, onCheckedChange: (checked) => props.onChange(checked === true), className: "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40" }));
|
|
353
365
|
case "file":
|
|
354
366
|
case "image":
|
|
355
|
-
return (_jsx(FileUploadInput, { kind: props.kind, id: props.id, value: props.value, onChange: props.onChange, ...(props.accept !== undefined && { accept: props.accept }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.entityType !== undefined && { entityType: props.entityType }), ...(props.
|
|
367
|
+
return (_jsx(FileUploadInput, { kind: props.kind, id: props.id, value: props.value, onChange: props.onChange, ...(props.accept !== undefined && { accept: props.accept }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.entityType !== undefined && { entityType: props.entityType }), ...(props.uploadActionForm !== undefined && {
|
|
368
|
+
uploadActionForm: props.uploadActionForm,
|
|
369
|
+
}), ...(props.entityId !== undefined && { entityId: props.entityId }), ...(props.fieldName !== undefined && { fieldName: props.fieldName }), ...(props.imageVariant !== undefined && { imageVariant: props.imageVariant }), ...(props.capture !== undefined && { capture: props.capture }), ...(props.directory !== undefined && { directory: props.directory }), ...(props.onFilesPicked !== undefined && { onFilesPicked: props.onFilesPicked }), ...(props.pickLabel !== undefined && { pickLabel: props.pickLabel }), ...(props.testId !== undefined && { testId: props.testId }) }));
|
|
370
|
+
case "files-dropzone":
|
|
371
|
+
return (_jsx(FilesDropzoneInput, { id: props.id, value: props.value, onChange: props.onChange, ...(props.accept !== undefined && { accept: props.accept }), ...(props.maxSize !== undefined && { maxSize: props.maxSize }), ...(props.maxCount !== undefined && { maxCount: props.maxCount }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.entityType !== undefined && { entityType: props.entityType }), ...(props.uploadActionForm !== undefined && {
|
|
372
|
+
uploadActionForm: props.uploadActionForm,
|
|
373
|
+
}), ...(props.entityId !== undefined && { entityId: props.entityId }), ...(props.fieldName !== undefined && { fieldName: props.fieldName }), ...(props.testId !== undefined && { testId: props.testId }) }));
|
|
356
374
|
case "date":
|
|
357
375
|
return (_jsx(DateInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, ...(props.locale !== undefined && { locale: props.locale }), ...(props.min !== undefined && { min: props.min }), ...(props.max !== undefined && { max: props.max }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
|
|
358
376
|
case "select": {
|
|
@@ -615,13 +633,13 @@ function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, empty
|
|
|
615
633
|
const row = item.row;
|
|
616
634
|
const opensOnClick = rowIsLink && !rowClickIsHidden(row);
|
|
617
635
|
return (_jsxs(Fragment, { children: [_jsxs(TableRow, { "data-testid": rowTestId(row), "data-tone": rowTone?.(row), onClick: opensOnClick ? () => onRowClick(row) : undefined, className: cn(rowToneClass(rowTone?.(row)), opensOnClick && "cursor-pointer", fillsHeight && "h-10 border-border-row hover:bg-muted"), children: [isRowExpandable && (_jsx(TableCell, { "data-testid": getCellTestId?.(row, "expand") ?? `cell-${row.id}-expand`, className: EXPAND_TOGGLE_CELL_CLASS, children: renderExpandToggle(row) })), columns.map((col, colIndex) => (_jsx(TableCell, { "data-testid": getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`, "data-highlighted": col.highlighted === true ? "true" : undefined,
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
636
|
+
// Text wraps instead of truncating: nowrap cells keep their
|
|
637
|
+
// full width, so a wide table scrolled sideways and its
|
|
638
|
+
// last column slid under the sticky actions column with
|
|
639
|
+
// nothing visibly cut off. max-w-xs bounds a single column;
|
|
640
|
+
// badges and dates stay nowrap by their own classes.
|
|
623
641
|
className: cn(col.fileDisplay === undefined
|
|
624
|
-
? "max-w-xs
|
|
642
|
+
? "max-w-xs whitespace-normal break-words"
|
|
625
643
|
: FIT_CONTENT_CELL_CLASS, colIndex === 0
|
|
626
644
|
? "font-medium text-foreground"
|
|
627
645
|
: "text-foreground-secondary", NUMERIC_COLUMN_TYPES.has(col.type) && "text-right", TABULAR_COLUMN_TYPES.has(col.type) && "tabular-nums", col.highlighted === true && "bg-accent/40"), title: cellTitle(row.values[col.field]), children: _jsx(FirstCellLink, { enabled: colIndex === 0 && opensOnClick && onCellChange === undefined, onOpen: () => onRowClick?.(row), empty: isEmptyCellValue(row.values[col.field]), emptyLabel: rowClickLabel, children: _jsx(DataTableCell, { value: row.values[col.field], row: row.values, field: col.field, type: col.type, renderer: col.renderer, translate: tableTranslate, locale: tableLocale, ...(col.optionLabels !== undefined && {
|
|
@@ -635,7 +653,7 @@ function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, empty
|
|
|
635
653
|
// would hide the neighboring data column on narrow
|
|
636
654
|
// viewports. bg-background sets the column apart during
|
|
637
655
|
// scroll — no border-l (divider too heavy).
|
|
638
|
-
className: cn("text-right md:sticky md:right-0 md:z-10 md:bg-background", fillsHeight && "md:bg-card"),
|
|
656
|
+
className: cn("whitespace-nowrap text-right md:sticky md:right-0 md:z-10 md:bg-background", fillsHeight && "md:bg-card"),
|
|
639
657
|
// Action-cell events must not trigger the row click/activation
|
|
640
658
|
// (typically "Open Detail" — the user wanted the action,
|
|
641
659
|
// not navigation). We stopPropagation for mouse and
|
|
@@ -670,7 +688,7 @@ function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, empty
|
|
|
670
688
|
const hasMenu = menuActions !== undefined && menuActions.length > 0;
|
|
671
689
|
const opensOnClick = rowIsLink && !rowClickIsHidden(row);
|
|
672
690
|
const body = (_jsxs(_Fragment, { children: [cardImageColumn?.fileDisplay !== undefined && (_jsx("div", { "data-testid": `card-image-${row.id}`, className: "shrink-0", children: _jsx(CardLeadingImage, { value: row.values[cardImageColumn.field], display: cardImageColumn.fileDisplay }) })), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-1", children: [_jsxs("div", { className: "flex min-w-0 items-start justify-between gap-2", children: [cardTitleColumn !== undefined && (_jsx("span", { "data-testid": getCellTestId?.(row, cardTitleColumn.field) ??
|
|
673
|
-
`cell-${row.id}-${cardTitleColumn.field}`, className: "min-w-0
|
|
691
|
+
`cell-${row.id}-${cardTitleColumn.field}`, className: "min-w-0 break-words text-base font-semibold text-foreground", children: cardCell(row, cardTitleColumn) })), showStatus && (_jsx("span", { "data-testid": getCellTestId?.(row, cardStatusColumn.field) ??
|
|
674
692
|
`cell-${row.id}-${cardStatusColumn.field}`, className: "shrink-0", children: cardCell(row, cardStatusColumn) }))] }), metaColumns.length > 0 && (_jsx("div", { className: "min-w-0 overflow-x-clip", children: _jsx("div", { "data-testid": `card-meta-${row.id}`, className: "-ms-3 flex min-w-0 flex-wrap items-center text-[13px] leading-5 tabular-nums text-foreground-secondary", children: metaColumns.map((col) => (_jsxs("div", { className: "flex h-5 min-w-0 max-w-full items-center", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 shrink-0 text-center", children: "\u00B7" }), _jsx("div", { "data-testid": getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`, className: "min-w-0 truncate leading-5", onPointerEnter: showFullTextOnHover, children: isBadgeColumn(col) ? (_jsx("span", { className: "inline-flex align-middle", children: cardCell(row, col) })) : (cardCell(row, col)) })] }, col.field))) }) }))] }), opensOnClick && (_jsx(ChevronRight, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": "true" }))] }));
|
|
675
693
|
const rowItem = (_jsxs("li", { "data-testid": rowTestId(row), "data-tone": rowTone?.(row), className: cn("flex min-h-[72px] items-center gap-2 border-b border-border-row py-3", rowToneClass(rowTone?.(row)), isRowExpandable ? "pl-2" : "pl-4", hasMenu ? "pr-2" : "pr-4"), ...(opensOnClick && { onClick: () => onRowClick(row) }), children: [renderExpandToggle(row), opensOnClick ? (_jsx("button", { type: "button", className: "flex min-w-0 flex-1 cursor-pointer items-center gap-2 bg-transparent p-0 text-left", onKeyDown: (e) => {
|
|
676
694
|
if (e.key !== "Enter")
|
|
@@ -784,14 +802,14 @@ function RowActionsCell({ row, actions, mode = "adaptive", rowIsLink = false, co
|
|
|
784
802
|
if (hasExplicitDisplay) {
|
|
785
803
|
const inline = visible.filter((a) => a.display !== undefined);
|
|
786
804
|
const rest = visible.filter((a) => a.display === undefined);
|
|
787
|
-
return (_jsxs("div", { className: "inline-flex 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 })] }));
|
|
805
|
+
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 })] }));
|
|
788
806
|
}
|
|
789
807
|
if (rowIsLink) {
|
|
790
808
|
return (_jsx("div", { className: "inline-flex items-center justify-end", children: _jsx(RowActionsKebab, { row: row, actions: visible }) }));
|
|
791
809
|
}
|
|
792
810
|
const primary = primaryRowAction(visible);
|
|
793
811
|
const rest = visible.filter((a) => a.id !== primary?.id);
|
|
794
|
-
return (_jsxs("div", { className: "inline-flex 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 })] }));
|
|
812
|
+
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 })] }));
|
|
795
813
|
}
|
|
796
814
|
function isEmptyCellValue(value) {
|
|
797
815
|
return (value === null || value === undefined || (typeof value === "string" && value.trim() === ""));
|
|
@@ -1087,13 +1105,13 @@ function SortableHeader({ field, label, sortable, highlighted, numeric, fitConte
|
|
|
1087
1105
|
const active = sort?.field === field ? sort : undefined;
|
|
1088
1106
|
const ariaSort = active?.dir === "asc" ? "ascending" : active?.dir === "desc" ? "descending" : "none";
|
|
1089
1107
|
if (!sortable || onSortChange === undefined) {
|
|
1090
|
-
return (_jsx(TableHead, { "data-testid": `column-${field}`, "data-sortable": sortable === true ? true : undefined, "data-highlighted": highlighted === true ? "true" : undefined, className: cn("font-medium text-muted-foreground", numeric === true && "text-right", fitContent === true && FIT_CONTENT_CELL_CLASS, highlighted === true && "bg-accent/40"), children: label }));
|
|
1108
|
+
return (_jsx(TableHead, { "data-testid": `column-${field}`, "data-sortable": sortable === true ? true : undefined, "data-highlighted": highlighted === true ? "true" : undefined, className: cn("font-medium whitespace-normal text-muted-foreground", numeric === true && "text-right", fitContent === true && FIT_CONTENT_CELL_CLASS, highlighted === true && "bg-accent/40"), children: label }));
|
|
1091
1109
|
}
|
|
1092
1110
|
const Icon = active?.dir === "asc" ? ArrowUp : active?.dir === "desc" ? ArrowDown : ArrowUpDown;
|
|
1093
1111
|
const next = nextSortState(active?.dir, field);
|
|
1094
1112
|
return (_jsx(TableHead, { "data-testid": `column-${field}`, "data-sortable": "true", "data-highlighted": highlighted === true ? "true" : undefined, "aria-sort": ariaSort, className: cn(active !== undefined
|
|
1095
1113
|
? "font-semibold text-foreground"
|
|
1096
|
-
: "font-medium text-muted-foreground", numeric === true && "text-right", fitContent === true && FIT_CONTENT_CELL_CLASS, highlighted === true && "bg-accent/40"), children: _jsxs("button", { type: "button", onClick: () => onSortChange(next), className: cn("group/sort inline-flex h-8 items-center gap-1.5 rounded-sm px-2 -mx-2 text-sm [font-weight:inherit]", "hover:bg-accent hover:text-accent-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", active !== undefined && "text-foreground"), children: [_jsx("span", { children: label }), _jsx(Icon, { "data-sort-icon": active === undefined ? "idle" : "active", className: cn("size-3.5", active === undefined &&
|
|
1114
|
+
: "font-medium text-muted-foreground", "whitespace-normal", numeric === true && "text-right", fitContent === true && FIT_CONTENT_CELL_CLASS, highlighted === true && "bg-accent/40"), children: _jsxs("button", { type: "button", onClick: () => onSortChange(next), className: cn("group/sort inline-flex h-8 items-center gap-1.5 rounded-sm px-2 -mx-2 text-sm [font-weight:inherit]", "hover:bg-accent hover:text-accent-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", active !== undefined && "text-foreground"), children: [_jsx("span", { children: label }), _jsx(Icon, { "data-sort-icon": active === undefined ? "idle" : "active", className: cn("size-3.5", active === undefined &&
|
|
1097
1115
|
"opacity-0 group-hover/sort:opacity-40 group-focus-visible/sort:opacity-40"), "aria-hidden": "true" })] }) }));
|
|
1098
1116
|
}
|
|
1099
1117
|
// 3-State-Toggle: kein Sort → asc → desc → kein Sort. Idiomatisch für
|
|
@@ -1426,9 +1444,21 @@ const SUBMIT_CONTROL_SELECTOR = [
|
|
|
1426
1444
|
"input[type=submit]:not(:disabled)",
|
|
1427
1445
|
"input[type=image]:not(:disabled)",
|
|
1428
1446
|
].join(", ");
|
|
1429
|
-
function FormRoot({ onSubmit, testId, className, children, }) {
|
|
1447
|
+
function FormRoot({ onSubmit, testId, className, children, focusFirstErrorKey, }) {
|
|
1448
|
+
const rootRef = useRef(null);
|
|
1449
|
+
useEffect(() => {
|
|
1450
|
+
if (focusFirstErrorKey === undefined || focusFirstErrorKey === 0)
|
|
1451
|
+
return;
|
|
1452
|
+
const firstInvalid = rootRef.current?.querySelector('[aria-invalid="true"]');
|
|
1453
|
+
if (firstInvalid === null || firstInvalid === undefined)
|
|
1454
|
+
return;
|
|
1455
|
+
firstInvalid.focus();
|
|
1456
|
+
firstInvalid.scrollIntoView({ block: "center" });
|
|
1457
|
+
}, [focusFirstErrorKey]);
|
|
1430
1458
|
if (useContext(InsideFormContext)) {
|
|
1431
|
-
return (_jsx("div", {
|
|
1459
|
+
return (_jsx("div", { ref: (node) => {
|
|
1460
|
+
rootRef.current = node;
|
|
1461
|
+
}, "data-kumiko-form-root": "", onKeyDownCapture: (e) => {
|
|
1432
1462
|
// Implicit submission: the section's inputs belong to the OUTER
|
|
1433
1463
|
// <form> in the DOM, so the browser would otherwise route Enter to
|
|
1434
1464
|
// that form's default button.
|
|
@@ -1457,7 +1487,9 @@ function FormRoot({ onSubmit, testId, className, children, }) {
|
|
|
1457
1487
|
onSubmit();
|
|
1458
1488
|
}, "data-testid": testId, className: className, children: children }));
|
|
1459
1489
|
}
|
|
1460
|
-
return (_jsx("form", {
|
|
1490
|
+
return (_jsx("form", { ref: (node) => {
|
|
1491
|
+
rootRef.current = node;
|
|
1492
|
+
}, onSubmit: (e) => {
|
|
1461
1493
|
e.preventDefault();
|
|
1462
1494
|
onSubmit(e);
|
|
1463
1495
|
}, "data-testid": testId, className: className, children: children }));
|
|
@@ -1636,26 +1668,26 @@ function FormSectionNav({ items }) {
|
|
|
1636
1668
|
// Form inside the Drawer primitive: the drawer header carries the title, so
|
|
1637
1669
|
// only the description (as help text), the scrolling sections and a pinned
|
|
1638
1670
|
// footer remain. The scroll area is a sibling of the footer, never its parent.
|
|
1639
|
-
function DrawerFormLayout({ onSubmit, subtitle, actions, secondaryActions, summary, testId, children, }) {
|
|
1640
|
-
return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: "flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-6 py-5", children: [summary !== undefined && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-summary` : undefined, className: "flex flex-col gap-0.5 rounded-lg bg-muted px-3.5 py-3", children: [_jsx("span", { className: "text-sm font-semibold text-foreground", children: summary.title }), summary.subtitle !== undefined && (_jsx("span", { className: "text-[13px] text-foreground-secondary", children: summary.subtitle }))] })), subtitle !== undefined && (_jsx("p", { "data-testid": testId !== undefined ? `${testId}-subtitle` : undefined, className: "text-[13px] text-foreground-secondary", children: subtitle })), _jsx(InsideFormContext.Provider, { value: true, children: _jsx(ScreenFormContext.Provider, { value: true, children: _jsx(InsideDrawerProvider, { value: false, children: _jsx(DrawerBodyContext.Provider, { value: true, children: _jsx("div", { className: "flex flex-col gap-5", children: children }) }) }) }) })] }), (actions !== undefined || secondaryActions !== undefined) && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-footer` : undefined, className: "flex h-16 shrink-0 items-center justify-end gap-2 border-t border-border px-6", children: [secondaryActions, actions] }))] }));
|
|
1671
|
+
function DrawerFormLayout({ onSubmit, focusFirstErrorKey, subtitle, actions, secondaryActions, summary, testId, children, }) {
|
|
1672
|
+
return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: "flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-6 py-5", children: [summary !== undefined && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-summary` : undefined, className: "flex flex-col gap-0.5 rounded-lg bg-muted px-3.5 py-3", children: [_jsx("span", { className: "text-sm font-semibold text-foreground", children: summary.title }), summary.subtitle !== undefined && (_jsx("span", { className: "text-[13px] text-foreground-secondary", children: summary.subtitle }))] })), subtitle !== undefined && (_jsx("p", { "data-testid": testId !== undefined ? `${testId}-subtitle` : undefined, className: "text-[13px] text-foreground-secondary", children: subtitle })), _jsx(InsideFormContext.Provider, { value: true, children: _jsx(ScreenFormContext.Provider, { value: true, children: _jsx(InsideDrawerProvider, { value: false, children: _jsx(DrawerBodyContext.Provider, { value: true, children: _jsx("div", { className: "flex flex-col gap-5", children: children }) }) }) }) })] }), (actions !== undefined || secondaryActions !== undefined) && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-footer` : undefined, className: "flex h-16 shrink-0 items-center justify-end gap-2 border-t border-border px-6", children: [secondaryActions, actions] }))] }));
|
|
1641
1673
|
}
|
|
1642
|
-
function DefaultForm({ onSubmit, children, title, subtitle, actions, secondaryActions, testId, width, stickyActions, headerRegion, titleAction, fillHeight, chromeless, screenForm, unsavedCount, sectionNav, sideRail, summary, }) {
|
|
1674
|
+
function DefaultForm({ onSubmit, focusFirstErrorKey, children, title, subtitle, actions, secondaryActions, testId, width, stickyActions, headerRegion, titleAction, fillHeight, chromeless, screenForm, unsavedCount, sectionNav, sideRail, summary, }) {
|
|
1643
1675
|
const insideDrawer = useInsideDrawer();
|
|
1644
1676
|
const isEmbeddedForm = useContext(EmbeddedFormContext);
|
|
1645
1677
|
const contextWidth = useContext(ScreenWidthContext);
|
|
1646
1678
|
// Eingebettet (AuthCard etc.): nacktes <form>, gestapelte Felder mit gap —
|
|
1647
1679
|
// der Container trägt Card/Titel selbst, sonst Card-in-Card.
|
|
1648
1680
|
if (useContext(BareFormContext)) {
|
|
1649
|
-
return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: cn("flex flex-col gap-4",
|
|
1681
|
+
return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: cn("flex flex-col gap-4",
|
|
1650
1682
|
// Bare forms stack sections without a card; without a divider a
|
|
1651
1683
|
// section boundary reads as a layout gap, not structure.
|
|
1652
1684
|
"[&>section:not(:first-child)]:border-t"), children: [headerRegion, _jsx(InsideFormContext.Provider, { value: true, children: children }), (secondaryActions !== undefined || actions !== undefined) && (_jsxs("div", { className: "flex items-center justify-end gap-2", children: [secondaryActions, actions] }))] }));
|
|
1653
1685
|
}
|
|
1654
1686
|
if (insideDrawer) {
|
|
1655
|
-
return (_jsx(DrawerFormLayout, { onSubmit: onSubmit, subtitle: subtitle, actions: actions, secondaryActions: secondaryActions, summary: summary, testId: testId, children: children }));
|
|
1687
|
+
return (_jsx(DrawerFormLayout, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, subtitle: subtitle, actions: actions, secondaryActions: secondaryActions, summary: summary, testId: testId, children: children }));
|
|
1656
1688
|
}
|
|
1657
1689
|
if (isEmbeddedForm) {
|
|
1658
|
-
return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: "flex w-full flex-col gap-6", children: [_jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: false }), _jsx(FormSections, { chromeless: true, stickyActions: undefined, fillHeight: false, screenForm: true, children: children }), (actions !== undefined || secondaryActions !== undefined) && (_jsx("div", { className: "max-w-4xl border-t border-border", children: _jsx(FormFooter, { actions: actions, secondaryActions: secondaryActions, testId: testId, chromeless: true, stickyActions: undefined, fillHeight: false, ...(unsavedCount !== undefined && { unsavedCount }) }) }))] }));
|
|
1690
|
+
return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: "flex w-full flex-col gap-6", children: [_jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: false }), _jsx(FormSections, { chromeless: true, stickyActions: undefined, fillHeight: false, screenForm: true, children: children }), (actions !== undefined || secondaryActions !== undefined) && (_jsx("div", { className: "max-w-4xl border-t border-border", children: _jsx(FormFooter, { actions: actions, secondaryActions: secondaryActions, testId: testId, chromeless: true, stickyActions: undefined, fillHeight: false, ...(unsavedCount !== undefined && { unsavedCount }) }) }))] }));
|
|
1659
1691
|
}
|
|
1660
1692
|
const isScreenForm = screenForm === true && fillHeight === true && chromeless !== true;
|
|
1661
1693
|
const screenFormWidth = width ?? contextWidth;
|
|
@@ -1665,7 +1697,7 @@ function DefaultForm({ onSubmit, children, title, subtitle, actions, secondaryAc
|
|
|
1665
1697
|
// from xl, the section navigation; the footer stays pinned below it.
|
|
1666
1698
|
if (isScreenForm) {
|
|
1667
1699
|
const showsSectionNav = sectionNav !== undefined && sectionNav.length >= SECTION_NAV_MIN_ITEMS;
|
|
1668
|
-
return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { className: "flex min-h-0 flex-1 flex-col lg:flex-row", children: [sideRail, _jsx("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: cn("min-h-0 flex-1 overflow-y-auto", screenPaddingClassName), children: _jsxs("div", { className: "flex gap-18", children: [_jsxs("div", { className: cn("flex w-full min-w-0 flex-col gap-9", screenFormWidth !== undefined
|
|
1700
|
+
return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { className: "flex min-h-0 flex-1 flex-col lg:flex-row", children: [sideRail, _jsx("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: cn("min-h-0 flex-1 overflow-y-auto", screenPaddingClassName), children: _jsxs("div", { className: "flex gap-18", children: [_jsxs("div", { className: cn("flex w-full min-w-0 flex-col gap-9", screenFormWidth !== undefined
|
|
1669
1701
|
? screenFormColumnWidthClassName[screenFormWidth]
|
|
1670
1702
|
: "max-w-[640px]"), children: [_jsx(InsideScreenFormColumnContext.Provider, { value: true, children: headerRegion }), _jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: false }), sections] }), showsSectionNav && _jsx(FormSectionNav, { items: sectionNav })] }) })] }), footer] }));
|
|
1671
1703
|
}
|
|
@@ -1684,13 +1716,13 @@ function DefaultForm({ onSubmit, children, title, subtitle, actions, secondaryAc
|
|
|
1684
1716
|
// the screen-level subtitle (screen.description) is independent of that
|
|
1685
1717
|
// and must survive tabs mode.
|
|
1686
1718
|
if (chromeless === true) {
|
|
1687
|
-
return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: cn("flex flex-col w-full", fillHeight === true && "h-full min-h-0"), children: [_jsxs(FormScreenShell, { maxWidth: "full", ...(fillHeight === true && { fillHeight: true }), className: cn("!p-0", pinnedShellClassName), children: [headerRegion !== undefined && (_jsx("div", { className: cn("flex flex-col", fillHeight === true && "shrink-0"), children: headerRegion })), _jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: fillHeight }), sections, !pinnedFooter && footer] }), pinnedFooter && footer] }));
|
|
1719
|
+
return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: cn("flex flex-col w-full", fillHeight === true && "h-full min-h-0"), children: [_jsxs(FormScreenShell, { maxWidth: "full", ...(fillHeight === true && { fillHeight: true }), className: cn("!p-0", pinnedShellClassName), children: [headerRegion !== undefined && (_jsx("div", { className: cn("flex flex-col", fillHeight === true && "shrink-0"), children: headerRegion })), _jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: fillHeight }), sections, !pinnedFooter && footer] }), pinnedFooter && footer] }));
|
|
1688
1720
|
}
|
|
1689
1721
|
// One card form: title as header (no divider under it), sections divided
|
|
1690
1722
|
// between each other, muted action footer. Shell width comes from
|
|
1691
1723
|
// FormScreenShell (layout.width, else the app-wide screenWidth default, else
|
|
1692
1724
|
// 4xl).
|
|
1693
|
-
return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: cn("flex flex-col w-full", fillHeight === true && "h-full min-h-0"), children: [_jsxs(FormScreenShell, { ...(width !== undefined && { maxWidth: width }), ...(fillHeight === true && { fillHeight: true }), ...(pinnedShellClassName !== undefined && { className: pinnedShellClassName }), children: [headerRegion !== undefined && (_jsx("div", { className: cn("flex flex-col gap-6 mb-8", fillHeight === true && "shrink-0"), children: headerRegion })), _jsxs("div", { "data-slot": "card", className: cn(cardSurface(), "overflow-hidden",
|
|
1725
|
+
return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: cn("flex flex-col w-full", fillHeight === true && "h-full min-h-0"), children: [_jsxs(FormScreenShell, { ...(width !== undefined && { maxWidth: width }), ...(fillHeight === true && { fillHeight: true }), ...(pinnedShellClassName !== undefined && { className: pinnedShellClassName }), children: [headerRegion !== undefined && (_jsx("div", { className: cn("flex flex-col gap-6 mb-8", fillHeight === true && "shrink-0"), children: headerRegion })), _jsxs("div", { "data-slot": "card", className: cn(cardSurface(), "overflow-hidden",
|
|
1694
1726
|
// No flex-1: a fillHeight card sizes to its content (flex's
|
|
1695
1727
|
// initial 0 1 auto) and only claims more than that once its
|
|
1696
1728
|
// FormScreenShell ancestor is itself height-constrained and
|
|
@@ -1777,8 +1809,10 @@ function DefaultGrid({ columns, children, testId, maxRows, flow, list, ariaLabel
|
|
|
1777
1809
|
? "flex-col items-start"
|
|
1778
1810
|
: // items-end aligns inputs when labels wrap to different heights; a
|
|
1779
1811
|
// below-control appendix grows only the one cell and would push its
|
|
1780
|
-
// neighbours down, so such rows align to the top instead.
|
|
1781
|
-
|
|
1812
|
+
// neighbours down, so such rows align to the top instead. An error
|
|
1813
|
+
// line does the same: bottom-aligned, it would lift that field's
|
|
1814
|
+
// label above its neighbours'.
|
|
1815
|
+
"flex-wrap items-end has-[[data-appendix-below]]:items-start has-[[role=alert]]:items-start"), children: children }));
|
|
1782
1816
|
}
|
|
1783
1817
|
// "auto": content-sized items in a wrapping row (e.g. a metrics band of
|
|
1784
1818
|
// self-sized tiles) instead of N equal-width, container-stretched tracks.
|
|
@@ -1920,7 +1954,7 @@ export function DefaultCard({ slots, options, className, testId, dataAttributes,
|
|
|
1920
1954
|
// The one region allowed to shrink and scroll internally — mirrors
|
|
1921
1955
|
// FormSections' fillHeight === true && "min-h-0" treatment of the
|
|
1922
1956
|
// section it wraps.
|
|
1923
|
-
fillHeight && "min-h-0 flex flex-col"), children: children })), s.footer !== undefined && (_jsx("div", { className: cn(cardFooter, footerInset && cardFooterInset, footerBordered && cardFooterBorder, fillHeight && "shrink-0"), children: s.footer }))] }));
|
|
1957
|
+
fillHeight && "min-h-0 flex flex-col"), children: children })), s.footer !== undefined && s.footer !== null && s.footer !== false && (_jsx("div", { className: cn(cardFooter, footerInset && cardFooterInset, footerBordered && cardFooterBorder, fillHeight && "shrink-0"), children: s.footer }))] }));
|
|
1924
1958
|
return (_jsx(InsideScreenFormColumnContext.Provider, { value: false, children: screenBody ? _jsx(FormScreenShell, { children: card }) : card }));
|
|
1925
1959
|
}
|
|
1926
1960
|
// otpauth:// enrollment URI → scannable QR (errorCorrectionLevel "H", ~30%
|
|
@@ -2011,6 +2045,7 @@ export const defaultPrimitives = {
|
|
|
2011
2045
|
MetricBand: DefaultMetricBand,
|
|
2012
2046
|
PageHeader: DefaultPageHeader,
|
|
2013
2047
|
JsonView: DefaultJsonView,
|
|
2048
|
+
FileValueList,
|
|
2014
2049
|
FillContainer: DefaultFillContainer,
|
|
2015
2050
|
ActionOverflowMenu,
|
|
2016
2051
|
SecretReveal: DefaultSecretReveal,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useRef } from "react";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
|
+
const EQUAL_WIDTH_MAX_SEGMENTS = 4;
|
|
4
5
|
// WAI-ARIA radiogroup pattern (role="radiogroup" + role="radio" children):
|
|
5
6
|
// arrow keys move focus AND selection in the same step, only the checked
|
|
6
7
|
// segment (or the first when none is checked) sits in the tab order.
|
|
@@ -35,7 +36,12 @@ export function SegmentedControl({ id, name, value, onChange, options, disabled,
|
|
|
35
36
|
selectAt(nextEnabledIndex(index, -1));
|
|
36
37
|
}
|
|
37
38
|
};
|
|
38
|
-
return (_jsxs("div", { role: "radiogroup", ...labelProps, "aria-required": required, "aria-invalid": hasError === true ? true : undefined, "data-testid": `segmented-${id}`, className: cn(
|
|
39
|
+
return (_jsxs("div", { role: "radiogroup", ...labelProps, "aria-required": required, "aria-invalid": hasError === true ? true : undefined, "data-testid": `segmented-${id}`, className: cn(
|
|
40
|
+
// Few segments share one row at equal width so labels wrap inside
|
|
41
|
+
// their segment instead of leaving one tall and its siblings short.
|
|
42
|
+
options.length <= EQUAL_WIDTH_MAX_SEGMENTS
|
|
43
|
+
? "inline-grid max-w-full grid-flow-col auto-cols-fr"
|
|
44
|
+
: "inline-flex w-fit flex-wrap", "min-h-9 overflow-hidden rounded-md border", hasError === true ? "border-destructive" : "border-input"), children: [_jsx("input", { type: "hidden", name: name, value: value }), options.map((opt, index) => {
|
|
39
45
|
const checked = opt.value === value;
|
|
40
46
|
return (_jsx("button", { ref: (node) => {
|
|
41
47
|
buttonRefs.current[index] = node;
|
|
@@ -55,7 +61,7 @@ export function SegmentedControl({ id, name, value, onChange, options, disabled,
|
|
|
55
61
|
// and the next row's top border cut a notch into the row above.
|
|
56
62
|
// Harmless on a single-row layout: there's no leftover space to
|
|
57
63
|
// grow into, so `w-fit` on the container still holds.
|
|
58
|
-
"-ml-px -mt-px grow border-l border-t px-3 py-1.5 text-sm transition-colors", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "disabled:pointer-events-none disabled:opacity-50", hasError === true ? "border-destructive/50" : "border-border", checked
|
|
64
|
+
"-ml-px -mt-px grow border-l border-t px-3 py-1.5 text-center text-sm transition-colors", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "disabled:pointer-events-none disabled:opacity-50", hasError === true ? "border-destructive/50" : "border-border", checked
|
|
59
65
|
? "bg-primary/10 font-semibold text-primary"
|
|
60
66
|
: "bg-transparent font-medium text-foreground hover:bg-accent"), children: opt.label }, opt.value));
|
|
61
67
|
})] }));
|
|
@@ -8,7 +8,20 @@ export type CreateEventSourceLiveEventsOptions = {
|
|
|
8
8
|
* handshake, or opened the stream with a `session-ended` frame (stale auth
|
|
9
9
|
* cookie downgraded to anonymous). Same codes as the dispatcher path. */
|
|
10
10
|
readonly onSessionEnded?: () => void;
|
|
11
|
+
/** Reconnect-timer seam; defaults to the global setTimeout/clearTimeout. */
|
|
12
|
+
readonly timers?: LiveEventTimers;
|
|
13
|
+
/** Jitter source in [0, 1); defaults to Math.random. */
|
|
14
|
+
readonly random?: () => number;
|
|
15
|
+
/** Clock for synthetic resync events; defaults to Date.now. */
|
|
16
|
+
readonly now?: () => number;
|
|
11
17
|
};
|
|
18
|
+
export type LiveEventTimers = {
|
|
19
|
+
/** Schedules the callback and returns a function that cancels it. */
|
|
20
|
+
readonly set: (callback: () => void, delayMs: number) => () => void;
|
|
21
|
+
};
|
|
22
|
+
export declare const RECONNECT_BASE_DELAY_MS = 1000;
|
|
23
|
+
export declare const RECONNECT_MAX_DELAY_MS = 30000;
|
|
24
|
+
export declare function reconnectDelayMs(attempt: number, random: number): number;
|
|
12
25
|
/** Liefert einen `LiveEventSubscriber` der EventSource-backed ist.
|
|
13
26
|
* Normalerweise einmal im App-Bootstrap gerufen und als value an
|
|
14
27
|
* `<LiveEventsProvider>` durchgereicht — createKumikoApp tut das. */
|
|
@@ -1,5 +1,20 @@
|
|
|
1
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
1
2
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
-
import { isSessionEndedError } from "../../index.js";
|
|
3
|
+
import { isSessionEndedError, LIVE_RESYNC_EVENT_TYPE, } from "../../index.js";
|
|
4
|
+
export const RECONNECT_BASE_DELAY_MS = 1_000;
|
|
5
|
+
export const RECONNECT_MAX_DELAY_MS = 30_000;
|
|
6
|
+
const defaultTimers = {
|
|
7
|
+
set: (callback, delayMs) => {
|
|
8
|
+
const handle = setTimeout(callback, delayMs);
|
|
9
|
+
return () => clearTimeout(handle);
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
// Equal jitter: half the exponential delay is fixed, half random, so a server
|
|
13
|
+
// restart doesn't make every tab reconnect in the same instant.
|
|
14
|
+
export function reconnectDelayMs(attempt, random) {
|
|
15
|
+
const ceiling = Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * 2 ** attempt);
|
|
16
|
+
return Math.floor(ceiling / 2 + (random * ceiling) / 2);
|
|
17
|
+
}
|
|
3
18
|
// Wire name of the first frame on a stream downgraded from a stale auth cookie (sse-route.ts).
|
|
4
19
|
const SESSION_ENDED_EVENT = "session-ended";
|
|
5
20
|
function sessionEndedFrameCode(event) {
|
|
@@ -27,7 +42,7 @@ function hasErrorCode(body) {
|
|
|
27
42
|
async function isSessionEndedRejection(url) {
|
|
28
43
|
const controller = new AbortController();
|
|
29
44
|
try {
|
|
30
|
-
//
|
|
45
|
+
// kumiko-lint-ignore direct-fetch url is the app's own SSE endpoint, probed with same-origin credentials
|
|
31
46
|
const res = await fetch(url, {
|
|
32
47
|
credentials: "same-origin",
|
|
33
48
|
headers: { Accept: "text/event-stream" },
|
|
@@ -51,9 +66,15 @@ async function isSessionEndedRejection(url) {
|
|
|
51
66
|
* `<LiveEventsProvider>` durchgereicht — createKumikoApp tut das. */
|
|
52
67
|
export function createEventSourceLiveEvents(options = {}) {
|
|
53
68
|
const url = options.url ?? "/api/sse";
|
|
69
|
+
const timers = options.timers ?? defaultTimers;
|
|
70
|
+
const random = options.random ?? Math.random;
|
|
71
|
+
const now = options.now ?? Date.now;
|
|
54
72
|
const subscribers = new Set();
|
|
55
73
|
let source;
|
|
56
74
|
const wiredEntities = new Set();
|
|
75
|
+
let cancelReconnect;
|
|
76
|
+
let reconnectAttempt = 0;
|
|
77
|
+
let needsResync = false;
|
|
57
78
|
// `subscribers` is a flat Set across all entities — the browser-side
|
|
58
79
|
// addEventListener(entityName, ...) gate below only filters which frames
|
|
59
80
|
// arrive at all, not which subscriber a given frame is for. Without this
|
|
@@ -73,61 +94,104 @@ export function createEventSourceLiveEvents(options = {}) {
|
|
|
73
94
|
sub.listener(event);
|
|
74
95
|
}
|
|
75
96
|
};
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
97
|
+
const emitResync = () => {
|
|
98
|
+
const createdAt = Temporal.Instant.fromEpochMilliseconds(now()).toString();
|
|
99
|
+
for (const sub of [...subscribers]) {
|
|
100
|
+
sub.listener({
|
|
101
|
+
type: sub.entityName,
|
|
102
|
+
data: { aggregateType: sub.entityName, eventType: LIVE_RESYNC_EVENT_TYPE, createdAt },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const wireEntity = (connection, entityName) => {
|
|
107
|
+
// skip: already wired for this entity on this connection
|
|
108
|
+
if (wiredEntities.has(entityName))
|
|
79
109
|
return;
|
|
110
|
+
connection.addEventListener(entityName, (e) => {
|
|
111
|
+
const data = propertyOf(e, "data");
|
|
112
|
+
if (typeof data === "string")
|
|
113
|
+
handleEvent(data);
|
|
114
|
+
});
|
|
115
|
+
wiredEntities.add(entityName);
|
|
116
|
+
};
|
|
117
|
+
const connect = () => {
|
|
80
118
|
// skip: no window/EventSource available (SSR/non-browser), nothing to connect
|
|
81
119
|
if (typeof window === "undefined" || typeof EventSource === "undefined")
|
|
82
120
|
return;
|
|
83
121
|
const connection = new EventSource(url);
|
|
84
122
|
source = connection;
|
|
123
|
+
wiredEntities.clear();
|
|
124
|
+
for (const sub of subscribers)
|
|
125
|
+
wireEntity(connection, sub.entityName);
|
|
85
126
|
const { onSessionEnded } = options;
|
|
86
|
-
// skip: no session-end consumer, a refused handshake needs no probe
|
|
87
|
-
if (onSessionEnded === undefined)
|
|
88
|
-
return;
|
|
89
127
|
let sessionEndedSignalled = false;
|
|
90
128
|
const signalSessionEnded = () => {
|
|
91
129
|
// skip: the frame and the error probe can both report the same session end
|
|
92
130
|
if (sessionEndedSignalled)
|
|
93
131
|
return;
|
|
94
132
|
sessionEndedSignalled = true;
|
|
95
|
-
onSessionEnded();
|
|
133
|
+
onSessionEnded?.();
|
|
96
134
|
};
|
|
135
|
+
connection.addEventListener("open", () => {
|
|
136
|
+
reconnectAttempt = 0;
|
|
137
|
+
// skip: first connect, nothing was missed
|
|
138
|
+
if (!needsResync)
|
|
139
|
+
return;
|
|
140
|
+
needsResync = false;
|
|
141
|
+
emitResync();
|
|
142
|
+
});
|
|
97
143
|
connection.addEventListener(SESSION_ENDED_EVENT, (e) => {
|
|
98
144
|
const code = sessionEndedFrameCode(e);
|
|
99
145
|
if (code !== undefined && isSessionEndedError({ code, httpStatus: 401 }))
|
|
100
146
|
signalSessionEnded();
|
|
101
147
|
});
|
|
102
148
|
connection.addEventListener("error", () => {
|
|
149
|
+
needsResync = true;
|
|
103
150
|
// skip: CONNECTING means a dropped stream the browser retries itself;
|
|
104
151
|
// only a refused handshake (non-200) closes the EventSource for good.
|
|
105
152
|
if (connection.readyState !== EventSource.CLOSED)
|
|
106
153
|
return;
|
|
107
|
-
void isSessionEndedRejection(url).then((sessionEnded) => {
|
|
108
|
-
|
|
154
|
+
void (onSessionEnded === undefined ? Promise.resolve(false) : isSessionEndedRejection(url)).then((sessionEnded) => {
|
|
155
|
+
// skip: closeIfEmpty or a newer connection took over while probing
|
|
156
|
+
if (source !== connection)
|
|
157
|
+
return;
|
|
158
|
+
if (sessionEnded) {
|
|
109
159
|
signalSessionEnded();
|
|
160
|
+
// skip: the session ended, reconnecting would only be refused again
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
scheduleReconnect();
|
|
110
164
|
});
|
|
111
165
|
});
|
|
112
166
|
};
|
|
113
|
-
const
|
|
114
|
-
// skip:
|
|
115
|
-
if (
|
|
167
|
+
const scheduleReconnect = () => {
|
|
168
|
+
// skip: a reconnect is already pending
|
|
169
|
+
if (cancelReconnect !== undefined)
|
|
116
170
|
return;
|
|
117
|
-
|
|
118
|
-
|
|
171
|
+
const delay = reconnectDelayMs(reconnectAttempt, random());
|
|
172
|
+
reconnectAttempt += 1;
|
|
173
|
+
cancelReconnect = timers.set(() => {
|
|
174
|
+
cancelReconnect = undefined;
|
|
175
|
+
source = undefined;
|
|
176
|
+
connect();
|
|
177
|
+
}, delay);
|
|
178
|
+
};
|
|
179
|
+
const ensureConnected = () => {
|
|
180
|
+
// skip: connected, or waiting for the backoff timer to reconnect
|
|
181
|
+
if (source !== undefined || cancelReconnect !== undefined)
|
|
119
182
|
return;
|
|
120
|
-
|
|
121
|
-
const data = propertyOf(e, "data");
|
|
122
|
-
if (typeof data === "string")
|
|
123
|
-
handleEvent(data);
|
|
124
|
-
});
|
|
125
|
-
wiredEntities.add(entityName);
|
|
183
|
+
connect();
|
|
126
184
|
};
|
|
127
185
|
const closeIfEmpty = () => {
|
|
128
186
|
// skip: subscribers remain, connection still needed
|
|
129
187
|
if (subscribers.size > 0)
|
|
130
188
|
return;
|
|
189
|
+
if (cancelReconnect !== undefined) {
|
|
190
|
+
cancelReconnect();
|
|
191
|
+
cancelReconnect = undefined;
|
|
192
|
+
}
|
|
193
|
+
reconnectAttempt = 0;
|
|
194
|
+
needsResync = false;
|
|
131
195
|
// skip: already closed, nothing to close
|
|
132
196
|
if (source === undefined)
|
|
133
197
|
return;
|
|
@@ -137,7 +201,8 @@ export function createEventSourceLiveEvents(options = {}) {
|
|
|
137
201
|
};
|
|
138
202
|
return (entityName, listener) => {
|
|
139
203
|
ensureConnected();
|
|
140
|
-
|
|
204
|
+
if (source !== undefined)
|
|
205
|
+
wireEntity(source, entityName);
|
|
141
206
|
const sub = { entityName, listener };
|
|
142
207
|
subscribers.add(sub);
|
|
143
208
|
return () => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function createBoundedLoadCache<T>(max: number, dispose: (value: T) => void): (key: string, load: () => Promise<T>) => Promise<T>;
|
|
@@ -0,0 +1,25 @@
|
|
|
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) {
|
|
4
|
+
const entries = new Map();
|
|
5
|
+
return (key, load) => {
|
|
6
|
+
const cached = entries.get(key);
|
|
7
|
+
if (cached !== undefined)
|
|
8
|
+
return cached;
|
|
9
|
+
const loading = load();
|
|
10
|
+
entries.set(key, loading);
|
|
11
|
+
// A failed load must not stick: the next mount retries instead of replaying the rejection.
|
|
12
|
+
loading.catch(() => {
|
|
13
|
+
if (entries.get(key) === loading)
|
|
14
|
+
entries.delete(key);
|
|
15
|
+
});
|
|
16
|
+
if (entries.size > max) {
|
|
17
|
+
const [oldestKey, oldest] = entries.entries().next().value ?? [];
|
|
18
|
+
if (oldestKey !== undefined && oldest !== undefined) {
|
|
19
|
+
entries.delete(oldestKey);
|
|
20
|
+
void oldest.then(dispose);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return loading;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Maximize2Icon, Minimize2Icon } from "lucide-react";
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
4
|
import { useTranslation } from "../../index.js";
|
|
5
5
|
import { clamp } from "../lib/clamp.js";
|
|
6
6
|
import { cn } from "../lib/cn.js";
|
|
@@ -78,6 +78,39 @@ function sidePanelClass(side, narrow, variant, belowHeader) {
|
|
|
78
78
|
return `inset-y-8 right-8 h-auto ${WIDTH_CLASS} rounded-[2rem] border shadow-2xl overflow-hidden`;
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
+
const DRAWER_INSET_PROPERTY = {
|
|
82
|
+
left: "--kumiko-drawer-inset-left",
|
|
83
|
+
right: "--kumiko-drawer-inset-right",
|
|
84
|
+
};
|
|
85
|
+
// Publishes the width a non-modal side drawer occupies so the shell content (styles.css) can
|
|
86
|
+
// narrow instead of sitting underneath it. Read from the layout box (offsetLeft/offsetWidth), not
|
|
87
|
+
// getBoundingClientRect: the slide-in animation moves the painted rect, and the observer would
|
|
88
|
+
// not fire again once it settles.
|
|
89
|
+
// ponytail: one owner per side, so two non-modal drawers open on the same side overwrite each
|
|
90
|
+
// other's value; a registry that takes the max is needed once a screen stacks them.
|
|
91
|
+
function usePublishDrawerInset(sheet, side, active) {
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!active || sheet === null || (side !== "left" && side !== "right"))
|
|
94
|
+
return;
|
|
95
|
+
const property = DRAWER_INSET_PROPERTY[side];
|
|
96
|
+
const root = document.documentElement;
|
|
97
|
+
const publish = () => {
|
|
98
|
+
const inset = side === "right"
|
|
99
|
+
? window.innerWidth - sheet.offsetLeft
|
|
100
|
+
: sheet.offsetLeft + sheet.offsetWidth;
|
|
101
|
+
root.style.setProperty(property, `${Math.max(0, Math.round(inset))}px`);
|
|
102
|
+
};
|
|
103
|
+
publish();
|
|
104
|
+
const observer = new ResizeObserver(publish);
|
|
105
|
+
observer.observe(sheet);
|
|
106
|
+
window.addEventListener("resize", publish);
|
|
107
|
+
return () => {
|
|
108
|
+
observer.disconnect();
|
|
109
|
+
window.removeEventListener("resize", publish);
|
|
110
|
+
root.style.removeProperty(property);
|
|
111
|
+
};
|
|
112
|
+
}, [sheet, side, active]);
|
|
113
|
+
}
|
|
81
114
|
/** Slide-in panel beside a list (e.g. mail reader next to the inbox) —
|
|
82
115
|
* thin wrapper over the Sheet primitive with header/body/footer slots
|
|
83
116
|
* so screens skip per-route Radix boilerplate. */
|
|
@@ -95,6 +128,8 @@ export function Drawer({ open, onOpenChange, side = "right", title, description,
|
|
|
95
128
|
: Math.min(maxWidthPx, Math.round(window.innerWidth * 0.9));
|
|
96
129
|
const [resizedWidthPx, setResizedWidthPx] = useState(() => clamp(resize?.defaultWidthPx ?? defaultWidthFromViewport(), minWidthPx, effectiveMaxWidthPx()));
|
|
97
130
|
const [maximized, setMaximized] = useState(false);
|
|
131
|
+
const [sheet, setSheet] = useState(null);
|
|
132
|
+
usePublishDrawerInset(sheet, side, open && !modal && !narrow);
|
|
98
133
|
const blurPx = backdrop?.blurPx ?? DEFAULT_BLUR_PX;
|
|
99
134
|
const dimPercent = backdrop?.dimPercent ?? DEFAULT_DIM_PERCENT;
|
|
100
135
|
const overlayStyle = {
|
|
@@ -134,7 +169,7 @@ export function Drawer({ open, onOpenChange, side = "right", title, description,
|
|
|
134
169
|
// instead of resizing relative to what's on screen.
|
|
135
170
|
setResizedWidthPx((current) => clamp((maximized ? effectiveWidthPx : current) + delta, minWidthPx, effectiveMaxWidthPx()));
|
|
136
171
|
};
|
|
137
|
-
return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, modal: modal, children: _jsxs(DrawerSheetContent, { side: side, "data-testid": testId, overlayStyle: overlayStyle,
|
|
172
|
+
return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, modal: modal, children: _jsxs(DrawerSheetContent, { ref: setSheet, side: side, "data-testid": testId, overlayStyle: overlayStyle,
|
|
138
173
|
// Radix already drops the overlay and the focus trap for a
|
|
139
174
|
// non-modal Root; only the dismiss-on-outside-interaction is left
|
|
140
175
|
// to suppress, so a click into the page behind keeps the panel open.
|