@12-apps/ui 6.0.2 → 6.2.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 (40) hide show
  1. package/components-catalog.md +8 -0
  2. package/dist/data-display/CardKit.js +266 -0
  3. package/dist/data-display/CardKit.js.map +1 -0
  4. package/dist/types/components/data-display/CardKit/CardKebab.d.ts +23 -0
  5. package/dist/types/components/data-display/CardKit/CardKebab.d.ts.map +1 -0
  6. package/dist/types/components/data-display/CardKit/CardKit.types.d.ts +55 -0
  7. package/dist/types/components/data-display/CardKit/CardKit.types.d.ts.map +1 -0
  8. package/dist/types/components/data-display/CardKit/card-actions-context.d.ts +51 -0
  9. package/dist/types/components/data-display/CardKit/card-actions-context.d.ts.map +1 -0
  10. package/dist/types/components/data-display/CardKit/index.d.ts +15 -0
  11. package/dist/types/components/data-display/CardKit/index.d.ts.map +1 -0
  12. package/dist/types/components/data-display/CardKit/list-card-parts.d.ts +60 -0
  13. package/dist/types/components/data-display/CardKit/list-card-parts.d.ts.map +1 -0
  14. package/dist/types/components/data-display/CardKit/row-actions-to-menu.d.ts +17 -0
  15. package/dist/types/components/data-display/CardKit/row-actions-to-menu.d.ts.map +1 -0
  16. package/dist/types/components/data-display/CardKit/use-remove-confirm.d.ts +62 -0
  17. package/dist/types/components/data-display/CardKit/use-remove-confirm.d.ts.map +1 -0
  18. package/dist/types/components/data-display/CardKit/use-row-confirm.d.ts +57 -0
  19. package/dist/types/components/data-display/CardKit/use-row-confirm.d.ts.map +1 -0
  20. package/dist/types/utils/export-rows.d.ts +31 -0
  21. package/dist/types/utils/export-rows.d.ts.map +1 -0
  22. package/dist/types/utils/index.d.ts +1 -0
  23. package/dist/types/utils/index.d.ts.map +1 -1
  24. package/dist/utils.js +50 -0
  25. package/dist/utils.js.map +1 -1
  26. package/package.json +8 -4
  27. package/src/components/data-display/CardKit/CardKebab.tsx +43 -0
  28. package/src/components/data-display/CardKit/CardKit.md +87 -0
  29. package/src/components/data-display/CardKit/CardKit.types.ts +60 -0
  30. package/src/components/data-display/CardKit/card-actions-context.tsx +98 -0
  31. package/src/components/data-display/CardKit/index.ts +19 -0
  32. package/src/components/data-display/CardKit/list-card-parts.tsx +163 -0
  33. package/src/components/data-display/CardKit/row-actions-to-menu.ts +29 -0
  34. package/src/components/data-display/CardKit/use-remove-confirm.tsx +106 -0
  35. package/src/components/data-display/CardKit/use-row-confirm.tsx +91 -0
  36. package/src/utils/export-rows.ts +95 -0
  37. package/src/utils/index.ts +1 -0
  38. package/dist/types/components/data-display/DataViews/pedidos.server.d.ts +0 -42
  39. package/dist/types/components/data-display/DataViews/pedidos.server.d.ts.map +0 -1
  40. package/src/components/data-display/DataViews/pedidos.server.ts +0 -118
