@12-apps/ui 1.7.0 → 1.8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/ui",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "license": "MIT",
5
5
  "packageManager": "pnpm@9.0.0",
6
6
  "type": "module",
@@ -194,7 +194,7 @@
194
194
  "react-dom": "^18.0.0 || ^19.0.0"
195
195
  },
196
196
  "dependencies": {
197
- "@12-apps/forms-core": "^1.6.0",
197
+ "@12-apps/forms-core": "^1.7.0",
198
198
  "@emotion/react": "^11.14.0",
199
199
  "@emotion/styled": "^11.14.0",
200
200
  "@monaco-editor/react": "^4.7.0",
@@ -209,8 +209,8 @@
209
209
  "recharts": "^3.5.1"
210
210
  },
211
211
  "devDependencies": {
212
- "@12-apps/eslint-config": "^1.7.0",
213
- "@12-apps/typescript-config": "^1.7.0",
212
+ "@12-apps/eslint-config": "^1.0.1",
213
+ "@12-apps/typescript-config": "^1.0.2",
214
214
  "@playwright/test": "^1.55.0",
215
215
  "@storybook/addon-docs": "^9.1.10",
216
216
  "@storybook/addon-links": "^9.1.10",
@@ -2,16 +2,27 @@
2
2
 
3
3
  import { type ReactNode } from "react";
4
4
 
5
- import type { SxProps, Theme } from "@mui/material/styles";
5
+ import { useTheme, type CSSObject } from "@mui/material/styles";
6
+
7
+ /** See the note on `CardSx` in `card-surface` — these fragments get merged. */
8
+ type CardSx = CSSObject | Record<string, unknown>;
6
9
 
7
10
  import { Checkbox } from "../../form/Checkbox";
8
11
  import { Card } from "../../layout/Card";
9
12
  import { Box } from "../../../mui/Box";
10
13
  import { Text } from "../../typography/Text";
11
14
 
15
+ import { DragHandle, useDragItem } from "./data-views-drag";
16
+ import {
17
+ cardSurfaceStyles,
18
+ isActionable,
19
+ isSelectable,
20
+ slotTestIds,
21
+ type CardSurfaceProps,
22
+ } from "./card-surface";
12
23
  import { CARD_ASPECT_RATIOS, type CardAspectRatio } from "./data-views-types";
13
24
 
14
- interface BaseCardProps {
25
+ export interface BaseCardProps extends CardSurfaceProps {
15
26
  /** Extra body content below the caption (chips, meta). Optional. */
16
27
  children?: ReactNode;
17
28
  /** Tile proportion. Defaults to "4:3". */
@@ -33,15 +44,17 @@ interface BaseCardProps {
33
44
  subtitle?: ReactNode;
34
45
  /** The 3-dots menu (or any action node) pinned to the top-right corner. */
35
46
  menu?: ReactNode;
36
- /** Whether this card is in the grid's selection. */
37
- selected?: boolean;
38
47
  /** Toggle this card's selection. Omit to hide the checkbox (non-selectable). */
39
48
  onToggleSelect?: () => void;
40
- /** Card click (e.g. open/edit). The checkbox/menu stop propagation. */
41
- onClick?: () => void;
42
- /** Visually de-emphasise (e.g. a disabled/hidden entity). */
43
- dimmed?: boolean;
44
- dataTestId?: string;
49
+ /**
50
+ * This card's id for drag purposes.
51
+ *
52
+ * Draggable ONLY inside a {@link DragContainerProvider} — see
53
+ * `data-views-drag`. On the board that is how a card moves between columns;
54
+ * in a grade it is how the operator reorders. Outside a container this is
55
+ * inert and no grip appears.
56
+ */
57
+ dragId?: string | number;
45
58
  checkboxTestId?: string;
46
59
  }
47
60
 
@@ -50,10 +63,9 @@ function cardSx(opts: {
50
63
  aspectRatio: CardAspectRatio;
51
64
  pad: number;
52
65
  interactive: boolean;
53
- dimmed: boolean;
54
- selected: boolean;
55
- }): SxProps<Theme> {
56
- const { aspectRatio, pad, interactive, dimmed, selected } = opts;
66
+ draggable: boolean;
67
+ }): CardSx {
68
+ const { aspectRatio, pad, interactive, draggable } = opts;
57
69
  return {
58
70
  position: "relative",
59
71
  aspectRatio: String(CARD_ASPECT_RATIOS[aspectRatio]),
@@ -61,12 +73,8 @@ function cardSx(opts: {
61
73
  flexDirection: "column",
62
74
  overflow: "hidden",
63
75
  p: pad,
64
- transition: "border-color 120ms, box-shadow 120ms, background-color 120ms",
65
- cursor: interactive ? "pointer" : undefined,
66
- opacity: dimmed ? 0.6 : 1,
67
- borderColor: selected ? "primary.main" : undefined,
68
- boxShadow: selected ? (theme) => `inset 0 0 0 1px ${theme.palette.primary.main}` : undefined,
69
- backgroundColor: selected ? "action.selected" : undefined,
76
+ cursor: draggable ? "grab" : interactive ? "pointer" : undefined,
77
+ ...(draggable ? { touchAction: "none", "&:active": { cursor: "grabbing" } } : {}),
70
78
  };
71
79
  }
72
80
 
@@ -244,9 +252,29 @@ function CardContents({
244
252
  * per-slot helpers above; this shell just assembles them within a fixed-ratio
245
253
  * tile. Reads no context, so it renders standalone as well as inside the grid.
246
254
  */
255
+ /**
256
+ * Everything the tile derives from its props before it can render — pulled out
257
+ * so `BaseCard` itself stays inside the complexity budget.
258
+ */
259
+ function useCardShell(props: BaseCardProps) {
260
+ const actionable = isActionable(props.state);
261
+ return {
262
+ selectable: isSelectable(props),
263
+ actionable,
264
+ theme: useTheme(),
265
+ slot: slotTestIds(props.testId),
266
+ scale: props.scale ?? 1,
267
+ // The card's own veto over the container's decision — see `CardSurfaceProps`.
268
+ drag: useDragItem(props.draggable === false || !actionable ? undefined : props.dragId),
269
+ };
270
+ }
271
+
247
272
  export function BaseCard(props: BaseCardProps): React.JSX.Element {
248
- const { aspectRatio = "4:3", scale = 1, selected = false, dimmed = false } = props;
249
- const { menu, onToggleSelect, onClick, dataTestId, checkboxTestId } = props;
273
+ const { aspectRatio = "4:3", selected = false } = props;
274
+ const { menu, onToggleSelect, onClick } = props;
275
+ const { selectable, actionable, theme, slot, scale, drag } = useCardShell(props);
276
+ const dataTestId = props.testId;
277
+ const checkboxTestId = slot("checkbox");
250
278
  const pad = 1.5 * scale;
251
279
 
252
280
  return (
@@ -255,11 +283,53 @@ export function BaseCard(props: BaseCardProps): React.JSX.Element {
255
283
  borderRadius="lg"
256
284
  onClick={onClick}
257
285
  dataTestId={dataTestId}
258
- sx={cardSx({ aspectRatio, pad, interactive: onClick != null, dimmed, selected })}
286
+ className={props.className}
287
+ aria-label={props["aria-label"]}
288
+ aria-disabled={actionable ? undefined : true}
289
+ {...drag.itemProps}
290
+ // A PLAIN OBJECT, not a function. `Card` merges by spreading
291
+ // (`sx={{ ...defaults, ...sx }}`), and spreading a function yields
292
+ // nothing at all — the styles vanish silently, which is exactly how the
293
+ // first version of this shipped with no variants and no effects.
294
+ sx={{
295
+ // The shared half — variant, colour, selection, effects — identical to
296
+ // the one BaseListCard uses, so a Grade and a Lista of the same records
297
+ // are dressed by one set of rules.
298
+ ...cardSurfaceStyles(
299
+ {
300
+ ...props,
301
+ selectable,
302
+ shape: "tile",
303
+ // A card mid-drag is a ghost of itself; the drop target is what the
304
+ // eye should be on.
305
+ state: drag.dragging ? "disabled" : props.state,
306
+ },
307
+ theme,
308
+ ),
309
+ // …and the tile's own geometry on top.
310
+ ...cardSx({
311
+ aspectRatio,
312
+ pad,
313
+ interactive: onClick != null,
314
+ draggable: drag.draggable && drag.handleProps === undefined,
315
+ }),
316
+ }}
259
317
  >
318
+ {/* Bottom-left, opposite the menu and clear of the checkbox — a grip in
319
+ the same corner as a control that toggles selection means every drag
320
+ starts with a near-miss on it. */}
321
+ {drag.draggable && (
322
+ <Box sx={{ position: "absolute", bottom: 4, left: 4, zIndex: 2 }}>
323
+ <DragHandle
324
+ handleProps={drag.handleProps}
325
+ gated={drag.handleProps !== undefined}
326
+ testId={slot("drag")}
327
+ />
328
+ </Box>
329
+ )}
260
330
  <CardOverlays
261
331
  selected={selected}
262
- onToggleSelect={onToggleSelect}
332
+ onToggleSelect={selectable ? onToggleSelect ?? (() => {}) : undefined}
263
333
  checkboxTestId={checkboxTestId}
264
334
  menu={menu}
265
335
  />
@@ -0,0 +1,256 @@
1
+ "use client";
2
+
3
+ import { type ReactNode } from "react";
4
+
5
+ import { DescriptionItem, type DescriptionItemProps } from "../DescriptionItem";
6
+ import { Box } from "../../../mui/Box";
7
+ import { Text } from "../../typography/Text";
8
+
9
+ /**
10
+ * WHAT SITS IN EACH RAIL.
11
+ *
12
+ * Split from `base-list-card` at the size gate: that module is the shell, the
13
+ * rails and the states; this is the content of each column.
14
+ */
15
+
16
+ /** Below this the row drops its middle columns. */
17
+ export const META_BREAK = 520;
18
+ /** …and below this it leaves the shared rails and goes two-line. */
19
+ export const STACK_BREAK = 360;
20
+
21
+ /** Money and dates line up only if their digits are the same width. */
22
+ const TABULAR = { fontVariantNumeric: "tabular-nums" } as const;
23
+
24
+ const CLAMP = {
25
+ overflow: "hidden",
26
+ textOverflow: "ellipsis",
27
+ whiteSpace: "nowrap",
28
+ } as const;
29
+
30
+ /**
31
+ * Title over subtitle — and, when the row navigates, the anchor that covers it.
32
+ *
33
+ * The link is a real `<a>` stretched by `::after` over the whole card rather
34
+ * than a click handler on a `<div>`. That buys cmd-click, middle-click, "copy
35
+ * link address", the status bar preview and correct tab order, and it stops the
36
+ * buttons elsewhere in the row from being interactive elements nested inside a
37
+ * clickable one.
38
+ */
39
+ export function ListCardCaption({
40
+ title,
41
+ subtitle,
42
+ href,
43
+ target,
44
+ scale,
45
+ testId,
46
+ }: {
47
+ title?: ReactNode;
48
+ subtitle?: ReactNode;
49
+ href?: string;
50
+ target?: string;
51
+ scale: number;
52
+ testId?: string;
53
+ }): React.JSX.Element {
54
+ return (
55
+ <Box data-slot="caption" sx={{ minWidth: 0 }}>
56
+ {title != null && (
57
+ <Text variant="heading" size="sm" weight="bold" as="p">
58
+ <Box
59
+ {...(href ? { component: "a", href, target } : { component: "span" })}
60
+ data-testid={testId}
61
+ sx={{
62
+ display: "block",
63
+ lineHeight: 1.2,
64
+ fontSize: `${0.9 * scale}rem`,
65
+ color: "inherit",
66
+ textDecoration: "none",
67
+ ...CLAMP,
68
+ ...(href
69
+ ? {
70
+ "&::after": {
71
+ content: '""',
72
+ position: "absolute",
73
+ inset: 0,
74
+ // Under the checkbox, drag grip, actions and menu, which
75
+ // all raise themselves to 1.
76
+ zIndex: 0,
77
+ },
78
+ "&:hover": { textDecoration: "underline" },
79
+ }
80
+ : {}),
81
+ }}
82
+ >
83
+ {title}
84
+ </Box>
85
+ </Text>
86
+ )}
87
+ {subtitle != null && (
88
+ <Text variant="caption" size="xs" color="secondary" as="p">
89
+ <Box component="span" sx={{ display: "block", ...CLAMP }}>
90
+ {subtitle}
91
+ </Box>
92
+ </Text>
93
+ )}
94
+ </Box>
95
+ );
96
+ }
97
+
98
+ /**
99
+ * The middle columns: stacked label-over-value pairs, separated by a rule.
100
+ *
101
+ * Stacked rather than side by side because `DATA 05/08/2026` inline spends the
102
+ * horizontal room the value rail needs, and the separator is what stops two
103
+ * adjacent pairs reading as one four-word phrase.
104
+ */
105
+ export function ListCardMeta({
106
+ meta,
107
+ metaSlot,
108
+ }: {
109
+ meta?: DescriptionItemProps[];
110
+ metaSlot?: ReactNode;
111
+ }): React.JSX.Element {
112
+ const items = meta ?? [];
113
+ return (
114
+ <Box
115
+ data-slot="meta"
116
+ sx={{
117
+ display: "flex",
118
+ alignItems: "center",
119
+ gap: 2,
120
+ whiteSpace: "nowrap",
121
+ ...TABULAR,
122
+ // Rung 1 of the ladder.
123
+ [`@container (max-width: ${META_BREAK}px)`]: { display: "none" },
124
+ }}
125
+ >
126
+ {items.map((item, index) => (
127
+ <Box key={`${item.label}-${index}`} sx={{ display: "flex", alignItems: "center", gap: 2 }}>
128
+ {index > 0 && (
129
+ <Box
130
+ aria-hidden
131
+ sx={{ color: "text.disabled", opacity: 0.5, userSelect: "none", fontSize: "0.875rem" }}
132
+ >
133
+ |
134
+ </Box>
135
+ )}
136
+ {/* Centred: a stacked pair sitting in its own column reads as one
137
+ unit only when the label is over the value rather than flush left
138
+ of a value shorter than it — `MÉTODO` above `PIX` was visibly
139
+ unhinged from it. Overridable per item. */}
140
+ <DescriptionItem orientation="vertical" align="center" {...item} />
141
+ </Box>
142
+ ))}
143
+ {metaSlot}
144
+ </Box>
145
+ );
146
+ }
147
+
148
+ /** The value, on its own rail, never truncated and never shunted by the chip. */
149
+ function ListCardValue({ value, scale }: { value: ReactNode; scale: number }): React.JSX.Element {
150
+ return (
151
+ <Text variant="body" size="sm" weight="medium" as="span">
152
+ <Box
153
+ component="span"
154
+ sx={{ fontSize: `${0.875 * scale}rem`, whiteSpace: "nowrap", ...TABULAR }}
155
+ >
156
+ {value}
157
+ </Box>
158
+ </Text>
159
+ );
160
+ }
161
+
162
+ /**
163
+ * The actions and the menu, at the end of the row.
164
+ *
165
+ * Actions are revealed on hover and `:focus-within` — always-visible buttons on
166
+ * every row of fifty is a lot of chrome for something used on one of them.
167
+ * `:focus-within` is what keeps them reachable by keyboard, and `visibility`
168
+ * rather than `display` keeps their space reserved so nothing shifts.
169
+ */
170
+ function ListCardActions({
171
+ actions,
172
+ alwaysVisible,
173
+ menu,
174
+ testId,
175
+ }: {
176
+ actions?: ReactNode;
177
+ alwaysVisible?: boolean;
178
+ menu?: ReactNode;
179
+ testId: (slot: string) => string | undefined;
180
+ }): React.JSX.Element | null {
181
+ if (actions == null && menu == null) return null;
182
+ return (
183
+ // z-index 1: above the stretched link, so these stay clickable.
184
+ <Box
185
+ data-slot="actions"
186
+ sx={{ display: "flex", alignItems: "center", gap: 0.5, position: "relative", zIndex: 1 }}
187
+ >
188
+ {actions && (
189
+ <Box
190
+ data-testid={testId("actions")}
191
+ onClick={(event) => event.stopPropagation()}
192
+ sx={{
193
+ display: "flex",
194
+ alignItems: "center",
195
+ gap: 0.5,
196
+ ...(alwaysVisible
197
+ ? {}
198
+ : {
199
+ visibility: "hidden",
200
+ ".MuiPaper-root:hover &, .MuiPaper-root:focus-within &": {
201
+ visibility: "visible",
202
+ },
203
+ }),
204
+ }}
205
+ >
206
+ {actions}
207
+ </Box>
208
+ )}
209
+ {menu && (
210
+ <Box data-testid={testId("menu")} onClick={(event) => event.stopPropagation()}>
211
+ {menu}
212
+ </Box>
213
+ )}
214
+ </Box>
215
+ );
216
+ }
217
+
218
+ /** Value, status and actions — three rails, not one flex run. */
219
+ export function ListCardTail({
220
+ value,
221
+ status,
222
+ actions,
223
+ actionsAlwaysVisible,
224
+ menu,
225
+ scale,
226
+ testId,
227
+ }: {
228
+ value?: ReactNode;
229
+ status?: ReactNode;
230
+ actions?: ReactNode;
231
+ actionsAlwaysVisible?: boolean;
232
+ menu?: ReactNode;
233
+ scale: number;
234
+ testId: (slot: string) => string | undefined;
235
+ }): React.JSX.Element {
236
+ return (
237
+ <>
238
+ <Box data-slot="value" sx={{ textAlign: "right" }} data-testid={testId("value")}>
239
+ {value != null && <ListCardValue value={value} scale={scale} />}
240
+ </Box>
241
+ <Box
242
+ data-slot="status"
243
+ sx={{ display: "flex", justifyContent: "flex-start" }}
244
+ data-testid={testId("status")}
245
+ >
246
+ {status}
247
+ </Box>
248
+ <ListCardActions
249
+ actions={actions}
250
+ alwaysVisible={actionsAlwaysVisible}
251
+ menu={menu}
252
+ testId={testId}
253
+ />
254
+ </>
255
+ );
256
+ }