@12-apps/ui 1.18.0 → 1.20.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.
@@ -1,30 +1,25 @@
1
1
  "use client";
2
2
 
3
- import { useTheme } from "@mui/material/styles";
3
+ import { Collapse } from "@mui/material";
4
4
  import { type ReactNode } from "react";
5
5
 
6
6
  import { Card } from "../../layout/Card";
7
7
  import type { DescriptionItemProps } from "../DescriptionItem";
8
8
  import { Box } from "../../../mui/Box";
9
9
 
10
+ import { type CardSurfaceProps } from "./card-surface";
11
+ import { DropIndicator } from "./data-views-drag";
12
+ import { useDisclosure } from "./base-list-card-disclosure";
13
+ import { actionProps, metaShape, rowStyles, useRowShell } from "./base-list-card-geometry";
14
+ import { DiscloseSlot, DragSlot, SelectSlot } from "./base-list-card-gutters";
15
+ import { type DataViewsDensity } from "./data-views-layout-context";
16
+ import { cellRailsTemplate } from "./list-card-rails";
17
+ import { ListCardCells, cellTracks, useCellConfig, type ListCardCellConfig } from "./list-card-cells";
10
18
  import {
11
- CARD_RADIUS,
12
- cardSurfaceStyles,
13
- isActionable,
14
- isSelectable,
15
- slotTestIds,
16
- type CardSurfaceProps,
17
- } from "./card-surface";
18
- import { useDragItem } from "./data-views-drag";
19
- import { DragSlot, SelectSlot } from "./base-list-card-gutters";
20
- import { DENSITY_ROW_PADDING, type DataViewsDensity } from "./data-views-layout-context";
21
- import { RAIL_COUNT, RAIL_GAP, railsTemplateFor, useListRails } from "./list-card-rails";
22
- import {
19
+ ListCardActions,
23
20
  ListCardCaption,
24
21
  ListCardMeta,
25
22
  ListCardTail,
26
- META_BREAK,
27
- STACK_BREAK,
28
23
  } from "./base-list-card-slots";
29
24
 
30
25
  /**
@@ -70,8 +65,6 @@ export interface BaseListCardProps extends CardSurfaceProps {
70
65
  metaSlot?: ReactNode;
71
66
  /** The number this row is about. Its own rail, tabular figures, never truncated. */
72
67
  value?: ReactNode;
73
- /** A status chip or badge. Its own fixed slot, so it cannot move the value. */
74
- status?: ReactNode;
75
68
  /**
76
69
  * Inline actions — the one or two things done often enough to deserve a
77
70
  * button. Revealed on hover and `:focus-within` by default, so fifty rows of
@@ -84,8 +77,39 @@ export interface BaseListCardProps extends CardSurfaceProps {
84
77
  menu?: ReactNode;
85
78
  /** What opens the overflow menu on right-click / long-press. */
86
79
  onContextMenu?: (event: React.MouseEvent) => void;
87
- /** Extra content below the main row; spans every rail. */
80
+ /**
81
+ * THE EXPANDABLE BODY — and the whole reason this is a card and not a table
82
+ * row.
83
+ *
84
+ * A summary row of labelled columns is a table row with a border round it: it
85
+ * restates what the table already says and gives nobody a reason to switch to
86
+ * it. What a card can do that a table cell cannot is OPEN — carry the record's
87
+ * own detail underneath its summary, in whatever shape that record wants.
88
+ *
89
+ * So this container makes no claim about what goes in here. It spans every
90
+ * rail and imposes nothing: an order puts its line items and a totals ledger
91
+ * here, a cart adds its recovery actions, a role lists its permissions. The
92
+ * design of that area belongs entirely to the consumer, and none of it is the
93
+ * envelope's business.
94
+ *
95
+ * ABSENT ⇒ NO CHEVRON. A row given nothing to reveal renders no disclosure
96
+ * control at all, rather than one that opens onto an empty box, so a column of
97
+ * chevrons is a truthful index of which rows have more behind them.
98
+ */
88
99
  children?: ReactNode;