@@ -0,0 +1,57 @@
1
+ import { type JSX } from 'react';
2
+ /**
3
+ * How the popup should read for the rows about to be acted on.
4
+ *
5
+ * Built from the SELECTION rather than from a single row, because the same
6
+ * guard covers a bulk action: "Delete 4 categories?" and "Delete the category
7
+ * Drinks?" are the same question asked of a different-sized selection — and an
8
+ * operator who multi-selected by mistake is exactly who this popup is for.
9
+ */
10
+ export interface RowConfirmCopy {
11
+ title: string;
12
+ description: string;
13
+ /** Echoed into the popup; omit for a multi-row selection the title covers. */
14
+ entityName?: string;
15
+ /** The VERB. Required, never defaulted — see {@link useRowConfirm}. */
16
+ confirmText: string;
17
+ /** Override where the word "cancel" would mean the ACTION itself. */
18
+ cancelText?: string;
19
+ /** Require typing this before the confirm enables — for the unrecoverable few. */
20
+ typeToConfirm?: string;
21
+ }
22
+ interface RowConfirmOptions<T> {
23
+ /**
24
+ * The destructive write, over the whole selection. Called ONLY after the
25
+ * operator confirms; throw (or reject) to keep the popup open carrying the
26
+ * message.
27
+ */
28
+ write: (rows: T[]) => Promise<unknown>;
29
+ /** The wording, given the selection. */
30
+ describe: (rows: T[]) => RowConfirmCopy;
31
+ dataTestId: string;
32
+ /** Shown when `write` fails without a message of its own. */
33
+ errorText?: string;
34
+ }
35
+ export interface RowConfirm<T> {
36
+ /** Hand this a grid action's `rows`: it opens the popup and writes nothing. */
37
+ request: (rows: T[]) => void;
38
+ /** Render next to the grid. */
39
+ dialog: JSX.Element | null;
40
+ }
41
+ /**
42
+ * Confirmation for a grid's `RowAction` — the per-row kebab entry and the
43
+ * multi-select actions menu both, since they run the same handler.
44
+ *
45
+ * The SELECTION is held while the popup is open, so the write sees exactly the
46
+ * rows the operator was looking at when they were asked. Re-reading the grid's
47
+ * live selection at confirm time is the bug this shape avoids: the list can
48
+ * refresh underneath an open dialog.
49
+ *
50
+ * `confirmText` is required and comes from `describe`, with no default. A verb
51
+ * defaulted in the origin's language reads as finished to the next adopter
52
+ * right up until an operator sees it — and `ConfirmOptions` already refuses to
53
+ * default one for the same reason.
54
+ */
55
+ export declare function useRowConfirm<T>({ write, describe, dataTestId, errorText, }: RowConfirmOptions<T>): RowConfirm<T>;
56
+ export {};
57
+ //# sourceMappingURL=use-row-confirm.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-row-confirm.d.ts","sourceRoot":"","sources":["../../../../../src/components/data-display/CardKit/use-row-confirm.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAY,KAAK,GAAG,EAAE,MAAM,OAAO,CAAC;AAI3C;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,8EAA8E;IAC9E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,WAAW,EAAE,MAAM,CAAC;IACpB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kFAAkF;IAClF,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,UAAU,iBAAiB,CAAC,CAAC;IAC3B;;;;OAIG;IACH,KAAK,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACvC,wCAAwC;IACxC,QAAQ,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,cAAc,CAAC;IACxC,UAAU,EAAE,MAAM,CAAC;IACnB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,UAAU,CAAC,CAAC;IAC3B,+EAA+E;IAC/E,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,IAAI,CAAC;IAC7B,+BAA+B;IAC/B,MAAM,EAAE,GAAG,CAAC,OAAO,GAAG,IAAI,CAAC;CAC5B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,EAC/B,KAAK,EACL,QAAQ,EACR,UAAU,EACV,SAAS,GACV,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,CAuBtC"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Serialise rows and hand the browser a download.
3
+ *
4
+ * The companion of the grid's Export control, which emits a REQUEST and leaves
5
+ * the producing to the host — this is the producing half, for the common case
6
+ * where the host already has the rows and only needs a file out of them.
7
+ *
8
+ * Dependency-free on purpose: "Excel" is emitted as CSV, which Excel opens
9
+ * natively. A spreadsheet library would add ~700KB to a bundle for column
10
+ * widths nobody asked for, and the one thing a real `.xlsx` buys — types that
11
+ * survive the round trip — is not something a CSV export of an admin list is
12
+ * being asked for.
13
+ */
14
+ type ExportFormat = 'csv' | 'json';
15
+ /** One column of the exported file: its header, and how to read a row. */
16
+ export interface ExportColumn<T> {
17
+ /** Column header, used as the CSV column and the JSON key. */
18
+ header: string;
19
+ /** Extract the cell value for a row. */
20
+ value: (row: T) => string | number | boolean | null | undefined;
21
+ }
22
+ /**
23
+ * Serialise `rows` using `columns` and download them as `<baseName>.<ext>`.
24
+ *
25
+ * @param format - `csv` (also what the Excel option produces) or `json`.
26
+ * @param baseName - File name without extension; the consumer's word for the
27
+ * collection, so it is never derived from a type name here.
28
+ */
29
+ export declare function exportRows<T>(format: ExportFormat, rows: readonly T[], columns: readonly ExportColumn<T>[], baseName: string): void;
30
+ export {};
31
+ //# sourceMappingURL=export-rows.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"export-rows.d.ts","sourceRoot":"","sources":["../../../src/utils/export-rows.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,KAAK,YAAY,GAAG,KAAK,GAAG,MAAM,CAAC;AAEnC,0EAA0E;AAC1E,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,8DAA8D;IAC9D,MAAM,EAAE,MAAM,CAAC;IACf,wCAAwC;IACxC,KAAK,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,GAAG,SAAS,CAAC;CACjE;AAsDD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAC1B,MAAM,EAAE,YAAY,EACpB,IAAI,EAAE,SAAS,CAAC,EAAE,EAClB,OAAO,EAAE,SAAS,YAAY,CAAC,CAAC,CAAC,EAAE,EACnC,QAAQ,EAAE,MAAM,GACf,IAAI,CAMN"}
@@ -1,4 +1,5 @@
1
1
  export * from './cn';
