@cosmicdrift/kumiko-renderer 0.399.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 (63) hide show
  1. package/dist/app/entity-edit-prefill.js +7 -1
  2. package/dist/app/form-schema.js +42 -17
  3. package/dist/app/projection-detail-body.js +7 -0
  4. package/dist/app/projection-detail-header-model.d.ts +1 -1
  5. package/dist/app/projection-detail-header-model.js +2 -2
  6. package/dist/app/projection-detail-header-region.d.ts +1 -0
  7. package/dist/app/projection-detail-header-region.js +1 -1
  8. package/dist/app/projection-detail-header.js +4 -2
  9. package/dist/app/projection-detail-navigation.d.ts +1 -0
  10. package/dist/app/projection-detail-navigation.js +9 -1
  11. package/dist/app/use-related-list-tab-counts.d.ts +5 -0
  12. package/dist/app/use-related-list-tab-counts.js +68 -0
  13. package/dist/components/embedded-list-field.js +69 -91
  14. package/dist/components/embedded-list-paste.d.ts +2 -0
  15. package/dist/components/embedded-list-paste.js +73 -0
  16. package/dist/components/related-list-section.js +4 -8
  17. package/dist/components/render-edit-helpers.js +2 -2
  18. package/dist/components/render-field-choice-inputs.js +1 -1
  19. package/dist/embedded-list-types.d.ts +117 -0
  20. package/dist/embedded-list-types.js +1 -0
  21. package/dist/headless/form/form-controller.js +9 -1
  22. package/dist/headless/index.d.ts +1 -1
  23. package/dist/headless/index.js +1 -1
  24. package/dist/headless/view-model/edit.js +40 -50
  25. package/dist/headless/view-model/embedded-list-cells.d.ts +12 -0
  26. package/dist/headless/view-model/embedded-list-cells.js +49 -0
  27. package/dist/headless/view-model/embedded-list.d.ts +1 -1
  28. package/dist/headless/view-model/embedded-list.js +1 -1
  29. package/dist/headless/view-model/index.d.ts +1 -1
  30. package/dist/headless/view-model/index.js +1 -1
  31. package/dist/headless/view-model/list.js +2 -3
  32. package/dist/headless/view-model/types.d.ts +23 -6
  33. package/dist/hooks/related-list-parent-payload.d.ts +2 -0
  34. package/dist/hooks/related-list-parent-payload.js +7 -0
  35. package/dist/i18n-defaults.js +3 -0
  36. package/dist/primitives.d.ts +4 -88
  37. package/dist/web/lib/file-upload-form-data.d.ts +2 -0
  38. package/dist/web/lib/file-upload-form-data.js +2 -0
  39. package/dist/web/lib/upload-file-ref.d.ts +1 -1
  40. package/dist/web/primitives/data-table-cell.d.ts +2 -0
  41. package/dist/web/primitives/data-table-cell.js +1 -1
  42. package/dist/web/primitives/embedded-file-cell.d.ts +11 -0
  43. package/dist/web/primitives/embedded-file-cell.js +50 -0
  44. package/dist/web/primitives/embedded-list-cards.d.ts +38 -0
  45. package/dist/web/primitives/embedded-list-cards.js +71 -0
  46. package/dist/web/primitives/embedded-list-cell.d.ts +41 -0
  47. package/dist/web/primitives/embedded-list-cell.js +132 -0
  48. package/dist/web/primitives/embedded-list-input.d.ts +1 -1
  49. package/dist/web/primitives/embedded-list-input.js +29 -142
  50. package/dist/web/primitives/file-upload.d.ts +2 -1
  51. package/dist/web/primitives/file-upload.js +2 -1
  52. package/dist/web/primitives/input-choice-kinds.js +1 -1
  53. package/dist/web/primitives/input-optional-props.d.ts +2 -1
  54. package/dist/web/primitives/input-optional-props.js +2 -1
  55. package/dist/web/primitives/locale-number.d.ts +3 -0
  56. package/dist/web/primitives/locale-number.js +8 -0
  57. package/dist/web/primitives/number-input.d.ts +2 -1
  58. package/dist/web/primitives/number-input.js +4 -11
  59. package/dist/web/primitives/segmented-select.d.ts +6 -1
  60. package/dist/web/primitives/segmented-select.js +8 -4
  61. package/dist/web/widgets/status-badge.d.ts +1 -0
  62. package/dist/web/widgets/status-badge.js +1 -1
  63. package/package.json +5 -5
