@cosmicdrift/kumiko-renderer 0.398.0 → 0.400.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 (84) hide show
  1. package/dist/app/entity-edit-prefill.js +7 -1
  2. package/dist/app/entity-list-facet-specs.js +2 -0
  3. package/dist/app/entity-list-screen.js +3 -3
  4. package/dist/app/form-schema.js +42 -17
  5. package/dist/app/list-facets.d.ts +7 -4
  6. package/dist/app/list-facets.js +23 -6
  7. package/dist/app/projection-detail-body.js +7 -0
  8. package/dist/app/projection-detail-header-model.d.ts +1 -1
  9. package/dist/app/projection-detail-header-model.js +2 -2
  10. package/dist/app/projection-detail-header-region.d.ts +1 -0
  11. package/dist/app/projection-detail-header-region.js +1 -1
  12. package/dist/app/projection-detail-header.js +4 -2
  13. package/dist/app/projection-detail-navigation.d.ts +1 -0
  14. package/dist/app/projection-detail-navigation.js +9 -1
  15. package/dist/app/projection-list-body.js +1 -1
  16. package/dist/app/reference-facet-bridge.d.ts +14 -2
  17. package/dist/app/reference-facet-bridge.js +37 -18
  18. package/dist/app/use-projection-list-query.d.ts +2 -2
  19. package/dist/app/use-projection-list-query.js +2 -1
  20. package/dist/app/use-related-list-tab-counts.d.ts +5 -0
  21. package/dist/app/use-related-list-tab-counts.js +68 -0
  22. package/dist/components/embedded-list-field.js +69 -91
  23. package/dist/components/embedded-list-paste.d.ts +2 -0
  24. package/dist/components/embedded-list-paste.js +73 -0
  25. package/dist/components/related-list-section.js +7 -11
  26. package/dist/components/render-edit-fields-section.js +1 -1
  27. package/dist/components/render-edit-helpers.js +2 -2
  28. package/dist/components/render-field-choice-inputs.js +1 -1
  29. package/dist/data-table-facet-types.d.ts +5 -0
  30. package/dist/embedded-list-types.d.ts +117 -0
  31. package/dist/embedded-list-types.js +1 -0
  32. package/dist/headless/form/form-controller.js +9 -1
  33. package/dist/headless/index.d.ts +1 -1
  34. package/dist/headless/index.js +1 -1
  35. package/dist/headless/view-model/edit.js +40 -50
  36. package/dist/headless/view-model/embedded-list-cells.d.ts +12 -0
  37. package/dist/headless/view-model/embedded-list-cells.js +49 -0
  38. package/dist/headless/view-model/embedded-list.d.ts +1 -1
  39. package/dist/headless/view-model/embedded-list.js +1 -1
  40. package/dist/headless/view-model/index.d.ts +1 -1
  41. package/dist/headless/view-model/index.js +1 -1
  42. package/dist/headless/view-model/list.js +2 -3
  43. package/dist/headless/view-model/types.d.ts +23 -6
  44. package/dist/hooks/related-list-parent-payload.d.ts +2 -0
  45. package/dist/hooks/related-list-parent-payload.js +7 -0
  46. package/dist/i18n-defaults.js +11 -0
  47. package/dist/primitives.d.ts +4 -88
  48. package/dist/web/layout/notification-bell.d.ts +5 -0
  49. package/dist/web/layout/notification-bell.js +39 -0
  50. package/dist/web/layout/shell-header.js +4 -1
  51. package/dist/web/layout/use-in-app-inbox.d.ts +15 -0
  52. package/dist/web/layout/use-in-app-inbox.js +61 -0
  53. package/dist/web/lib/file-upload-form-data.d.ts +2 -0
  54. package/dist/web/lib/file-upload-form-data.js +2 -0
  55. package/dist/web/lib/upload-file-ref.d.ts +1 -1
  56. package/dist/web/primitives/data-table-cell.d.ts +2 -0
  57. package/dist/web/primitives/data-table-cell.js +1 -1
  58. package/dist/web/primitives/embedded-file-cell.d.ts +11 -0
  59. package/dist/web/primitives/embedded-file-cell.js +50 -0
  60. package/dist/web/primitives/embedded-list-cards.d.ts +38 -0
  61. package/dist/web/primitives/embedded-list-cards.js +71 -0
  62. package/dist/web/primitives/embedded-list-cell.d.ts +41 -0
  63. package/dist/web/primitives/embedded-list-cell.js +132 -0
  64. package/dist/web/primitives/embedded-list-input.d.ts +1 -1
  65. package/dist/web/primitives/embedded-list-input.js +29 -142
  66. package/dist/web/primitives/facet-filter.js +4 -0
  67. package/dist/web/primitives/facet-search-filter.d.ts +9 -0
  68. package/dist/web/primitives/facet-search-filter.js +31 -0
  69. package/dist/web/primitives/file-upload.d.ts +2 -1
  70. package/dist/web/primitives/file-upload.js +2 -1
  71. package/dist/web/primitives/input-choice-kinds.js +1 -1
  72. package/dist/web/primitives/input-optional-props.d.ts +2 -1
  73. package/dist/web/primitives/input-optional-props.js +2 -1
  74. package/dist/web/primitives/locale-number.d.ts +3 -0
  75. package/dist/web/primitives/locale-number.js +8 -0
  76. package/dist/web/primitives/number-input.d.ts +2 -1
  77. package/dist/web/primitives/number-input.js +4 -11
  78. package/dist/web/primitives/segmented-select.d.ts +6 -1
  79. package/dist/web/primitives/segmented-select.js +8 -4
  80. package/dist/web/sse/live-events.js +14 -3
  81. package/dist/web/widgets/status-badge.d.ts +1 -0
  82. package/dist/web/widgets/status-badge.js +1 -1
  83. package/package.json +5 -5
  84. package/src/changes.json +18 -0