2
+ export * from './export-rows';
2
3
  export * from './viewport';
3
4
  export * from './withDefaults';
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/utils/index.ts"],"names":[],"mappings":"AAAA,cAAc,MAAM,CAAC;AACrB,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/utils/index.ts"],"names":[],"mappings":"AAAA,cAAc,MAAM,CAAC;AACrB,cAAc,eAAe,CAAC;AAC9B,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC"}
package/dist/utils.js CHANGED
@@ -11,11 +11,61 @@ import {
11
11
  withDefaults
12
12
  } from "./chunk-Y2JYDXL7.js";
13
13
  import "./chunk-PZ5AY32C.js";
14
+
15
+ // src/utils/export-rows.ts
16
+ function escapeCsvCell(value) {
17
+ if (/[",\n\r]/.test(value)) {
18
+ return `"${value.replace(/"/g, '""')}"`;
19
+ }
20
+ return value;
21
+ }
22
+ function toCsv(rows, columns) {
23
+ const head = columns.map((column) => escapeCsvCell(column.header)).join(",");
24
+ const body = rows.map(
25
+ (row) => columns.map((column) => {
26
+ const cell = column.value(row);
27
+ return escapeCsvCell(cell == null ? "" : String(cell));
28
+ }).join(",")
29
+ ).join("\n");
30
+ return `${head}
31
+ ${body}`;
32
+ }
33
+ function toJson(rows, columns) {
34
+ const records = rows.map((row) => {
35
+ const record = {};
36
+ for (const column of columns) {
37
+ const cell = column.value(row);
38
+ record[column.header] = cell == null ? null : cell;
39
+ }
40
+ return record;
41
+ });
42
+ return JSON.stringify(records, null, 2);
43
+ }
44
+ function triggerDownload(content, filename, mimeType) {
45
+ if (typeof window === "undefined") return;
46
+ const blob = new Blob([content], { type: mimeType });
47
+ const url = URL.createObjectURL(blob);
48
+ const anchor = document.createElement("a");
49
+ anchor.href = url;
50
+ anchor.download = filename;
51
+ document.body.appendChild(anchor);
52
+ anchor.click();
53
+ anchor.remove();
54
+ URL.revokeObjectURL(url);
55
+ }
56
+ function exportRows(format, rows, columns, baseName) {
57
+ if (format === "json") {
58
+ triggerDownload(toJson(rows, columns), `${baseName}.json`, "application/json");
59
+ return;
60
+ }
61
+ triggerDownload(toCsv(rows, columns), `${baseName}.csv`, "text/csv;charset=utf-8");
62
+ }
14
63
  export {
15
64
  DYNAMIC_VIEWPORT_HEIGHT,
16
65
  VIEWPORT_HEIGHT_FALLBACK,
17
66
  cn,
18
67
  dynamicViewportHeight,
68
+ exportRows,
19
69
  safeAreaBottom,
20
70
  withDefaults
21
71
  };
package/dist/utils.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
1
+ {"version":3,"sources":["../src/utils/export-rows.ts"],"sourcesContent":["/**\n * Serialise rows and hand the browser a download.\n *\n * The companion of the grid's Export control, which emits a REQUEST and leaves\n * the producing to the host — this is the producing half, for the common case\n * where the host already has the rows and only needs a file out of them.\n *\n * Dependency-free on purpose: \"Excel\" is emitted as CSV, which Excel opens\n * natively. A spreadsheet library would add ~700KB to a bundle for column\n * widths nobody asked for, and the one thing a real `.xlsx` buys — types that\n * survive the round trip — is not something a CSV export of an admin list is\n * being asked for.\n */\n\ntype ExportFormat = 'csv' | 'json';\n\n/** One column of the exported file: its header, and how to read a row. */\nexport interface ExportColumn<T> {\n /** Column header, used as the CSV column and the JSON key. */\n header: string;\n /** Extract the cell value for a row. */\n value: (row: T) => string | number | boolean | null | undefined;\n}\n\nfunction escapeCsvCell(value: string): string {\n // Quote when the cell contains a comma, a quote or a newline; double any\n // embedded quote. Without this a name with a comma silently shifts every\n // column after it by one, which reads as corrupt data rather than as a bug.\n if (/[\",\\n\\r]/.test(value)) {\n return `\"${value.replace(/\"/g, '\"\"')}\"`;\n }\n return value;\n}\n\nfunction toCsv<T>(rows: readonly T[], columns: readonly ExportColumn<T>[]): string {\n const head = columns.map((column) => escapeCsvCell(column.header)).join(',');\n const body = rows\n .map((row) =>\n columns\n .map((column) => {\n const cell = column.value(row);\n return escapeCsvCell(cell == null ? '' : String(cell));\n })\n .join(','),\n )\n .join('\\n');\n return `${head}\\n${body}`;\n}\n\nfunction toJson<T>(rows: readonly T[], columns: readonly ExportColumn<T>[]): string {\n const records = rows.map((row) => {\n const record: Record<string, string | number | boolean | null> = {};\n for (const column of columns) {\n const cell = column.value(row);\n record[column.header] = cell == null ? null : (cell as string | number | boolean);\n }\n return record;\n });\n return JSON.stringify(records, null, 2);\n}\n\nfunction triggerDownload(content: string, filename: string, mimeType: string): void {\n // A no-op without a document, so a module that imports this can still be\n // loaded by a test runner or a server render without guarding the import.\n if (typeof window === 'undefined') return;\n const blob = new Blob([content], { type: mimeType });\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement('a');\n anchor.href = url;\n anchor.download = filename;\n document.body.appendChild(anchor);\n anchor.click();\n anchor.remove();\n URL.revokeObjectURL(url);\n}\n\n/**\n * Serialise `rows` using `columns` and download them as `<baseName>.<ext>`.\n *\n * @param format - `csv` (also what the Excel option produces) or `json`.\n * @param baseName - File name without extension; the consumer's word for the\n * collection, so it is never derived from a type name here.\n */\nexport function exportRows<T>(\n format: ExportFormat,\n rows: readonly T[],\n columns: readonly ExportColumn<T>[],\n baseName: string,\n): void {\n if (format === 'json') {\n triggerDownload(toJson(rows, columns), `${baseName}.json`, 'application/json');\n return;\n }\n triggerDownload(toCsv(rows, columns), `${baseName}.csv`, 'text/csv;charset=utf-8');\n}\n"],"mappings":";;;;;;;;;;;;;;;AAwBA,SAAS,cAAc,OAAuB;AAI5C,MAAI,WAAW,KAAK,KAAK,GAAG;AAC1B,WAAO,IAAI,MAAM,QAAQ,MAAM,IAAI,CAAC;AAAA,EACtC;AACA,SAAO;AACT;AAEA,SAAS,MAAS,MAAoB,SAA6C;AACjF,QAAM,OAAO,QAAQ,IAAI,CAAC,WAAW,cAAc,OAAO,MAAM,CAAC,EAAE,KAAK,GAAG;AAC3E,QAAM,OAAO,KACV;AAAA,IAAI,CAAC,QACJ,QACG,IAAI,CAAC,WAAW;AACf,YAAM,OAAO,OAAO,MAAM,GAAG;AAC7B,aAAO,cAAc,QAAQ,OAAO,KAAK,OAAO,IAAI,CAAC;AAAA,IACvD,CAAC,EACA,KAAK,GAAG;AAAA,EACb,EACC,KAAK,IAAI;AACZ,SAAO,GAAG,IAAI;AAAA,EAAK,IAAI;AACzB;AAEA,SAAS,OAAU,MAAoB,SAA6C;AAClF,QAAM,UAAU,KAAK,IAAI,CAAC,QAAQ;AAChC,UAAM,SAA2D,CAAC;AAClE,eAAW,UAAU,SAAS;AAC5B,YAAM,OAAO,OAAO,MAAM,GAAG;AAC7B,aAAO,OAAO,MAAM,IAAI,QAAQ,OAAO,OAAQ;AAAA,IACjD;AACA,WAAO;AAAA,EACT,CAAC;AACD,SAAO,KAAK,UAAU,SAAS,MAAM,CAAC;AACxC;AAEA,SAAS,gBAAgB,SAAiB,UAAkB,UAAwB;AAGlF,MAAI,OAAO,WAAW,YAAa;AACnC,QAAM,OAAO,IAAI,KAAK,CAAC,OAAO,GAAG,EAAE,MAAM,SAAS,CAAC;AACnD,QAAM,MAAM,IAAI,gBAAgB,IAAI;AACpC,QAAM,SAAS,SAAS,cAAc,GAAG;AACzC,SAAO,OAAO;AACd,SAAO,WAAW;AAClB,WAAS,KAAK,YAAY,MAAM;AAChC,SAAO,MAAM;AACb,SAAO,OAAO;AACd,MAAI,gBAAgB,GAAG;AACzB;AASO,SAAS,WACd,QACA,MACA,SACA,UACM;AACN,MAAI,WAAW,QAAQ;AACrB,oBAAgB,OAAO,MAAM,OAAO,GAAG,GAAG,QAAQ,SAAS,kBAAkB;AAC7E;AAAA,EACF;AACA,kBAAgB,MAAM,MAAM,OAAO,GAAG,GAAG,QAAQ,QAAQ,wBAAwB;AACnF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/ui",
3
- "version": "6.0.2",
3
+ "version": "6.2.0",
4
4
  "license": "MIT",
5
5
  "packageManager": "pnpm@9.0.0",
6
6
  "type": "module",
@@ -532,6 +532,10 @@
532
532
  "./utility/WorkflowStep": {
533
533
  "types": "./dist/types/components/utility/WorkflowStep/index.d.ts",
534
534
  "default": "./dist/utility/WorkflowStep.js"
535
+ },
536
+ "./data-display/CardKit": {
537
+ "types": "./dist/types/components/data-display/CardKit/index.d.ts",
538
+ "default": "./dist/data-display/CardKit.js"
535
539
  }
536
540
  },
537
541
  "typesVersions": {
@@ -592,7 +596,7 @@
592
596
  "react-dom": "^18.0.0 || ^19.0.0"
593
597
  },
594
598
  "dependencies": {
595
- "@12-apps/forms-core": "^1.19.2",
599
+ "@12-apps/forms-core": "^1.20.0",
596
600
  "@emotion/react": "^11.14.0",
597
601
  "@emotion/styled": "^11.14.0",
598
602
  "@monaco-editor/react": "^4.7.0",
@@ -607,8 +611,8 @@
607
611
  "recharts": "^3.5.1"
608
612
  },
609
613
  "devDependencies": {
610
- "@12-apps/eslint-config": "^1.21.0",
611
- "@12-apps/typescript-config": "^1.20.0",
614
+ "@12-apps/eslint-config": "^1.21.1",
615
+ "@12-apps/typescript-config": "^1.20.1",
612
616
  "@playwright/test": "^1.55.0",
613
617
  "@storybook/addon-docs": "^9.1.10",
614
618
  "@storybook/addon-links": "^9.1.10",
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+
3
+ import type { JSX } from 'react';
4
+
5
+ import { Button } from '../../form/Button';
6
+ import { DropdownMenu, type DropdownMenuItem } from '../../navigation/DropdownMenu';
7
+
8
+ /**
9
+ * The "⋮" trigger every row and card menu opens from.
10
+ *
11
+ * One component rather than a `DropdownMenu` per entity, so the kebab looks and
12
+ * behaves identically in the card, in the list row and in the table row. The
13
+ * entity's menu owns its own popups; this is the trigger and the item list.
14
+ */
15
+ export function CardKebab({
16
+ items,
17
+ menuLabel,
18
+ dataTestId,
19
+ }: {
20
+ items: DropdownMenuItem[];
21
+ /**
22
+ * The trigger's accessible name — required, and with no default.
23
+ *
24
+ * It is the only text this component renders, and a screen reader announces
25
+ * it verbatim. A default here would be a word in the origin's language that
26
+ * reads as finished to the next adopter right up until somebody using a
27
+ * screen reader meets it.
28
+ */
29
+ menuLabel: string;
30
+ dataTestId?: string;
31
+ }): JSX.Element {
32
+ return (
33
+ <DropdownMenu
34
+ size="sm"
35
+ items={items}
36
+ trigger={
37
+ <Button variant="text" size="sm" aria-label={menuLabel} dataTestId={dataTestId}>
38
+ ⋮
39
+ </Button>
40
+ }
41
+ />
42
+ );
43
+ }
@@ -0,0 +1,87 @@
1
+ # CardKit
2
+
3
+ The furniture an admin list's cards and row menus sit in.
4
+
5
+ `BaseCard` and `BaseListCard` deliberately say nothing about what goes inside
6
+ them: the envelope is the library's and the design is the consumer's. That is
7
+ right for a card component and exactly the wrong place for every entity in an
8
+ admin app to invent its own kebab, its own two-column body, its own label/value
9
+ pair and its own confirm-before-deleting. Those are not per-entity decisions —
10
+ they are the same shape repeated — so they live here once.
11
+
12
+ **The line this kit does not cross** is the entity card itself. A "product card"
13
+ or a "discount card" knows a domain, and a domain is the consumer's.
14
+
15
+ ## What is in it
16
+
17
+ | Export | What it is |
18
+ |---|---|
19
+ | `CardKebab` | The `⋮` trigger every row and card menu opens from |
20
+ | `rowActionsToMenuItems` | One `RowAction[]` → the kebab's items, so a grid and a card cannot drift |
21
+ | `CardActionsProvider` / `useCardActions` | The tenant, the refresh and one shared error snackbar |
22
+ | `DetailColumns` `Fact` `Ledger` `BodyHeading` `TagList` | The expanded list-card body's repeated shapes |
23
+ | `useRowConfirm` | Confirm a grid SELECTION before a destructive write |
24
+ | `useRemoveConfirm` | Confirm a single row from a self-contained menu |
25
+ | `KindCardProps` / `KindListCardProps` | The `renderCard` / `renderListRow` contracts, named |
26
+
27
+ ## Every sentence is yours
28
+
29
+ Three props exist only because a default would have been someone else's
30
+ language shipped as a finished-looking silence:
31
+
32
+ - `CardKebab`'s **`menuLabel`** — the trigger's accessible name, announced
33
+ verbatim by a screen reader.
34
+ - `CardActionsProvider`'s **`errorTitle`** — the one sentence the provider
35
+ renders on its own. The message beside it comes from whatever failed.
36
+ - Both hooks' **`confirmText`** — the verb. `ConfirmOptions` already refuses to
37
+ default one, for the same reason: a confirm button reading "OK" tells the
38
+ operator nothing about what is about to happen.
39
+
40
+ ## Which confirm hook
41
+
42
+ They differ by the surface they serve, not by what they do.
43
+
44
+ **`useRowConfirm`** is for a grid whose actions are declared as `RowAction[]`
45
+ and act on a **selection**. The popup is described from that selection, because
46
+ the per-row kebab entry and the multi-select menu run the same handler —
47
+ "Delete the category Drinks?" and "Delete 4 categories?" are one question asked
48
+ of a different-sized selection, and an operator who multi-selected by mistake is
49
+ exactly who the popup is for. The selection is held while the popup is open, so
50
+ the write sees the rows the operator was looking at when they were asked; the
51
+ list can refresh underneath an open dialog, and re-reading the live selection at
52
+ confirm time is the bug this shape avoids. An empty selection opens nothing.
53
+
54
+ **`useRemoveConfirm`** is for a self-contained menu already inside a
55
+ `CardActionsProvider`. It reads the refresh and the error channel from context
56
+ and answers the `{ ok, error }` envelope those actions use.
57
+
58
+ Both close the three gaps a hand-rolled version keeps re-opening:
59
+
60
+ 1. the write leaves **only** on confirm;
61
+ 2. a refusal is surfaced **twice** — inside the popup, where the operator is
62
+ still deciding, and in the shared snackbar behind it, which is what remains
63
+ once they dismiss it;
64
+ 3. a failed attempt leaves the popup **open** to be retried, instead of
65
+ vanishing over a delete that never happened.
66
+
67
+ ## `useCardActions` throws outside its provider
68
+
69
+ Deliberately, rather than answering a null object. A menu whose `onRefresh`
70
+ silently did nothing would leave the operator looking at a stale row after a
71
+ delete that worked — which reads as the delete having failed. Failing at mount
72
+ says where the provider is missing.
73
+
74
+ ## The context is small on purpose
75
+
76
+ It is **not** an edit-dialog host. A menu that read its dialogs from here would
77
+ stop being portable, because the set of dialogs is per-entity. What it may read
78
+ is three facts: the tenant it acts inside, what to do after a write lands, and
79
+ where to put a failure.
80
+
81
+ ## See also
82
+
83
+ - `exportRows` (`@12-apps/ui/utils`) — the producing half of the grid's Export
84
+ control, for the common case where the host already has the rows.
85
+ - `useServerDataViews` (`@12-apps/app-shell/react`) — the router half of
86
+ `DataViewServer`. It lives there rather than here because it needs a router,
87
+ and a design system should not.
@@ -0,0 +1,60 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import type { CardAspectRatio, DataViewCardSelection } from '../DataViews';
4
+
5
+ /**
6
+ * THE CARD KIT — what an admin list needs above `BaseCard`, and no more.
7
+ *
8
+ * `BaseCard` and `BaseListCard` deliberately say nothing about what goes inside
9
+ * them: the envelope is the library's and the design is the consumer's. That is
10
+ * the right call for a card component and exactly the wrong place for every
11
+ * entity in an admin app to invent its own kebab, its own two-column body, its
12
+ * own label/value pair and its own confirm-before-deleting. Those shapes are
13
+ * not per-entity decisions — they are the same shape repeated — so they live
14
+ * here once.
15
+ *
16
+ * The line this kit does NOT cross is the entity card itself. A "product card"
17
+ * or a "discount card" knows a domain, and a domain is the consumer's. What is
18
+ * here is the furniture such a card sits in.
19
+ */
20
+
21
+ /**
22
+ * Props every KIND CARD receives — the grid's `renderCard` contract, named.
23
+ *
24
+ * `row` is `Record<string, unknown>` rather than a generic on purpose: a grid
25
+ * renders rows of one shape and the renderer is registered per kind, so the
26
+ * cast happens once inside each card rather than threading a type parameter
27
+ * through the grid, the layout and the selection.
28
+ */
29
+ export interface KindCardProps {
30
+ row: Record<string, unknown>;
31
+ selection: DataViewCardSelection;
32
+ /** Force a ratio (a uniform grid); defaults to the card's own. */
33
+ aspectRatio?: CardAspectRatio;
34
+ }
35
+
36
+ /**
37
+ * Props every kind LIST card receives — the "list" layout's counterpart.
38
+ *
39
+ * The same `(row, selection)` pair `renderListRow` hands over, and nothing
40
+ * more. No density: a row inside a list group takes the group's, so a card
41
+ * accepting one could only contradict its list. No `aspectRatio`: a full-width
42
+ * row has no ratio to force.
43
+ */
44
+ export interface KindListCardProps {
45
+ row: Record<string, unknown>;
46
+ selection: DataViewCardSelection;
47
+ }
48
+
49
+ /** One labelled fact inside an expanded body. */
50
+ export interface CardFactProps {
51
+ label: string;
52
+ value: ReactNode;
53
+ }
54
+
55
+ /** One line of a money ledger. `info` tints a line that is not a plain cost. */
56
+ export interface CardLedgerLine {
57
+ label: string;
58
+ value: ReactNode;
59
+ tone?: 'default' | 'info';
60
+ }
@@ -0,0 +1,98 @@
1
+ 'use client';
2
+
3
+ import { createContext, useCallback, useContext, useState, type JSX, type ReactNode } from 'react';
4
+
5
+ import { Snackbar } from '@mui/material';
6
+
7
+ import { Alert } from '../Alert';
8
+
9
+ /**
10
+ * The ambient wiring a self-contained row/card menu needs, and nothing else.
11
+ *
12
+ * A menu that owns its own popups still needs three things it cannot know: the
13
+ * tenant it is acting inside, what to do after a write lands, and somewhere to
14
+ * put a failure. Passing those down through the grid, the layout and the card
15
+ * is four props of drilling per entity; holding them in context is one.
16
+ *
17
+ * Kept deliberately SMALL — it is not an edit-dialog host. A menu that read its
18
+ * dialogs from here would stop being portable, because the set of dialogs is
19
+ * per-entity. What it may read is the three facts below.
20
+ *
21
+ * The provider renders ONE shared error snackbar, which is the whole reason the
22
+ * error channel is here rather than per-menu: before it, every page carried its
23
+ * own error alert and every menu had to find its page's.
24
+ */
25
+ interface CardActionsValue {
26
+ /** The tenant every action in this subtree acts inside. */
27
+ tenantSlug: string;
28
+ /** Re-read the list after a write. */
29
+ onRefresh: () => void;
30
+ /** Surface a failed action's message (the shared snackbar). */
31
+ notifyError: (message: string) => void;
32
+ }
33
+
34
+ const CardActionsContext = createContext<CardActionsValue | null>(null);
35
+
36
+ export function CardActionsProvider({
37
+ tenantSlug,
38
+ onRefresh,
39
+ errorTitle,
40
+ autoHideMs = 6_000,
41
+ children,
42
+ }: {
43
+ tenantSlug: string;
44
+ onRefresh: () => void;
45
+ /**
46
+ * The snackbar's heading — required, no default.
47
+ *
48
+ * It is the one sentence this provider renders on its own, and a default
49
+ * would be the origin's language shipped as a finished-looking silence. The
50
+ * MESSAGE beside it comes from whatever failed, which is already the host's.
51
+ */
52
+ errorTitle: string;
53
+ /** How long the snackbar stays up. */
54
+ autoHideMs?: number;
55
+ children: ReactNode;
56
+ }): JSX.Element {
57
+ const [error, setError] = useState<string | null>(null);
58
+ const notifyError = useCallback((message: string) => setError(message), []);
59
+
60
+ return (
61
+ <CardActionsContext.Provider value={{ tenantSlug, onRefresh, notifyError }}>
62
+ {children}
63
+ <Snackbar
64
+ open={error !== null}
65
+ autoHideDuration={autoHideMs}
66
+ onClose={() => setError(null)}
67
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
68
+ >
69
+ <span>
70
+ <Alert
71
+ variant="danger"
72
+ title={errorTitle}
73
+ description={error ?? ''}
74
+ closable
75
+ onClose={() => setError(null)}
76
+ data-testid="card-action-error"
77
+ />
78
+ </span>
79
+ </Snackbar>
80
+ </CardActionsContext.Provider>
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Read the tenant + refresh + error wiring.
86
+ *
87
+ * THROWS outside a provider rather than answering a null object, deliberately:
88
+ * a menu whose `onRefresh` silently did nothing would leave the operator
89
+ * looking at a stale row after a delete that worked, which reads as the delete
90
+ * having failed. Failing at mount says where the provider is missing.
91
+ */
92
+ export function useCardActions(): CardActionsValue {
93
+ const value = useContext(CardActionsContext);
94
+ if (!value) {
95
+ throw new Error('useCardActions must be used within a CardActionsProvider');
96
+ }
97
+ return value;
98
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@12-apps/ui/data-display/CardKit` — the furniture an admin list's cards and
3
+ * row menus sit in.
4
+ *
5
+ * See `./CardKit.types` for where the line falls: the envelope and the repeated
6
+ * shapes are here; the entity card that knows a domain is the consumer's.
7
+ */
8
+ export { CardKebab } from './CardKebab';
9
+ export { CardActionsProvider, useCardActions } from './card-actions-context';
10
+ export { rowActionsToMenuItems } from './row-actions-to-menu';
11
+ export { BodyHeading, DetailColumns, Fact, Ledger, TagList } from './list-card-parts';
12
+ export { useRowConfirm, type RowConfirm, type RowConfirmCopy } from './use-row-confirm';
13
+ export { useRemoveConfirm, type CardWriteResult } from './use-remove-confirm';
14
+ export type {
15
+ CardFactProps,
16
+ CardLedgerLine,
17
+ KindCardProps,
18
+ KindListCardProps,
19
+ } from './CardKit.types';