@12-apps/ui 1.21.1 → 1.22.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 +1 -1
- package/src/components/data-display/Chart/Chart.md +22 -0
- package/src/components/data-display/Chart/Chart.types.ts +26 -0
- package/src/components/data-display/Chart/chart-axis.ts +118 -0
- package/src/components/data-display/Chart/chart-context.ts +75 -0
- package/src/components/data-display/Chart/chart-internals.ts +32 -8
- package/src/components/data-display/Chart/chart-renderers.tsx +58 -66
- package/src/components/data-display/Chart/chart-spec.ts +3 -0
- package/src/components/data-display/Table/Table.styles.ts +22 -1
- package/src/components/form/Input/Input.tsx +10 -0
- package/src/components/layout/Drawer/Drawer.md +11 -4
- package/src/components/layout/Drawer/Drawer.tsx +11 -2
- package/src/components/layout/Drawer/Drawer.types.ts +20 -0
package/package.json
CHANGED
|
@@ -67,6 +67,28 @@ import { Chart } from '@procurement/ui';
|
|
|
67
67
|
- `curved` (boolean): Use curved lines for line/area charts (default: true)
|
|
68
68
|
- `stacked` (boolean): Stack data series for bar/area charts
|
|
69
69
|
- `showValues` (boolean): Display data values on chart
|
|
70
|
+
- `valueFormatter` (function): Formats tooltip values and value-axis ticks
|
|
71
|
+
- `allowDecimalTicks` (boolean): Allow half-steps on the value axis (default: true; set
|
|
72
|
+
false for a COUNT metric, or the axis repeats itself — `0, 1, 1, 2, 2`)
|
|
73
|
+
|
|
74
|
+
### Axis & bar geometry
|
|
75
|
+
|
|
76
|
+
Defaults that hold the chart to the visual-pass rules; every one is opt-out.
|
|
77
|
+
|
|
78
|
+
- `maxCategoryTicks` (number): Cap on drawn category labels (default: 8). Above it every
|
|
79
|
+
nth label is kept and **the last one always is** — Recharts' own width-aware thinning
|
|
80
|
+
then runs on top, so a narrow viewport drops more. Full values stay in the tooltip.
|
|
81
|
+
- `tickLabelMaxChars` (number): Category labels longer than this are truncated with an
|
|
82
|
+
ellipsis (default: 12)
|
|
83
|
+
- `barRadius` (number): Bar corner radius in px, top corners only (default: 3). Stacked
|
|
84
|
+
segments stay square — a rounded top notches into the segment above it.
|
|
85
|
+
- `maxBarWidth` (number): Cap on a bar's thickness in px (default: 38). It is also what
|
|
86
|
+
keeps the first and last bar clear of the chart frame in a wide card.
|
|
87
|
+
|
|
88
|
+
Tick labels are laid out with a size-derived `tickMargin` (10–18px) rather than
|
|
89
|
+
Recharts' default of 2. The bottom value tick is centred ON the x-axis line, so its box
|
|
90
|
+
reaches below it; at the default margin it collides with the first category label at the
|
|
91
|
+
axis corner, at every viewport.
|
|
70
92
|
|
|
71
93
|
### Display Options
|
|
72
94
|
|
|
@@ -63,5 +63,31 @@ export interface ChartProps {
|
|
|
63
63
|
centerLabel?: string | { label: string; value: string | number };
|
|
64
64
|
/** Formats numeric tooltip values and y-axis ticks (e.g. centavos → BRL). */
|
|
65
65
|
valueFormatter?: (value: number) => string;
|
|
66
|
+
/**
|
|
67
|
+
* Whether the value axis may place ticks between whole numbers. Default true.
|
|
68
|
+
*
|
|
69
|
+
* Set it false for a metric that only takes whole values — a COUNT. Recharts
|
|
70
|
+
* spaces ticks evenly across the domain, so a count topping out at 2 gets
|
|
71
|
+
* 0/0.5/1/1.5/2; an integer `valueFormatter` then renders that as
|
|
72
|
+
* `0, 1, 1, 2, 2`, and the axis reads as though it repeats itself (FUT-755).
|
|
73
|
+
* Formatting alone cannot fix it — the duplicate ticks are already chosen by
|
|
74
|
+
* the time the formatter runs.
|
|
75
|
+
*/
|
|
76
|
+
allowDecimalTicks?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Cap on how many category-axis labels are drawn. Default 8; above it every
|
|
79
|
+
* nth label is kept, the LAST one always among them. Recharts' own
|
|
80
|
+
* width-aware thinning still runs on top, so a narrow viewport drops more.
|
|
81
|
+
*/
|
|
82
|
+
maxCategoryTicks?: number;
|
|
83
|
+
/** Category tick labels longer than this are truncated with an ellipsis. Default 12. */
|
|
84
|
+
tickLabelMaxChars?: number;
|
|
85
|
+
/** Bar corner radius in px (top corners only; stacked segments stay square). Default 3. */
|
|
86
|
+
barRadius?: number;
|
|
87
|
+
/**
|
|
88
|
+
* Cap on a single bar's thickness in px. Default 38 — it is also what keeps
|
|
89
|
+
* the first and last bar clear of the chart frame in a wide card.
|
|
90
|
+
*/
|
|
91
|
+
maxBarWidth?: number;
|
|
66
92
|
'data-testid'?: string;
|
|
67
93
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { ChartDataPoint, ChartProps } from './Chart.types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cartesian axis + bar geometry: the rules that keep a chart readable rather
|
|
5
|
+
* than merely correct (FUT-391 visual pass).
|
|
6
|
+
*
|
|
7
|
+
* Three of them are pass/fail by looking at the rendered chart:
|
|
8
|
+
*
|
|
9
|
+
* - **No overlapping text.** The bottom value tick sits ON the x-axis line and
|
|
10
|
+
* the first category tick sits ON the y-axis line, so the two labels meet at
|
|
11
|
+
* the corner. Recharts' default `tickMargin` of 2 leaves the category label's
|
|
12
|
+
* cap height inside the value label's descender — a few px of overlap at
|
|
13
|
+
* every viewport. The margin has to clear the value label's box, which grows
|
|
14
|
+
* with the font, hence a per-size value ({@link SIZE_PRESETS}).
|
|
15
|
+
* - **Skip every nth tick, always keep the last, truncate at ~12 chars.** With
|
|
16
|
+
* more categories than fit, Recharts thins them only when it can measure text
|
|
17
|
+
* (never server-side, where `getStringSize` returns 0 and every label renders
|
|
18
|
+
* on top of its neighbour). Thinning here is deterministic and viewport-free.
|
|
19
|
+
* - **Bars rounded, capped, never touching the frame.** A two-category bar
|
|
20
|
+
* chart in a wide card draws 150px slabs flush against the axis; the cap is
|
|
21
|
+
* what turns them back into bars.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Category ticks kept before thinning starts. */
|
|
25
|
+
const DEFAULT_MAX_CATEGORY_TICKS = 8;
|
|
26
|
+
/** Category tick labels longer than this are truncated with an ellipsis. */
|
|
27
|
+
const DEFAULT_TICK_LABEL_MAX_CHARS = 12;
|
|
28
|
+
/** Bar corner radius, px. */
|
|
29
|
+
const DEFAULT_BAR_RADIUS = 3;
|
|
30
|
+
/** Cap on a single bar's thickness, px. */
|
|
31
|
+
const DEFAULT_MAX_BAR_WIDTH = 38;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Thin a category axis to at most `maxTicks` labels, keeping every nth.
|
|
35
|
+
*
|
|
36
|
+
* The walk runs from the END so the last category is always labelled — the
|
|
37
|
+
* most recent day/bucket is the one a reader looks for first. Returns
|
|
38
|
+
* `undefined` when everything already fits, which leaves Recharts' own
|
|
39
|
+
* width-aware thinning in charge of the common small-data case.
|
|
40
|
+
*
|
|
41
|
+
* Also `undefined` when a value repeats: Recharts places an explicit tick by
|
|
42
|
+
* looking its value up in the scale, so a duplicate would land the label on
|
|
43
|
+
* the first row that carries it rather than on the row it was picked for.
|
|
44
|
+
*/
|
|
45
|
+
export function selectCategoryTicks(
|
|
46
|
+
values: ReadonlyArray<string | number>,
|
|
47
|
+
maxTicks: number,
|
|
48
|
+
): Array<string | number> | undefined {
|
|
49
|
+
if (maxTicks < 1 || values.length <= maxTicks) return undefined;
|
|
50
|
+
if (new Set(values).size !== values.length) return undefined;
|
|
51
|
+
const step = Math.ceil(values.length / maxTicks);
|
|
52
|
+
const kept: Array<string | number> = [];
|
|
53
|
+
for (let index = values.length - 1; index >= 0; index -= step) {
|
|
54
|
+
const value = values[index];
|
|
55
|
+
if (value !== undefined) kept.push(value);
|
|
56
|
+
}
|
|
57
|
+
return kept.reverse();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The category-axis values of `data`, in row order, skipping empty cells. */
|
|
61
|
+
export function categoryValues(
|
|
62
|
+
data: ReadonlyArray<ChartDataPoint>,
|
|
63
|
+
xAxisKey: string,
|
|
64
|
+
): Array<string | number> {
|
|
65
|
+
const values: Array<string | number> = [];
|
|
66
|
+
for (const row of data) {
|
|
67
|
+
const value = row[xAxisKey];
|
|
68
|
+
if (value !== null && value !== undefined) values.push(value);
|
|
69
|
+
}
|
|
70
|
+
return values;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Shorten a tick label to `maxChars`, ellipsis included, never mid-render. */
|
|
74
|
+
export function truncateTickLabel(value: unknown, maxChars: number): string {
|
|
75
|
+
const text = value === null || value === undefined ? '' : String(value);
|
|
76
|
+
if (maxChars < 1 || text.length <= maxChars) return text;
|
|
77
|
+
return `${text.slice(0, maxChars - 1)}…`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Resolved geometry for the shared cartesian axes. */
|
|
81
|
+
export interface CartesianAxisConfig {
|
|
82
|
+
/** Gap between an axis line and its tick labels, px. */
|
|
83
|
+
tickMargin: number;
|
|
84
|
+
/** Explicit category ticks once thinning applies; `undefined` = Recharts decides. */
|
|
85
|
+
categoryTicks: Array<string | number> | undefined;
|
|
86
|
+
/** Category tick label formatter (truncation). */
|
|
87
|
+
tickFormatter: (value: unknown) => string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Resolved geometry for bar series. */
|
|
91
|
+
export interface BarGeometry {
|
|
92
|
+
/** Per-corner radius, top corners only; `0` for stacked segments. */
|
|
93
|
+
radius: number | [number, number, number, number];
|
|
94
|
+
/** Cap on a bar's thickness, px — also what keeps bars off the frame. */
|
|
95
|
+
maxBarSize: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function resolveAxisConfig(props: ChartProps, tickMargin: number): CartesianAxisConfig {
|
|
99
|
+
const maxChars = props.tickLabelMaxChars ?? DEFAULT_TICK_LABEL_MAX_CHARS;
|
|
100
|
+
return {
|
|
101
|
+
tickMargin,
|
|
102
|
+
categoryTicks: selectCategoryTicks(
|
|
103
|
+
categoryValues(props.data, props.xAxisKey ?? 'name'),
|
|
104
|
+
props.maxCategoryTicks ?? DEFAULT_MAX_CATEGORY_TICKS,
|
|
105
|
+
),
|
|
106
|
+
tickFormatter: (value: unknown) => truncateTickLabel(value, maxChars),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function resolveBarGeometry(props: ChartProps): BarGeometry {
|
|
111
|
+
const radius = props.barRadius ?? DEFAULT_BAR_RADIUS;
|
|
112
|
+
return {
|
|
113
|
+
// A stacked segment rounded on its top corners tears a notch out of the
|
|
114
|
+
// segment above it, so stacks stay square.
|
|
115
|
+
radius: props.stacked ? 0 : [radius, radius, 0, 0],
|
|
116
|
+
maxBarSize: props.maxBarWidth ?? DEFAULT_MAX_BAR_WIDTH,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Theme } from '@mui/material';
|
|
2
|
+
import type { CSSProperties } from 'react';
|
|
3
|
+
|
|
4
|
+
import type { ChartDataPoint, ChartProps, ChartSeries } from './Chart.types';
|
|
5
|
+
import { resolveAxisConfig, resolveBarGeometry, type BarGeometry, type CartesianAxisConfig } from './chart-axis';
|
|
6
|
+
import {
|
|
7
|
+
getAxisStyles,
|
|
8
|
+
getDefaultColors,
|
|
9
|
+
getSizeStyles,
|
|
10
|
+
resolveSeries,
|
|
11
|
+
type SizeStyles,
|
|
12
|
+
} from './chart-internals';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Everything the per-type renderers in `chart-renderers.tsx` read, resolved
|
|
16
|
+
* once from props + theme. Kept out of that file so each renderer is a small
|
|
17
|
+
* JSX function over already-decided values — palette, series, axis geometry,
|
|
18
|
+
* bar geometry — rather than a place where those decisions get made again
|
|
19
|
+
* slightly differently.
|
|
20
|
+
*/
|
|
21
|
+
export interface ChartRenderContext {
|
|
22
|
+
props: ChartProps;
|
|
23
|
+
theme: Theme;
|
|
24
|
+
sizeStyles: SizeStyles;
|
|
25
|
+
chartColors: string[];
|
|
26
|
+
chartSeries: ChartSeries[];
|
|
27
|
+
strokeType: 'monotone' | 'linear';
|
|
28
|
+
axisStyle: CSSProperties;
|
|
29
|
+
gridStroke: string;
|
|
30
|
+
/** Multiplier on `gridStroke`'s alpha — see `getAxisStyles`. */
|
|
31
|
+
gridOpacity: number;
|
|
32
|
+
axisConfig: CartesianAxisConfig;
|
|
33
|
+
barGeometry: BarGeometry;
|
|
34
|
+
animationDuration: number;
|
|
35
|
+
commonProps: {
|
|
36
|
+
data: ChartDataPoint[];
|
|
37
|
+
margin: { top?: number; right?: number; bottom?: number; left?: number };
|
|
38
|
+
onClick: (payload: unknown) => void;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildChartContext(props: ChartProps, theme: Theme): ChartRenderContext {
|
|
43
|
+
const sizeStyles = getSizeStyles(props.size, props.height);
|
|
44
|
+
const chartColors = getDefaultColors(theme, props.variant ?? 'default', props.colors);
|
|
45
|
+
const { axisStyle, gridStroke, gridOpacity } = getAxisStyles(
|
|
46
|
+
theme,
|
|
47
|
+
props.variant ?? 'default',
|
|
48
|
+
sizeStyles.fontSize,
|
|
49
|
+
);
|
|
50
|
+
const handleChartClick = (payload: unknown): void => {
|
|
51
|
+
const chartData = payload as { activePayload?: Array<{ payload: ChartDataPoint }> };
|
|
52
|
+
if (props.onClick && chartData?.activePayload?.[0]) {
|
|
53
|
+
props.onClick(chartData.activePayload[0].payload);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
props,
|
|
58
|
+
theme,
|
|
59
|
+
sizeStyles,
|
|
60
|
+
chartColors,
|
|
61
|
+
chartSeries: resolveSeries(props.data, props.series, props.xAxisKey ?? 'name', chartColors),
|
|
62
|
+
strokeType: (props.curved ?? true) ? 'monotone' : 'linear',
|
|
63
|
+
axisStyle: { ...axisStyle, fontSize: sizeStyles.fontSize },
|
|
64
|
+
gridStroke,
|
|
65
|
+
gridOpacity,
|
|
66
|
+
axisConfig: resolveAxisConfig(props, sizeStyles.tickMargin),
|
|
67
|
+
barGeometry: resolveBarGeometry(props),
|
|
68
|
+
animationDuration: (props.animate ?? true) ? (props.animationDuration ?? 1500) : 0,
|
|
69
|
+
commonProps: {
|
|
70
|
+
data: props.data,
|
|
71
|
+
margin: { top: 20, right: 30, left: 20, bottom: 20, ...props.margin },
|
|
72
|
+
onClick: handleChartClick,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -12,19 +12,29 @@ import type { ChartProps, ChartSeries } from './Chart.types';
|
|
|
12
12
|
export interface SizeStyles {
|
|
13
13
|
height: number;
|
|
14
14
|
fontSize: string;
|
|
15
|
+
/**
|
|
16
|
+
* Gap between an axis line and its tick labels, px.
|
|
17
|
+
*
|
|
18
|
+
* Recharts' default of 2 is not enough. The bottom VALUE tick is centred on
|
|
19
|
+
* the x-axis line, so its text box reaches ~0.79em BELOW that line, while
|
|
20
|
+
* the category labels begin 2px under the 6px tick mark — the two collide at
|
|
21
|
+
* the axis corner, at every viewport. Each value below is ~0.8em of its own
|
|
22
|
+
* font size, which clears the value label's descender with ~6px to spare.
|
|
23
|
+
*/
|
|
24
|
+
tickMargin: number;
|
|
15
25
|
}
|
|
16
26
|
|
|
17
27
|
const SIZE_PRESETS: Record<NonNullable<ChartProps['size']>, SizeStyles> = {
|
|
18
|
-
xs: { height: 200, fontSize: '0.75rem' },
|
|
19
|
-
sm: { height: 300, fontSize: '0.875rem' },
|
|
20
|
-
md: { height: 400, fontSize: '1rem' },
|
|
21
|
-
lg: { height: 500, fontSize: '1.125rem' },
|
|
22
|
-
xl: { height: 600, fontSize: '1.25rem' },
|
|
28
|
+
xs: { height: 200, fontSize: '0.75rem', tickMargin: 10 },
|
|
29
|
+
sm: { height: 300, fontSize: '0.875rem', tickMargin: 12 },
|
|
30
|
+
md: { height: 400, fontSize: '1rem', tickMargin: 14 },
|
|
31
|
+
lg: { height: 500, fontSize: '1.125rem', tickMargin: 16 },
|
|
32
|
+
xl: { height: 600, fontSize: '1.25rem', tickMargin: 18 },
|
|
23
33
|
};
|
|
24
34
|
|
|
25
35
|
export function getSizeStyles(size: ChartProps['size'], height?: number): SizeStyles {
|
|
26
36
|
const preset = SIZE_PRESETS[size ?? 'md'] ?? SIZE_PRESETS.md;
|
|
27
|
-
return { height: height ?? preset.height
|
|
37
|
+
return { ...preset, height: height ?? preset.height };
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
const NEON_PALETTE = ['#00ffff', '#ff00ff', '#ffff00', '#00ff00', '#ff0080', '#8000ff'];
|
|
@@ -135,14 +145,28 @@ export function resolveSeries(
|
|
|
135
145
|
interface AxisTextStyles {
|
|
136
146
|
axisStyle: CSSProperties;
|
|
137
147
|
gridStroke: string;
|
|
148
|
+
/**
|
|
149
|
+
* Multiplier applied to `gridStroke`'s own alpha. Gridlines must read one
|
|
150
|
+
* step BELOW the card border — the border is `divider`, so half of it —
|
|
151
|
+
* without being dashed at data weight. Expressing the step as an opacity
|
|
152
|
+
* rather than a second colour keeps it derived from the theme's divider
|
|
153
|
+
* whatever form that takes (rgba, hex, or a CSS variable, which the colour
|
|
154
|
+
* helpers cannot decompose).
|
|
155
|
+
*/
|
|
156
|
+
gridOpacity: number;
|
|
138
157
|
}
|
|
139
158
|
|
|
140
159
|
export function getAxisStyles(theme: Theme, variant: ChartProps['variant'], fontSize: string): AxisTextStyles {
|
|
160
|
+
const neon = variant === 'neon';
|
|
141
161
|
return {
|
|
142
162
|
axisStyle: {
|
|
143
163
|
fontSize,
|
|
144
|
-
fill:
|
|
164
|
+
fill: neon ? '#00ffff' : theme.palette.text.secondary,
|
|
165
|
+
// Ticks are columns of digits: they have to line up between rows.
|
|
166
|
+
fontVariantNumeric: 'tabular-nums',
|
|
145
167
|
},
|
|
146
|
-
gridStroke:
|
|
168
|
+
gridStroke: neon ? alpha('#00ffff', 0.1) : theme.palette.divider,
|
|
169
|
+
// The neon grid is already a 10%-alpha cyan; halving it again would erase it.
|
|
170
|
+
gridOpacity: neon ? 1 : 0.5,
|
|
147
171
|
};
|
|
148
172
|
}
|
|
@@ -25,15 +25,9 @@ import {
|
|
|
25
25
|
YAxis,
|
|
26
26
|
} from 'recharts';
|
|
27
27
|
|
|
28
|
-
import type {
|
|
28
|
+
import type { ChartProps, ChartSeries } from './Chart.types';
|
|
29
29
|
import { ChartLegendContent, ChartTooltipContent } from './ChartComposables';
|
|
30
|
-
import {
|
|
31
|
-
getAxisStyles,
|
|
32
|
-
getDefaultColors,
|
|
33
|
-
getSizeStyles,
|
|
34
|
-
resolveSeries,
|
|
35
|
-
type SizeStyles,
|
|
36
|
-
} from './chart-internals';
|
|
30
|
+
import { buildChartContext, type ChartRenderContext } from './chart-context';
|
|
37
31
|
|
|
38
32
|
/**
|
|
39
33
|
* Per-type Recharts renderers for the prop-driven Chart, dispatched from a
|
|
@@ -41,23 +35,6 @@ import {
|
|
|
41
35
|
* {@link ChartRenderContext} so every function stays small.
|
|
42
36
|
*/
|
|
43
37
|
|
|
44
|
-
interface ChartRenderContext {
|
|
45
|
-
props: ChartProps;
|
|
46
|
-
theme: Theme;
|
|
47
|
-
sizeStyles: SizeStyles;
|
|
48
|
-
chartColors: string[];
|
|
49
|
-
chartSeries: ChartSeries[];
|
|
50
|
-
strokeType: 'monotone' | 'linear';
|
|
51
|
-
axisStyle: React.CSSProperties;
|
|
52
|
-
gridStroke: string;
|
|
53
|
-
animationDuration: number;
|
|
54
|
-
commonProps: {
|
|
55
|
-
data: ChartDataPoint[];
|
|
56
|
-
margin: { top?: number; right?: number; bottom?: number; left?: number };
|
|
57
|
-
onClick: (payload: unknown) => void;
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
38
|
function chartExtras(ctx: ChartRenderContext): React.ReactNode[] {
|
|
62
39
|
const { props } = ctx;
|
|
63
40
|
const nodes: React.ReactNode[] = [];
|
|
@@ -72,15 +49,40 @@ function chartExtras(ctx: ChartRenderContext): React.ReactNode[] {
|
|
|
72
49
|
return nodes;
|
|
73
50
|
}
|
|
74
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Gridlines: solid, and one step below the card border. A dashed line at the
|
|
54
|
+
* border's own weight competes with the data drawn over it.
|
|
55
|
+
*/
|
|
56
|
+
function chartGrid(ctx: ChartRenderContext): React.ReactElement {
|
|
57
|
+
return <CartesianGrid key="grid" stroke={ctx.gridStroke} strokeOpacity={ctx.gridOpacity} />;
|
|
58
|
+
}
|
|
59
|
+
|
|
75
60
|
function cartesianAxes(ctx: ChartRenderContext): React.ReactNode[] {
|
|
76
|
-
const { props } = ctx;
|
|
61
|
+
const { props, axisConfig } = ctx;
|
|
77
62
|
const nodes: React.ReactNode[] = [];
|
|
78
|
-
if (props.showCartesianGrid !== false)
|
|
79
|
-
nodes.push(<CartesianGrid key="grid" strokeDasharray="3 3" stroke={ctx.gridStroke} />);
|
|
80
|
-
}
|
|
63
|
+
if (props.showCartesianGrid !== false) nodes.push(chartGrid(ctx));
|
|
81
64
|
nodes.push(
|
|
82
|
-
<XAxis
|
|
83
|
-
|
|
65
|
+
<XAxis
|
|
66
|
+
key="x"
|
|
67
|
+
dataKey={props.xAxisKey ?? 'name'}
|
|
68
|
+
style={ctx.axisStyle}
|
|
69
|
+
label={props.xAxisLabel}
|
|
70
|
+
tickMargin={axisConfig.tickMargin}
|
|
71
|
+
ticks={axisConfig.categoryTicks}
|
|
72
|
+
tickFormatter={axisConfig.tickFormatter}
|
|
73
|
+
/>,
|
|
74
|
+
<YAxis
|
|
75
|
+
key="y"
|
|
76
|
+
style={ctx.axisStyle}
|
|
77
|
+
label={props.yAxisLabel}
|
|
78
|
+
// Sized from its own widest tick: a fixed 60px lets "R$ 1.234,56" spill
|
|
79
|
+
// out of the card, which is the other way axis text ends up on top of
|
|
80
|
+
// something.
|
|
81
|
+
width="auto"
|
|
82
|
+
tickMargin={axisConfig.tickMargin}
|
|
83
|
+
tickFormatter={props.valueFormatter}
|
|
84
|
+
allowDecimals={props.allowDecimalTicks !== false}
|
|
85
|
+
/>,
|
|
84
86
|
);
|
|
85
87
|
return nodes;
|
|
86
88
|
}
|
|
@@ -119,6 +121,8 @@ function renderBar(ctx: ChartRenderContext): React.ReactElement {
|
|
|
119
121
|
name={s.name}
|
|
120
122
|
fill={s.fill ?? s.color ?? ctx.chartColors[index % ctx.chartColors.length]}
|
|
121
123
|
stackId={ctx.props.stacked ? 'stack' : undefined}
|
|
124
|
+
radius={ctx.barGeometry.radius}
|
|
125
|
+
maxBarSize={ctx.barGeometry.maxBarSize}
|
|
122
126
|
animationDuration={ctx.animationDuration}
|
|
123
127
|
/>
|
|
124
128
|
))}
|
|
@@ -233,11 +237,19 @@ function renderScatter(ctx: ChartRenderContext): React.ReactElement {
|
|
|
233
237
|
const { props } = ctx;
|
|
234
238
|
return (
|
|
235
239
|
<ScatterChart {...ctx.commonProps}>
|
|
236
|
-
{props.showCartesianGrid !== false && (
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
240
|
+
{props.showCartesianGrid !== false && chartGrid(ctx)}
|
|
241
|
+
<XAxis
|
|
242
|
+
dataKey={props.xAxisKey ?? 'name'}
|
|
243
|
+
style={ctx.axisStyle}
|
|
244
|
+
label={props.xAxisLabel}
|
|
245
|
+
tickMargin={ctx.axisConfig.tickMargin}
|
|
246
|
+
/>
|
|
247
|
+
<YAxis
|
|
248
|
+
dataKey={ctx.chartSeries[0]?.dataKey}
|
|
249
|
+
style={ctx.axisStyle}
|
|
250
|
+
label={props.yAxisLabel}
|
|
251
|
+
tickMargin={ctx.axisConfig.tickMargin}
|
|
252
|
+
/>
|
|
241
253
|
{chartExtras(ctx)}
|
|
242
254
|
<Scatter
|
|
243
255
|
name={ctx.chartSeries[0]?.name ?? 'Data'}
|
|
@@ -253,7 +265,15 @@ function composedSeries(ctx: ChartRenderContext, s: ChartSeries, index: number):
|
|
|
253
265
|
const color = s.color ?? ctx.chartColors[index % ctx.chartColors.length];
|
|
254
266
|
if (s.type === 'bar') {
|
|
255
267
|
return (
|
|
256
|
-
<Bar
|
|
268
|
+
<Bar
|
|
269
|
+
key={s.dataKey}
|
|
270
|
+
dataKey={s.dataKey}
|
|
271
|
+
name={s.name}
|
|
272
|
+
fill={color}
|
|
273
|
+
radius={ctx.barGeometry.radius}
|
|
274
|
+
maxBarSize={ctx.barGeometry.maxBarSize}
|
|
275
|
+
animationDuration={ctx.animationDuration}
|
|
276
|
+
/>
|
|
257
277
|
);
|
|
258
278
|
}
|
|
259
279
|
if (s.type === 'area') {
|
|
@@ -302,37 +322,9 @@ const RENDERERS: Record<string, (ctx: ChartRenderContext) => React.ReactElement>
|
|
|
302
322
|
composed: renderComposed,
|
|
303
323
|
};
|
|
304
324
|
|
|
305
|
-
function buildContext(props: ChartProps, theme: Theme): ChartRenderContext {
|
|
306
|
-
const sizeStyles = getSizeStyles(props.size, props.height);
|
|
307
|
-
const chartColors = getDefaultColors(theme, props.variant ?? 'default', props.colors);
|
|
308
|
-
const { axisStyle, gridStroke } = getAxisStyles(theme, props.variant ?? 'default', sizeStyles.fontSize);
|
|
309
|
-
const handleChartClick = (payload: unknown): void => {
|
|
310
|
-
const chartData = payload as { activePayload?: Array<{ payload: ChartDataPoint }> };
|
|
311
|
-
if (props.onClick && chartData?.activePayload?.[0]) {
|
|
312
|
-
props.onClick(chartData.activePayload[0].payload);
|
|
313
|
-
}
|
|
314
|
-
};
|
|
315
|
-
return {
|
|
316
|
-
props,
|
|
317
|
-
theme,
|
|
318
|
-
sizeStyles,
|
|
319
|
-
chartColors,
|
|
320
|
-
chartSeries: resolveSeries(props.data, props.series, props.xAxisKey ?? 'name', chartColors),
|
|
321
|
-
strokeType: (props.curved ?? true) ? 'monotone' : 'linear',
|
|
322
|
-
axisStyle: { ...axisStyle, fontSize: sizeStyles.fontSize },
|
|
323
|
-
gridStroke,
|
|
324
|
-
animationDuration: (props.animate ?? true) ? (props.animationDuration ?? 1500) : 0,
|
|
325
|
-
commonProps: {
|
|
326
|
-
data: props.data,
|
|
327
|
-
margin: { top: 20, right: 30, left: 20, bottom: 20, ...props.margin },
|
|
328
|
-
onClick: handleChartClick,
|
|
329
|
-
},
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
|
-
|
|
333
325
|
/** Render the Recharts element for the requested chart type. */
|
|
334
326
|
export function renderChartByType(props: ChartProps, theme: Theme): React.ReactElement {
|
|
335
327
|
const renderer = RENDERERS[props.type ?? 'line'];
|
|
336
|
-
const ctx =
|
|
328
|
+
const ctx = buildChartContext(props, theme);
|
|
337
329
|
return renderer ? renderer(ctx) : <></>;
|
|
338
330
|
}
|
|
@@ -166,6 +166,9 @@ export function specToChartProps(
|
|
|
166
166
|
showTooltip: spec.tooltip ?? true,
|
|
167
167
|
height: spec.height,
|
|
168
168
|
valueFormatter: chartValueFormatter(spec.numberFormat),
|
|
169
|
+
// An integer metric is a COUNT: half a row does not exist, so the axis must
|
|
170
|
+
// not offer half-steps it would then round into duplicates (FUT-755).
|
|
171
|
+
allowDecimalTicks: spec.numberFormat !== 'integer',
|
|
169
172
|
...renderer(spec, ctx),
|
|
170
173
|
};
|
|
171
174
|
}
|
|
@@ -51,6 +51,27 @@ const getStripeColorFromTheme = (theme: { palette: { primary: { main: string };
|
|
|
51
51
|
return colorMap[stripeColor];
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The zebra stripe for one body row.
|
|
56
|
+
*
|
|
57
|
+
* `neutral` — the default — resolves to `palette.action.hover`, which ALREADY
|
|
58
|
+
* carries its subtlety in its own alpha (`rgba(0,0,0,0.04)` in the light
|
|
59
|
+
* theme). MUI's `alpha()` REPLACES that channel rather than multiplying it, so
|
|
60
|
+
* re-alpha-ing it at 0.5 amplified the tint 12.5× into `rgba(0,0,0,0.5)` —
|
|
61
|
+
* solid #808080 on a white card, which reads as a selected or errored row
|
|
62
|
+
* rather than as a stripe (FUT-755). It is used as-is instead.
|
|
63
|
+
*
|
|
64
|
+
* The named colours are opaque `*.main` values with no alpha of their own, so
|
|
65
|
+
* they still need one.
|
|
66
|
+
*/
|
|
67
|
+
const stripeRowColor = (
|
|
68
|
+
theme: Parameters<typeof getStripeColorFromTheme>[0],
|
|
69
|
+
stripeColor: TableStripeColor = 'neutral',
|
|
70
|
+
): string => {
|
|
71
|
+
const color = getStripeColorFromTheme(theme, stripeColor);
|
|
72
|
+
return stripeColor === 'neutral' ? color : alpha(color, 0.15);
|
|
73
|
+
};
|
|
74
|
+
|
|
54
75
|
// One variant applies at a time, so a lookup replaces the mutually exclusive
|
|
55
76
|
// spreads.
|
|
56
77
|
const TABLE_VARIANTS: Record<string, (args: VariantArgs) => CSSObject> = {
|
|
@@ -61,7 +82,7 @@ const TABLE_VARIANTS: Record<string, (args: VariantArgs) => CSSObject> = {
|
|
|
61
82
|
striped: ({ theme, stripeColor }) => ({
|
|
62
83
|
backgroundColor: theme.palette.background.paper,
|
|
63
84
|
'& .MuiTableBody-root .MuiTableRow-root:nth-of-type(even)': {
|
|
64
|
-
backgroundColor:
|
|
85
|
+
backgroundColor: stripeRowColor(theme, stripeColor) } }),
|
|
65
86
|
glass: ({ theme }) => ({
|
|
66
87
|
backgroundColor: alpha(theme.palette.background.paper, 0.1),
|
|
67
88
|
backdropFilter: 'blur(20px)',
|
|
@@ -86,6 +86,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
|
86
86
|
onFocus,
|
|
87
87
|
onBlur,
|
|
88
88
|
'data-testid': dataTestId,
|
|
89
|
+
'aria-label': ariaLabel,
|
|
89
90
|
...props
|
|
90
91
|
},
|
|
91
92
|
ref,
|
|
@@ -105,8 +106,17 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
|
105
106
|
{...interactionProps(loading, onClick, props.disabled)}
|
|
106
107
|
onFocus={onFocus}
|
|
107
108
|
onBlur={onBlur}
|
|
109
|
+
/*
|
|
110
|
+
* `aria-label` has to ride `inputProps` to reach the `<input>` (FUT-755).
|
|
111
|
+
* Spread with the rest it lands on the FormControl DIV that TextField
|
|
112
|
+
* renders as its root, which carries no role — so the field itself kept
|
|
113
|
+
* no accessible name, and a source grep saying "this input is labelled"
|
|
114
|
+
* disagreed with the DOM. The reports search box and the block-title
|
|
115
|
+
* inputs were both named in source and anonymous to a screen reader.
|
|
116
|
+
*/
|
|
108
117
|
inputProps={{
|
|
109
118
|
'data-testid': dataTestId,
|
|
119
|
+
...(ariaLabel === undefined ? {} : { 'aria-label': ariaLabel }),
|
|
110
120
|
}}
|
|
111
121
|
InputProps={{
|
|
112
122
|
startAdornment: startAdornment && (
|
|
@@ -42,10 +42,17 @@ The Drawer component is perfect for:
|
|
|
42
42
|
|
|
43
43
|
### Styling Props
|
|
44
44
|
|
|
45
|
-
| Prop | Type
|
|
46
|
-
| ----------- |
|
|
47
|
-
| `glass` | `boolean`
|
|
48
|
-
| `
|
|
45
|
+
| Prop | Type | Default | Description |
|
|
46
|
+
| ----------- | ----------- | ------- | ------------------------------------------------ |
|
|
47
|
+
| `glass` | `boolean` | `false` | Enable glass/blur effect styling |
|
|
48
|
+
| `paperSx` | `CSSObject` | - | Extra styles for the sliding surface (see below) |
|
|
49
|
+
| `className` | `string` | - | Additional CSS class names |
|
|
50
|
+
|
|
51
|
+
`paperSx` is merged after the width/height the drawer derives from its anchor,
|
|
52
|
+
onto MUI's drawer paper. A temporary drawer PORTALS that surface, so a consumer
|
|
53
|
+
cannot reach it with a wrapper's descendant selector — this is how a bottom
|
|
54
|
+
sheet asks for rounded top corners, or a docked panel for its own offset. Omit
|
|
55
|
+
it and the drawer renders exactly as before.
|
|
49
56
|
|
|
50
57
|
## Sub-components
|
|
51
58
|
|
|
@@ -36,6 +36,11 @@ const buildDrawerStyles = (
|
|
|
36
36
|
variant: DrawerProps['variant'],
|
|
37
37
|
width: DrawerProps['width'],
|
|
38
38
|
height: DrawerProps['height'],
|
|
39
|
+
// Merged LAST into the surface's own rules, so a caller can shape the sliding
|
|
40
|
+
// surface itself (rounded corners on a bottom sheet, a docked panel's offset)
|
|
41
|
+
// without reaching for the MUI paper class from outside. Undefined by default:
|
|
42
|
+
// omit it and the drawer styles exactly as it always has.
|
|
43
|
+
paperSx?: DrawerProps['paperSx'],
|
|
39
44
|
) => {
|
|
40
45
|
const baseStyles = {
|
|
41
46
|
width: ['left', 'right'].includes(anchor) ? width : '100%',
|
|
@@ -53,6 +58,7 @@ const buildDrawerStyles = (
|
|
|
53
58
|
backdropFilter: 'blur(20px)',
|
|
54
59
|
border: `1px solid ${alpha(theme.palette.primary.main, 0.2)}`,
|
|
55
60
|
boxShadow: `0 8px 32px ${alpha(theme.palette.common.black, 0.1)}`,
|
|
61
|
+
...paperSx,
|
|
56
62
|
},
|
|
57
63
|
};
|
|
58
64
|
}
|
|
@@ -63,6 +69,7 @@ const buildDrawerStyles = (
|
|
|
63
69
|
width: ['left', 'right'].includes(anchor) ? width : '100%',
|
|
64
70
|
height: ['top', 'bottom'].includes(anchor) ? height : '100%',
|
|
65
71
|
boxSizing: 'border-box',
|
|
72
|
+
...paperSx,
|
|
66
73
|
},
|
|
67
74
|
};
|
|
68
75
|
};
|
|
@@ -79,6 +86,7 @@ export const Drawer: React.FC<DrawerProps> = ({
|
|
|
79
86
|
backdrop = true,
|
|
80
87
|
hideBackdrop = false,
|
|
81
88
|
keepMounted = false,
|
|
89
|
+
paperSx,
|
|
82
90
|
className,
|
|
83
91
|
dataTestId,
|
|
84
92
|
...rest
|
|
@@ -99,7 +107,7 @@ export const Drawer: React.FC<DrawerProps> = ({
|
|
|
99
107
|
invisible: !backdrop,
|
|
100
108
|
},
|
|
101
109
|
}}
|
|
102
|
-
sx={buildDrawerStyles(theme, drawerAnchor, variant, width, height)}
|
|
110
|
+
sx={buildDrawerStyles(theme, drawerAnchor, variant, width, height, paperSx)}
|
|
103
111
|
className={className}
|
|
104
112
|
data-testid={dataTestId || 'drawer'}
|
|
105
113
|
{...rest}
|
|
@@ -113,6 +121,7 @@ export const DrawerHeader: React.FC<DrawerHeaderProps> = ({
|
|
|
113
121
|
children,
|
|
114
122
|
onClose,
|
|
115
123
|
showCloseButton = true,
|
|
124
|
+
closeLabel,
|
|
116
125
|
dataTestId,
|
|
117
126
|
}) => {
|
|
118
127
|
const theme = useTheme();
|
|
@@ -136,7 +145,7 @@ export const DrawerHeader: React.FC<DrawerHeaderProps> = ({
|
|
|
136
145
|
<IconButton
|
|
137
146
|
onClick={onClose}
|
|
138
147
|
edge="end"
|
|
139
|
-
aria-label=
|
|
148
|
+
aria-label={closeLabel ?? 'Close drawer'}
|
|
140
149
|
data-testid={dataTestId ? `${dataTestId}-close` : 'drawer-close'}
|
|
141
150
|
>
|
|
142
151
|
<Close />
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CSSObject } from '@mui/material/styles';
|
|
1
2
|
import type { HTMLAttributes,ReactNode } from 'react';
|
|
2
3
|
|
|
3
4
|
export type DrawerVariant = 'left' | 'right' | 'top' | 'bottom' | 'glass';
|
|
@@ -16,6 +17,16 @@ export interface DrawerProps extends Omit<HTMLAttributes<HTMLDivElement>, 'child
|
|
|
16
17
|
backdrop?: boolean;
|
|
17
18
|
hideBackdrop?: boolean;
|
|
18
19
|
keepMounted?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Extra styles for the sliding surface itself (MUI's drawer paper), merged
|
|
22
|
+
* after the width/height the drawer derives from its anchor.
|
|
23
|
+
*
|
|
24
|
+
* Opt-in and additive: omit it and the drawer renders exactly as it always
|
|
25
|
+
* has. It exists because a temporary drawer PORTALS its surface, so a
|
|
26
|
+
* consumer cannot reach it with a wrapper's descendant selector — a bottom
|
|
27
|
+
* sheet that wants rounded top corners has no other way to ask.
|
|
28
|
+
*/
|
|
29
|
+
paperSx?: CSSObject;
|
|
19
30
|
className?: string;
|
|
20
31
|
dataTestId?: string;
|
|
21
32
|
}
|
|
@@ -24,6 +35,15 @@ export interface DrawerHeaderProps {
|
|
|
24
35
|
children: ReactNode;
|
|
25
36
|
onClose?: () => void;
|
|
26
37
|
showCloseButton?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Accessible name for the close button. Defaults to "Close drawer".
|
|
40
|
+
*
|
|
41
|
+
* A drawer that is not a drawer to its user needs to say what it closes:
|
|
42
|
+
* the report builder's block panel is a docked side panel, and "Fechar
|
|
43
|
+
* painel" is what its spec asks to hear (FUT-755). Left unset the wording is
|
|
44
|
+
* unchanged, so existing consumers asserting on the default keep passing.
|
|
45
|
+
*/
|
|
46
|
+
closeLabel?: string;
|
|
27
47
|
dataTestId?: string;
|
|
28
48
|
}
|
|
29
49
|
|