@12-apps/ui 1.7.0 → 1.8.1

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
+ }
@@ -20,6 +20,7 @@ import { Text } from "../../typography/Text";
20
20
 
21
21
  import type { OverflowField } from "./data-views-overflow";
22
22
  import { RangePresets } from "./data-views-preset-chips";
23
+ import { RangeBounds } from "./data-views-range-pill";
23
24
  import { presetsFor } from "./data-views-range-presets";
24
25
  import { isRangeSet } from "./data-views-range-values";
25
26
  import type { RangeValue } from "./data-views-types";
@@ -112,7 +113,22 @@ function OverflowPill<T extends Record<string, unknown>>({
112
113
  }
113
114
 
114
115
 
115
- /** One overflowed range: its two bounds, as the same inputs the pill uses. */
116
+ /**
117
+ * One overflowed range: its two bounds, as the same inputs the pill uses.
118
+ *
119
+ * The bounds DELEGATE to {@link RangeBounds} rather than being built here,
120
+ * which is the whole point — this panel used to render a raw
121
+ * `<input type="date">`, so the masked `dd/mm/aaaa` field existed only while
122
+ * the filter FITTED on the bar. The moment "Data" overflowed it reverted to the
123
+ * native control the mask replaced, and a merchant on a narrow screen never saw
124
+ * the fix at all (FUT-744). `Valor` had the twin bug: a bare `type="number"`
125
+ * plus `Number(raw)` dropped the decimal comma this panel is meant to accept.
126
+ *
127
+ * That also retires the intrinsic-width workaround this pair used to need — a
128
+ * native date input reports a wide minimum (its own mask plus the picker
129
+ * glyph), which is what made two of them refuse to shrink and scrolled the
130
+ * panel sideways. A text input has no such minimum.
131
+ */
116
132
  function OverflowRange<T extends Record<string, unknown>>({
117
133
  field,
118
134
  value,
@@ -123,14 +139,10 @@ function OverflowRange<T extends Record<string, unknown>>({
123
139
  value: RangeValue;
124
140
  onChange: (range: RangeValue) => void;
125
141
  testIdPrefix: string;
126
- }): React.JSX.Element {
127
- const day = field.range?.kind === "day";
128
- const set = (bound: "min" | "max", raw: string): void => {
129
- const next: RangeValue = { ...value };
130
- if (raw === "") delete next[bound];
131
- else next[bound] = day ? raw : Number(raw);
132
- onChange(next);
133
- };
142
+ }): React.JSX.Element | null {
143
+ // `group === "range"` is what selects this component, so `range` is always
144
+ // set; the guard is for the type, not for a state the caller can reach.
145
+ if (!field.range) return null;
134
146
  return (
135
147
  <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
136
148
  {/* The overflow offers the same one-click windows the pill does. A field
@@ -138,44 +150,17 @@ function OverflowRange<T extends Record<string, unknown>>({
138
150
  controls — otherwise "Data" gains and loses its presets as the window
139
151
  is resized. */}
140
152
  <RangePresets
141
- presets={field.range ? presetsFor(field.range) : []}
153
+ presets={presetsFor(field.range)}
154
+ value={value}
155
+ onChange={onChange}
156
+ testId={`${testIdPrefix}-more-${field.id}`}
157
+ />
158
+ <RangeBounds
159
+ field={field.range}
142
160
  value={value}
143
161
  onChange={onChange}
144
162
  testId={`${testIdPrefix}-more-${field.id}`}
145
163
  />
146
- <Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0 }}>
147
- {(["min", "max"] as const).map((bound, index) => (
148
- <Box key={bound} sx={{ display: "contents" }}>
149
- {index === 1 && <Box component="span" sx={{ color: "text.disabled" }}>–</Box>}
150
- <Box
151
- component="input"
152
- type={day ? "date" : "number"}
153
- placeholder={bound === "min" ? "de" : "até"}
154
- aria-label={`${field.label} ${bound === "min" ? "de" : "até"}`}
155
- data-testid={`${testIdPrefix}-more-${field.id}-${bound}`}
156
- value={value[bound] ?? ""}
157
- onChange={(event: React.ChangeEvent<HTMLInputElement>) => set(bound, event.target.value)}
158
- sx={{
159
- // `flex:1` + `minWidth:0` rather than `width:100%`. A native
160
- // date input reports a wide intrinsic minimum (its dd/mm/aaaa
161
- // mask plus the picker glyph), and a flex child defaults to
162
- // min-width:auto — so two of them plus the dash refused to
163
- // shrink and the whole panel scrolled sideways.
164
- flex: "1 1 0",
165
- minWidth: 0,
166
- px: 1,
167
- py: 0.75,
168
- border: 1,
169
- borderStyle: "solid",
170
- borderColor: "divider",
171
- borderRadius: 1,
172
- font: "inherit",
173
- fontSize: "0.8125rem",
174
- }}
175
- />
176
- </Box>
177
- ))}
178
- </Box>
179
164
  </Box>
180
165
  );
181
166
  }
@@ -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