@12-apps/ui 6.1.0 → 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 (37) 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 +5 -1
  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
@@ -0,0 +1,163 @@
1
+ 'use client';
2
+
3
+ import type { JSX, ReactNode } from 'react';
4
+
5
+ import { Box } from '../../../mui/Box';
6
+
7
+ import type { CardFactProps, CardLedgerLine } from './CardKit.types';
8
+
9
+ /**
10
+ * THE SHARED FURNITURE OF AN EXPANDED LIST-CARD BODY.
11
+ *
12
+ * `BaseListCard` says nothing about what goes inside a row's expanded area — it
13
+ * spans the rails and stops, because the design belongs to the consumer. Right
14
+ * for the card; wrong as the place where every entity in an admin app invents
15
+ * its own two-column layout, its own label/value pair and its own ledger.
16
+ *
17
+ * So the four shapes that actually repeat live here, once. Nothing here knows a
18
+ * domain: they take `ReactNode` and lay it out.
19
+ */
20
+
21
+ /** The expanded body's two-column split: detail on the left, a ledger right. */
22
+ export function DetailColumns({ left, right }: { left: ReactNode; right: ReactNode }): JSX.Element {
23
+ return (
24
+ <Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 3 }}>
25
+ <Box sx={{ minWidth: 0 }}>{left}</Box>
26
+ <Box sx={{ minWidth: 0 }}>{right}</Box>
27
+ </Box>
28
+ );
29
+ }
30
+
31
+ /**
32
+ * One labelled fact inside an expanded body.
33
+ *
34
+ * Horizontal, not stacked: the body is already a wide area with room for a
35
+ * label beside its value, and stacking them there would ape the summary rails
36
+ * above without their alignment.
37
+ */
38
+ export function Fact({ label, value }: CardFactProps): JSX.Element {
39
+ return (
40
+ <Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, fontSize: 13, py: 0.25 }}>
41
+ <Box component="span" sx={{ color: 'text.secondary', flexShrink: 0 }}>
42
+ {label}
43
+ </Box>
44
+ <Box component="span" sx={{ minWidth: 0, textAlign: 'right', overflowWrap: 'anywhere' }}>
45
+ {value}
46
+ </Box>
47
+ </Box>
48
+ );
49
+ }
50
+
51
+ /**
52
+ * A money ledger: rows of label/amount, then a ruled total.
53
+ *
54
+ * The total is separated by a RULE rather than by weight alone, because a
55
+ * subtotal and a total in one column with only bold between them is the classic
56
+ * misread — the eye lands on whichever number is larger.
57
+ *
58
+ * The figures arrive pre-formatted. This is a layout, and a component that
59
+ * formatted money would be choosing a locale on the consumer's behalf.
60
+ */
61
+ export function Ledger({
62
+ lines,
63
+ total,
64
+ }: {
65
+ lines: CardLedgerLine[];
66
+ total: { label: string; value: ReactNode };
67
+ }): JSX.Element {
68
+ return (
69
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
70
+ {lines.map((line) => (
71
+ <Box
72
+ key={line.label}
73
+ sx={{
74
+ display: 'flex',
75
+ justifyContent: 'space-between',
76
+ fontSize: 13,
77
+ color: line.tone === 'info' ? 'info.main' : 'text.secondary',
78
+ }}
79
+ >
80
+ <Box component="span">{line.label}</Box>
81
+ <Box component="span" sx={{ fontVariantNumeric: 'tabular-nums' }}>
82
+ {line.value}
83
+ </Box>
84
+ </Box>
85
+ ))}
86
+ <Box
87
+ sx={{
88
+ display: 'flex',
89
+ justifyContent: 'space-between',
90
+ fontWeight: 700,
91
+ borderTop: 1,
92
+ borderColor: 'divider',
93
+ pt: 0.5,
94
+ mt: 0.5,
95
+ }}
96
+ >
97
+ <Box component="span">{total.label}</Box>
98
+ <Box component="span" sx={{ fontVariantNumeric: 'tabular-nums' }}>
99
+ {total.value}
100
+ </Box>
101
+ </Box>
102
+ </Box>
103
+ );
104
+ }
105
+
106
+ /** A heading inside the expanded body, for a section that needs naming. */
107
+ export function BodyHeading({ children }: { children: ReactNode }): JSX.Element {
108
+ return (
109
+ <Box
110
+ sx={{
111
+ fontSize: 11,
112
+ fontWeight: 700,
113
+ letterSpacing: 0.4,
114
+ textTransform: 'uppercase',
115
+ color: 'text.disabled',
116
+ mb: 0.5,
117
+ }}
118
+ >
119
+ {children}
120
+ </Box>
121
+ );
122
+ }
123
+
124
+ /**
125
+ * A wrapping run of small items — permission names, role names, target ids.
126
+ *
127
+ * The whole reason several entities earn a list card at all: a `string[]` has no
128
+ * honest table cell. Truncated to "3 items" it says nothing; printed in full it
129
+ * destroys the column. Here it simply wraps.
130
+ */
131
+ export function TagList({
132
+ items,
133
+ empty,
134
+ }: {
135
+ items: readonly string[];
136
+ /** What to show for an empty run — the consumer's sentence, no default. */
137
+ empty: string;
138
+ }): JSX.Element {
139
+ if (items.length === 0) {
140
+ return <Box sx={{ fontSize: 13, color: 'text.disabled' }}>{empty}</Box>;
141
+ }
142
+ return (
143
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
144
+ {items.map((item) => (
145
+ <Box
146
+ key={item}
147
+ component="span"
148
+ sx={{
149
+ fontSize: 12,
150
+ px: 0.75,
151
+ py: 0.25,
152
+ borderRadius: 1,
153
+ bgcolor: 'action.hover',
154
+ color: 'text.secondary',
155
+ fontFamily: 'monospace',
156
+ }}
157
+ >
158
+ {item}
159
+ </Box>
160
+ ))}
161
+ </Box>
162
+ );
163
+ }
@@ -0,0 +1,29 @@
1
+ import type { DropdownMenuItem } from '../../navigation/DropdownMenu';
2
+ import type { RowAction } from '../DataViews';
3
+
4
+ /**
5
+ * Adapt a list of {@link RowAction}s into single-row kebab items.
6
+ *
7
+ * Applies each action's `isVisible` guard, resolves its per-row `rowLabel`
8
+ * override, carries its colour, and fires `onSelect([row])` on click.
9
+ *
10
+ * The point is that ONE definition drives both surfaces. An entity that already
11
+ * declares its per-row actions as `RowAction[]` — because the table's multi-
12
+ * select menu needs them — gets the card's kebab from the same array through
13
+ * `renderRowMenu` / `renderCard`, with no second list to keep in step. Two
14
+ * lists is how a "Duplicar" that appears in the table and not on the card
15
+ * happens, and nothing reports it.
16
+ */
17
+ export function rowActionsToMenuItems<T extends Record<string, unknown>>(
18
+ actions: RowAction<T>[],
19
+ row: T,
20
+ ): DropdownMenuItem[] {
21
+ return actions
22
+ .filter((action) => action.isVisible?.(row) ?? true)
23
+ .map((action) => ({
24
+ id: action.id,
25
+ label: action.rowLabel?.(row) ?? action.label,
26
+ color: action.color,
27
+ onClick: () => void action.onSelect([row]),
28
+ }));
29
+ }
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ import type { JSX } from 'react';
4
+
5
+ import { ConfirmActionDialog, useConfirmAction } from '../../feedback/ConfirmAction';
6
+
7
+ import { useCardActions } from './card-actions-context';
8
+
9
+ /** The `{ ok, error }` envelope a write action answers with. */
10
+ export interface CardWriteResult {
11
+ ok: boolean;
12
+ error?: string;
13
+ }
14
+
15
+ interface RemoveConfirmOptions {
16
+ /** The destructive write. Called ONLY after the operator confirms. */
17
+ write: () => Promise<CardWriteResult>;
18
+ /** The popup's question. */
19
+ title: string;
20
+ /** The row being removed, echoed into the popup so it names a thing. */
21
+ entityName: string;
22
+ /**
23
+ * What happens, and whether it can be walked back. Say which — the difference
24
+ * between a soft delete a recycle bin recovers and one nothing recovers is
25
+ * the whole content of the decision being asked for.
26
+ */
27
+ description: string;
28
+ /** The confirm button's VERB. Required, never defaulted. */
29
+ confirmText: string;
30
+ /** Used when the write fails without a message of its own. */
31
+ fallbackError: string;
32
+ dataTestId: string;
33
+ }
34
+
35
+ interface RemoveConfirm {
36
+ /** Hand this to the menu item's `onClick`: it opens the popup, nothing more. */
37
+ request: () => void;
38
+ /** Render alongside the trigger. */
39
+ dialog: JSX.Element;
40
+ }
41
+
42
+ /**
43
+ * The confirm-before-removing wiring every card/row menu repeats, in one place.
44
+ *
45
+ * It closes the three gaps a hand-rolled version keeps re-opening: the write
46
+ * leaves ONLY on confirm; a refusal is surfaced twice — inside the popup, where
47
+ * the operator is still deciding, and in the shared snackbar behind it — and a
48
+ * failed attempt leaves the popup OPEN to be retried instead of vanishing over
49
+ * a delete that never happened.
50
+ *
51
+ * The difference from {@link useRowConfirm} is which surface it serves: this one
52
+ * is for a SELF-CONTAINED menu that already sits inside a
53
+ * `CardActionsProvider`, so it reads the refresh and the error channel from
54
+ * context and answers the `{ ok, error }` envelope those actions use.
55
+ * `useRowConfirm` is for a grid whose actions are declared as `RowAction[]` and
56
+ * act on a selection.
57
+ *
58
+ * ```tsx
59
+ * const remove = useRemoveConfirm({
60
+ * write: () => deleteCategory({ tenantSlug, id: row.id }),
61
+ * title: copy.deleteTitle,
62
+ * entityName: row.name,
63
+ * description: copy.deleteDescription,
64
+ * confirmText: copy.delete,
65
+ * fallbackError: copy.deleteFailed,
66
+ * dataTestId: 'category-delete-confirm',
67
+ * });
68
+ * ```
69
+ */
70
+ export function useRemoveConfirm({
71
+ write,
72
+ title,
73
+ entityName,
74
+ description,
75
+ confirmText,
76
+ fallbackError,
77
+ dataTestId,
78
+ }: RemoveConfirmOptions): RemoveConfirm {
79
+ const { onRefresh, notifyError } = useCardActions();
80
+
81
+ const state = useConfirmAction(async () => {
82
+ const result = await write();
83
+ if (!result.ok) {
84
+ const message = result.error ?? fallbackError;
85
+ notifyError(message);
86
+ // Rejecting is what keeps the popup open carrying `message`; the snackbar
87
+ // above is what remains once the operator dismisses it.
88
+ throw new Error(message);
89
+ }
90
+ onRefresh();
91
+ }, fallbackError);
92
+
93
+ return {
94
+ request: state.request,
95
+ dialog: (
96
+ <ConfirmActionDialog
97
+ state={state}
98
+ title={title}
99
+ entityName={entityName}
100
+ description={description}
101
+ confirmText={confirmText}
102
+ dataTestId={dataTestId}
103
+ />
104
+ ),
105
+ };
106
+ }
@@ -0,0 +1,91 @@
1
+ 'use client';
2
+
3
+ import { useState, type JSX } from 'react';
4
+
5
+ import { ConfirmActionDialog, useConfirmAction } from '../../feedback/ConfirmAction';
6
+
7
+ /**
8
+ * How the popup should read for the rows about to be acted on.
9
+ *
10
+ * Built from the SELECTION rather than from a single row, because the same
11
+ * guard covers a bulk action: "Delete 4 categories?" and "Delete the category
12
+ * Drinks?" are the same question asked of a different-sized selection — and an
13
+ * operator who multi-selected by mistake is exactly who this popup is for.
14
+ */
15
+ export interface RowConfirmCopy {
16
+ title: string;
17
+ description: string;
18
+ /** Echoed into the popup; omit for a multi-row selection the title covers. */
19
+ entityName?: string;
20
+ /** The VERB. Required, never defaulted — see {@link useRowConfirm}. */
21
+ confirmText: string;
22
+ /** Override where the word "cancel" would mean the ACTION itself. */
23
+ cancelText?: string;
24
+ /** Require typing this before the confirm enables — for the unrecoverable few. */
25
+ typeToConfirm?: string;
26
+ }
27
+
28
+ interface RowConfirmOptions<T> {
29
+ /**
30
+ * The destructive write, over the whole selection. Called ONLY after the
31
+ * operator confirms; throw (or reject) to keep the popup open carrying the
32
+ * message.
33
+ */
34
+ write: (rows: T[]) => Promise<unknown>;
35
+ /** The wording, given the selection. */
36
+ describe: (rows: T[]) => RowConfirmCopy;
37
+ dataTestId: string;
38
+ /** Shown when `write` fails without a message of its own. */
39
+ errorText?: string;
40
+ }
41
+
42
+ export interface RowConfirm<T> {
43
+ /** Hand this a grid action's `rows`: it opens the popup and writes nothing. */
44
+ request: (rows: T[]) => void;
45
+ /** Render next to the grid. */
46
+ dialog: JSX.Element | null;
47
+ }
48
+
49
+ /**
50
+ * Confirmation for a grid's `RowAction` — the per-row kebab entry and the
51
+ * multi-select actions menu both, since they run the same handler.
52
+ *
53
+ * The SELECTION is held while the popup is open, so the write sees exactly the
54
+ * rows the operator was looking at when they were asked. Re-reading the grid's
55
+ * live selection at confirm time is the bug this shape avoids: the list can
56
+ * refresh underneath an open dialog.
57
+ *
58
+ * `confirmText` is required and comes from `describe`, with no default. A verb
59
+ * defaulted in the origin's language reads as finished to the next adopter
60
+ * right up until an operator sees it — and `ConfirmOptions` already refuses to
61
+ * default one for the same reason.
62
+ */
63
+ export function useRowConfirm<T>({
64
+ write,
65
+ describe,
66
+ dataTestId,
67
+ errorText,
68
+ }: RowConfirmOptions<T>): RowConfirm<T> {
69
+ const [target, setTarget] = useState<T[] | null>(null);
70
+
71
+ const state = useConfirmAction(async () => {
72
+ if (!target) return;
73
+ await write(target);
74
+ setTarget(null);
75
+ }, errorText);
76
+
77
+ const request = (rows: T[]): void => {
78
+ // An empty selection has nothing to confirm — asking would be a popup about
79
+ // nothing, and confirming it would write nothing.
80
+ if (rows.length === 0) return;
81
+ setTarget(rows);
82
+ state.request();
83
+ };
84
+
85
+ return {
86
+ request,
87
+ dialog: target ? (
88
+ <ConfirmActionDialog state={state} {...describe(target)} dataTestId={dataTestId} />
89
+ ) : null,
90
+ };
91
+ }
@@ -0,0 +1,95 @@
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
+
15
+ type ExportFormat = 'csv' | 'json';
16
+
17
+ /** One column of the exported file: its header, and how to read a row. */
18
+ export interface ExportColumn<T> {
19
+ /** Column header, used as the CSV column and the JSON key. */
20
+ header: string;
21
+ /** Extract the cell value for a row. */
22
+ value: (row: T) => string | number | boolean | null | undefined;
23
+ }
24
+
25
+ function escapeCsvCell(value: string): string {
26
+ // Quote when the cell contains a comma, a quote or a newline; double any
27
+ // embedded quote. Without this a name with a comma silently shifts every
28
+ // column after it by one, which reads as corrupt data rather than as a bug.
29
+ if (/[",\n\r]/.test(value)) {
30
+ return `"${value.replace(/"/g, '""')}"`;
31
+ }
32
+ return value;
33
+ }
34
+
35
+ function toCsv<T>(rows: readonly T[], columns: readonly ExportColumn<T>[]): string {
36
+ const head = columns.map((column) => escapeCsvCell(column.header)).join(',');
37
+ const body = rows
38
+ .map((row) =>
39
+ columns
40
+ .map((column) => {
41
+ const cell = column.value(row);
42
+ return escapeCsvCell(cell == null ? '' : String(cell));
43
+ })
44
+ .join(','),
45
+ )
46
+ .join('\n');
47
+ return `${head}\n${body}`;
48
+ }
49
+
50
+ function toJson<T>(rows: readonly T[], columns: readonly ExportColumn<T>[]): string {
51
+ const records = rows.map((row) => {
52
+ const record: Record<string, string | number | boolean | null> = {};
53
+ for (const column of columns) {
54
+ const cell = column.value(row);
55
+ record[column.header] = cell == null ? null : (cell as string | number | boolean);
56
+ }
57
+ return record;
58
+ });
59
+ return JSON.stringify(records, null, 2);
60
+ }
61
+
62
+ function triggerDownload(content: string, filename: string, mimeType: string): void {
63
+ // A no-op without a document, so a module that imports this can still be
64
+ // loaded by a test runner or a server render without guarding the import.
65
+ if (typeof window === 'undefined') return;
66
+ const blob = new Blob([content], { type: mimeType });
67
+ const url = URL.createObjectURL(blob);
68
+ const anchor = document.createElement('a');
69
+ anchor.href = url;
70
+ anchor.download = filename;
71
+ document.body.appendChild(anchor);
72
+ anchor.click();
73
+ anchor.remove();
74
+ URL.revokeObjectURL(url);
75
+ }
76
+
77
+ /**
78
+ * Serialise `rows` using `columns` and download them as `<baseName>.<ext>`.
79
+ *
80
+ * @param format - `csv` (also what the Excel option produces) or `json`.
81
+ * @param baseName - File name without extension; the consumer's word for the
82
+ * collection, so it is never derived from a type name here.
83
+ */
84
+ export function exportRows<T>(
85
+ format: ExportFormat,
86
+ rows: readonly T[],
87
+ columns: readonly ExportColumn<T>[],
88
+ baseName: string,
89
+ ): void {
90
+ if (format === 'json') {
91
+ triggerDownload(toJson(rows, columns), `${baseName}.json`, 'application/json');
92
+ return;
93
+ }
94
+ triggerDownload(toCsv(rows, columns), `${baseName}.csv`, 'text/csv;charset=utf-8');
95
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './cn';
2
+ export * from './export-rows';
2
3
  export * from './viewport';
3
4
  export * from './withDefaults';