@cosmicdrift/kumiko-renderer 0.378.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 +49 -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/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 +3 -0
- package/dist/components/render-edit.js +25 -6
- package/dist/components/render-field.d.ts +3 -1
- package/dist/components/render-field.js +19 -7
- 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/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 +4 -0
- package/dist/headless/view-model/edit.js +10 -5
- package/dist/headless/view-model/types.d.ts +10 -2
- 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 +22 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -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 +29 -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/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-upload.d.ts +2 -1
- package/dist/web/primitives/file-upload.js +11 -7
- 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 +62 -29
- 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/upload-zone.d.ts +11 -2
- package/dist/web/widgets/upload-zone.js +30 -7
- package/package.json +5 -5
- package/src/changes.json +104 -0
|
@@ -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";
|
|
@@ -33,6 +34,7 @@ import { EmbeddedListInput } from "./embedded-list-input.js";
|
|
|
33
34
|
import { CardLeadingImage, FileDisplayCell } from "./file-thumbnail.js";
|
|
34
35
|
import { FileUploadInput } from "./file-upload.js";
|
|
35
36
|
import { FileValueList } from "./file-value-list.js";
|
|
37
|
+
import { FilesDropzoneInput } from "./files-dropzone.js";
|
|
36
38
|
import { DefaultJsonView } from "./json-view.js";
|
|
37
39
|
import { screenFormColumnWidthClassName, screenPaddingClassName, screenWidthClassName, } from "./layout.js";
|
|
38
40
|
import { DefaultLightbox } from "./lightbox.js";
|
|
@@ -170,14 +172,23 @@ const FieldDescriptionContext = createContext(undefined);
|
|
|
170
172
|
function fieldDescriptionId(id) {
|
|
171
173
|
return `${id}-description`;
|
|
172
174
|
}
|
|
175
|
+
function fieldErrorId(id) {
|
|
176
|
+
return `${id}-error`;
|
|
177
|
+
}
|
|
173
178
|
function DefaultField(props) {
|
|
174
|
-
|
|
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 }) }) }));
|
|
175
186
|
}
|
|
176
187
|
function DefaultFieldBody({ id, label, required, issues, labelAppendix, fieldAppendix, appendixPlacement, description, status, children, layout, accent, hideLabel, testId, changed, }) {
|
|
177
188
|
const t = useTranslation();
|
|
178
189
|
const hasError = issues !== undefined && issues.length > 0;
|
|
179
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: "*" }))] }));
|
|
180
|
-
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;
|
|
181
192
|
// Inline (boolean/checkbox): Control links, Label rechts — shadcn-Muster.
|
|
182
193
|
if (layout === "inline") {
|
|
183
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] }));
|
|
@@ -353,7 +364,13 @@ function DefaultInput(props) {
|
|
|
353
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" }));
|
|
354
365
|
case "file":
|
|
355
366
|
case "image":
|
|
356
|
-
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 }) }));
|
|
357
374
|
case "date":
|
|
358
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 }) }));
|
|
359
376
|
case "select": {
|
|
@@ -616,13 +633,13 @@ function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, empty
|
|
|
616
633
|
const row = item.row;
|
|
617
634
|
const opensOnClick = rowIsLink && !rowClickIsHidden(row);
|
|
618
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,
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
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.
|
|
624
641
|
className: cn(col.fileDisplay === undefined
|
|
625
|
-
? "max-w-xs
|
|
642
|
+
? "max-w-xs whitespace-normal break-words"
|
|
626
643
|
: FIT_CONTENT_CELL_CLASS, colIndex === 0
|
|
627
644
|
? "font-medium text-foreground"
|
|
628
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 && {
|
|
@@ -636,7 +653,7 @@ function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, empty
|
|
|
636
653
|
// would hide the neighboring data column on narrow
|
|
637
654
|
// viewports. bg-background sets the column apart during
|
|
638
655
|
// scroll — no border-l (divider too heavy).
|
|
639
|
-
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"),
|
|
640
657
|
// Action-cell events must not trigger the row click/activation
|
|
641
658
|
// (typically "Open Detail" — the user wanted the action,
|
|
642
659
|
// not navigation). We stopPropagation for mouse and
|
|
@@ -671,7 +688,7 @@ function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, empty
|
|
|
671
688
|
const hasMenu = menuActions !== undefined && menuActions.length > 0;
|
|
672
689
|
const opensOnClick = rowIsLink && !rowClickIsHidden(row);
|
|
673
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) ??
|
|
674
|
-
`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) ??
|
|
675
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" }))] }));
|
|
676
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) => {
|
|
677
694
|
if (e.key !== "Enter")
|
|
@@ -785,14 +802,14 @@ function RowActionsCell({ row, actions, mode = "adaptive", rowIsLink = false, co
|
|
|
785
802
|
if (hasExplicitDisplay) {
|
|
786
803
|
const inline = visible.filter((a) => a.display !== undefined);
|
|
787
804
|
const rest = visible.filter((a) => a.display === undefined);
|
|
788
|
-
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 })] }));
|
|
789
806
|
}
|
|
790
807
|
if (rowIsLink) {
|
|
791
808
|
return (_jsx("div", { className: "inline-flex items-center justify-end", children: _jsx(RowActionsKebab, { row: row, actions: visible }) }));
|
|
792
809
|
}
|
|
793
810
|
const primary = primaryRowAction(visible);
|
|
794
811
|
const rest = visible.filter((a) => a.id !== primary?.id);
|
|
795
|
-
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 })] }));
|
|
796
813
|
}
|
|
797
814
|
function isEmptyCellValue(value) {
|
|
798
815
|
return (value === null || value === undefined || (typeof value === "string" && value.trim() === ""));
|
|
@@ -1088,13 +1105,13 @@ function SortableHeader({ field, label, sortable, highlighted, numeric, fitConte
|
|
|
1088
1105
|
const active = sort?.field === field ? sort : undefined;
|
|
1089
1106
|
const ariaSort = active?.dir === "asc" ? "ascending" : active?.dir === "desc" ? "descending" : "none";
|
|
1090
1107
|
if (!sortable || onSortChange === undefined) {
|
|
1091
|
-
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 }));
|
|
1092
1109
|
}
|
|
1093
1110
|
const Icon = active?.dir === "asc" ? ArrowUp : active?.dir === "desc" ? ArrowDown : ArrowUpDown;
|
|
1094
1111
|
const next = nextSortState(active?.dir, field);
|
|
1095
1112
|
return (_jsx(TableHead, { "data-testid": `column-${field}`, "data-sortable": "true", "data-highlighted": highlighted === true ? "true" : undefined, "aria-sort": ariaSort, className: cn(active !== undefined
|
|
1096
1113
|
? "font-semibold text-foreground"
|
|
1097
|
-
: "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 &&
|
|
1098
1115
|
"opacity-0 group-hover/sort:opacity-40 group-focus-visible/sort:opacity-40"), "aria-hidden": "true" })] }) }));
|
|
1099
1116
|
}
|
|
1100
1117
|
// 3-State-Toggle: kein Sort → asc → desc → kein Sort. Idiomatisch für
|
|
@@ -1427,9 +1444,21 @@ const SUBMIT_CONTROL_SELECTOR = [
|
|
|
1427
1444
|
"input[type=submit]:not(:disabled)",
|
|
1428
1445
|
"input[type=image]:not(:disabled)",
|
|
1429
1446
|
].join(", ");
|
|
1430
|
-
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]);
|
|
1431
1458
|
if (useContext(InsideFormContext)) {
|
|
1432
|
-
return (_jsx("div", {
|
|
1459
|
+
return (_jsx("div", { ref: (node) => {
|
|
1460
|
+
rootRef.current = node;
|
|
1461
|
+
}, "data-kumiko-form-root": "", onKeyDownCapture: (e) => {
|
|
1433
1462
|
// Implicit submission: the section's inputs belong to the OUTER
|
|
1434
1463
|
// <form> in the DOM, so the browser would otherwise route Enter to
|
|
1435
1464
|
// that form's default button.
|
|
@@ -1458,7 +1487,9 @@ function FormRoot({ onSubmit, testId, className, children, }) {
|
|
|
1458
1487
|
onSubmit();
|
|
1459
1488
|
}, "data-testid": testId, className: className, children: children }));
|
|
1460
1489
|
}
|
|
1461
|
-
return (_jsx("form", {
|
|
1490
|
+
return (_jsx("form", { ref: (node) => {
|
|
1491
|
+
rootRef.current = node;
|
|
1492
|
+
}, onSubmit: (e) => {
|
|
1462
1493
|
e.preventDefault();
|
|
1463
1494
|
onSubmit(e);
|
|
1464
1495
|
}, "data-testid": testId, className: className, children: children }));
|
|
@@ -1637,26 +1668,26 @@ function FormSectionNav({ items }) {
|
|
|
1637
1668
|
// Form inside the Drawer primitive: the drawer header carries the title, so
|
|
1638
1669
|
// only the description (as help text), the scrolling sections and a pinned
|
|
1639
1670
|
// footer remain. The scroll area is a sibling of the footer, never its parent.
|
|
1640
|
-
function DrawerFormLayout({ onSubmit, subtitle, actions, secondaryActions, summary, testId, children, }) {
|
|
1641
|
-
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] }))] }));
|
|
1642
1673
|
}
|
|
1643
|
-
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, }) {
|
|
1644
1675
|
const insideDrawer = useInsideDrawer();
|
|
1645
1676
|
const isEmbeddedForm = useContext(EmbeddedFormContext);
|
|
1646
1677
|
const contextWidth = useContext(ScreenWidthContext);
|
|
1647
1678
|
// Eingebettet (AuthCard etc.): nacktes <form>, gestapelte Felder mit gap —
|
|
1648
1679
|
// der Container trägt Card/Titel selbst, sonst Card-in-Card.
|
|
1649
1680
|
if (useContext(BareFormContext)) {
|
|
1650
|
-
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",
|
|
1651
1682
|
// Bare forms stack sections without a card; without a divider a
|
|
1652
1683
|
// section boundary reads as a layout gap, not structure.
|
|
1653
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] }))] }));
|
|
1654
1685
|
}
|
|
1655
1686
|
if (insideDrawer) {
|
|
1656
|
-
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 }));
|
|
1657
1688
|
}
|
|
1658
1689
|
if (isEmbeddedForm) {
|
|
1659
|
-
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 }) }) }))] }));
|
|
1660
1691
|
}
|
|
1661
1692
|
const isScreenForm = screenForm === true && fillHeight === true && chromeless !== true;
|
|
1662
1693
|
const screenFormWidth = width ?? contextWidth;
|
|
@@ -1666,7 +1697,7 @@ function DefaultForm({ onSubmit, children, title, subtitle, actions, secondaryAc
|
|
|
1666
1697
|
// from xl, the section navigation; the footer stays pinned below it.
|
|
1667
1698
|
if (isScreenForm) {
|
|
1668
1699
|
const showsSectionNav = sectionNav !== undefined && sectionNav.length >= SECTION_NAV_MIN_ITEMS;
|
|
1669
|
-
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
|
|
1670
1701
|
? screenFormColumnWidthClassName[screenFormWidth]
|
|
1671
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] }));
|
|
1672
1703
|
}
|
|
@@ -1685,13 +1716,13 @@ function DefaultForm({ onSubmit, children, title, subtitle, actions, secondaryAc
|
|
|
1685
1716
|
// the screen-level subtitle (screen.description) is independent of that
|
|
1686
1717
|
// and must survive tabs mode.
|
|
1687
1718
|
if (chromeless === true) {
|
|
1688
|
-
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] }));
|
|
1689
1720
|
}
|
|
1690
1721
|
// One card form: title as header (no divider under it), sections divided
|
|
1691
1722
|
// between each other, muted action footer. Shell width comes from
|
|
1692
1723
|
// FormScreenShell (layout.width, else the app-wide screenWidth default, else
|
|
1693
1724
|
// 4xl).
|
|
1694
|
-
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",
|
|
1695
1726
|
// No flex-1: a fillHeight card sizes to its content (flex's
|
|
1696
1727
|
// initial 0 1 auto) and only claims more than that once its
|
|
1697
1728
|
// FormScreenShell ancestor is itself height-constrained and
|
|
@@ -1778,8 +1809,10 @@ function DefaultGrid({ columns, children, testId, maxRows, flow, list, ariaLabel
|
|
|
1778
1809
|
? "flex-col items-start"
|
|
1779
1810
|
: // items-end aligns inputs when labels wrap to different heights; a
|
|
1780
1811
|
// below-control appendix grows only the one cell and would push its
|
|
1781
|
-
// neighbours down, so such rows align to the top instead.
|
|
1782
|
-
|
|
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 }));
|
|
1783
1816
|
}
|
|
1784
1817
|
// "auto": content-sized items in a wrapping row (e.g. a metrics band of
|
|
1785
1818
|
// self-sized tiles) instead of N equal-width, container-stretched tracks.
|
|
@@ -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 () => {
|
|
@@ -3,7 +3,7 @@ export type UploadZoneProps = {
|
|
|
3
3
|
/** Uploads a single file (POST + optional follow-up mutation). Throws on
|
|
4
4
|
* failure — the message ends up in the row's error line. Runs in
|
|
5
5
|
* parallel per file within a batch (no sequential waiting). */
|
|
6
|
-
readonly onUpload: (file: File) => Promise<void>;
|
|
6
|
+
readonly onUpload: (file: File) => Promise<string | undefined | void>;
|
|
7
7
|
readonly title: ReactNode;
|
|
8
8
|
readonly hint?: ReactNode;
|
|
9
9
|
readonly accept?: readonly string[];
|
|
@@ -14,6 +14,15 @@ export type UploadZoneProps = {
|
|
|
14
14
|
readonly capture?: "environment" | "user";
|
|
15
15
|
readonly disabled?: boolean;
|
|
16
16
|
readonly testId?: string;
|
|
17
|
+
/** Turns a failed upload into the row's error text. Default: the error's own message. */
|
|
18
|
+
readonly describeError?: (cause: unknown) => string;
|
|
19
|
+
/** Controlled use, for a form field: a string returned by `onUpload` becomes the row's value
|
|
20
|
+
* and done rows get a remove button that calls this with it. The zone does not drop the row;
|
|
21
|
+
* the owner removes the value from `doneValues`, which the zone then follows. */
|
|
22
|
+
readonly onRemove?: (value: string) => void;
|
|
23
|
+
/** The values the owner currently holds. Done rows whose value left the list disappear, and
|
|
24
|
+
* going from some to none (an external reset) clears every row, error rows included. */
|
|
25
|
+
readonly doneValues?: readonly string[];
|
|
17
26
|
};
|
|
18
27
|
export declare function matchesAccept(file: File, accept?: readonly string[]): boolean;
|
|
19
28
|
/** Drop zone + multi-file picker with a per-file status row (uploading/done/
|
|
@@ -21,4 +30,4 @@ export declare function matchesAccept(file: File, accept?: readonly string[]): b
|
|
|
21
30
|
* independently — unlike `FileField` (single file, FileRef value inside a
|
|
22
31
|
* form field). `onUpload` decides what "upload" means (storage POST, ingest
|
|
23
32
|
* mutation, or both); the zone only tracks uploading/done/error. */
|
|
24
|
-
export declare function UploadZone({ onUpload, title, hint, accept, multiple, capture, disabled, testId, }: UploadZoneProps): ReactNode;
|
|
33
|
+
export declare function UploadZone({ onUpload, title, hint, accept, multiple, capture, disabled, testId, describeError, onRemove, doneValues, }: UploadZoneProps): ReactNode;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { CheckCircle2, FileUp, Loader2, TriangleAlert, Upload } from "lucide-react";
|
|
3
|
-
import { useId, useRef, useState } from "react";
|
|
2
|
+
import { CheckCircle2, FileUp, Loader2, TriangleAlert, Upload, X } from "lucide-react";
|
|
3
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
4
4
|
import { useTranslation } from "../../index.js";
|
|
5
5
|
import { toAcceptAttr } from "../lib/accept-attr.js";
|
|
6
6
|
import { cn } from "../lib/cn.js";
|
|
@@ -44,7 +44,7 @@ function partitionByAccept(files, accept) {
|
|
|
44
44
|
* independently — unlike `FileField` (single file, FileRef value inside a
|
|
45
45
|
* form field). `onUpload` decides what "upload" means (storage POST, ingest
|
|
46
46
|
* mutation, or both); the zone only tracks uploading/done/error. */
|
|
47
|
-
export function UploadZone({ onUpload, title, hint, accept, multiple = true, capture, disabled, testId, }) {
|
|
47
|
+
export function UploadZone({ onUpload, title, hint, accept, multiple = true, capture, disabled, testId, describeError, onRemove, doneValues, }) {
|
|
48
48
|
const t = useTranslation();
|
|
49
49
|
const inputId = useId();
|
|
50
50
|
const inputRef = useRef(null);
|
|
@@ -54,16 +54,31 @@ export function UploadZone({ onUpload, title, hint, accept, multiple = true, cap
|
|
|
54
54
|
// preview leaves it undefined. These ids are React keys/row ids, not
|
|
55
55
|
// globally unique identifiers, so a per-instance counter is enough.
|
|
56
56
|
const nextRowId = useRef(0);
|
|
57
|
+
const previousDoneCount = useRef(0);
|
|
58
|
+
const doneCount = doneValues?.length;
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (doneCount === undefined)
|
|
61
|
+
return;
|
|
62
|
+
if (doneCount === 0 && previousDoneCount.current > 0)
|
|
63
|
+
setRows([]);
|
|
64
|
+
previousDoneCount.current = doneCount;
|
|
65
|
+
}, [doneCount]);
|
|
57
66
|
async function uploadOne(file) {
|
|
58
67
|
const rowId = String(nextRowId.current++);
|
|
59
68
|
const uploadFile = await resizeImageBeforeUpload(file);
|
|
60
69
|
setRows((prev) => [...prev, { id: rowId, fileName: uploadFile.name, status: "uploading" }]);
|
|
61
70
|
try {
|
|
62
|
-
await onUpload(uploadFile);
|
|
63
|
-
setRows((prev) => prev.map((row) =>
|
|
71
|
+
const value = await onUpload(uploadFile);
|
|
72
|
+
setRows((prev) => prev.map((row) => row.id === rowId
|
|
73
|
+
? { ...row, status: "done", ...(typeof value === "string" && { value }) }
|
|
74
|
+
: row));
|
|
64
75
|
}
|
|
65
76
|
catch (cause) {
|
|
66
|
-
const message =
|
|
77
|
+
const message = describeError !== undefined
|
|
78
|
+
? describeError(cause)
|
|
79
|
+
: cause instanceof Error
|
|
80
|
+
? cause.message
|
|
81
|
+
: t("kumiko.widget.upload.error");
|
|
67
82
|
setRows((prev) => prev.map((row) => (row.id === rowId ? { ...row, status: "error", error: message } : row)));
|
|
68
83
|
}
|
|
69
84
|
}
|
|
@@ -95,6 +110,10 @@ export function UploadZone({ onUpload, title, hint, accept, multiple = true, cap
|
|
|
95
110
|
return;
|
|
96
111
|
void uploadFiles(e.dataTransfer.files);
|
|
97
112
|
}
|
|
113
|
+
function removeRow(row) {
|
|
114
|
+
if (row.value !== undefined)
|
|
115
|
+
onRemove?.(row.value);
|
|
116
|
+
}
|
|
98
117
|
const acceptAttr = toAcceptAttr(accept);
|
|
99
118
|
return (_jsxs("div", { "data-testid": testId, className: "flex flex-col gap-4", children: [_jsxs("label", { htmlFor: inputId, onDragOver: (e) => {
|
|
100
119
|
e.preventDefault();
|
|
@@ -104,5 +123,9 @@ export function UploadZone({ onUpload, title, hint, accept, multiple = true, cap
|
|
|
104
123
|
if (!(e.relatedTarget instanceof Node) || !e.currentTarget.contains(e.relatedTarget)) {
|
|
105
124
|
setDragOver(false);
|
|
106
125
|
}
|
|
107
|
-
}, onDrop: handleDrop, "data-testid": testId !== undefined ? `${testId}-dropzone` : undefined, className: cn("flex w-full cursor-pointer flex-col items-center gap-3 rounded-lg border-2 border-dashed p-10 text-center transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2", dragOver ? "border-primary bg-muted/40" : "border-border", disabled === true && "cursor-not-allowed opacity-50"), children: [_jsx(Upload, { className: "size-8 text-muted-foreground", "aria-hidden": "true" }), _jsx("span", { className: "text-sm", children: title }), hint !== undefined && _jsx("span", { className: "text-sm text-muted-foreground", children: hint }), _jsx("input", { ref: inputRef, id: inputId, type: "file", multiple: multiple, className: "sr-only", "data-testid": testId !== undefined ? `${testId}-input` : undefined, disabled: disabled, ...(acceptAttr !== undefined && { accept: acceptAttr }), ...(capture !== undefined && { capture }), onChange: (e) => void uploadFiles(e.target.files) })] }), rows.length > 0 && (_jsx("ul", { className: "flex flex-col gap-2", children: rows
|
|
126
|
+
}, onDrop: handleDrop, "data-testid": testId !== undefined ? `${testId}-dropzone` : undefined, className: cn("flex w-full cursor-pointer flex-col items-center gap-3 rounded-lg border-2 border-dashed p-10 text-center transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2", dragOver ? "border-primary bg-muted/40" : "border-border", disabled === true && "cursor-not-allowed opacity-50"), children: [_jsx(Upload, { className: "size-8 text-muted-foreground", "aria-hidden": "true" }), _jsx("span", { className: "text-sm", children: title }), hint !== undefined && _jsx("span", { className: "text-sm text-muted-foreground", children: hint }), _jsx("input", { ref: inputRef, id: inputId, type: "file", multiple: multiple, className: "sr-only", "data-testid": testId !== undefined ? `${testId}-input` : undefined, disabled: disabled, ...(acceptAttr !== undefined && { accept: acceptAttr }), ...(capture !== undefined && { capture }), onChange: (e) => void uploadFiles(e.target.files) })] }), rows.length > 0 && (_jsx("ul", { className: "flex flex-col gap-2", children: rows
|
|
127
|
+
.filter((row) => row.value === undefined ||
|
|
128
|
+
doneValues === undefined ||
|
|
129
|
+
doneValues.includes(row.value))
|
|
130
|
+
.map((row) => (_jsxs("li", { "data-testid": testId !== undefined ? `${testId}-row` : undefined, className: "flex items-center justify-between gap-3 rounded-md border border-border px-4 py-2", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(FileUp, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": "true" }), _jsx("span", { className: "truncate text-sm", children: row.fileName })] }), _jsxs("output", { className: "flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground", children: [STATUS_ICON[row.status], _jsx("span", { className: "sr-only", children: t(STATUS_LABEL_KEY[row.status]) }), row.status === "error" && row.error !== undefined && _jsx("span", { children: row.error }), row.value !== undefined && onRemove !== undefined && disabled !== true && (_jsx("button", { type: "button", className: "rounded-sm p-0.5 text-muted-foreground hover:text-foreground", "aria-label": t("kumiko.widget.upload.remove"), "data-testid": testId !== undefined ? `${testId}-remove` : undefined, onClick: () => removeRow(row), children: _jsx(X, { className: "size-3.5", "aria-hidden": "true" }) }))] })] }, row.id))) }))] }));
|
|
108
131
|
}
|