100
+ /** Open on first render, for an uncontrolled card. Ignored when `expanded` is set. */
101
+ defaultExpanded?: boolean;
102
+ /**
103
+ * Open state, when the LIST wants to own it.
104
+ *
105
+ * Left unset the card keeps its own, and rows open independently — several at
106
+ * once, which is what comparing two records requires. Pass this (with
107
+ * `onExpandedChange`) to impose something else, an accordion being the usual
108
+ * reason.
109
+ */
110
+ expanded?: boolean;
111
+ /** Fired with the state the card is asking to move to. */
112
+ onExpandedChange?: (expanded: boolean) => void;
89
113
  /** Toggle this row's selection. Receives the raw event so a list can range-select. */
90
114
  onToggleSelect?: (event?: React.MouseEvent) => void;
91
115
  /** How much air the row gets. Vertical only — a full-width row cannot narrow. */
@@ -102,237 +126,175 @@ export interface BaseListCardProps extends CardSurfaceProps {
102
126
  divider?: boolean;
103
127
  /** This row's id for drag purposes. Inert outside a `DragContainerProvider`. */
104
128
  dragId?: string | number;
129
+ /**
130
+ * The record this row is about, read by the list's cell config.
131
+ *
132
+ * Required only when cells are configured — the config's `primary`/`secondary`
133
+ * are functions of the row, so without one there is nothing to render them
134
+ * from and the card falls back to its named slots.
135
+ */
136
+ row?: Record<string, unknown>;
137
+ /**
138
+ * A cell config for a STANDALONE card.
139
+ *
140
+ * Inside a `ListCardGroup` the list's config wins and this is ignored: one
141
+ * declaration of the list's shape is the whole point, and a row permitted to
142
+ * override it could put its columns somewhere no other row has them.
143
+ */
144
+ cells?: readonly ListCardCellConfig<never>[];
105
145
  }
106
146
 
107
147
 
108
148
 
149
+
109
150
  /**
110
- * Whether a click was really a click, or the tail of a text selection.
151
+ * Which cell config applies, and whether this row resolves its own tracks.
111
152
  *
112
- * Without this, selecting an order id to copy it navigates away instead — the
113
- * single most annoying thing a clickable row does.
153
+ * The list's config wins over the card's: one declaration of the list's shape is
154
+ * the point, so a row inside a group cannot introduce a column of its own. And
155
+ * the template is standalone-only — inside a group the GROUP owns it and the row
156
+ * is subgrid over it, because a row resolving its own tracks is exactly what
157
+ * stops a list from lining up.
114
158
  */
