@cosmicdrift/kumiko-renderer 0.377.0 → 0.379.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) 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 +51 -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/form-derivation-patch.d.ts +8 -0
  12. package/dist/components/form-derivation-patch.js +24 -0
  13. package/dist/components/form-screen-layout.d.ts +13 -0
  14. package/dist/components/form-screen-layout.js +13 -0
  15. package/dist/components/grid-cell-for-field.d.ts +4 -1
  16. package/dist/components/grid-cell-for-field.js +6 -2
  17. package/dist/components/list-empty-state.d.ts +4 -1
  18. package/dist/components/list-empty-state.js +4 -1
  19. package/dist/components/reference-create-dialog.js +1 -1
  20. package/dist/components/related-list-section.js +4 -1
  21. package/dist/components/render-edit-logic.d.ts +1 -1
  22. package/dist/components/render-edit-logic.js +8 -2
  23. package/dist/components/render-edit-types.d.ts +7 -1
  24. package/dist/components/render-edit.js +38 -6
  25. package/dist/components/render-field.d.ts +3 -1
  26. package/dist/components/render-field.js +27 -8
  27. package/dist/components/render-list.d.ts +3 -1
  28. package/dist/components/render-list.js +29 -8
  29. package/dist/components/write-form-section.js +4 -1
  30. package/dist/field-issue-text.d.ts +4 -0
  31. package/dist/field-issue-text.js +14 -0
  32. package/dist/file-ids-of.d.ts +1 -0
  33. package/dist/file-ids-of.js +7 -0
  34. package/dist/flow-diagram/icons.d.ts +3 -0
  35. package/dist/flow-diagram/icons.js +14 -0
  36. package/dist/flow-diagram/index.d.ts +2 -0
  37. package/dist/flow-diagram/index.js +1 -0
  38. package/dist/flow-diagram/render.d.ts +3 -0
  39. package/dist/flow-diagram/render.js +234 -0
  40. package/dist/flow-diagram/types.d.ts +18 -0
  41. package/dist/flow-diagram/types.js +1 -0
  42. package/dist/headless/dispatcher/index.d.ts +1 -0
  43. package/dist/headless/dispatcher/index.js +1 -0
  44. package/dist/headless/dispatcher/request-id.d.ts +1 -0
  45. package/dist/headless/dispatcher/request-id.js +15 -0
  46. package/dist/headless/form/form-controller.js +16 -1
  47. package/dist/headless/form/zod-bridge.js +7 -0
  48. package/dist/headless/index.d.ts +2 -1
  49. package/dist/headless/index.js +2 -1
  50. package/dist/headless/plural-key.d.ts +1 -0
  51. package/dist/headless/plural-key.js +5 -0
  52. package/dist/headless/view-model/edit.d.ts +8 -1
  53. package/dist/headless/view-model/edit.js +23 -7
  54. package/dist/headless/view-model/types.d.ts +11 -3
  55. package/dist/hooks/live-refetch-scheduler.d.ts +6 -0
  56. package/dist/hooks/live-refetch-scheduler.js +45 -0
  57. package/dist/hooks/use-query.js +8 -4
  58. package/dist/hooks/use-reference-lookup.d.ts +1 -0
  59. package/dist/hooks/use-reference-lookup.js +6 -3
  60. package/dist/i18n-defaults.js +28 -3
  61. package/dist/index.d.ts +6 -2
  62. package/dist/index.js +4 -1
  63. package/dist/leave-guard-dialog.d.ts +2 -0
  64. package/dist/leave-guard-dialog.js +28 -0
  65. package/dist/leave-guard.d.ts +15 -0
  66. package/dist/leave-guard.js +39 -0
  67. package/dist/live/dispatcher-live.js +1 -16
  68. package/dist/primitives.d.ts +39 -0
  69. package/dist/sse/live-events.d.ts +3 -0
  70. package/dist/sse/live-events.js +3 -0
  71. package/dist/web/app/create-app.js +2 -2
  72. package/dist/web/app/dashboard-body.js +13 -1
  73. package/dist/web/app/nav.js +51 -9
  74. package/dist/web/index.d.ts +2 -2
  75. package/dist/web/index.js +1 -1
  76. package/dist/web/layout/nav-tree.js +13 -3
  77. package/dist/web/layout/shell-header.js +3 -3
  78. package/dist/web/lib/file-upload-form-data.d.ts +2 -0
  79. package/dist/web/lib/file-upload-form-data.js +2 -0
  80. package/dist/web/lib/upload-error-key.d.ts +7 -0
  81. package/dist/web/lib/upload-error-key.js +23 -0
  82. package/dist/web/lib/upload-file-ref.d.ts +1 -1
  83. package/dist/web/primitives/embedded-list-input.js +5 -4
  84. package/dist/web/primitives/file-thumbnail.d.ts +6 -0
  85. package/dist/web/primitives/file-thumbnail.js +19 -19
  86. package/dist/web/primitives/file-upload.d.ts +2 -1
  87. package/dist/web/primitives/file-upload.js +11 -7
  88. package/dist/web/primitives/file-value-list.d.ts +3 -0
  89. package/dist/web/primitives/file-value-list.js +22 -0
  90. package/dist/web/primitives/files-dropzone.d.ts +16 -0
  91. package/dist/web/primitives/files-dropzone.js +52 -0
  92. package/dist/web/primitives/index.js +65 -30
  93. package/dist/web/primitives/segmented-select.js +8 -2
  94. package/dist/web/sse/live-events.d.ts +13 -0
  95. package/dist/web/sse/live-events.js +88 -23
  96. package/dist/web/widgets/bounded-load-cache.d.ts +1 -0
  97. package/dist/web/widgets/bounded-load-cache.js +25 -0
  98. package/dist/web/widgets/drawer.js +37 -2
  99. package/dist/web/widgets/index.d.ts +1 -0
  100. package/dist/web/widgets/index.js +1 -0
  101. package/dist/web/widgets/pdf-preview.d.ts +10 -0
  102. package/dist/web/widgets/pdf-preview.js +140 -0
  103. package/dist/web/widgets/upload-zone.d.ts +11 -2
  104. package/dist/web/widgets/upload-zone.js +30 -7
  105. package/package.json +14 -5
  106. package/src/changes.json +143 -0
  107. package/src/web/styles.css +13 -9
