@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.
- package/package.json +4 -4
- package/src/components/data-display/DataViews/base-list-card-disclosure.ts +40 -0
- package/src/components/data-display/DataViews/base-list-card-geometry.tsx +254 -0
- package/src/components/data-display/DataViews/base-list-card-gutters.tsx +83 -4
- package/src/components/data-display/DataViews/base-list-card-slots.tsx +2 -11
- package/src/components/data-display/DataViews/base-list-card.tsx +202 -251
- package/src/components/data-display/DataViews/data-views-drag.tsx +64 -1
- package/src/components/data-display/DataViews/data-views-types.ts +10 -1
- package/src/components/data-display/DataViews/list-card-cells.tsx +160 -0
- package/src/components/data-display/DataViews/list-card-rails.tsx +73 -9
|
@@ -1,30 +1,25 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
151
|
+
* Which cell config applies, and whether this row resolves its own tracks.
|
|
111
152
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
|
116
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
141
|
-
*
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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,
|
|
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
|
-
<
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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. */
|