@@ -4,8 +4,10 @@ import { type ComponentType, type FormEvent, type KeyboardEvent, type ReactNode,
4
4
  import type { DataTableBulk } from "./data-table-bulk-types.ts";
5
5
  import type { DataTableDateRangeFacet, DataTableFacet } from "./data-table-facet-types.ts";
6
6
  import type { DataTableTableBlock } from "./data-table-view-types.ts";
7
+ import type { EmbeddedListInputProps } from "./embedded-list-types.ts";
7
8
  import type { FieldIssue, ListColumnViewModel, ListRowViewModel, RuntimeRenderer } from "./headless/index.ts";
8
9
  import type { MetricBandProps, MetricProps } from "./metric-types.ts";
10
+ export type { EmbeddedListCellType, EmbeddedListColumn, EmbeddedListInputProps, EmbeddedListTotal, } from "./embedded-list-types.ts";
9
11
  export type { RuntimeRenderer };
10
12
  /** Escape hatch for `data-*` test hooks (e.g. an E2E selector) that don't
11
13
  * warrant a dedicated typed prop. Web merges these onto the rendered
@@ -460,6 +462,8 @@ export type InputProps = {
460
462
  * decides; the web impl keeps its short-option-set heuristic. Custom
461
463
  * impls may ignore it — it is a request, not a contract. */
462
464
  readonly display?: "radio" | "dropdown";
465
+ /** Tone per option value; the selected segment of a radio presentation takes its colors. */
466
+ readonly optionTones?: Readonly<Partial<Record<string, SelectOptionTone>>>;
463
467
  } | ({
464
468
  readonly kind: "combobox";
465
469
  readonly id: string;
@@ -797,94 +801,6 @@ export type DataTableProps = {
797
801
  readonly renderExpandedRow?: (row: ListRowViewModel) => ReactNode;
798
802
  } & DataTableTableBlock;
799
803
  export type DataTableTableBlockProps = Pick<DataTableProps, keyof DataTableTableBlock>;
800
- /** Cell type for one column of an embedded-list field. Mirrors
801
- * EmbeddedListCellViewModel["type"] from `@cosmicdrift/kumiko-renderer/headless`. */
802
- export type EmbeddedListCellType = "text" | "number" | "boolean" | "date" | "money" | "decimal" | "select" | "reference" | "timestamp";
803
- /** One column of an embedded-list table (one entry per key of the source
804
- * EmbeddedFieldDef's `schema`). */
805
- export type EmbeddedListColumn = {
806
- readonly field: string;
807
- readonly label: string;
808
- readonly type: EmbeddedListCellType;
809
- readonly required: boolean;
810
- /** Read-only, value supplied by the caller (already computed from
811
- * sibling cells) — not directly user-editable. */
812
- readonly derived: boolean;
813
- /** Shown but not editable by the user; unlike `derived` it is not computed. */
814
- readonly readOnly?: boolean;
815
- /** Only for `type: "select"`. */
816
- readonly options?: readonly string[];
817
- readonly optionLabels?: Readonly<Record<string, string>>;
818
- /** Only for `type: "select"`; `"radio"` renders segmented buttons, the
819
- * default is a dropdown. */
820
- readonly display?: "radio" | "dropdown";
821
- /** Only for `type: "reference"` — pre-fetched by the caller, ONE query
822
- * per column shared across every row (not one query per cell). */
823
- readonly referenceOptions?: readonly {
824
- readonly value: string;
825
- readonly label: string;
826
- }[];
827
- readonly referenceLoading?: boolean;
828
- };
829
- /** One entry of an embedded-list totals row (e.g. an invoice's total
830
- * amount, summed across all rows by the caller). */
831
- export type EmbeddedListTotal = {
832
- readonly field: string;
833
- readonly label: string;
834
- readonly value: number;
835
- /** Set for labelled multi-column totals, which have no single column to
836
- * read the money type from. */
837
- readonly money?: boolean;
838
- };
839
- /** Row-array + totals table for a `createEmbeddedListField` (invoice-
840
- * positions-style) field — structurally too different from `Input`'s
841
- * value/onChange union to fit there, so it's its own primitive, same as
842
- * `DataTable`. Controlled: the caller (the eventual form-field wrapper)
843
- * computes rows/derived values/issue groups and wires every callback;
844
- * this primitive only renders and reports interaction. */
845
- export type EmbeddedListInputProps = {
846
- readonly id: string;
847
- readonly columns: readonly EmbeddedListColumn[];
848
- readonly rows: readonly Readonly<Record<string, unknown>>[];
849
- readonly totals?: readonly EmbeddedListTotal[];
850
- /** Currency for money cells and the totals row — one value for the whole
851
- * list (currency lives on the head aggregate, not the row). Web impl
852
- * falls back to "EUR" when absent. */
853
- readonly currency?: string;
854
- /** Whole-list read-only (e.g. a released invoice) — hides row-mutation
855
- * affordances and disables every cell. */
856
- readonly disabled?: boolean;
857
- /** Rows are fixed: no add, remove, duplicate or move; cells stay editable. */
858
- readonly fixedRows?: boolean;
859
- readonly minItems?: number;
860
- readonly maxItems?: number;
861
- /** Shown under the whole list (e.g. under the totals row). */
862
- readonly listIssues?: readonly FieldIssue[];
863
- /** Keyed by row index. */
864
- readonly rowIssues?: Readonly<Record<number, readonly FieldIssue[]>>;
865
- /** Keyed `${rowIndex}.${field}`. */
866
- readonly cellIssues?: Readonly<Record<string, readonly FieldIssue[]>>;
867
- readonly onCellChange: (rowIndex: number, field: string, value: unknown) => void;
868
- readonly onAddRow: () => void;
869
- readonly onRemoveRow: (rowIndex: number) => void;
870
- readonly onDuplicateRow: (rowIndex: number) => void;
871
- readonly onMoveRow: (fromIndex: number, toIndex: number) => void;
872
- /** Tab/newline-delimited clipboard paste starting at (rowIndex,
873
- * columnIndex) — parsed to a 2D string grid by the Web impl (paste
874
- * events are a browser-only concept); undefined caller = no paste
875
- * handling wired up. */
876
- readonly onPasteCells?: (rowIndex: number, columnIndex: number, grid: readonly (readonly string[])[]) => void;
877
- readonly addLabel: string;
878
- /** Accessible name of the (otherwise headerless) row-actions column. */
879
- readonly actionsLabel: string;
880
- readonly removeLabel: string;
881
- readonly duplicateLabel: string;
882
- readonly moveUpLabel: string;
883
- readonly moveDownLabel: string;
884
- readonly emptyLabel: string;
885
- readonly emptyCtaLabel: string;
886
- readonly testId?: string;
887
- };
888
804
  export type { FormWidth };
889
805
  /** One entry of a form's "on this page" navigation: `id` matches the
890
806
  * `SectionProps.id` of the section it scrolls to. */
@@ -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 {};