@@ -8,6 +8,7 @@ import { ArrowDown, ArrowUp, ArrowUpDown, Building, CalendarDays, Check, Chevron
8
8
  // package.json#browser (Metro), hence the explicit browser subpath.
9
9
  import QRCode from "qrcode/lib/browser.js";
10
10
  import { Children, createContext, Fragment, isValidElement, useContext, useEffect, useId, useRef, useState, } from "react";
11
+ import { fieldIssueText } from "../../field-issue-text.js";
11
12
  import { applyFormatSpec, EMPTY_VALUE_PLACEHOLDER, isSafeHref } from "../../headless/index.js";
12
13
  import { InsideDrawerProvider, needsActionConfirm, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, useColumnRenderer, useInsideDrawer, useOptionalLocale, useOptionalPrimitives, useOptionalTranslation, useTranslation, WriteFailedError, } from "../../index.js";
13
14
  import { Icon, NAV_ICONS } from "../icons.js";
@@ -32,6 +33,8 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMe
32
33
  import { EmbeddedListInput } from "./embedded-list-input.js";
33
34
  import { CardLeadingImage, FileDisplayCell } from "./file-thumbnail.js";
34
35
  import { FileUploadInput } from "./file-upload.js";
36
+ import { FileValueList } from "./file-value-list.js";
37
+ import { FilesDropzoneInput } from "./files-dropzone.js";
35
38
  import { DefaultJsonView } from "./json-view.js";
36
39
  import { screenFormColumnWidthClassName, screenPaddingClassName, screenWidthClassName, } from "./layout.js";
37
40
  import { DefaultLightbox } from "./lightbox.js";
@@ -169,14 +172,23 @@ const FieldDescriptionContext = createContext(undefined);
169
172
  function fieldDescriptionId(id) {
170
173
  return `${id}-description`;
171
174
  }
175
+ function fieldErrorId(id) {
176
+ return `${id}-error`;
177
+ }
172
178
  function DefaultField(props) {
173
- 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 }) }) }));
174
186
  }
