@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.
Files changed (79) hide show
  1. package/dist/app/action-form-shim.js +1 -2
  2. package/dist/app/action-form-support.js +16 -4
  3. package/dist/app/kumiko-screen.js +49 -38
  4. package/dist/app/list-empty-content.d.ts +1 -0
  5. package/dist/app/list-empty-content.js +5 -0
  6. package/dist/app/row-actions.d.ts +7 -1
  7. package/dist/app/row-actions.js +6 -1
  8. package/dist/app/secret-mint-body.js +3 -2
  9. package/dist/components/embedded-list-field.js +16 -7
  10. package/dist/components/file-list-field.js +4 -0
  11. package/dist/components/grid-cell-for-field.d.ts +4 -1
  12. package/dist/components/grid-cell-for-field.js +6 -2
  13. package/dist/components/list-empty-state.d.ts +4 -1
  14. package/dist/components/list-empty-state.js +4 -1
  15. package/dist/components/reference-create-dialog.js +1 -1
  16. package/dist/components/related-list-section.js +4 -1
  17. package/dist/components/render-edit-logic.d.ts +1 -1
  18. package/dist/components/render-edit-logic.js +8 -2
  19. package/dist/components/render-edit-types.d.ts +3 -0
  20. package/dist/components/render-edit.js +25 -6
  21. package/dist/components/render-field.d.ts +3 -1
  22. package/dist/components/render-field.js +19 -7
  23. package/dist/components/render-list.d.ts +3 -1
  24. package/dist/components/render-list.js +29 -8
  25. package/dist/components/write-form-section.js +4 -1
  26. package/dist/field-issue-text.d.ts +4 -0
  27. package/dist/field-issue-text.js +14 -0
  28. package/dist/headless/dispatcher/index.d.ts +1 -0
  29. package/dist/headless/dispatcher/index.js +1 -0
  30. package/dist/headless/dispatcher/request-id.d.ts +1 -0
  31. package/dist/headless/dispatcher/request-id.js +15 -0
  32. package/dist/headless/form/form-controller.js +16 -1
  33. package/dist/headless/form/zod-bridge.js +7 -0
  34. package/dist/headless/index.d.ts +2 -1
  35. package/dist/headless/index.js +2 -1
  36. package/dist/headless/plural-key.d.ts +1 -0
  37. package/dist/headless/plural-key.js +5 -0
  38. package/dist/headless/view-model/edit.d.ts +4 -0
  39. package/dist/headless/view-model/edit.js +10 -5
  40. package/dist/headless/view-model/types.d.ts +10 -2
  41. package/dist/hooks/live-refetch-scheduler.d.ts +6 -0
  42. package/dist/hooks/live-refetch-scheduler.js +45 -0
  43. package/dist/hooks/use-query.js +8 -4
  44. package/dist/hooks/use-reference-lookup.d.ts +1 -0
  45. package/dist/hooks/use-reference-lookup.js +6 -3
  46. package/dist/i18n-defaults.js +22 -3
  47. package/dist/index.d.ts +3 -1
  48. package/dist/index.js +3 -1
  49. package/dist/leave-guard-dialog.d.ts +2 -0
  50. package/dist/leave-guard-dialog.js +28 -0
  51. package/dist/leave-guard.d.ts +15 -0
  52. package/dist/leave-guard.js +39 -0
  53. package/dist/live/dispatcher-live.js +1 -16
  54. package/dist/primitives.d.ts +29 -0
  55. package/dist/sse/live-events.d.ts +3 -0
  56. package/dist/sse/live-events.js +3 -0
  57. package/dist/web/app/create-app.js +2 -2
  58. package/dist/web/app/dashboard-body.js +13 -1
  59. package/dist/web/app/nav.js +51 -9
  60. package/dist/web/layout/nav-tree.js +13 -3
  61. package/dist/web/layout/shell-header.js +3 -3
  62. package/dist/web/lib/file-upload-form-data.d.ts +2 -0
  63. package/dist/web/lib/file-upload-form-data.js +2 -0
  64. package/dist/web/lib/upload-error-key.d.ts +7 -0
  65. package/dist/web/lib/upload-error-key.js +23 -0
  66. package/dist/web/lib/upload-file-ref.d.ts +1 -1
  67. package/dist/web/primitives/embedded-list-input.js +5 -4
  68. package/dist/web/primitives/file-upload.d.ts +2 -1
  69. package/dist/web/primitives/file-upload.js +11 -7
  70. package/dist/web/primitives/files-dropzone.d.ts +16 -0
  71. package/dist/web/primitives/files-dropzone.js +52 -0
  72. package/dist/web/primitives/index.js +62 -29
  73. package/dist/web/primitives/segmented-select.js +8 -2
  74. package/dist/web/sse/live-events.d.ts +13 -0
  75. package/dist/web/sse/live-events.js +88 -23
  76. package/dist/web/widgets/upload-zone.d.ts +11 -2
  77. package/dist/web/widgets/upload-zone.js +30 -7
  78. package/package.json +5 -5
  79. 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
- return (_jsx(FieldLabelledContext.Provider, { value: props.label !== "", children: _jsx(FieldDescriptionContext.Provider, { value: props.description !== undefined ? fieldDescriptionId(props.id) : undefined, children: _jsx(DefaultFieldBody, { ...props }) }) }));
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(issue.i18nKey, issue.params) }, `${issue.path}:${issue.code}`))) })) : null;
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.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 }) }));
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
- // Cells truncate long values with ellipsis instead of
620
- // wrapping — lists stay single-line + scannable (Linear
621
- // pattern). max-w-xs gives a sensible default upper
622
- // bound; the table container scrolls horizontally
623
- // if the sum of the columns gets too wide.
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 truncate"
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 truncate text-base font-semibold text-foreground", children: cardCell(row, cardTitleColumn) })), showStatus && (_jsx("span", { "data-testid": getCellTestId?.(row, cardStatusColumn.field) ??
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", { "data-kumiko-form-root": "", onKeyDownCapture: (e) => {
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", { onSubmit: (e) => {
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
- "flex-wrap items-end has-[[data-appendix-below]]:items-start"), children: children }));
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("inline-flex min-h-9 w-fit flex-wrap 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
+ 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
- // guard-allow: same-origin fetch — url is the app's own SSE endpoint, probed with same-origin credentials
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 ensureConnected = () => {
77
- // skip: EventSource already connected
78
- if (source !== undefined)
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
- if (sessionEnded)
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 ensureListenersForEntity = (entityName) => {
114
- // skip: not connected yet, listeners get wired once ensureConnected runs
115
- if (source === undefined)
167
+ const scheduleReconnect = () => {
168
+ // skip: a reconnect is already pending
169
+ if (cancelReconnect !== undefined)
116
170
  return;
117
- // skip: already wired for this entity
118
- if (wiredEntities.has(entityName))
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
- source.addEventListener(entityName, (e) => {
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
- ensureListenersForEntity(entityName);
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) => (row.id === rowId ? { ...row, status: "done" } : 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 = cause instanceof Error ? cause.message : t("kumiko.widget.upload.error");
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.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.id))) }))] }));
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
  }