@@ -0,0 +1,61 @@
1
+ import { IN_APP_NOTIFICATION_DELIVERED_EVENT, IN_APP_NOTIFICATION_QUERIES, IN_APP_NOTIFICATION_WRITES, } from "@cosmicdrift/kumiko-types/in-app-notification-types";
2
+ import { useCallback, useEffect, useState } from "react";
3
+ import { useDispatcher } from "../../context/dispatcher-context.js";
4
+ import { useMutation } from "../../hooks/use-mutation.js";
5
+ import { useQuery } from "../../hooks/use-query.js";
6
+ import { useLiveEvents } from "../../sse/live-events.js";
7
+ export const IN_APP_INBOX_PAGE_SIZE = 20;
8
+ /** Headless state of the notification bell: the unread count always, the list only while `open`.
9
+ * The delivered signal refetches both; no polling. */
10
+ export function useInAppInbox(open) {
11
+ const dispatcher = useDispatcher();
12
+ const subscribe = useLiveEvents();
13
+ const count = useQuery(IN_APP_NOTIFICATION_QUERIES.unreadCount, {});
14
+ const firstPage = useQuery(IN_APP_NOTIFICATION_QUERIES.inbox, { limit: IN_APP_INBOX_PAGE_SIZE }, { enabled: open });
15
+ const [olderRows, setOlderRows] = useState([]);
16
+ const [olderCursor, setOlderCursor] = useState(undefined);
17
+ const markReadWrite = useMutation(IN_APP_NOTIFICATION_WRITES.markRead);
18
+ const markAllReadWrite = useMutation(IN_APP_NOTIFICATION_WRITES.markAllRead);
19
+ const refetchCount = count.refetch;
20
+ const refetchFirstPage = firstPage.refetch;
21
+ const refetchAll = useCallback(async () => {
22
+ setOlderRows([]);
23
+ setOlderCursor(undefined);
24
+ await Promise.all([refetchCount(), open ? refetchFirstPage() : Promise.resolve()]);
25
+ }, [refetchCount, refetchFirstPage, open]);
26
+ useEffect(() => subscribe(IN_APP_NOTIFICATION_DELIVERED_EVENT, () => void refetchAll()), [subscribe, refetchAll]);
27
+ const firstRows = firstPage.data?.rows ?? [];
28
+ const nextCursor = olderCursor === undefined ? (firstPage.data?.nextCursor ?? null) : olderCursor;
29
+ const loadMore = useCallback(async () => {
30
+ // skip: no older page left
31
+ if (nextCursor === null)
32
+ return;
33
+ const result = await dispatcher.query(IN_APP_NOTIFICATION_QUERIES.inbox, {
34
+ limit: IN_APP_INBOX_PAGE_SIZE,
35
+ cursor: nextCursor,
36
+ });
37
+ // skip: keep the loaded rows, the next click retries
38
+ if (!result.isSuccess)
39
+ return;
40
+ setOlderRows((previous) => [...previous, ...result.data.rows]);
41
+ setOlderCursor(result.data.nextCursor);
42
+ }, [dispatcher, nextCursor]);
43
+ const markRead = useCallback(async (id) => {
44
+ await markReadWrite.mutate({ id });
45
+ await refetchAll();
46
+ }, [markReadWrite.mutate, refetchAll]);
47
+ const markAllRead = useCallback(async () => {
48
+ await markAllReadWrite.mutate({});
49
+ await refetchAll();
50
+ }, [markAllReadWrite.mutate, refetchAll]);
51
+ return {
52
+ unreadCount: count.data?.count ?? 0,
53
+ rows: [...firstRows, ...olderRows],
54
+ hasMore: nextCursor !== null,
55
+ loading: firstPage.loading,
56
+ failed: firstPage.error !== null,
57
+ loadMore,
58
+ markRead,
59
+ markAllRead,
60
+ };
61
+ }
@@ -3,6 +3,8 @@ export type FileUploadFormDataInput = {
3
3
  readonly entityType?: string | undefined;
4
4
  readonly entityId?: string | undefined;
5
5
  readonly fieldName?: string | undefined;
6
+ /** File sub-field of the embedded `fieldName`. */
7
+ readonly subField?: string | undefined;
6
8
  /** Screen QN of an actionForm: the upload is pending for that form's field, not for an entity. */
7
9
  readonly actionForm?: string | undefined;
8
10
  };
