@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.
@@ -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' : 'flex-start',
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