@12-apps/ui 1.19.0 → 1.21.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.19.0",
3
+ "version": "1.21.0",
4
4
  "license": "MIT",
5
5
  "packageManager": "pnpm@9.0.0",
6
6
  "type": "module",
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import { useId, useState } from "react";
4
+
5
+ import type { BaseListCardProps } from "./base-list-card";
6
+
7
+ /**
8
+ * The row's open state, whether it owns it or the list does.
9
+ *
10
+ * Controlled the moment `expanded` is passed, so a list imposing an accordion
11
+ * is never fighting a second copy of the truth inside each row. Uncontrolled
12
+ * otherwise, and rows then open independently — which is what comparing two
13
+ * records side by side actually needs.
14
+ */
15
+ export function useDisclosure(props: BaseListCardProps): {
16
+ expandable: boolean;
17
+ expanded: boolean;
18
+ toggle: () => void;
19
+ regionId: string;
20
+ } {
21
+ const [own, setOwn] = useState(props.defaultExpanded ?? false);
22
+ // CONTROLLED ONLY WITH AN OWNER. `expanded` without `onExpandedChange` is a
23
+ // value nothing can ever change — the chevron would move nothing and the row
24
+ // would look broken. Storybook can persist a stray arg into a URL and produce
25
+ // exactly that, so the pair is required before the card gives up its state.
26
+ const controlled = props.expanded != null && props.onExpandedChange != null;
27
+ const expanded = controlled ? props.expanded === true : own;
28
+ // `useId` rather than a counter: the id has to survive an SSR pass and match
29
+ // on hydration, or `aria-controls` points at nothing on the first paint.
30
+ const regionId = useId();
31
+ return {
32
+ expandable: props.children != null,
33
+ expanded,
34
+ regionId,
35
+ toggle: () => {
36
+ if (!controlled) setOwn((open) => !open);
37
+ props.onExpandedChange?.(!expanded);
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,282 @@
1
+ "use client";
2
+
3
+ import { useTheme } from "@mui/material/styles";
4
+
5
+ import {
6
+ CARD_RADIUS,
7
+ cardSurfaceStyles,
8
+ isActionable,
9
+ isSelectable,
10
+ slotTestIds,
11
+ } from "./card-surface";
12
+ import { useDragItem } from "./data-views-drag";
13
+ import { DENSITY_ROW_PADDING } from "./data-views-layout-context";
14
+ import { RAIL_COUNT, RAIL_GAP, railsTemplateFor, useListRails } from "./list-card-rails";
15
+ import { STACK_BREAK } from "./base-list-card-slots";
16
+ import type { BaseListCardProps } from "./base-list-card";
17
+
18
+ /**
19
+ * THE ROW'S GEOMETRY AND SURFACE, split from the component at the size gate.
20
+ *
21
+ * Everything here answers "what shape is this row, and where do its slots land"
22
+ * — the rails, the density padding, the two-line collapse, the drag and
23
+ * selection plumbing. None of it renders anything, which is why the two halves
24
+ * can live apart without either becoming harder to follow.
25
+ */
26
+ /**
27
+ * Whether a click was really a click, or the tail of a text selection.
28
+ *
29
+ * Without this, selecting an order id to copy it navigates away instead — the
30
+ * single most annoying thing a clickable row does.
31
+ */
32
+ function isTextSelection(): boolean {
33
+ return (globalThis.getSelection?.()?.toString().length ?? 0) > 0;
34
+ }
35
+
36
+ /** Keyboard equivalence for a card that acts but does not navigate. */
37
+ function clickKeys(onClick: () => void) {
38
+ return (event: React.KeyboardEvent): void => {
39
+ if (event.key !== "Enter" && event.key !== " ") return;
40
+ event.preventDefault();
41
+ onClick();
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Geometry: the rails, the density padding, the two-line collapse.
47
+ *
48
+ * SCALE IS `zoom`, NOT A SET OF MULTIPLIED FONT SIZES.
49
+ *
50
+ * It used to be the latter, on exactly three values — title, value, row padding
51
+ * — so the slider left the subtitle, the meta labels, the rules, the checkbox,
52
+ * the grip, the chip and the menu at their fixed sizes, and the row came apart
53
+ * as you dragged it. Multiplying the rest is not a fix: the checkbox and the
54
+ * chip are MUI internals sized in their own px, and no prop we pass reaches
55
+ * them.
56
+ *
57
+ * `zoom` scales the used value of every length in the subtree — ours and theirs
58
+ * — and unlike `transform: scale` it REFLOWS, so the row still occupies the
59
+ * space it draws and the rails still resolve. Verified against the group: at
60
+ * 1.4 all four rows keep identical rail edges, because subgrid tracks and the
61
+ * zoom that reads them are the same for every row.
62
+ */
63
+ /**
64
+ * DIVIDER COMPOSES WITH THE VARIANT — it changes the row's SHAPE, not its
65
+ * surface.
66
+ *
67
+ * It used to set `border: 0` and hard-code its own rule colour, which discarded
68
+ * whatever the variant had drawn: `outline`, `text` and `ghost` all collapsed
69
+ * to one identical flush row, and the variant control silently stopped meaning
70
+ * anything the moment `divider` was on.
71
+ *
72
+ * Now only the GEOMETRY changes here — the box's sides go, one edge stays — and
73
+ * no `borderColor` is written, so the colour set by `cardSurfaceStyles` survives.
74
+ * A variant that draws no border of its own gets the neutral rule from
75
+ * {@link rowStyles}.
76
+ */
77
+ export function rowSx(opts: {
78
+ inGroup: boolean;
79
+ railCount: number;
80
+ cellTemplate: string | null;
81
+ gutters: { disclose: boolean; drag: boolean; select: boolean };
82
+ metaColumns: number;
83
+ pad: number;
84
+ padY: number;
85
+ scale: number;
86
+ divider: boolean;
87
+ interactive: boolean;
88
+ draggable: boolean;
89
+ }): Record<string, unknown> {
90
+ const { inGroup, railCount, cellTemplate, gutters, metaColumns, pad, padY, scale, divider, interactive, draggable } = opts;
91
+ return {
92
+ position: "relative",
93
+ borderRadius: CARD_RADIUS,
94
+ display: "grid",
95
+ zoom: scale,
96
+ // SUBGRID AND `container-type` CANNOT COEXIST on one element: containment
97
+ // makes the box size independently of its parent, so the browser drops the
98
+ // subgrid and every slot stacks into one column. (Observed exactly that —
99
+ // computed `grid-template-columns: 1222px` on a card asking for subgrid.)
100
+ //
101
+ // So inside a group the GROUP is the query container and the row is the
102
+ // subgrid; standalone, the row is both the container and its own grid.
103
+ ...(inGroup
104
+ ? { gridColumn: `span ${railCount}`, gridTemplateColumns: "subgrid" }
105
+ : {
106
+ containerType: "inline-size",
107
+ gridTemplateColumns: cellTemplate ?? railsTemplateFor(gutters, metaColumns),
108
+ }),
109
+ alignItems: "center",
110
+ // Wide enough that the meta cluster, the value and the status read as three
111
+ // columns rather than one run of text — the complaint that started all this
112
+ // was `MÉTODO PIX R$ 13,90` scanning as a single phrase.
113
+ columnGap: RAIL_GAP,
114
+ width: "100%",
115
+ px: pad,
116
+ py: padY,
117
+ cursor: draggable ? "grab" : interactive ? "pointer" : undefined,
118
+ ...(draggable ? { touchAction: "none", "&:active": { cursor: "grabbing" } } : {}),
119
+ ...(interactive ? { "&:hover": { backgroundColor: "action.hover" } } : {}),
120
+ // A real focus ring, which a clickable <div> never had.
121
+ "&:focus-visible": { outline: 2, outlineStyle: "solid", outlineColor: "primary.main", outlineOffset: 2 },
122
+ // TWO-LINE, below the point where the shared rails stop helping.
123
+ //
124
+ // The standard mobile transaction row: what the record IS and what it COST
125
+ // on the first line, the supporting detail on the second. Better than
126
+ // truncating four columns into ellipses, and the reason each slot carries a
127
+ // `data-slot` — the placement is explicit rather than whatever order the
128
+ // children happen to be in.
129
+ //
130
+ // [ select ][ leading ][ title …………… ][ menu ]
131
+ // [ (cells) …………… ][ value ]
132
+ //
133
+ // The MENU stays in the top-right corner at every width. It is the row's
134
+ // one fixed landmark — an overflow that moves to the second line on a phone
135
+ // is an overflow nobody finds twice.
136
+ [`@container (max-width: ${STACK_BREAK}px)`]: {
137
+ gridTemplateColumns: "auto auto minmax(0, 1fr) max-content",
138
+ ...(inGroup ? { gridColumn: `span ${RAIL_COUNT}` } : {}),
139
+ rowGap: 0.75,
140
+ columnGap: 1,
141
+ '& > [data-slot="drag"]': { display: "none" },
142
+ '& > [data-slot="select"]': { gridArea: "1 / 1" },
143
+ '& > [data-slot="leading"]': { gridArea: "1 / 2" },
144
+ '& > [data-slot="caption"]': { gridArea: "1 / 3" },
145
+ '& > [data-slot="actions"]': { gridArea: "1 / 4", justifyContent: "flex-end" },
146
+ '& > [data-slot="meta"]': { display: "none" },
147
+ // Second line, starting under the title rather than under the checkbox.
148
+ '& > [data-slot="value"]': { gridArea: "2 / 4", textAlign: "right" },
149
+ },
150
+ ...(divider
151
+ ? { borderRadius: 0, borderWidth: 0, borderBottomWidth: 1, borderStyle: "solid" }
152
+ : {}),
153
+ };
154
+ }
155
+
156
+ /**
157
+ * A selectable full-width row: a marker, a title over a subtitle, labelled
158
+ * middle columns, a value, a status, actions and a menu.
159
+ *
160
+ * A SHELL, like {@link BaseCard}. Domain rows live in the app and compose it.
161
+ */
162
+ /**
163
+ * The id this row drags under, or nothing.
164
+ *
165
+ * `undefined` is the inert answer: `useDragItem` treats a missing id as "no
166
+ * drag", which is how the card's own veto (`draggable={false}`) and an
167
+ * unactionable record both switch dragging off without the card knowing
168
+ * anything about the container above it.
169
+ */
170
+ function dragIdFor(
171
+ props: BaseListCardProps,
172
+ actionable: boolean,
173
+ ): string | number | undefined {
174
+ return props.draggable === false || !actionable ? undefined : props.dragId;
175
+ }
176
+
177
+ /**
178
+ * What the meta cluster amounts to — asked once, used three times: to size the
179
+ * rail, to close the cluster with a rule, and to decide whether the value has
180
+ * anything to be divided FROM.
181
+ */
182
+ export function metaShape(props: BaseListCardProps): { columns: number; present: boolean } {
183
+ const columns = props.meta?.length ?? 0;
184
+ return { columns, present: columns > 0 || props.metaSlot != null };
185
+ }
186
+
187
+ /**
188
+ * Everything the row derives before it can render — extracted so the component
189
+ * stays inside the size budget.
190
+ */
191
+ export function useRowShell(props: BaseListCardProps) {
192
+ const actionable = isActionable(props.state);
193
+ const group = useListRails();
194
+ const scale = props.scale ?? 1;
195
+ return {
196
+ group,
197
+ actionable,
198
+ scale,
199
+ theme: useTheme(),
200
+ selectable: isSelectable(props),
201
+ slot: slotTestIds(props.testId),
202
+ drag: useDragItem(dragIdFor(props, actionable)),
203
+ // Standalone holds no gutter open: there is no list beside it to line up
204
+ // with, so a reserved-but-empty rail is pure inset.
205
+ reserve: group?.reserveGutters ?? false,
206
+ // 1, not 1.5. The row's contents still have to line up with the toolbar
207
+ // above them, but 12px of card padding stacked on the checkbox's own 9px
208
+ // and an empty drag gutter's 24px read as a row indented for no reason.
209
+ //
210
+ // Not multiplied by `scale` — the row's `zoom` already scales it, and doing
211
+ // both would square the factor on padding alone.
212
+ pad: 1,
213
+ padY: DENSITY_ROW_PADDING[group?.density ?? props.density ?? "cozy"],
214
+ // A card that navigates does so with a real anchor, so it takes no click
215
+ // handler of its own — the stretched link IS the target.
216
+ acts: props.onClick != null && props.href == null && actionable,
217
+ };
218
+ }
219
+
220
+ /** The click/keyboard props a row that acts (but does not navigate) needs. */
221
+ export function actionProps(onClick: (() => void) | undefined) {
222
+ return {
223
+ role: "button",
224
+ tabIndex: 0,
225
+ onClick: () => {
226
+ if (isTextSelection()) return;
227
+ onClick?.();
228
+ },
229
+ onKeyDown: clickKeys(() => onClick?.()),
230
+ };
231
+ }
232
+
233
+ /** The surface and the geometry, merged — the row's whole `sx` in one place. */
234
+
235
+ /**
236
+ * The row's painted surface, plus the divider's colour fallback.
237
+ *
238
+ * A variant with no border of its own (`text`, `ghost`) would draw its rule in
239
+ * the inherited colour — the TEXT colour, i.e. a black bar across the row. The
240
+ * neutral divider token is supplied only in that case; a variant that has a
241
+ * border keeps it, which is the whole point of composing the two.
242
+ */
243
+ function rowSurface(
244
+ props: BaseListCardProps,
245
+ shell: ReturnType<typeof useRowShell>,
246
+ ): Record<string, unknown> {
247
+ const { theme, selectable, drag } = shell;
248
+ const surface = cardSurfaceStyles(
249
+ { ...props, selectable, shape: "row", state: drag.dragging ? "disabled" : props.state },
250
+ theme,
251
+ );
252
+ if (props.divider !== true || surface.borderColor != null) return surface;
253
+ return { ...surface, borderBottomColor: theme.palette.divider };
254
+ }
255
+
256
+ export function rowStyles(
257
+ props: BaseListCardProps,
258
+ shell: ReturnType<typeof useRowShell>,
259
+ cellTemplate: string | null,
260
+ ): Record<string, unknown> {
261
+ const { group, selectable, drag, reserve, pad, padY, scale, acts } = shell;
262
+ return {
263
+ ...rowSurface(props, shell),
264
+ ...rowSx({
265
+ inGroup: group !== null,
266
+ railCount: group?.railCount ?? RAIL_COUNT,
267
+ cellTemplate,
268
+ gutters: {
269
+ disclose: reserve || props.children != null,
270
+ drag: reserve || drag.draggable,
271
+ select: reserve || selectable,
272
+ },
273
+ metaColumns: metaShape(props).columns,
274
+ pad,
275
+ padY,
276
+ scale,
277
+ divider: props.divider ?? false,
278
+ interactive: acts || props.href != null,
279
+ draggable: drag.draggable && drag.handleProps === undefined,
280
+ }),
281
+ };
282
+ }
@@ -1,16 +1,21 @@
1
1
  "use client";
2
2
 
3
+ import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
4
+ import { IconButton } from "@mui/material";
5
+
3
6
  import { Checkbox } from "../../form/Checkbox";
4
7
  import { Box } from "../../../mui/Box";
5
8
 
6
9
  import { DragHandle, useDragItem } from "./data-views-drag";
7
10
 
8
11
  /**
9
- * THE TWO GUTTERS AT THE HEAD OF A ROW — the drag grip and the select checkbox.
12
+ * THE THREE GUTTERS AT THE HEAD OF A ROW — the disclosure chevron, the drag grip
13
+ * and the select checkbox.
10
14
  *
11
- * Split from `base-list-card` at the size gate. Both answer the same question in
12
- * the same way: whether to occupy a rail, hold one open, or leave the template
13
- * altogether. See `railsTemplateFor` for why the third option has to exist.
15
+ * Split from `base-list-card` at the size gate. All three answer the same
16
+ * question in the same way: whether to occupy a rail, hold one open, or leave
17
+ * the template altogether. See `railsTemplateFor` for why the third option has
18
+ * to exist.
14
19
  */
15
20
 
16
21
  /**
@@ -88,3 +93,77 @@ export function SelectSlot({
88
93
  </Box>
89
94
  );
90
95
  }
96
+
97
+ /**
98
+ * THE DISCLOSURE CHEVRON — the affordance that says this row has more.
99
+ *
100
+ * Rendered only when the card was given a body to reveal. A chevron on a row
101
+ * that opens onto nothing is worse than no chevron: it is a promise the row
102
+ * cannot keep, and an operator who presses it twice learns to stop trusting the
103
+ * column. So `BaseListCard` passes `null` here when it has no children, the
104
+ * slot returns nothing, and — standalone — the rail leaves the template
105
+ * entirely.
106
+ *
107
+ * ROTATED, NOT SWAPPED. One glyph turned 90° rather than a right-chevron
108
+ * exchanged for a down-chevron: the rotation is animatable and reads as the
109
+ * same object moving, where a swap is two icons flickering. It also keeps the
110
+ * button's box identical in both states, so the caption beside it cannot shift
111
+ * as the row opens.
112
+ *
113
+ * `stopPropagation`, because a row is frequently clickable in its own right —
114
+ * opening the record is not the same act as peeking at its detail, and without
115
+ * this the chevron would do both at once.
116
+ */
117
+ export function DiscloseSlot({
118
+ expandable,
119
+ expanded,
120
+ reserve,
121
+ onToggle,
122
+ controls,
123
+ testId,
124
+ }: {
125
+ expandable: boolean;
126
+ expanded: boolean;
127
+ reserve: boolean;
128
+ onToggle: () => void;
129
+ /** The id of the region this button opens, for `aria-controls`. */
130
+ controls: string;
131
+ testId?: string;
132
+ }): React.JSX.Element | null {
133
+ if (!expandable) return reserve ? <Box data-slot="disclose" /> : null;
134
+ return (
135
+ // z-index 1 and flex for the same reasons the other two gutters use them:
136
+ // above a stretched link that would otherwise swallow the click, and out of
137
+ // a baseline that would drop the glyph below the row's centre.
138
+ <Box
139
+ data-slot="disclose"
140
+ sx={{ position: "relative", zIndex: 1, display: "flex", alignItems: "center" }}
141
+ >
142
+ <IconButton
143
+ size="small"
144
+ aria-expanded={expanded}
145
+ aria-controls={expanded ? controls : undefined}
146
+ aria-label={expanded ? "Recolher detalhes" : "Expandir detalhes"}
147
+ data-testid={testId}
148
+ onClick={(event) => {
149
+ event.stopPropagation();
150
+ onToggle();
151
+ }}
152
+ // The row's own keyboard handler treats Enter/Space as "open the
153
+ // record"; the button already handles both, so the event must not reach
154
+ // it and fire the second action too.
155
+ onKeyDown={(event) => event.stopPropagation()}
156
+ sx={{
157
+ p: 0.25,
158
+ transform: expanded ? "rotate(90deg)" : "rotate(0deg)",
159
+ transition: "transform 150ms ease",
160
+ // Honour a reduced-motion preference: the state is carried by the
161
+ // angle, not by the animation, so dropping the tween loses nothing.
162
+ "@media (prefers-reduced-motion: reduce)": { transition: "none" },
163
+ }}
164
+ >
165
+ <KeyboardArrowRightIcon fontSize="small" />
166
+ </IconButton>
167
+ </Box>
168
+ );
169
+ }
@@ -235,7 +235,7 @@ function ListCardValue({ value }: { value: ReactNode }): React.JSX.Element {
235
235
  * `:focus-within` is what keeps them reachable by keyboard, and `visibility`
236
236
  * rather than `display` keeps their space reserved so nothing shifts.
237
237
  */
238
- function ListCardActions({
238
+ export function ListCardActions({
239
239
  actions,
240
240
  alwaysVisible,
241
241
  menu,
@@ -283,11 +283,10 @@ function ListCardActions({
283
283
  );
284
284
  }
285
285
 
286
- /** Value, status and actions — three rails, not one flex run. */
286
+ /** Value and actions — separate rails, not one flex run. */
287
287
  export function ListCardTail({
288
288
  value,
289
289
  separated,
290
- status,
291
290
  actions,
292
291
  actionsAlwaysVisible,
293
292
  menu,
@@ -296,7 +295,6 @@ export function ListCardTail({
296
295
  value?: ReactNode;
297
296
  /** Whether a meta cluster precedes the value and wants dividing from it. */
298
297
  separated?: boolean;
299
- status?: ReactNode;
300
298
  actions?: ReactNode;
301
299
  actionsAlwaysVisible?: boolean;
302
300
  menu?: ReactNode;
@@ -314,13 +312,6 @@ export function ListCardTail({
314
312
  >
315
313
  {value != null && <ListCardValue value={value} />}
316
314
  </Box>
317
- <Box
318
- data-slot="status"
319
- sx={{ display: "flex", justifyContent: "flex-start" }}
320
- data-testid={testId("status")}
321
- >
322
- {status}
323
- </Box>
324
315
  <ListCardActions
325
316
  actions={actions}
326
317
  alwaysVisible={actionsAlwaysVisible}