@@ -7,6 +7,8 @@ export function buildFileUploadFormData(input) {
7
7
  formData.append("entityId", input.entityId);
8
8
  if (input.fieldName !== undefined)
9
9
  formData.append("fieldName", input.fieldName);
10
+ if (input.subField !== undefined)
11
+ formData.append("subField", input.subField);
10
12
  if (input.actionForm !== undefined)
11
13
  formData.append("actionForm", input.actionForm);
12
14
  return formData;
@@ -1,5 +1,5 @@
1
1
  import { type FileUploadFormDataInput } from "./file-upload-form-data.ts";
2
- export type FileRefTarget = Pick<FileUploadFormDataInput, "entityType" | "entityId" | "fieldName" | "actionForm">;
2
+ export type FileRefTarget = Pick<FileUploadFormDataInput, "entityType" | "entityId" | "fieldName" | "subField" | "actionForm">;
3
3
  /** Carries the server's own `error` message when the upload endpoint sent one. */
4
4
  export declare class FileUploadFailedError extends Error {
5
5
  readonly fromServer: boolean;
@@ -1,6 +1,7 @@
1
1
  import type { SelectOptionTone } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import { type ReactNode } from "react";
3
3
  import type { CellLink, CellLinkItem, ListColumnFileDisplay, ListRowViewModel, RuntimeCellLink, RuntimeCellLinkItems } from "../../headless/index.ts";
4
+ import { type PrimitivesRegistry } from "../../index.ts";
4
5
  import "../react-css-custom-properties.ts";
5
6
  export declare function isEmptyCellValue(value: unknown): boolean;
6
7
  export declare function isUnlabeledFalse(type: string, value: unknown, renderer: unknown): boolean;
@@ -22,6 +23,7 @@ export declare function isComponentRendererRef(renderer: unknown): {
22
23
  } | undefined;
23
24
  export declare function defaultCellRender(value: unknown, type: string, optionLabels?: Readonly<Record<string, string>>, locale?: string, columnKey?: string, grouping?: boolean): string;
24
25
  export declare function cellTitle(value: unknown): string | undefined;
26
+ export declare function selectPill(value: unknown, optionLabels: Readonly<Record<string, string>> | undefined, optionTones: Readonly<Partial<Record<string, SelectOptionTone>>> | undefined, locale: string | undefined, RegisteredStatusBadge: PrimitivesRegistry["StatusBadge"]): ReactNode;
25
27
  type DataTableCellProps = {
26
28
  readonly value: unknown;
27
29
  readonly row: Readonly<Record<string, unknown>>;
@@ -201,7 +201,7 @@ export function cellTitle(value) {
201
201
  return String(value);
202
202
  return undefined;
203
203
  }
204
- function selectPill(value, optionLabels, optionTones, locale, RegisteredStatusBadge) {
204
+ export function selectPill(value, optionLabels, optionTones, locale, RegisteredStatusBadge) {
205
205
  const label = defaultCellRender(value, "select", optionLabels, locale);
206
206
  const declaredTone = typeof value === "string" && optionTones && Object.hasOwn(optionTones, value)
207
207
  ? optionTones[value]
@@ -0,0 +1,11 @@
1
+ import { type ReactNode } from "react";
2
+ import type { EmbeddedListColumn, EmbeddedListInputProps } from "../../index.ts";
3
+ export declare function EmbeddedFileCell({ cellId, label, column, value, readOnly, uploadTarget, onChange, }: {
4
+ readonly cellId: string;
5
+ readonly label: string;
6
+ readonly column: EmbeddedListColumn;
7
+ readonly value: unknown;
8
+ readonly readOnly: boolean;
9
+ readonly uploadTarget: EmbeddedListInputProps["uploadTarget"];
10
+ readonly onChange: (value: unknown) => void;
11
+ }): ReactNode;
@@ -0,0 +1,50 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // File/image cell of an embedded list row: thumbs plus an add tile. The value is a fileRef id
3
+ // array; uploads carry the list's field name and the cell's `subField` so the server can
4
+ // resolve the sub-field's accept/maxSize and attach the ref to the right slot.
5
+ import { Loader2, Plus, X } from "lucide-react";
6
+ import { useRef, useState } from "react";
7
+ import { fileIdsOf } from "../../file-ids-of.js";
8
+ import { useSessionEndedSignal, useTranslation } from "../../index.js";
9
+ import { toAcceptAttr } from "../lib/accept-attr.js";
10
+ import { cn } from "../lib/cn.js";
11
+ import { resizeImageBeforeUpload } from "../lib/resize-image.js";
12
+ import { uploadErrorKey } from "../lib/upload-error-key.js";
13
+ import { isSessionEndedUploadError, uploadFileRef } from "../lib/upload-file-ref.js";
14
+ import { FileNameLink, FileThumbnail } from "./file-thumbnail.js";
15
+ export function EmbeddedFileCell({ cellId, label, column, value, readOnly, uploadTarget, onChange, }) {
16
+ const t = useTranslation();
17
+ const sessionEnded = useSessionEndedSignal();
18
+ const inputRef = useRef(null);
19
+ const [uploading, setUploading] = useState(false);
20
+ const [error, setError] = useState(null);
21
+ const ids = fileIdsOf(value);
22
+ const isImage = column.type === "images";
23
+ const canAdd = !readOnly &&
24
+ uploadTarget !== undefined &&
25
+ (column.maxCount === undefined || ids.length < column.maxCount);
26
+ const acceptAttr = toAcceptAttr(column.accept);
27
+ async function onPick(e) {
28
+ const file = e.target.files?.[0];
29
+ if (file === undefined || uploadTarget === undefined)
30
+ return;
31
+ setUploading(true);
32
+ setError(null);
33
+ try {
34
+ const uploadFile = await resizeImageBeforeUpload(file);
35
+ const id = await uploadFileRef(uploadFile, { ...uploadTarget, subField: column.field });
36
+ onChange([...ids, id]);
37
+ }
38
+ catch (cause) {
39
+ if (isSessionEndedUploadError(cause))
40
+ sessionEnded?.notify();
41
+ setError(t(uploadErrorKey(cause)));
42
+ }
43
+ finally {
44
+ setUploading(false);
45
+ if (inputRef.current)
46
+ inputRef.current.value = "";
47
+ }
48
+ }
49
+ return (_jsxs("div", { "data-cell-id": cellId, className: "flex min-w-0 flex-wrap items-center gap-2", children: [ids.map((fileId) => (_jsxs("div", { className: "relative", children: [isImage ? (_jsx(FileThumbnail, { fileId: fileId, className: "size-16" })) : (_jsx(FileNameLink, { fileId: fileId, fallbackLabel: t("kumiko.field.file-upload.attached") })), !readOnly && (_jsx("button", { type: "button", "aria-label": t("kumiko.field.file-upload.remove"), className: "absolute -right-2 -top-2 flex size-6 items-center justify-center rounded-full border bg-card text-muted-foreground", onClick: () => onChange(ids.filter((id) => id !== fileId)), children: _jsx(X, { className: "size-3", "aria-hidden": "true" }) }))] }, fileId))), canAdd && (_jsxs(_Fragment, { children: [_jsx("input", { ref: inputRef, type: "file", className: "hidden", ...(acceptAttr !== undefined && { accept: acceptAttr }), ...(column.capture !== undefined && { capture: column.capture }), onChange: (e) => void onPick(e) }), _jsx("button", { type: "button", "aria-label": `${label}: ${t("kumiko.field.file-upload.upload")}`, disabled: uploading, className: cn("flex size-16 items-center justify-center rounded-md border border-dashed text-muted-foreground"), onClick: () => inputRef.current?.click(), children: uploading ? (_jsx(Loader2, { className: "size-5 animate-spin", "aria-hidden": "true" })) : (_jsx(Plus, { className: "size-5", "aria-hidden": "true" })) })] })), error !== null && _jsx("span", { className: "w-full text-xs text-destructive", children: error })] }));
50
+ }
@@ -0,0 +1,38 @@
1
+ import type { ClipboardEvent, KeyboardEvent, ReactNode } from "react";
2
+ import type { FieldIssue } from "../../headless/index.ts";
3
+ import type { EmbeddedListColumn, EmbeddedListInputProps } from "../../index.ts";
4
+ export type EmbeddedListCardsProps = {
5
+ readonly columns: readonly EmbeddedListColumn[];
6
+ readonly rows: EmbeddedListInputProps["rows"];
7
+ readonly titleColumn: EmbeddedListColumn | undefined;
8
+ readonly lastEditableIndex: number;
9
+ readonly rowIssues: EmbeddedListInputProps["rowIssues"];
10
+ readonly cellIssues: EmbeddedListInputProps["cellIssues"];
11
+ readonly listIssues: readonly FieldIssue[] | undefined;
12
+ readonly totalsNode: ReactNode;
13
+ readonly disabled: boolean;
14
+ readonly showControls: boolean;
15
+ readonly addDisabled: boolean;
16
+ readonly minItems: number | undefined;
17
+ readonly maxItems: number | undefined;
18
+ readonly currency: string;
19
+ readonly locale: string;
20
+ readonly uploadTarget: EmbeddedListInputProps["uploadTarget"];
21
+ readonly onCellChange: EmbeddedListInputProps["onCellChange"];
22
+ readonly onAddRow: () => void;
23
+ readonly onRemoveRow: (rowIndex: number) => void;
24
+ readonly onDuplicateRow: (rowIndex: number) => void;
25
+ readonly onMoveRow: (fromIndex: number, toIndex: number) => void;
26
+ readonly handlePaste: (rowIndex: number, columnIndex: number) => (event: ClipboardEvent<HTMLElement>) => void;
27
+ readonly handleLastCellKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
28
+ readonly pasteEnabled: boolean;
29
+ readonly cellId: (rowIndex: number, field: string) => string;
30
+ readonly cellLabel: (column: EmbeddedListColumn, rowIndex: number) => string;
31
+ readonly testId: string | undefined;
32
+ readonly addLabel: string;
33
+ readonly duplicateLabel: string;
34
+ readonly moveUpLabel: string;
35
+ readonly moveDownLabel: string;
36
+ readonly removeLabel: string;
37
+ };
38
+ export declare function EmbeddedListCards(props: EmbeddedListCardsProps): ReactNode;
@@ -0,0 +1,71 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Mobile card layout for EmbeddedListInput. One card per row: the title, then
3
+ // one block per visible column. Columns marked hiddenInCard are skipped, and a
4
+ // column with cardAttachedTo sits unlabeled next to its anchor control.
5
+ import { Plus } from "lucide-react";
6
+ import { useTranslation } from "../../index.js";
7
+ import { Button as UiButton } from "../ui/button.js";
8
+ import { IssueMessages, isDerivedCell, RowActions, readOnlyCellText, renderCellControl, } from "./embedded-list-cell.js";
9
+ import { formatLocaleNumber } from "./locale-number.js";
10
+ function CardLimitsHint({ limits, row, locale, testId, }) {
11
+ const t = useTranslation();
12
+ const rawMin = row[limits.min];
13
+ const rawMax = row[limits.max];
14
+ const min = typeof rawMin === "number" ? formatLocaleNumber(rawMin, locale, { grouping: true }) : undefined;
15
+ const max = typeof rawMax === "number" ? formatLocaleNumber(rawMax, locale, { grouping: true }) : undefined;
16
+ let text;
17
+ if (min !== undefined && max !== undefined) {
18
+ text = t("kumiko.field.embedded-list.limit-range", { min, max });
19
+ }
20
+ else if (min !== undefined) {
21
+ text = t("kumiko.field.embedded-list.limit-min", { min });
22
+ }
23
+ else if (max !== undefined) {
24
+ text = t("kumiko.field.embedded-list.limit-max", { max });
25
+ }
26
+ if (text === undefined)
27
+ return null;
28
+ return (_jsx("div", { "data-testid": testId, className: "text-xs text-muted-foreground", children: text }));
29
+ }
30
+ export function EmbeddedListCards(props) {
31
+ const { columns, rows, titleColumn, lastEditableIndex, rowIssues, cellIssues, listIssues, totalsNode, disabled, showControls, addDisabled, minItems, maxItems, currency, locale, uploadTarget, onCellChange, onAddRow, onRemoveRow, onDuplicateRow, onMoveRow, handlePaste, handleLastCellKeyDown, pasteEnabled, cellId, cellLabel, testId, addLabel, duplicateLabel, moveUpLabel, moveDownLabel, removeLabel, } = props;
32
+ const testIdFor = (suffix) => testId !== undefined ? `${testId}-${suffix}` : undefined;
33
+ return (_jsxs("div", { "data-testid": testIdFor("mobile"), className: "md:hidden flex flex-col gap-3", children: [rows.map((row, rowIndex) => {
34
+ const isLastRow = rowIndex === rows.length - 1;
35
+ const rowIssuesForRow = rowIssues?.[rowIndex];
36
+ const isVisible = (column) => column !== titleColumn &&
37
+ column.hiddenInCard !== true &&
38
+ column.isHiddenInRow?.(row) !== true;
39
+ const renderControl = (column) => renderCellControl({
40
+ cellId: cellId(rowIndex, column.field),
41
+ label: cellLabel(column, rowIndex),
42
+ column,
43
+ value: row[column.field],
44
+ disabled,
45
+ onChange: (value) => onCellChange(rowIndex, column.field, value),
46
+ currency,
47
+ locale,
48
+ derived: isDerivedCell(column, row),
49
+ touch: true,
50
+ uploadTarget,
51
+ });
52
+ const renderIssues = (column) => (_jsx(IssueMessages, { issues: cellIssues?.[`${rowIndex}.${column.field}`], testId: testIdFor(`cell-${rowIndex}-${column.field}-errors`) }));
53
+ const anchorHasAttachment = (anchor) => columns.find((other) => other.cardAttachedTo === anchor.field &&
54
+ other.hiddenInCard !== true &&
55
+ other.field !== anchor.field);
56
+ return (_jsxs("div", { "data-testid": testIdFor(`row-${rowIndex}`), className: "flex flex-col gap-3 rounded-lg border bg-card p-4", children: [titleColumn !== undefined && (_jsxs("div", { "data-testid": testIdFor(`row-${rowIndex}-title`), className: "font-medium", children: [rowIndex + 1, " ", readOnlyCellText(titleColumn, row[titleColumn.field], currency, locale)] })), columns.map((column, columnIndex) => {
57
+ if (!isVisible(column))
58
+ return null;
59
+ const anchor = column.cardAttachedTo !== undefined
60
+ ? columns.find((other) => other.field === column.cardAttachedTo)
61
+ : undefined;
62
+ if (anchor !== undefined && isVisible(anchor))
63
+ return null;
64
+ const attached = anchorHasAttachment(column);
65
+ const isLastCell = isLastRow && columnIndex === lastEditableIndex;
66
+ const showLabel = column.cardAttachedTo === undefined;
67
+ const control = (_jsxs("div", { className: attached !== undefined ? "min-w-0 flex-1" : undefined, children: [renderControl(column), column.cardLimits !== undefined && (_jsx(CardLimitsHint, { limits: column.cardLimits, row: row, locale: locale, testId: testIdFor(`cell-${rowIndex}-${column.field}-limits`) }))] }));
68
+ return (_jsxs("div", { className: "flex flex-col gap-1", onPaste: pasteEnabled ? handlePaste(rowIndex, columnIndex) : undefined, onKeyDown: isLastCell ? handleLastCellKeyDown : undefined, children: [showLabel && (_jsxs("span", { className: "text-xs font-medium text-muted-foreground", children: [column.label, column.isRequiredInRow?.(row) === true && " *"] })), attached !== undefined ? (_jsxs("div", { className: "flex items-start gap-3", children: [control, _jsx("div", { className: "shrink-0", children: renderControl(attached) })] })) : (control), renderIssues(column), attached !== undefined && renderIssues(attached)] }, column.field));
69
+ }), rowIssuesForRow !== undefined && rowIssuesForRow.length > 0 && (_jsx(IssueMessages, { issues: rowIssuesForRow, testId: testIdFor(`row-${rowIndex}-issues`) })), showControls && (_jsx("div", { className: "flex items-center justify-end gap-1 border-t pt-3", children: _jsx(RowActions, { rowIndex: rowIndex, rowsLength: rows.length, minItems: minItems, maxItems: maxItems, onDuplicateRow: onDuplicateRow, onMoveRow: onMoveRow, onRemoveRow: onRemoveRow, duplicateLabel: duplicateLabel, moveUpLabel: moveUpLabel, moveDownLabel: moveDownLabel, removeLabel: removeLabel, testIdPrefix: testIdFor(`row-${rowIndex}`), buttonSizeClass: "size-10" }) }))] }, rowIndex));
70
+ }), showControls && (_jsxs(UiButton, { type: "button", variant: "outline", onClick: onAddRow, disabled: addDisabled, "data-testid": testIdFor("add"), children: [_jsx(Plus, { className: "size-4", "aria-hidden": "true" }), addLabel] })), totalsNode, _jsx(IssueMessages, { issues: listIssues, testId: testIdFor("list-issues") })] }));
71
+ }
@@ -0,0 +1,41 @@
1
+ import type { ReactNode } from "react";
2
+ import { type FieldIssue } from "../../headless/index.ts";
3
+ import type { EmbeddedListCellType, EmbeddedListColumn, EmbeddedListInputProps } from "../../index.ts";
4
+ export declare function columnAlignClass(type: EmbeddedListCellType): string;
5
+ export declare function IssueMessages({ issues, testId, constrainWidth, }: {
6
+ readonly issues: readonly FieldIssue[] | undefined;
7
+ readonly testId?: string;
8
+ readonly constrainWidth?: boolean;
9
+ }): ReactNode;
10
+ export declare function readOnlyCellText(column: EmbeddedListColumn, value: unknown, currency: string, locale: string): string;
11
+ export declare function isDerivedCell(column: EmbeddedListColumn, row: Readonly<Record<string, unknown>>): boolean;
12
+ export declare function renderCellControl({ cellId, column, label, value, disabled, onChange, currency, locale, derived, touch, uploadTarget, }: {
13
+ readonly derived: boolean;
14
+ readonly touch: boolean;
15
+ readonly uploadTarget: EmbeddedListInputProps["uploadTarget"];
16
+ readonly cellId: string;
17
+ readonly label: string;
18
+ readonly column: EmbeddedListColumn;
19
+ readonly value: unknown;
20
+ readonly disabled: boolean;
21
+ readonly onChange: (value: unknown) => void;
22
+ readonly currency: string;
23
+ readonly locale: string;
24
+ }): ReactNode;
25
+ type RowActionsProps = {
26
+ readonly rowIndex: number;
27
+ readonly rowsLength: number;
28
+ readonly minItems: number | undefined;
29
+ readonly maxItems: number | undefined;
30
+ readonly onDuplicateRow: (rowIndex: number) => void;
31
+ readonly onMoveRow: (fromIndex: number, toIndex: number) => void;
32
+ readonly onRemoveRow: (rowIndex: number) => void;
33
+ readonly duplicateLabel: string;
34
+ readonly moveUpLabel: string;
35
+ readonly moveDownLabel: string;
36
+ readonly removeLabel: string;
37
+ readonly testIdPrefix: string | undefined;
38
+ readonly buttonSizeClass?: string;
39
+ };
40
+ export declare function RowActions({ rowIndex, rowsLength, minItems, maxItems, onDuplicateRow, onMoveRow, onRemoveRow, duplicateLabel, moveUpLabel, moveDownLabel, removeLabel, testIdPrefix, buttonSizeClass, }: RowActionsProps): ReactNode;
41
+ export {};
@@ -0,0 +1,132 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ArrowDown, ArrowUp, Copy, Trash2 } from "lucide-react";
3
+ import { fieldIssueText } from "../../field-issue-text.js";
4
+ import { fileIdsOf } from "../../file-ids-of.js";
5
+ import { applyFormatSpec } from "../../headless/index.js";
6
+ import { useTranslation } from "../../index.js";
7
+ import { cn } from "../lib/cn.js";
8
+ import { Button as UiButton } from "../ui/button.js";
9
+ import { Checkbox } from "../ui/checkbox.js";
10
+ import { Input as UiInput } from "../ui/input.js";
11
+ import { ComboboxInput } from "./combobox.js";
12
+ import { selectPill } from "./data-table-cell.js";
13
+ import { DateInput } from "./date-input.js";
14
+ import { EmbeddedFileCell } from "./embedded-file-cell.js";
15
+ import { formatLocaleNumber } from "./locale-number.js";
16
+ import { formatMoney, MoneyInput } from "./money-input.js";
17
+ import { NumberInput } from "./number-input.js";
18
+ import { SegmentedControl } from "./segmented-select.js";
19
+ import { TimestampInput } from "./timestamp-input.js";
20
+ export function columnAlignClass(type) {
21
+ switch (type) {
22
+ case "money":
23
+ case "number":
24
+ case "decimal":
25
+ return "text-right";
26
+ case "boolean":
27
+ return "text-center";
28
+ case "date":
29
+ case "timestamp":
30
+ case "text":
31
+ case "select":
32
+ case "reference":
33
+ case "images":
34
+ case "files":
35
+ return "text-left";
36
+ default: {
37
+ const exhaustiveCheck = type;
38
+ return exhaustiveCheck;
39
+ }
40
+ }
41
+ }
42
+ export function IssueMessages({ issues, testId, constrainWidth, }) {
43
+ const t = useTranslation();
44
+ if (issues === undefined || issues.length === 0)
45
+ return null;
46
+ return (_jsx("div", { role: "alert", "data-testid": testId, className: cn("text-xs text-destructive", constrainWidth === true && "max-w-[16rem] whitespace-normal break-words"), children: issues.map((issue) => (_jsx("div", { children: fieldIssueText(t, issue) }, `${issue.path}:${issue.code}`))) }));
47
+ }
48
+ export function readOnlyCellText(column, value, currency, locale) {
49
+ if (value === null || value === undefined || value === "")
50
+ return "";
51
+ switch (column.type) {
52
+ case "boolean":
53
+ return value === true ? "✓" : "";
54
+ case "date":
55
+ case "timestamp":
56
+ return applyFormatSpec({ format: column.type, locale }, value);
57
+ case "number":
58
+ case "decimal":
59
+ return typeof value === "number"
60
+ ? formatLocaleNumber(value, locale, { grouping: true })
61
+ : String(value);
62
+ case "money":
63
+ return typeof value === "number" ? formatMoney(value, currency, locale) : String(value);
64
+ case "select":
65
+ return typeof value === "string" ? (column.optionLabels?.[value] ?? value) : String(value);
66
+ case "reference":
67
+ return typeof value === "string"
68
+ ? (column.referenceOptions?.find((o) => o.value === value)?.label ?? value)
69
+ : String(value);
70
+ case "text":
71
+ return String(value);
72
+ case "images":
73
+ case "files":
74
+ return String(fileIdsOf(value).length);
75
+ default: {
76
+ const exhaustiveCheck = column.type;
77
+ return exhaustiveCheck;
78
+ }
79
+ }
80
+ }
81
+ export function isDerivedCell(column, row) {
82
+ return column.isDerivedInRow?.(row) ?? column.derived;
83
+ }
84
+ export function renderCellControl({ cellId, column, label, value, disabled, onChange, currency, locale, derived, touch, uploadTarget, }) {
85
+ if (column.readOnly === true && !derived) {
86
+ return (_jsx("div", { "data-cell-id": cellId, "data-readonly-cell": "", className: cn("min-w-0 whitespace-normal break-words py-1.5 text-sm", columnAlignClass(column.type)), children: readOnlyCellText(column, value, currency, locale) }));
87
+ }
88
+ const isDisabled = disabled || derived;
89
+ const controlHeight = touch ? "h-11" : undefined;
90
+ switch (column.type) {
91
+ case "text":
92
+ return (_jsx(UiInput, { "data-cell-id": cellId, type: "text", "aria-label": label, className: cn("min-w-0", controlHeight), disabled: isDisabled, value: typeof value === "string" ? value : "", onChange: (e) => onChange(e.target.value) }));
93
+ case "number":
94
+ case "decimal":
95
+ return (_jsx("div", { "data-cell-id": cellId, className: "w-full min-w-0", children: _jsx(NumberInput, { id: cellId, name: cellId, ariaLabel: label, locale: locale, value: typeof value === "number" ? value : "", onChange: (v) => onChange(v), disabled: isDisabled, className: cn("text-right", controlHeight) }) }));
96
+ case "boolean":
97
+ return (_jsx("div", { className: "flex justify-center", children: _jsx(Checkbox, { "data-cell-id": cellId, "aria-label": label, disabled: isDisabled, checked: value === true, onCheckedChange: (checked) => onChange(checked === true) }) }));
98
+ case "date":
99
+ return (_jsx("div", { "data-cell-id": cellId, className: "w-full min-w-0", children: _jsx(DateInput, { id: cellId, name: cellId, ariaLabel: label, value: typeof value === "string" ? value : "", onChange: (v) => onChange(v), disabled: isDisabled }) }));
100
+ case "timestamp":
101
+ return (_jsx("div", { "data-cell-id": cellId, className: "w-full min-w-0", children: _jsx(TimestampInput, { id: cellId, name: cellId, ariaLabel: label, value: typeof value === "string" ? value : "", onChange: (v) => onChange(v), disabled: isDisabled }) }));
102
+ case "money":
103
+ return (_jsx("div", { "data-cell-id": cellId, className: "w-full min-w-0", children: _jsx(MoneyInput, { id: cellId, name: cellId, ariaLabel: label, value: typeof value === "number" ? value : "", onChange: (v) => onChange(v), currency: currency, disabled: isDisabled }) }));
104
+ case "images":
105
+ case "files":
106
+ return (_jsx(EmbeddedFileCell, { cellId: cellId, label: label, column: column, value: value, readOnly: isDisabled, uploadTarget: uploadTarget, onChange: onChange }));
107
+ case "select": {
108
+ if (derived) {
109
+ return (_jsx("div", { "data-cell-id": cellId, className: "min-w-0 py-1.5", children: selectPill(value, column.optionLabels, column.optionTones, locale, undefined) }));
110
+ }
111
+ const options = (column.options ?? []).map((opt) => ({
112
+ value: opt,
113
+ label: column.optionLabels?.[opt] ?? opt,
114
+ }));
115
+ if (column.display === "radio" && options.length > 0) {
116
+ return (_jsx("div", { "data-cell-id": cellId, className: "w-full min-w-0", children: _jsx(SegmentedControl, { id: cellId, name: cellId, labelProps: { "aria-label": label }, value: typeof value === "string" ? value : "", onChange: (v) => onChange(v), options: options, disabled: isDisabled, ...(column.optionTones !== undefined && { tones: column.optionTones }), ...(touch && { size: "touch" }) }) }));
117
+ }
118
+ return (_jsx("div", { "data-cell-id": cellId, className: "w-full min-w-0", children: _jsx(ComboboxInput, { id: cellId, name: cellId, ariaLabel: label, value: typeof value === "string" ? value : "", onChange: (v) => onChange(v), options: options, disabled: isDisabled }) }));
119
+ }
120
+ case "reference":
121
+ return (_jsx("div", { "data-cell-id": cellId, className: "w-full min-w-0", children: _jsx(ComboboxInput, { id: cellId, name: cellId, ariaLabel: label, value: typeof value === "string" ? value : "", onChange: (v) => onChange(v), options: column.referenceOptions ?? [], disabled: isDisabled, loading: column.referenceLoading }) }));
122
+ default: {
123
+ const exhaustiveCheck = column.type;
124
+ return exhaustiveCheck;
125
+ }
126
+ }
127
+ }
128
+ export function RowActions({ rowIndex, rowsLength, minItems, maxItems, onDuplicateRow, onMoveRow, onRemoveRow, duplicateLabel, moveUpLabel, moveDownLabel, removeLabel, testIdPrefix, buttonSizeClass = "size-7", }) {
129
+ const duplicateDisabled = maxItems !== undefined && rowsLength >= maxItems;
130
+ const removeDisabled = rowsLength <= (minItems ?? 0);
131
+ return (_jsxs("div", { className: "inline-flex items-center", children: [_jsx(UiButton, { type: "button", variant: "ghost", size: "icon", className: buttonSizeClass, "aria-label": duplicateLabel, disabled: duplicateDisabled, onClick: () => onDuplicateRow(rowIndex), "data-testid": testIdPrefix !== undefined ? `${testIdPrefix}-duplicate` : undefined, children: _jsx(Copy, { className: "size-4", "aria-hidden": "true" }) }), _jsx(UiButton, { type: "button", variant: "ghost", size: "icon", className: buttonSizeClass, "aria-label": moveUpLabel, disabled: rowIndex === 0, onClick: () => onMoveRow(rowIndex, rowIndex - 1), "data-testid": testIdPrefix !== undefined ? `${testIdPrefix}-move-up` : undefined, children: _jsx(ArrowUp, { className: "size-4", "aria-hidden": "true" }) }), _jsx(UiButton, { type: "button", variant: "ghost", size: "icon", className: buttonSizeClass, "aria-label": moveDownLabel, disabled: rowIndex === rowsLength - 1, onClick: () => onMoveRow(rowIndex, rowIndex + 1), "data-testid": testIdPrefix !== undefined ? `${testIdPrefix}-move-down` : undefined, children: _jsx(ArrowDown, { className: "size-4", "aria-hidden": "true" }) }), _jsx(UiButton, { type: "button", variant: "ghost", size: "icon", className: buttonSizeClass, "aria-label": removeLabel, disabled: removeDisabled, onClick: () => onRemoveRow(rowIndex), "data-testid": testIdPrefix !== undefined ? `${testIdPrefix}-remove` : undefined, children: _jsx(Trash2, { className: "size-4", "aria-hidden": "true" }) })] }));
132
+ }
@@ -5,5 +5,5 @@ type ScrollAffordance = {
5
5
  readonly canScrollRight: boolean;
6
6
  };
7
7
  export declare function computeScrollAffordance(scrollLeft: number, scrollWidth: number, clientWidth: number): ScrollAffordance;
8
- export declare function EmbeddedListInput({ id, columns, rows, totals, currency, disabled, fixedRows, minItems, maxItems, listIssues, rowIssues, cellIssues, onCellChange, onAddRow, onRemoveRow, onDuplicateRow, onMoveRow, onPasteCells, addLabel, actionsLabel, removeLabel, duplicateLabel, moveUpLabel, moveDownLabel, emptyLabel, emptyCtaLabel, testId, }: EmbeddedListInputProps): ReactNode;
8
+ export declare function EmbeddedListInput({ id, columns, rows, totals, currency, disabled, fixedRows, minItems, maxItems, listIssues, rowIssues, cellIssues, onCellChange, onAddRow, onRemoveRow, onDuplicateRow, onMoveRow, onPasteCells, addLabel, actionsLabel, removeLabel, duplicateLabel, moveUpLabel, moveDownLabel, emptyLabel, emptyCtaLabel, testId, uploadTarget, }: EmbeddedListInputProps): ReactNode;
9
9
  export {};