175
187
  function DefaultFieldBody({ id, label, required, issues, labelAppendix, fieldAppendix, appendixPlacement, description, status, children, layout, accent, hideLabel, testId, changed, }) {
176
188
  const t = useTranslation();
177
189
  const hasError = issues !== undefined && issues.length > 0;
178
190
  const labelEl = (_jsxs(UiLabel, { id: fieldLabelId(id), htmlFor: id, className: cn("min-w-0 gap-0 text-[13px]", hasError ? "text-destructive" : "text-foreground", hideLabel === true && "sr-only"), children: [_jsx("span", { className: layout === "inline" ? undefined : "truncate", title: typeof label === "string" ? label : undefined, children: label }), required === true && (_jsx("span", { "data-required": true, className: "ml-0.5 shrink-0 text-destructive", children: "*" }))] }));
179
- const errorsEl = hasError ? (_jsx("div", { role: "alert", "data-testid": testId !== undefined ? `${testId}-errors` : undefined, className: "text-xs text-destructive", children: issues.map((issue) => (_jsx("div", { children: t(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;
180
192
  // Inline (boolean/checkbox): Control links, Label rechts — shadcn-Muster.
181
193
  if (layout === "inline") {
182
194
  return (_jsxs("div", { "data-testid": testId, className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(FieldLayoutContext.Provider, { value: "inline", children: children }), labelEl, labelAppendix !== undefined && labelAppendix] }), errorsEl] }));
@@ -352,7 +364,13 @@ function DefaultInput(props) {
352
364
  return booleanLayout === "inline" ? (_jsx(Checkbox, { id: props.id, name: props.name, disabled: props.disabled, "aria-required": props.required, "aria-invalid": props.hasError === true ? true : undefined, checked: props.value, onCheckedChange: (checked) => props.onChange(checked === true) })) : (_jsx(Switch, { id: props.id, name: props.name, disabled: props.disabled, "aria-required": props.required, "aria-invalid": props.hasError === true ? true : undefined, checked: props.value, onCheckedChange: (checked) => props.onChange(checked === true), className: "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40" }));
353
365
  case "file":
354
366
  case "image":
355
- return (_jsx(FileUploadInput, { kind: props.kind, id: props.id, value: props.value, onChange: props.onChange, ...(props.accept !== undefined && { accept: props.accept }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.entityType !== undefined && { entityType: props.entityType }), ...(props.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 }) }));
356
374
  case "date":
357
375
  return (_jsx(DateInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, ...(props.locale !== undefined && { locale: props.locale }), ...(props.min !== undefined && { min: props.min }), ...(props.max !== undefined && { max: props.max }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
358
376
  case "select": {
@@ -615,13 +633,13 @@ function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, empty
615
633
  const row = item.row;
616
634
  const opensOnClick = rowIsLink && !rowClickIsHidden(row);
617
635
  return (_jsxs(Fragment, { children: [_jsxs(TableRow, { "data-testid": rowTestId(row), "data-tone": rowTone?.(row), onClick: opensOnClick ? () => onRowClick(row) : undefined, className: cn(rowToneClass(rowTone?.(row)), opensOnClick && "cursor-pointer", fillsHeight && "h-10 border-border-row hover:bg-muted"), children: [isRowExpandable && (_jsx(TableCell, { "data-testid": getCellTestId?.(row, "expand") ?? `cell-${row.id}-expand`, className: EXPAND_TOGGLE_CELL_CLASS, children: renderExpandToggle(row) })), columns.map((col, colIndex) => (_jsx(TableCell, { "data-testid": getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`, "data-highlighted": col.highlighted === true ? "true" : undefined,
618
- // Cells truncate long values with ellipsis instead of
619
- // wrapping — lists stay single-line + scannable (Linear
620
- // pattern). max-w-xs gives a sensible default upper
621
- // bound; the table container scrolls horizontally
622
- // 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.
623
641
  className: cn(col.fileDisplay === undefined
624
- ? "max-w-xs truncate"
642
+ ? "max-w-xs whitespace-normal break-words"
625
643
  : FIT_CONTENT_CELL_CLASS, colIndex === 0
626
644
  ? "font-medium text-foreground"
627
645
  : "text-foreground-secondary", NUMERIC_COLUMN_TYPES.has(col.type) && "text-right", TABULAR_COLUMN_TYPES.has(col.type) && "tabular-nums", col.highlighted === true && "bg-accent/40"), title: cellTitle(row.values[col.field]), children: _jsx(FirstCellLink, { enabled: colIndex === 0 && opensOnClick && onCellChange === undefined, onOpen: () => onRowClick?.(row), empty: isEmptyCellValue(row.values[col.field]), emptyLabel: rowClickLabel, children: _jsx(DataTableCell, { value: row.values[col.field], row: row.values, field: col.field, type: col.type, renderer: col.renderer, translate: tableTranslate, locale: tableLocale, ...(col.optionLabels !== undefined && {
@@ -635,7 +653,7 @@ function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, empty
635
653
  // would hide the neighboring data column on narrow
636
654
  // viewports. bg-background sets the column apart during
637
655
  // scroll — no border-l (divider too heavy).
638
- className: cn("text-right md:sticky md:right-0 md:z-10 md:bg-background", fillsHeight && "md:bg-card"),
656
+ className: cn("whitespace-nowrap text-right md:sticky md:right-0 md:z-10 md:bg-background", fillsHeight && "md:bg-card"),
639
657
  // Action-cell events must not trigger the row click/activation
640
658
  // (typically "Open Detail" — the user wanted the action,
641
659
  // not navigation). We stopPropagation for mouse and
@@ -670,7 +688,7 @@ function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, empty
670
688
  const hasMenu = menuActions !== undefined && menuActions.length > 0;
671
689
  const opensOnClick = rowIsLink && !rowClickIsHidden(row);
672
690
  const body = (_jsxs(_Fragment, { children: [cardImageColumn?.fileDisplay !== undefined && (_jsx("div", { "data-testid": `card-image-${row.id}`, className: "shrink-0", children: _jsx(CardLeadingImage, { value: row.values[cardImageColumn.field], display: cardImageColumn.fileDisplay }) })), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-1", children: [_jsxs("div", { className: "flex min-w-0 items-start justify-between gap-2", children: [cardTitleColumn !== undefined && (_jsx("span", { "data-testid": getCellTestId?.(row, cardTitleColumn.field) ??
673
- `cell-${row.id}-${cardTitleColumn.field}`, className: "min-w-0 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) ??
674
692
  `cell-${row.id}-${cardStatusColumn.field}`, className: "shrink-0", children: cardCell(row, cardStatusColumn) }))] }), metaColumns.length > 0 && (_jsx("div", { className: "min-w-0 overflow-x-clip", children: _jsx("div", { "data-testid": `card-meta-${row.id}`, className: "-ms-3 flex min-w-0 flex-wrap items-center text-[13px] leading-5 tabular-nums text-foreground-secondary", children: metaColumns.map((col) => (_jsxs("div", { className: "flex h-5 min-w-0 max-w-full items-center", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 shrink-0 text-center", children: "\u00B7" }), _jsx("div", { "data-testid": getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`, className: "min-w-0 truncate leading-5", onPointerEnter: showFullTextOnHover, children: isBadgeColumn(col) ? (_jsx("span", { className: "inline-flex align-middle", children: cardCell(row, col) })) : (cardCell(row, col)) })] }, col.field))) }) }))] }), opensOnClick && (_jsx(ChevronRight, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": "true" }))] }));
675
693
  const rowItem = (_jsxs("li", { "data-testid": rowTestId(row), "data-tone": rowTone?.(row), className: cn("flex min-h-[72px] items-center gap-2 border-b border-border-row py-3", rowToneClass(rowTone?.(row)), isRowExpandable ? "pl-2" : "pl-4", hasMenu ? "pr-2" : "pr-4"), ...(opensOnClick && { onClick: () => onRowClick(row) }), children: [renderExpandToggle(row), opensOnClick ? (_jsx("button", { type: "button", className: "flex min-w-0 flex-1 cursor-pointer items-center gap-2 bg-transparent p-0 text-left", onKeyDown: (e) => {
676
694
  if (e.key !== "Enter")
@@ -784,14 +802,14 @@ function RowActionsCell({ row, actions, mode = "adaptive", rowIsLink = false, co
784
802
  if (hasExplicitDisplay) {
785
803
  const inline = visible.filter((a) => a.display !== undefined);
786
804
  const rest = visible.filter((a) => a.display === undefined);
787
- return (_jsxs("div", { className: "inline-flex items-center gap-1 justify-end", children: [inline.map((a) => (_jsx(RowActionButton, { row: row, action: a, compact: compact }, a.id))), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
805
+ return (_jsxs("div", { className: "inline-flex flex-wrap items-center gap-1 justify-end", children: [inline.map((a) => (_jsx(RowActionButton, { row: row, action: a, compact: compact }, a.id))), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
788
806
  }
789
807
  if (rowIsLink) {
790
808
  return (_jsx("div", { className: "inline-flex items-center justify-end", children: _jsx(RowActionsKebab, { row: row, actions: visible }) }));
791
809
  }
792
810
  const primary = primaryRowAction(visible);
793
811
  const rest = visible.filter((a) => a.id !== primary?.id);
794
- return (_jsxs("div", { className: "inline-flex items-center gap-1 justify-end", children: [primary !== undefined && _jsx(RowActionButton, { row: row, action: primary, asLink: true }), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
812
+ return (_jsxs("div", { className: "inline-flex flex-wrap items-center gap-1 justify-end", children: [primary !== undefined && _jsx(RowActionButton, { row: row, action: primary, asLink: true }), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
795
813
  }
796
814
  function isEmptyCellValue(value) {
797
815
  return (value === null || value === undefined || (typeof value === "string" && value.trim() === ""));
@@ -1087,13 +1105,13 @@ function SortableHeader({ field, label, sortable, highlighted, numeric, fitConte
1087
1105
  const active = sort?.field === field ? sort : undefined;
1088
1106
  const ariaSort = active?.dir === "asc" ? "ascending" : active?.dir === "desc" ? "descending" : "none";
1089
1107
  if (!sortable || onSortChange === undefined) {
1090
- return (_jsx(TableHead, { "data-testid": `column-${field}`, "data-sortable": sortable === true ? true : undefined, "data-highlighted": highlighted === true ? "true" : undefined, className: cn("font-medium text-muted-foreground", numeric === true && "text-right", fitContent === true && FIT_CONTENT_CELL_CLASS, highlighted === true && "bg-accent/40"), children: label }));
1108
+ return (_jsx(TableHead, { "data-testid": `column-${field}`, "data-sortable": sortable === true ? true : undefined, "data-highlighted": highlighted === true ? "true" : undefined, className: cn("font-medium whitespace-normal text-muted-foreground", numeric === true && "text-right", fitContent === true && FIT_CONTENT_CELL_CLASS, highlighted === true && "bg-accent/40"), children: label }));
1091
1109
  }
1092
1110
  const Icon = active?.dir === "asc" ? ArrowUp : active?.dir === "desc" ? ArrowDown : ArrowUpDown;
1093
1111
  const next = nextSortState(active?.dir, field);
1094
1112
  return (_jsx(TableHead, { "data-testid": `column-${field}`, "data-sortable": "true", "data-highlighted": highlighted === true ? "true" : undefined, "aria-sort": ariaSort, className: cn(active !== undefined
1095
1113
  ? "font-semibold text-foreground"
1096
- : "font-medium text-muted-foreground", numeric === true && "text-right", fitContent === true && FIT_CONTENT_CELL_CLASS, highlighted === true && "bg-accent/40"), children: _jsxs("button", { type: "button", onClick: () => onSortChange(next), className: cn("group/sort inline-flex h-8 items-center gap-1.5 rounded-sm px-2 -mx-2 text-sm [font-weight:inherit]", "hover:bg-accent hover:text-accent-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", active !== undefined && "text-foreground"), children: [_jsx("span", { children: label }), _jsx(Icon, { "data-sort-icon": active === undefined ? "idle" : "active", className: cn("size-3.5", active === undefined &&
1114
+ : "font-medium text-muted-foreground", "whitespace-normal", numeric === true && "text-right", fitContent === true && FIT_CONTENT_CELL_CLASS, highlighted === true && "bg-accent/40"), children: _jsxs("button", { type: "button", onClick: () => onSortChange(next), className: cn("group/sort inline-flex h-8 items-center gap-1.5 rounded-sm px-2 -mx-2 text-sm [font-weight:inherit]", "hover:bg-accent hover:text-accent-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", active !== undefined && "text-foreground"), children: [_jsx("span", { children: label }), _jsx(Icon, { "data-sort-icon": active === undefined ? "idle" : "active", className: cn("size-3.5", active === undefined &&
1097
1115
  "opacity-0 group-hover/sort:opacity-40 group-focus-visible/sort:opacity-40"), "aria-hidden": "true" })] }) }));
1098
1116
  }
1099
1117
  // 3-State-Toggle: kein Sort → asc → desc → kein Sort. Idiomatisch für
@@ -1426,9 +1444,21 @@ const SUBMIT_CONTROL_SELECTOR = [
1426
1444
  "input[type=submit]:not(:disabled)",
1427
1445
  "input[type=image]:not(:disabled)",
1428
1446
  ].join(", ");
1429
- function FormRoot({ onSubmit, testId, className, children, }) {
1447
+ function FormRoot({ onSubmit, testId, className, children, focusFirstErrorKey, }) {
1448
+ const rootRef = useRef(null);
1449
+ useEffect(() => {
1450
+ if (focusFirstErrorKey === undefined || focusFirstErrorKey === 0)
1451
+ return;
1452
+ const firstInvalid = rootRef.current?.querySelector('[aria-invalid="true"]');
1453
+ if (firstInvalid === null || firstInvalid === undefined)
1454
+ return;
1455
+ firstInvalid.focus();
1456
+ firstInvalid.scrollIntoView({ block: "center" });
1457
+ }, [focusFirstErrorKey]);
1430
1458
  if (useContext(InsideFormContext)) {
1431
- return (_jsx("div", { "data-kumiko-form-root": "", onKeyDownCapture: (e) => {
1459
+ return (_jsx("div", { ref: (node) => {
1460
+ rootRef.current = node;
1461
+ }, "data-kumiko-form-root": "", onKeyDownCapture: (e) => {
1432
1462
  // Implicit submission: the section's inputs belong to the OUTER
1433
1463
  // <form> in the DOM, so the browser would otherwise route Enter to
1434
1464
  // that form's default button.
@@ -1457,7 +1487,9 @@ function FormRoot({ onSubmit, testId, className, children, }) {
1457
1487
  onSubmit();
1458
1488
  }, "data-testid": testId, className: className, children: children }));
1459
1489
  }
1460
- return (_jsx("form", { onSubmit: (e) => {
1490
+ return (_jsx("form", { ref: (node) => {
1491
+ rootRef.current = node;
1492
+ }, onSubmit: (e) => {
1461
1493
  e.preventDefault();
1462
1494
  onSubmit(e);
1463
1495
  }, "data-testid": testId, className: className, children: children }));
@@ -1636,26 +1668,26 @@ function FormSectionNav({ items }) {
1636
1668
  // Form inside the Drawer primitive: the drawer header carries the title, so
1637
1669
  // only the description (as help text), the scrolling sections and a pinned
1638
1670
  // footer remain. The scroll area is a sibling of the footer, never its parent.
1639
- function DrawerFormLayout({ onSubmit, subtitle, actions, secondaryActions, summary, testId, children, }) {
1640
- return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: "flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-6 py-5", children: [summary !== undefined && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-summary` : undefined, className: "flex flex-col gap-0.5 rounded-lg bg-muted px-3.5 py-3", children: [_jsx("span", { className: "text-sm font-semibold text-foreground", children: summary.title }), summary.subtitle !== undefined && (_jsx("span", { className: "text-[13px] text-foreground-secondary", children: summary.subtitle }))] })), subtitle !== undefined && (_jsx("p", { "data-testid": testId !== undefined ? `${testId}-subtitle` : undefined, className: "text-[13px] text-foreground-secondary", children: subtitle })), _jsx(InsideFormContext.Provider, { value: true, children: _jsx(ScreenFormContext.Provider, { value: true, children: _jsx(InsideDrawerProvider, { value: false, children: _jsx(DrawerBodyContext.Provider, { value: true, children: _jsx("div", { className: "flex flex-col gap-5", children: children }) }) }) }) })] }), (actions !== undefined || secondaryActions !== undefined) && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-footer` : undefined, className: "flex h-16 shrink-0 items-center justify-end gap-2 border-t border-border px-6", children: [secondaryActions, actions] }))] }));
1671
+ function DrawerFormLayout({ onSubmit, focusFirstErrorKey, subtitle, actions, secondaryActions, summary, testId, children, }) {
1672
+ return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: "flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-6 py-5", children: [summary !== undefined && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-summary` : undefined, className: "flex flex-col gap-0.5 rounded-lg bg-muted px-3.5 py-3", children: [_jsx("span", { className: "text-sm font-semibold text-foreground", children: summary.title }), summary.subtitle !== undefined && (_jsx("span", { className: "text-[13px] text-foreground-secondary", children: summary.subtitle }))] })), subtitle !== undefined && (_jsx("p", { "data-testid": testId !== undefined ? `${testId}-subtitle` : undefined, className: "text-[13px] text-foreground-secondary", children: subtitle })), _jsx(InsideFormContext.Provider, { value: true, children: _jsx(ScreenFormContext.Provider, { value: true, children: _jsx(InsideDrawerProvider, { value: false, children: _jsx(DrawerBodyContext.Provider, { value: true, children: _jsx("div", { className: "flex flex-col gap-5", children: children }) }) }) }) })] }), (actions !== undefined || secondaryActions !== undefined) && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-footer` : undefined, className: "flex h-16 shrink-0 items-center justify-end gap-2 border-t border-border px-6", children: [secondaryActions, actions] }))] }));
1641
1673
  }
1642
- function DefaultForm({ onSubmit, children, title, subtitle, actions, secondaryActions, testId, width, stickyActions, headerRegion, titleAction, fillHeight, chromeless, screenForm, unsavedCount, sectionNav, sideRail, summary, }) {
1674
+ function DefaultForm({ onSubmit, focusFirstErrorKey, children, title, subtitle, actions, secondaryActions, testId, width, stickyActions, headerRegion, titleAction, fillHeight, chromeless, screenForm, unsavedCount, sectionNav, sideRail, summary, }) {
1643
1675
  const insideDrawer = useInsideDrawer();
1644
1676
  const isEmbeddedForm = useContext(EmbeddedFormContext);
1645
1677
  const contextWidth = useContext(ScreenWidthContext);
1646
1678
  // Eingebettet (AuthCard etc.): nacktes <form>, gestapelte Felder mit gap —
1647
1679
  // der Container trägt Card/Titel selbst, sonst Card-in-Card.
1648
1680
  if (useContext(BareFormContext)) {
1649
- return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: cn("flex flex-col gap-4",
1681
+ return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: cn("flex flex-col gap-4",
1650
1682
  // Bare forms stack sections without a card; without a divider a
1651
1683
  // section boundary reads as a layout gap, not structure.
1652
1684
  "[&>section:not(:first-child)]:border-t"), children: [headerRegion, _jsx(InsideFormContext.Provider, { value: true, children: children }), (secondaryActions !== undefined || actions !== undefined) && (_jsxs("div", { className: "flex items-center justify-end gap-2", children: [secondaryActions, actions] }))] }));
1653
1685
  }
1654
1686
  if (insideDrawer) {
1655
- return (_jsx(DrawerFormLayout, { onSubmit: onSubmit, subtitle: subtitle, actions: actions, secondaryActions: secondaryActions, summary: summary, testId: testId, children: children }));
1687
+ return (_jsx(DrawerFormLayout, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, subtitle: subtitle, actions: actions, secondaryActions: secondaryActions, summary: summary, testId: testId, children: children }));
1656
1688
  }
1657
1689
  if (isEmbeddedForm) {
1658
- return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: "flex w-full flex-col gap-6", children: [_jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: false }), _jsx(FormSections, { chromeless: true, stickyActions: undefined, fillHeight: false, screenForm: true, children: children }), (actions !== undefined || secondaryActions !== undefined) && (_jsx("div", { className: "max-w-4xl border-t border-border", children: _jsx(FormFooter, { actions: actions, secondaryActions: secondaryActions, testId: testId, chromeless: true, stickyActions: undefined, fillHeight: false, ...(unsavedCount !== undefined && { unsavedCount }) }) }))] }));
1690
+ return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: "flex w-full flex-col gap-6", children: [_jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: false }), _jsx(FormSections, { chromeless: true, stickyActions: undefined, fillHeight: false, screenForm: true, children: children }), (actions !== undefined || secondaryActions !== undefined) && (_jsx("div", { className: "max-w-4xl border-t border-border", children: _jsx(FormFooter, { actions: actions, secondaryActions: secondaryActions, testId: testId, chromeless: true, stickyActions: undefined, fillHeight: false, ...(unsavedCount !== undefined && { unsavedCount }) }) }))] }));
1659
1691
  }
1660
1692
  const isScreenForm = screenForm === true && fillHeight === true && chromeless !== true;
1661
1693
  const screenFormWidth = width ?? contextWidth;
@@ -1665,7 +1697,7 @@ function DefaultForm({ onSubmit, children, title, subtitle, actions, secondaryAc
1665
1697
  // from xl, the section navigation; the footer stays pinned below it.
1666
1698
  if (isScreenForm) {
1667
1699
  const showsSectionNav = sectionNav !== undefined && sectionNav.length >= SECTION_NAV_MIN_ITEMS;
1668
- return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { className: "flex min-h-0 flex-1 flex-col lg:flex-row", children: [sideRail, _jsx("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: cn("min-h-0 flex-1 overflow-y-auto", screenPaddingClassName), children: _jsxs("div", { className: "flex gap-18", children: [_jsxs("div", { className: cn("flex w-full min-w-0 flex-col gap-9", screenFormWidth !== undefined
1700
+ return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { className: "flex min-h-0 flex-1 flex-col lg:flex-row", children: [sideRail, _jsx("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: cn("min-h-0 flex-1 overflow-y-auto", screenPaddingClassName), children: _jsxs("div", { className: "flex gap-18", children: [_jsxs("div", { className: cn("flex w-full min-w-0 flex-col gap-9", screenFormWidth !== undefined
1669
1701
  ? screenFormColumnWidthClassName[screenFormWidth]
1670
1702
  : "max-w-[640px]"), children: [_jsx(InsideScreenFormColumnContext.Provider, { value: true, children: headerRegion }), _jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: false }), sections] }), showsSectionNav && _jsx(FormSectionNav, { items: sectionNav })] }) })] }), footer] }));
1671
1703
  }
@@ -1684,13 +1716,13 @@ function DefaultForm({ onSubmit, children, title, subtitle, actions, secondaryAc
1684
1716
  // the screen-level subtitle (screen.description) is independent of that
1685
1717
  // and must survive tabs mode.
1686
1718
  if (chromeless === true) {
1687
- return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: cn("flex flex-col w-full", fillHeight === true && "h-full min-h-0"), children: [_jsxs(FormScreenShell, { maxWidth: "full", ...(fillHeight === true && { fillHeight: true }), className: cn("!p-0", pinnedShellClassName), children: [headerRegion !== undefined && (_jsx("div", { className: cn("flex flex-col", fillHeight === true && "shrink-0"), children: headerRegion })), _jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: fillHeight }), sections, !pinnedFooter && footer] }), pinnedFooter && footer] }));
1719
+ return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: cn("flex flex-col w-full", fillHeight === true && "h-full min-h-0"), children: [_jsxs(FormScreenShell, { maxWidth: "full", ...(fillHeight === true && { fillHeight: true }), className: cn("!p-0", pinnedShellClassName), children: [headerRegion !== undefined && (_jsx("div", { className: cn("flex flex-col", fillHeight === true && "shrink-0"), children: headerRegion })), _jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: fillHeight }), sections, !pinnedFooter && footer] }), pinnedFooter && footer] }));
1688
1720
  }
1689
1721
  // One card form: title as header (no divider under it), sections divided
1690
1722
  // between each other, muted action footer. Shell width comes from
1691
1723
  // FormScreenShell (layout.width, else the app-wide screenWidth default, else
1692
1724
  // 4xl).
1693
- return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: cn("flex flex-col w-full", fillHeight === true && "h-full min-h-0"), children: [_jsxs(FormScreenShell, { ...(width !== undefined && { maxWidth: width }), ...(fillHeight === true && { fillHeight: true }), ...(pinnedShellClassName !== undefined && { className: pinnedShellClassName }), children: [headerRegion !== undefined && (_jsx("div", { className: cn("flex flex-col gap-6 mb-8", fillHeight === true && "shrink-0"), children: headerRegion })), _jsxs("div", { "data-slot": "card", className: cn(cardSurface(), "overflow-hidden",
1725
+ return (_jsxs(FormRoot, { onSubmit: onSubmit, focusFirstErrorKey: focusFirstErrorKey, testId: testId, className: cn("flex flex-col w-full", fillHeight === true && "h-full min-h-0"), children: [_jsxs(FormScreenShell, { ...(width !== undefined && { maxWidth: width }), ...(fillHeight === true && { fillHeight: true }), ...(pinnedShellClassName !== undefined && { className: pinnedShellClassName }), children: [headerRegion !== undefined && (_jsx("div", { className: cn("flex flex-col gap-6 mb-8", fillHeight === true && "shrink-0"), children: headerRegion })), _jsxs("div", { "data-slot": "card", className: cn(cardSurface(), "overflow-hidden",
1694
1726
  // No flex-1: a fillHeight card sizes to its content (flex's
1695
1727
  // initial 0 1 auto) and only claims more than that once its
1696
1728
  // FormScreenShell ancestor is itself height-constrained and
@@ -1777,8 +1809,10 @@ function DefaultGrid({ columns, children, testId, maxRows, flow, list, ariaLabel
1777
1809
  ? "flex-col items-start"
1778
1810
  : // items-end aligns inputs when labels wrap to different heights; a
1779
1811
  // below-control appendix grows only the one cell and would push its
1780
- // neighbours down, so such rows align to the top instead.
1781
- "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 }));
1782
1816
  }
1783
1817
  // "auto": content-sized items in a wrapping row (e.g. a metrics band of
1784
1818
  // self-sized tiles) instead of N equal-width, container-stretched tracks.
@@ -1920,7 +1954,7 @@ export function DefaultCard({ slots, options, className, testId, dataAttributes,
1920
1954
  // The one region allowed to shrink and scroll internally — mirrors
1921
1955
  // FormSections' fillHeight === true && "min-h-0" treatment of the
1922
1956
  // section it wraps.
1923
- fillHeight && "min-h-0 flex flex-col"), children: children })), s.footer !== undefined && (_jsx("div", { className: cn(cardFooter, footerInset && cardFooterInset, footerBordered && cardFooterBorder, fillHeight && "shrink-0"), children: s.footer }))] }));
1957
+ fillHeight && "min-h-0 flex flex-col"), children: children })), s.footer !== undefined && s.footer !== null && s.footer !== false && (_jsx("div", { className: cn(cardFooter, footerInset && cardFooterInset, footerBordered && cardFooterBorder, fillHeight && "shrink-0"), children: s.footer }))] }));
1924
1958
  return (_jsx(InsideScreenFormColumnContext.Provider, { value: false, children: screenBody ? _jsx(FormScreenShell, { children: card }) : card }));
1925
1959
  }
1926
1960
  // otpauth:// enrollment URI → scannable QR (errorCorrectionLevel "H", ~30%
@@ -2011,6 +2045,7 @@ export const defaultPrimitives = {
2011
2045
  MetricBand: DefaultMetricBand,
2012
2046
  PageHeader: DefaultPageHeader,
2013
2047
  JsonView: DefaultJsonView,
2048
+ FileValueList,
2014
2049
  FillContainer: DefaultFillContainer,
2015
2050
  ActionOverflowMenu,
2016
2051
  SecretReveal: DefaultSecretReveal,
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useRef } from "react";
3
3
  import { cn } from "../lib/cn.js";
4
+ const EQUAL_WIDTH_MAX_SEGMENTS = 4;
4
5
  // WAI-ARIA radiogroup pattern (role="radiogroup" + role="radio" children):
5
6
  // arrow keys move focus AND selection in the same step, only the checked
6
7
  // segment (or the first when none is checked) sits in the tab order.
@@ -35,7 +36,12 @@ export function SegmentedControl({ id, name, value, onChange, options, disabled,
35
36
  selectAt(nextEnabledIndex(index, -1));
36
37
  }
37
38
  };
38
- return (_jsxs("div", { role: "radiogroup", ...labelProps, "aria-required": required, "aria-invalid": hasError === true ? true : undefined, "data-testid": `segmented-${id}`, className: cn("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 () => {
@@ -0,0 +1 @@
1
+ export declare function createBoundedLoadCache<T>(max: number, dispose: (value: T) => void): (key: string, load: () => Promise<T>) => Promise<T>;
@@ -0,0 +1,25 @@
1
+ // Evicts the oldest entry once `max` is exceeded and disposes only that one,
2
+ // so several documents on one page keep their own loading tasks alive.
3
+ export function createBoundedLoadCache(max, dispose) {
4
+ const entries = new Map();
5
+ return (key, load) => {
6
+ const cached = entries.get(key);
7
+ if (cached !== undefined)
8
+ return cached;
9
+ const loading = load();
10
+ entries.set(key, loading);
11
+ // A failed load must not stick: the next mount retries instead of replaying the rejection.
12
+ loading.catch(() => {
13
+ if (entries.get(key) === loading)
14
+ entries.delete(key);
15
+ });
16
+ if (entries.size > max) {
17
+ const [oldestKey, oldest] = entries.entries().next().value ?? [];
18
+ if (oldestKey !== undefined && oldest !== undefined) {
19
+ entries.delete(oldestKey);
20
+ void oldest.then(dispose);
21
+ }
22
+ }
23
+ return loading;
24
+ };
25
+ }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Maximize2Icon, Minimize2Icon } from "lucide-react";
3
- import { useState } from "react";
3
+ import { useEffect, useState } from "react";
4
4
  import { useTranslation } from "../../index.js";
5
5
  import { clamp } from "../lib/clamp.js";
6
6
  import { cn } from "../lib/cn.js";
@@ -78,6 +78,39 @@ function sidePanelClass(side, narrow, variant, belowHeader) {
78
78
  return `inset-y-8 right-8 h-auto ${WIDTH_CLASS} rounded-[2rem] border shadow-2xl overflow-hidden`;
79
79
  }
80
80
  }
81
+ const DRAWER_INSET_PROPERTY = {
82
+ left: "--kumiko-drawer-inset-left",
83
+ right: "--kumiko-drawer-inset-right",
84
+ };
85
+ // Publishes the width a non-modal side drawer occupies so the shell content (styles.css) can
86
+ // narrow instead of sitting underneath it. Read from the layout box (offsetLeft/offsetWidth), not
87
+ // getBoundingClientRect: the slide-in animation moves the painted rect, and the observer would
88
+ // not fire again once it settles.
89
+ // ponytail: one owner per side, so two non-modal drawers open on the same side overwrite each
90
+ // other's value; a registry that takes the max is needed once a screen stacks them.
91
+ function usePublishDrawerInset(sheet, side, active) {
92
+ useEffect(() => {
93
+ if (!active || sheet === null || (side !== "left" && side !== "right"))
94
+ return;
95
+ const property = DRAWER_INSET_PROPERTY[side];
96
+ const root = document.documentElement;
97
+ const publish = () => {
98
+ const inset = side === "right"
99
+ ? window.innerWidth - sheet.offsetLeft
100
+ : sheet.offsetLeft + sheet.offsetWidth;
101
+ root.style.setProperty(property, `${Math.max(0, Math.round(inset))}px`);
102
+ };
103
+ publish();
104
+ const observer = new ResizeObserver(publish);
105
+ observer.observe(sheet);
106
+ window.addEventListener("resize", publish);
107
+ return () => {
108
+ observer.disconnect();
109
+ window.removeEventListener("resize", publish);
110
+ root.style.removeProperty(property);
111
+ };
112
+ }, [sheet, side, active]);
113
+ }
81
114
  /** Slide-in panel beside a list (e.g. mail reader next to the inbox) —
82
115
  * thin wrapper over the Sheet primitive with header/body/footer slots
83
116
  * so screens skip per-route Radix boilerplate. */
@@ -95,6 +128,8 @@ export function Drawer({ open, onOpenChange, side = "right", title, description,
95
128
  : Math.min(maxWidthPx, Math.round(window.innerWidth * 0.9));
96
129
  const [resizedWidthPx, setResizedWidthPx] = useState(() => clamp(resize?.defaultWidthPx ?? defaultWidthFromViewport(), minWidthPx, effectiveMaxWidthPx()));
97
130
  const [maximized, setMaximized] = useState(false);
131
+ const [sheet, setSheet] = useState(null);
132
+ usePublishDrawerInset(sheet, side, open && !modal && !narrow);
98
133
  const blurPx = backdrop?.blurPx ?? DEFAULT_BLUR_PX;
99
134
  const dimPercent = backdrop?.dimPercent ?? DEFAULT_DIM_PERCENT;
100
135
  const overlayStyle = {
@@ -134,7 +169,7 @@ export function Drawer({ open, onOpenChange, side = "right", title, description,
134
169
  // instead of resizing relative to what's on screen.
135
170
  setResizedWidthPx((current) => clamp((maximized ? effectiveWidthPx : current) + delta, minWidthPx, effectiveMaxWidthPx()));
136
171
  };
137
- return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, modal: modal, children: _jsxs(DrawerSheetContent, { side: side, "data-testid": testId, overlayStyle: overlayStyle,
172
+ return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, modal: modal, children: _jsxs(DrawerSheetContent, { ref: setSheet, side: side, "data-testid": testId, overlayStyle: overlayStyle,
138
173
  // Radix already drops the overlay and the focus trap for a
139
174
  // non-modal Root; only the dismiss-on-outside-interaction is left
140
175
  // to suppress, so a click into the page behind keeps the panel open.