@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 +4 -4
- package/src/components/data-display/DataViews/base-card.tsx +93 -23
- package/src/components/data-display/DataViews/base-list-card-slots.tsx +256 -0
- package/src/components/data-display/DataViews/base-list-card.tsx +352 -0
- package/src/components/data-display/DataViews/card-surface.ts +297 -0
- package/src/components/data-display/DataViews/data-views-drag.tsx +135 -0
- package/src/components/data-display/DataViews/index.ts +7 -0
- package/src/components/data-display/DataViews/list-card-rails.tsx +141 -0
- package/src/components/data-display/DescriptionItem/DescriptionItem.tsx +26 -3
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { Box } from "../../../mui/Box";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* DRAGGING, OWNED BY THE CONTAINER AND NOT BY THE CARD.
|
|
9
|
+
*
|
|
10
|
+
* A card cannot know whether it is draggable. The same `BaseListCard` renders
|
|
11
|
+
* inside a reorderable queue, a read-only report and a board column that moves
|
|
12
|
+
* items between columns — and only the thing holding them knows which. So the
|
|
13
|
+
* container publishes a drag context and every card inside it becomes
|
|
14
|
+
* draggable; a card with no container above it renders exactly as before, with
|
|
15
|
+
* no handle and no listeners.
|
|
16
|
+
*
|
|
17
|
+
* This is a SEAM, not a drag implementation. It carries whatever props the
|
|
18
|
+
* host's library hands out — dnd-kit's `attributes`/`listeners`, react-dnd's
|
|
19
|
+
* connector output, or plain HTML5 `draggable` + `onDragStart` — because
|
|
20
|
+
* picking one of those for every consumer of `@12-apps/ui` is a decision this
|
|
21
|
+
* package has no business making. There is no drag dependency here, and adding
|
|
22
|
+
* one would force it on every app that installs the library.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The props a container hands to one item. Deliberately untyped-ish: each DnD library has its own shape. */
|
|
26
|
+
export type DragItemProps = Record<string, unknown>;
|
|
27
|
+
|
|
28
|
+
export interface DragContainerValue {
|
|
29
|
+
/**
|
|
30
|
+
* Props for the item's ROOT — the element that moves. dnd-kit wants its
|
|
31
|
+
* `setNodeRef` and transform style here; HTML5 wants `draggable` and
|
|
32
|
+
* `onDragStart`.
|
|
33
|
+
*/
|
|
34
|
+
itemProps: (id: string | number) => DragItemProps;
|
|
35
|
+
/**
|
|
36
|
+
* Props for the HANDLE alone, when the container wants drags to start only
|
|
37
|
+
* from the grip.
|
|
38
|
+
*
|
|
39
|
+
* Whole-card dragging breaks the two things a card must still do: selecting
|
|
40
|
+
* text in it, and hitting the checkbox without the row sliding away. When a
|
|
41
|
+
* container returns handle props, the card puts them on the grip and leaves
|
|
42
|
+
* the rest of itself alone.
|
|
43
|
+
*/
|
|
44
|
+
handleProps?: (id: string | number) => DragItemProps;
|
|
45
|
+
/** The id currently being dragged, so its card can dim itself. */
|
|
46
|
+
activeId?: string | number | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DragContainerContext = createContext<DragContainerValue | null>(null);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Marks everything inside as draggable. Cards read this; nothing else does.
|
|
53
|
+
*/
|
|
54
|
+
export function DragContainerProvider({
|
|
55
|
+
value,
|
|
56
|
+
children,
|
|
57
|
+
}: {
|
|
58
|
+
value: DragContainerValue;
|
|
59
|
+
children: ReactNode;
|
|
60
|
+
}): React.JSX.Element {
|
|
61
|
+
return <DragContainerContext.Provider value={value}>{children}</DragContainerContext.Provider>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* What one card needs to know about dragging.
|
|
66
|
+
*
|
|
67
|
+
* Returns the inert answer when there is no container OR no `dragId` — a card
|
|
68
|
+
* that forgot its id must not render a handle that does nothing, which is worse
|
|
69
|
+
* than no handle at all.
|
|
70
|
+
*/
|
|
71
|
+
export function useDragItem(dragId: string | number | undefined): {
|
|
72
|
+
draggable: boolean;
|
|
73
|
+
itemProps: DragItemProps;
|
|
74
|
+
handleProps: DragItemProps | undefined;
|
|
75
|
+
dragging: boolean;
|
|
76
|
+
} {
|
|
77
|
+
const container = useContext(DragContainerContext);
|
|
78
|
+
if (!container || dragId === undefined) {
|
|
79
|
+
return { draggable: false, itemProps: {}, handleProps: undefined, dragging: false };
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
draggable: true,
|
|
83
|
+
itemProps: container.itemProps(dragId),
|
|
84
|
+
handleProps: container.handleProps?.(dragId),
|
|
85
|
+
dragging: container.activeId === dragId,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The grip. Six dots, the convention everywhere, drawn rather than imported so
|
|
91
|
+
* this costs no icon dependency.
|
|
92
|
+
*
|
|
93
|
+
* `cursor: grab` on the handle only when the container gates drags to it —
|
|
94
|
+
* otherwise the whole card carries the cursor and the handle is just an
|
|
95
|
+
* affordance saying so.
|
|
96
|
+
*/
|
|
97
|
+
export function DragHandle({
|
|
98
|
+
handleProps,
|
|
99
|
+
gated,
|
|
100
|
+
testId,
|
|
101
|
+
}: {
|
|
102
|
+
handleProps: DragItemProps | undefined;
|
|
103
|
+
gated: boolean;
|
|
104
|
+
testId?: string;
|
|
105
|
+
}): React.JSX.Element {
|
|
106
|
+
return (
|
|
107
|
+
<Box
|
|
108
|
+
component="span"
|
|
109
|
+
aria-hidden={handleProps ? undefined : true}
|
|
110
|
+
data-testid={testId}
|
|
111
|
+
{...handleProps}
|
|
112
|
+
sx={{
|
|
113
|
+
display: "inline-grid",
|
|
114
|
+
gridTemplateColumns: "repeat(2, 3px)",
|
|
115
|
+
gap: "2px",
|
|
116
|
+
flexShrink: 0,
|
|
117
|
+
p: 0.5,
|
|
118
|
+
borderRadius: 1,
|
|
119
|
+
color: "text.disabled",
|
|
120
|
+
cursor: gated ? "grab" : "inherit",
|
|
121
|
+
touchAction: "none",
|
|
122
|
+
"&:active": { cursor: gated ? "grabbing" : "inherit" },
|
|
123
|
+
"&:hover": { color: "text.secondary" },
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
126
|
+
{Array.from({ length: 6 }).map((_, index) => (
|
|
127
|
+
<Box
|
|
128
|
+
key={index}
|
|
129
|
+
component="span"
|
|
130
|
+
sx={{ width: 3, height: 3, borderRadius: "50%", bgcolor: "currentColor" }}
|
|
131
|
+
/>
|
|
132
|
+
))}
|
|
133
|
+
</Box>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
* wires them to its backend and framework router.
|
|
7
7
|
*/
|
|
8
8
|
export { BaseCard } from "./base-card";
|
|
9
|
+
export { BaseListCard, type BaseListCardProps } from "./base-list-card";
|
|
10
|
+
export {
|
|
11
|
+
DragContainerProvider,
|
|
12
|
+
useDragItem,
|
|
13
|
+
type DragContainerValue,
|
|
14
|
+
type DragItemProps,
|
|
15
|
+
} from "./data-views-drag";
|
|
9
16
|
export { DataViewsGrid } from "./DataViewsGrid";
|
|
10
17
|
export { DataViewsTableBase, type DataViewsTableBaseProps } from "./DataViewsTableBase";
|
|
11
18
|
export { toSavedViewSummary, CARD_ASPECT_RATIOS, DATA_VIEWS_LAYOUTS } from "./data-views-types";
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { Box } from "../../../mui/Box";
|
|
6
|
+
import { DENSITY_ROW_PADDING, type DataViewsDensity } from "./data-views-layout-context";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* THE LIST OWNS THE COLUMNS, NOT THE ROW.
|
|
10
|
+
*
|
|
11
|
+
* Every row laying itself out with flex produces a list where nothing lines up.
|
|
12
|
+
* The caption takes `flex: 1` and shoves the rest right; the value sits after
|
|
13
|
+
* the meta cluster with the same gap as any other pair, so `MÉTODO PIX
|
|
14
|
+
* R$ 13,90` reads as one continuous run and the only unlabelled thing in the
|
|
15
|
+
* row is the number that matters most; and because the status chip's width
|
|
16
|
+
* varies with its text (`Pago` vs `Em aberto` vs `Cancelado`), the money jitters
|
|
17
|
+
* left and right on every row down the page.
|
|
18
|
+
*
|
|
19
|
+
* So the rails are declared ONCE, here, and each card is `grid-template-columns:
|
|
20
|
+
* subgrid` spanning them. One set of edges for the whole list, the value on its
|
|
21
|
+
* own rail, the status in a slot wide enough for the longest label it will ever
|
|
22
|
+
* hold. Adding a row cannot move the column.
|
|
23
|
+
*
|
|
24
|
+
* Subgrid needs the card to be a DIRECT child of this grid — no wrapper element
|
|
25
|
+
* between them, which is why the group renders its children bare.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** One rail. Fixed where alignment matters, flexible where it does not. */
|
|
29
|
+
export interface ListRails {
|
|
30
|
+
/** Reserved even when nothing drags: toggling drag mode must not shift the list. */
|
|
31
|
+
drag: string;
|
|
32
|
+
/** Reserved even when nothing is selectable, for the same reason. */
|
|
33
|
+
select: string;
|
|
34
|
+
leading: string;
|
|
35
|
+
/** The one column that absorbs spare width. */
|
|
36
|
+
caption: string;
|
|
37
|
+
meta: string;
|
|
38
|
+
/** The value's own rail, with a fixed right edge. */
|
|
39
|
+
value: string;
|
|
40
|
+
/** Wide enough for the longest status this list holds. */
|
|
41
|
+
status: string;
|
|
42
|
+
actions: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_RAILS: ListRails = {
|
|
46
|
+
drag: "auto",
|
|
47
|
+
select: "auto",
|
|
48
|
+
leading: "auto",
|
|
49
|
+
caption: "minmax(0, 1fr)",
|
|
50
|
+
meta: "auto",
|
|
51
|
+
// `max-content` on the value, so the rail is as wide as the widest amount in
|
|
52
|
+
// the list and no wider — a fixed px guess is either clipping or dead space.
|
|
53
|
+
value: "max-content",
|
|
54
|
+
status: "max-content",
|
|
55
|
+
actions: "auto",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
interface ListRailsValue {
|
|
59
|
+
density: DataViewsDensity;
|
|
60
|
+
/** Whether the gutters are held open even when empty. */
|
|
61
|
+
reserveGutters: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ListRailsContext = createContext<ListRailsValue | null>(null);
|
|
65
|
+
|
|
66
|
+
/** Is this card inside a group that owns the rails? */
|
|
67
|
+
export function useListRails(): ListRailsValue | null {
|
|
68
|
+
return useContext(ListRailsContext);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The column template, as one `grid-template-columns` value. */
|
|
72
|
+
export function railsTemplate(rails: ListRails): string {
|
|
73
|
+
return [
|
|
74
|
+
rails.drag,
|
|
75
|
+
rails.select,
|
|
76
|
+
rails.leading,
|
|
77
|
+
rails.caption,
|
|
78
|
+
rails.meta,
|
|
79
|
+
rails.value,
|
|
80
|
+
rails.status,
|
|
81
|
+
rails.actions,
|
|
82
|
+
].join(" ");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** How many rails there are — the span a card claims. */
|
|
86
|
+
export const RAIL_COUNT = 8;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A list of {@link BaseListCard}s that share one set of columns.
|
|
90
|
+
*
|
|
91
|
+
* Also the natural home for the things a review pointed out belong to the list
|
|
92
|
+
* rather than the card: the density every row answers, and — when it lands —
|
|
93
|
+
* shift-click range selection and the single `aria-live` region a list needs
|
|
94
|
+
* (fifty polite live regions is a screen-reader nightmare, which is why the
|
|
95
|
+
* card no longer takes one).
|
|
96
|
+
*/
|
|
97
|
+
export function ListCardGroup({
|
|
98
|
+
children,
|
|
99
|
+
density = "cozy",
|
|
100
|
+
rails,
|
|
101
|
+
reserveGutters = true,
|
|
102
|
+
gap,
|
|
103
|
+
dataTestId,
|
|
104
|
+
}: {
|
|
105
|
+
children: ReactNode;
|
|
106
|
+
density?: DataViewsDensity;
|
|
107
|
+
/** Override individual rails; anything omitted keeps its default. */
|
|
108
|
+
rails?: Partial<ListRails>;
|
|
109
|
+
/**
|
|
110
|
+
* Hold the drag and select gutters open even when nothing uses them.
|
|
111
|
+
*
|
|
112
|
+
* On by default: a selectable list and a read-only one should line up, and
|
|
113
|
+
* turning drag mode on should not shift every row sideways.
|
|
114
|
+
*/
|
|
115
|
+
reserveGutters?: boolean;
|
|
116
|
+
/** Row gap. Defaults to the density's own spacing. */
|
|
117
|
+
gap?: number;
|
|
118
|
+
dataTestId?: string;
|
|
119
|
+
}): React.JSX.Element {
|
|
120
|
+
const resolved = { ...DEFAULT_RAILS, ...rails };
|
|
121
|
+
return (
|
|
122
|
+
<ListRailsContext.Provider value={{ density, reserveGutters }}>
|
|
123
|
+
<Box
|
|
124
|
+
data-testid={dataTestId}
|
|
125
|
+
sx={{
|
|
126
|
+
// The query container for every row inside it — a row cannot be one
|
|
127
|
+
// itself and a subgrid at the same time (containment drops subgrid).
|
|
128
|
+
// Every row in a list is the same width anyway, so the group is the
|
|
129
|
+
// honest place to ask the question.
|
|
130
|
+
containerType: "inline-size",
|
|
131
|
+
display: "grid",
|
|
132
|
+
gridTemplateColumns: railsTemplate(resolved),
|
|
133
|
+
alignItems: "center",
|
|
134
|
+
rowGap: gap ?? DENSITY_ROW_PADDING[density],
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
{children}
|
|
138
|
+
</Box>
|
|
139
|
+
</ListRailsContext.Provider>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -21,6 +21,21 @@ export interface DescriptionItemProps {
|
|
|
21
21
|
*/
|
|
22
22
|
orientation?: 'vertical' | 'horizontal';
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Which edge the label and value line up on.
|
|
26
|
+
*
|
|
27
|
+
* - `start` (default): both flush left, the reading order for a form or a
|
|
28
|
+
* description list.
|
|
29
|
+
* - `center`: both centred over each other — for a pair sitting in a column
|
|
30
|
+
* of its own, where a left-flush label over a shorter value looks unhinged
|
|
31
|
+
* from it.
|
|
32
|
+
* - `end`: both flush right, which is what a numeric column wants so its
|
|
33
|
+
* digits line up with the value above and below.
|
|
34
|
+
*
|
|
35
|
+
* Applies to `vertical`; a `horizontal` pair is already a single line.
|
|
36
|
+
*/
|
|
37
|
+
align?: 'start' | 'center' | 'end';
|
|
38
|
+
|
|
24
39
|
/**
|
|
25
40
|
* Optional CSS class name
|
|
26
41
|
*/
|
|
@@ -54,13 +69,20 @@ export interface DescriptionItemProps {
|
|
|
54
69
|
* />
|
|
55
70
|
* ```
|
|
56
71
|
*/
|
|
72
|
+
/** `align` in the terms flexbox and CSS each want it. */
|
|
73
|
+
const FLEX_ALIGN = { start: 'flex-start', center: 'center', end: 'flex-end' } as const;
|
|
74
|
+
|
|
57
75
|
// Vertical stacks the label above the value; horizontal sets them side by side
|
|
58
76
|
// with the label sized to its content.
|
|
59
|
-
const containerSx = (isHorizontal: boolean) => ({
|
|
77
|
+
const containerSx = (isHorizontal: boolean, align: 'start' | 'center' | 'end') => ({
|
|
60
78
|
display: 'flex',
|
|
61
79
|
flexDirection: isHorizontal ? ('row' as const) : ('column' as const),
|
|
62
|
-
alignItems: isHorizontal ? 'center' :
|
|
80
|
+
alignItems: isHorizontal ? 'center' : FLEX_ALIGN[align],
|
|
63
81
|
gap: isHorizontal ? 1 : 0,
|
|
82
|
+
// `alignItems` places the two boxes; `textAlign` handles a label or value
|
|
83
|
+
// that WRAPS, whose second line would otherwise fall back to flush-left
|
|
84
|
+
// inside a box the alignment had already centred.
|
|
85
|
+
textAlign: align,
|
|
64
86
|
});
|
|
65
87
|
|
|
66
88
|
const labelStyle = (isHorizontal: boolean) => ({
|
|
@@ -79,6 +101,7 @@ export const DescriptionItem: React.FC<DescriptionItemProps> = ({
|
|
|
79
101
|
label,
|
|
80
102
|
value,
|
|
81
103
|
orientation = 'vertical',
|
|
104
|
+
align = 'start',
|
|
82
105
|
className,
|
|
83
106
|
'data-testid': dataTestId,
|
|
84
107
|
}) => {
|
|
@@ -88,7 +111,7 @@ export const DescriptionItem: React.FC<DescriptionItemProps> = ({
|
|
|
88
111
|
return (
|
|
89
112
|
<Box
|
|
90
113
|
className={className}
|
|
91
|
-
sx={containerSx(isHorizontal)}
|
|
114
|
+
sx={containerSx(isHorizontal, align)}
|
|
92
115
|
data-testid={dataTestId || 'description-item'}
|
|
93
116
|
>
|
|
94
117
|
<Text
|