115
- function isTextSelection(): boolean {
116
- return (globalThis.getSelection?.()?.toString().length ?? 0) > 0;
159
+ function useResolvedCells(
160
+ props: BaseListCardProps,
161
+ inGroup: boolean,
162
+ ): {
163
+ cells: readonly ListCardCellConfig<never>[] | null;
164
+ configured: boolean;
165
+ cellTemplate: string | null;
166
+ } {
167
+ const groupCells = useCellConfig();
168
+ const cells = groupCells ?? props.cells ?? null;
169
+ const configured = cells != null && cells.length > 0 && props.row != null;
170
+ const cellTemplate = configured && !inGroup ? cellRailsTemplate(cellTracks(cells)) : null;
171
+ return { cells, configured, cellTemplate };
117
172
  }
118
173
 
119
- /** Keyboard equivalence for a card that acts but does not navigate. */
120
- function clickKeys(onClick: () => void) {
121
- return (event: React.KeyboardEvent): void => {
122
- if (event.key !== "Enter" && event.key !== " ") return;
123
- event.preventDefault();
124
- onClick();
125
- };
126
- }
127
174
 
128
175
  /**
129
- * Geometry: the rails, the density padding, the two-line collapse.
130
- *
131
- * SCALE IS `zoom`, NOT A SET OF MULTIPLIED FONT SIZES.
132
- *
133
- * It used to be the latter, on exactly three values — title, value, row padding
134
- * — so the slider left the subtitle, the meta labels, the rules, the checkbox,
135
- * the grip, the chip and the menu at their fixed sizes, and the row came apart
136
- * as you dragged it. Multiplying the rest is not a fix: the checkbox and the
137
- * chip are MUI internals sized in their own px, and no prop we pass reaches
138
- * them.
176
+ * The row's middle: either the list's configured cells, or the named slots.
139
177
  *
140
- * `zoom` scales the used value of every length in the subtree — ours and theirs
141
- * — and unlike `transform: scale` it REFLOWS, so the row still occupies the
142
- * space it draws and the rails still resolve. Verified against the group: at
143
- * 1.4 all four rows keep identical rail edges, because subgrid tracks and the
144
- * zoom that reads them are the same for every row.
178
+ * Split out because the branch is the only conditional shape the card has, and
179
+ * inlined it pushed `BaseListCard` past both the line and the complexity gate.
145
180
  */
146
- function rowSx(opts: {
147
- inGroup: boolean;
148
- gutters: { drag: boolean; select: boolean };
149
- metaColumns: number;
150
- pad: number;
151
- padY: number;
152
- scale: number;
153
- divider: boolean;
154
- interactive: boolean;
155
- draggable: boolean;
156
- }): Record<string, unknown> {
157
- const { inGroup, gutters, metaColumns, pad, padY, scale, divider, interactive, draggable } = opts;
158
- return {
159
- position: "relative",
160
- borderRadius: CARD_RADIUS,
161
- display: "grid",
162
- zoom: scale,
163
- // SUBGRID AND `container-type` CANNOT COEXIST on one element: containment
164
- // makes the box size independently of its parent, so the browser drops the
165
- // subgrid and every slot stacks into one column. (Observed exactly that —
166
- // computed `grid-template-columns: 1222px` on a card asking for subgrid.)
167
- //
168
- // So inside a group the GROUP is the query container and the row is the
169
- // subgrid; standalone, the row is both the container and its own grid.
170
- ...(inGroup
171
- ? { gridColumn: `span ${RAIL_COUNT}`, gridTemplateColumns: "subgrid" }
172
- : {
173
- containerType: "inline-size",
174
- gridTemplateColumns: railsTemplateFor(gutters, metaColumns),
175
- }),
176
- alignItems: "center",
177
- // Wide enough that the meta cluster, the value and the status read as three
178
- // columns rather than one run of text — the complaint that started all this
179
- // was `MÉTODO PIX R$ 13,90` scanning as a single phrase.
180
- columnGap: RAIL_GAP,
181
- width: "100%",
182
- px: pad,
183
- py: padY,
184
- cursor: draggable ? "grab" : interactive ? "pointer" : undefined,
185
- ...(draggable ? { touchAction: "none", "&:active": { cursor: "grabbing" } } : {}),
186
- ...(interactive ? { "&:hover": { backgroundColor: "action.hover" } } : {}),
187
- // A real focus ring, which a clickable <div> never had.
188
- "&:focus-visible": { outline: 2, outlineStyle: "solid", outlineColor: "primary.main", outlineOffset: 2 },
189
- // TWO-LINE, below the point where the shared rails stop helping.
190
- //
191
- // The standard mobile transaction row: what the record IS and what it COST
192
- // on the first line, the supporting detail on the second. Better than
193
- // truncating four columns into ellipses, and the reason each slot carries a
194
- // `data-slot` — the placement is explicit rather than whatever order the
195
- // children happen to be in.
196
- //
197
- // [ select ][ leading ][ title …………… ][ menu ]
198
- // [ status …………… ][ value ]
199
- //
200
- // The MENU stays in the top-right corner at every width. It is the row's
201
- // one fixed landmark — an overflow that moves to the second line on a phone
202
- // is an overflow nobody finds twice.
203
- [`@container (max-width: ${STACK_BREAK}px)`]: {
204
- gridTemplateColumns: "auto auto minmax(0, 1fr) max-content",
205
- ...(inGroup ? { gridColumn: `span ${RAIL_COUNT}` } : {}),
206
- rowGap: 0.75,
207
- columnGap: 1,
208
- '& > [data-slot="drag"]': { display: "none" },
209
- '& > [data-slot="select"]': { gridArea: "1 / 1" },
210
- '& > [data-slot="leading"]': { gridArea: "1 / 2" },
211
- '& > [data-slot="caption"]': { gridArea: "1 / 3" },
212
- '& > [data-slot="actions"]': { gridArea: "1 / 4", justifyContent: "flex-end" },
213
- '& > [data-slot="meta"]': { display: "none" },
214
- // Second line, starting under the title rather than under the checkbox.
215
- '& > [data-slot="status"]': { gridArea: "2 / 3", justifyContent: "flex-start" },
216
- '& > [data-slot="value"]': { gridArea: "2 / 4", textAlign: "right" },
217
- },
218
- ...(divider
219
- ? { border: 0, borderRadius: 0, borderBottom: 1, borderBottomStyle: "solid", borderBottomColor: "divider" }
220
- : {}),
221
- };
181
+ function RowContent({
182
+ card,
183
+ cells,
184
+ configured,
185
+ slot,
186
+ actionable,
187
+ meta,
188
+ }: {
189
+ card: BaseListCardProps;
190
+ cells: readonly ListCardCellConfig<never>[] | null;
191
+ configured: boolean;
192
+ slot: (name: string) => string | undefined;
193
+ actionable: boolean;
194
+ meta: { columns: number; present: boolean };
195
+ }): React.JSX.Element {
196
+ if (configured && cells != null) {
197
+ return (
198
+ <>
199
+ {/* The list's columns, rendered from this row. */}
200
+ <ListCardCells cells={cells} row={card.row as never} />
201
+ {/* THE MENU IS NOT PART OF THE CONFIG and never optional: it is where a
202
+ * row's actions live, and a list that folded it into a cell would have
203
+ * nowhere left to put them. So it brackets the cells on its own rail,
204
+ * exactly as the gutters do at the other end. */}
205
+ <ListCardActions
206
+ actions={actionable ? card.actions : undefined}
207
+ alwaysVisible={card.actionsAlwaysVisible}
208
+ menu={card.menu}
209
+ testId={slot}
210
+ />
211
+ </>
212
+ );
213
+ }
214
+ return (
215
+ <>
216
+ <ListCardCaption
217
+ title={card.title}
218
+ subtitle={card.subtitle}
219
+ href={card.href}
220
+ target={card.target}
221
+ testId={slot("title")}
222
+ />
223
+ <ListCardMeta meta={card.meta} metaSlot={card.metaSlot} trailingRule={card.value != null} />
224
+ <ListCardTail
225
+ value={card.value}
226
+ separated={meta.present}
227
+ actions={actionable ? card.actions : undefined}
228
+ actionsAlwaysVisible={card.actionsAlwaysVisible}
229
+ menu={card.menu}
230
+ testId={slot}
231
+ />
232
+ </>
233
+ );
222
234
  }
223
235
 
224
236
  /**
225
- * A selectable full-width row: a marker, a title over a subtitle, labelled
226
- * middle columns, a value, a status, actions and a menu.
237
+ * The revealed body, and the rule that separates it from the summary.
227
238
  *
228
- * A SHELL, like {@link BaseCard}. Domain rows live in the app and compose it.
229
- */
230
- /**
231
- * The id this row drags under, or nothing.
239
+ * `gridColumn: 1 / -1` so it spans every rail: the summary's columns are the
240
+ * LIST's, and nothing about them should constrain a detail area whose design
241
+ * belongs to the consumer.
232
242
  *
233
- * `undefined` is the inert answer: `useDragItem` treats a missing id as "no
234
- * drag", which is how the card's own veto (`draggable={false}`) and an
235
- * unactionable record both switch dragging off without the card knowing
236
- * anything about the container above it.
237
- */
238
- function dragIdFor(
239
- props: BaseListCardProps,
240
- actionable: boolean,
241
- ): string | number | undefined {
242
- return props.draggable === false || !actionable ? undefined : props.dragId;
243
- }
244
-
245
- /**
246
- * What the meta cluster amounts to — asked once, used three times: to size the
247
- * rail, to close the cluster with a rule, and to decide whether the value has
248
- * anything to be divided FROM.
243
+ * Collapse rather than an unmount, so the body animates open and keeps its own
244
+ * state between peeks — a consumer putting a form or a scrolled table in here
245
+ * would otherwise have it reset on every toggle. `unmountOnExit` is deliberately
246
+ * NOT set: fifty closed rows pay for fifty hidden subtrees, which is the trade
247
+ * taken because the alternative re-runs every consumer's effects per toggle.
249
248
  */
250
- function metaShape(props: BaseListCardProps): { columns: number; present: boolean } {
251
- const columns = props.meta?.length ?? 0;
252
- return { columns, present: columns > 0 || props.metaSlot != null };
253
- }
254
-
255
- /**
256
- * Everything the row derives before it can render — extracted so the component
257
- * stays inside the size budget.
258
- */
259
- function useRowShell(props: BaseListCardProps) {
260
- const actionable = isActionable(props.state);
261
- const group = useListRails();
262
- const scale = props.scale ?? 1;
263
- return {
264
- group,
265
- actionable,
266
- scale,
267
- theme: useTheme(),
268
- selectable: isSelectable(props),
269
- slot: slotTestIds(props.testId),
270
- drag: useDragItem(dragIdFor(props, actionable)),
271
- // Standalone holds no gutter open: there is no list beside it to line up
272
- // with, so a reserved-but-empty rail is pure inset.
273
- reserve: group?.reserveGutters ?? false,
274
- // 1, not 1.5. The row's contents still have to line up with the toolbar
275
- // above them, but 12px of card padding stacked on the checkbox's own 9px
276
- // and an empty drag gutter's 24px read as a row indented for no reason.
277
- //
278
- // Not multiplied by `scale` — the row's `zoom` already scales it, and doing
279
- // both would square the factor on padding alone.
280
- pad: 1,
281
- padY: DENSITY_ROW_PADDING[group?.density ?? props.density ?? "cozy"],
282
- // A card that navigates does so with a real anchor, so it takes no click
283
- // handler of its own — the stretched link IS the target.
284
- acts: props.onClick != null && props.href == null && actionable,
285
- };
286
- }
287
-
288
- /** The click/keyboard props a row that acts (but does not navigate) needs. */
289
- function actionProps(onClick: (() => void) | undefined) {
290
- return {
291
- role: "button",
292
- tabIndex: 0,
293
- onClick: () => {
294
- if (isTextSelection()) return;
295
- onClick?.();
296
- },
297
- onKeyDown: clickKeys(() => onClick?.()),
298
- };
299
- }
300
-
301
- /** The surface and the geometry, merged — the row's whole `sx` in one place. */
302
- function rowStyles(
303
- props: BaseListCardProps,
304
- shell: ReturnType<typeof useRowShell>,
305
- ): Record<string, unknown> {
306
- const { group, theme, selectable, drag, reserve, pad, padY, scale, acts } = shell;
307
- return {
308
- ...cardSurfaceStyles(
309
- {
310
- ...props,
311
- selectable,
312
- shape: "row",
313
- state: drag.dragging ? "disabled" : props.state,
314
- },
315
- theme,
316
- ),
317
- ...rowSx({
318
- inGroup: group !== null,
319
- gutters: { drag: reserve || drag.draggable, select: reserve || selectable },
320
- metaColumns: metaShape(props).columns,
321
- pad,
322
- padY,
323
- scale,
324
- divider: props.divider ?? false,
325
- interactive: acts || props.href != null,
326
- draggable: drag.draggable && drag.handleProps === undefined,
327
- }),
328
- };
249
+ function ExpandableBody({
250
+ disclosure,
251
+ padY,
252
+ pad,
253
+ children,
254
+ }: {
255
+ disclosure: { expandable: boolean; expanded: boolean; regionId: string };
256
+ padY: number;
257
+ pad: number;
258
+ children: ReactNode;
259
+ }): React.JSX.Element {
260
+ if (!disclosure.expandable) return <></>;
261
+ return (
262
+ <Box sx={{ gridColumn: "1 / -1", minWidth: 0 }}>
263
+ <Collapse in={disclosure.expanded} timeout={150}>
264
+ <Box
265
+ id={disclosure.regionId}
266
+ role="region"
267
+ sx={{
268
+ minWidth: 0,
269
+ // `padY`, not a fraction of the horizontal padding. The card's own
270
+ // `py` wraps the summary AND the body, so once the body is in flow
271
+ // the summary has no bottom padding of its own — at `comfortable`
272
+ // the row visibly lost the height it had while closed.
273
+ mt: padY,
274
+ pt: padY,
275
+ // A RULE BETWEEN SUMMARY AND BODY, bled through the row's horizontal
276
+ // padding so it spans the card edge to edge. Inset, it reads as a
277
+ // border around the content rather than a division of the card.
278
+ borderTop: 1,
279
+ borderColor: "divider",
280
+ mx: -pad,
281
+ px: pad,
282
+ }}
283
+ >
284
+ {children}
285
+ </Box>
286
+ </Collapse>
287
+ </Box>
288
+ );
329
289
  }
330
290
 
331
291
  export function BaseListCard(props: BaseListCardProps): React.JSX.Element {
332
- const { selected = false, href, onClick } = props;
292
+ const { selected = false, onClick } = props;
333
293
  const shell = useRowShell(props);
334
- const { actionable, selectable, slot, drag, reserve, pad, acts } = shell;
294
+ const { actionable, selectable, slot, drag, reserve, pad, padY, acts } = shell;
335
295
  const meta = metaShape(props);
296
+ const disclosure = useDisclosure(props);
297
+ const { cells, configured, cellTemplate } = useResolvedCells(props, shell.group != null);
336
298
 
337
299
  return (
338
300
  <Card
@@ -345,8 +307,17 @@ export function BaseListCard(props: BaseListCardProps): React.JSX.Element {
345
307
  {...(acts ? actionProps(onClick) : {})}
346
308
  {...drag.itemProps}
347
309
  // A PLAIN OBJECT: `Card` merges by spreading, so a function sx vanishes.
348
- sx={rowStyles(props, shell)}
310
+ sx={rowStyles(props, shell, cellTemplate)}
349
311
  >
312
+ {drag.dropEdge != null && <DropIndicator edge={drag.dropEdge} />}
313
+ <DiscloseSlot
314
+ expandable={disclosure.expandable}
315
+ expanded={disclosure.expanded}
316
+ reserve={reserve}
317
+ onToggle={disclosure.toggle}
318
+ controls={disclosure.regionId}
319
+ testId={slot("disclose")}
320
+ />
350
321
  <DragSlot drag={drag} reserve={reserve} testId={slot("drag")} />
351
322
  <SelectSlot
352
323
  selectable={selectable}
@@ -358,32 +329,12 @@ export function BaseListCard(props: BaseListCardProps): React.JSX.Element {
358
329
  <Box data-slot="leading" sx={{ display: "flex", alignItems: "center" }}>
359
330
  {props.leading}
360
331
  </Box>
361
- <ListCardCaption
362
- title={props.title}
363
- subtitle={props.subtitle}
364
- href={href}
365
- target={props.target}
366
- testId={slot("title")}
367
- />
368
- <ListCardMeta
369
- meta={props.meta}
370
- metaSlot={props.metaSlot}
371
- trailingRule={props.value != null}
372
- />
373
- <ListCardTail
374
- value={props.value}
375
- separated={meta.present}
376
- status={props.status}
377
- actions={actionable ? props.actions : undefined}
378
- actionsAlwaysVisible={props.actionsAlwaysVisible}
379
- menu={props.menu}
380
- testId={slot}
381
- />
382
- {props.children != null && (
383
- <Box sx={{ gridColumn: "1 / -1", mt: pad * 0.5, minWidth: 0 }}>{props.children}</Box>
384
- )}
332
+ <RowContent card={props} cells={cells} configured={configured} slot={slot} actionable={actionable} meta={meta} />
333
+ <ExpandableBody disclosure={disclosure} padY={padY} pad={pad}>
334
+ {props.children}
335
+ </ExpandableBody>
385
336
  </Card>
386
337
  );
387
338
  }
388
339
 
389
- export { META_BREAK, STACK_BREAK };
340
+ export { META_BREAK, STACK_BREAK } from "./base-list-card-slots";
@@ -44,6 +44,23 @@ export interface DragContainerValue {
44
44
  handleProps?: (id: string | number) => DragItemProps;
45
45
  /** The id currently being dragged, so its card can dim itself. */
46
46
  activeId?: string | number | null;
47
+ /**
48
+ * WHERE THE DRAGGED ITEM WOULD LAND — the one thing a drag cannot be honest
49
+ * without.
50
+ *
51
+ * A list that dims the row you picked up and shows nothing else asks you to
52
+ * guess the outcome and find out by dropping. The marker turns that into a
53
+ * statement: THIS gap is where it goes.
54
+ *
55
+ * Published by the container, not computed by the card, for the same reason
56
+ * everything else here is: only the thing holding the items knows the
57
+ * geometry — which one you are over, which edge of it, and whether that
58
+ * position is legal. A card knows none of that about its neighbours.
59
+ *
60
+ * `null` while nothing is being dragged, or while the pointer is over no
61
+ * valid target.
62
+ */
63
+ dropIndicator?: { id: string | number; edge: "before" | "after" } | null;
47
64
  }
48
65
 
49
66
  const DragContainerContext = createContext<DragContainerValue | null>(null);
@@ -73,19 +90,65 @@ export function useDragItem(dragId: string | number | undefined): {
73
90
  itemProps: DragItemProps;
74
91
  handleProps: DragItemProps | undefined;
75
92
  dragging: boolean;
93
+ /** Which of this card's edges the marker sits on, or null for neither. */
94
+ dropEdge: "before" | "after" | null;
76
95
  } {
77
96
  const container = useContext(DragContainerContext);
78
97
  if (!container || dragId === undefined) {
79
- return { draggable: false, itemProps: {}, handleProps: undefined, dragging: false };
98
+ return { draggable: false, itemProps: {}, handleProps: undefined, dragging: false, dropEdge: null };
80
99
  }
100
+ const indicator = container.dropIndicator;
81
101
  return {
82
102
  draggable: true,
83
103
  itemProps: container.itemProps(dragId),
84
104
  handleProps: container.handleProps?.(dragId),
85
105
  dragging: container.activeId === dragId,
106
+ dropEdge: indicator != null && indicator.id === dragId ? indicator.edge : null,
86
107
  };
87
108
  }
88
109
 
110
+ /**
111
+ * THE DROP MARKER — one rule across the card's edge, drawn where the item lands.
112
+ *
113
+ * Shared by every draggable surface rather than reimplemented per card, so a
114
+ * reorderable list, a board column and a grid of tiles all say the same thing
115
+ * the same way. A consumer wiring its own DnD gets this for free by publishing
116
+ * `dropIndicator`; it never draws the marker itself.
117
+ *
118
+ * ABSOLUTE, AND OUTSIDE THE FLOW ON PURPOSE. A marker that took layout space
119
+ * would push every row below it down by its own height the moment it appeared —
120
+ * the list would twitch as you dragged across it, and the gap you were aiming
121
+ * at would move out from under the pointer. Drawn over the gap instead, nothing
122
+ * reflows.
123
+ *
124
+ * `pointerEvents: none` so it cannot swallow the dragover events that placed it,
125
+ * and `aria-hidden` because the position it reports belongs in a live region the
126
+ * LIST owns — fifty rows each announcing a marker is a screen-reader nightmare.
127
+ */
128
+ export function DropIndicator({ edge }: { edge: "before" | "after" }): React.JSX.Element {
129
+ return (
130
+ <Box
131
+ aria-hidden
132
+ data-slot="drop-indicator"
133
+ data-drop-edge={edge}
134
+ sx={{
135
+ position: "absolute",
136
+ left: 0,
137
+ right: 0,
138
+ // Centred ON the gap rather than inside the card: the item lands
139
+ // BETWEEN two rows, and a rule sitting within one of them reads as
140
+ // "into this row" instead.
141
+ ...(edge === "before" ? { top: -2 } : { bottom: -2 }),
142
+ height: 3,
143
+ borderRadius: 2,
144
+ bgcolor: "primary.main",
145
+ pointerEvents: "none",
146
+ zIndex: 2,
147
+ }}
148
+ />
149
+ );
150
+ }
151
+
89
152
  /**
90
153
  * The grip. Six dots, the convention everywhere, drawn rather than imported so
91
154
  * this costs no icon dependency.
@@ -63,7 +63,7 @@ export interface DataViewCardSelection {
63
63
  * ratio and grows in BOTH width and height as the zoom slider widens its grid
64
64
  * track, so a grid of cards stays visually uniform.
65
65
  */
66
- export type CardAspectRatio = "1:1" | "4:3" | "16:9" | "9:16" | "3:4";
66
+ export type CardAspectRatio = "1:1" | "4:3" | "16:9" | "9:16" | "3:4" | "3:1";
67
67
 
68
68
  /** Numeric width/height factor for each {@link CardAspectRatio} (CSS `aspect-ratio`). */
69
69
  export const CARD_ASPECT_RATIOS: Record<CardAspectRatio, number> = {
@@ -72,6 +72,15 @@ export const CARD_ASPECT_RATIOS: Record<CardAspectRatio, number> = {
72
72
  "16:9": 16 / 9,
73
73
  "9:16": 9 / 16,
74
74
  "3:4": 3 / 4,
75
+ /**
76
+ * The BANNER tile: too short for media, sized for two lines of text.
77
+ *
78
+ * Every other ratio here is picture-shaped, because they were added for cards
79
+ * built around a thumbnail. A card whose subject is a NAME and a NUMBER — a
80
+ * cart, an order, anything triaged rather than browsed — spends most of a 4:3
81
+ * tile on an icon that repeats down the whole grid and says nothing.
82
+ */
83
+ "3:1": 3,
75
84
  };
76
85
 
77
86
  /** One selectable value inside a filter pill. */