@12-apps/ui 1.21.1 → 1.23.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/DataViews/RESPONSIVE-BEHAVIOUR.md +37 -3
- package/src/components/data-display/DataViews/data-views-day-input.tsx +22 -3
- package/src/components/data-display/DataViews/data-views-display-panel.tsx +4 -2
- package/src/components/data-display/DataViews/data-views-grid-parts.tsx +13 -1
- package/src/components/data-display/DataViews/data-views-more-fields.tsx +16 -23
- package/src/components/data-display/DataViews/data-views-more-filters.tsx +7 -2
- package/src/components/data-display/DataViews/data-views-number-input.tsx +14 -4
- package/src/components/data-display/DataViews/data-views-overflow-costs.ts +87 -16
- package/src/components/data-display/DataViews/data-views-overflow.ts +87 -26
- 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
|
}
|
|
@@ -23,6 +23,10 @@ later; an eight-filter one degrades sooner. Both are correct.
|
|
|
23
23
|
| small desktop | 1280 | laptop |
|
|
24
24
|
| large desktop | 1600 | external monitor |
|
|
25
25
|
|
|
26
|
+
The tests also pin a **crowded** row at 1024, because a small desktop no longer
|
|
27
|
+
overflows at all: the rules about what happens when controls do NOT fit need a
|
|
28
|
+
width where some of them do not.
|
|
29
|
+
|
|
26
30
|
**These are toolbar ROW widths, not viewport widths.** The `ResizeObserver`
|
|
27
31
|
watches the row, and the row is narrower than the window by whatever the page
|
|
28
32
|
puts around it — around 48px in the Pedidos shell. So a 430px row is roughly a
|
|
@@ -77,8 +81,26 @@ Read this as "what the Pedidos table does", not "what the code hard-codes".
|
|
|
77
81
|
|
|
78
82
|
| | small mobile | large mobile | tablet | small desktop | large desktop |
|
|
79
83
|
| --- | --- | --- | --- | --- | --- |
|
|
80
|
-
| controls on the bar | 0 |
|
|
81
|
-
| "Mais" present | yes | yes | yes |
|
|
84
|
+
| controls on the bar | 0 | 1 | 1 | all 6 | all 6 |
|
|
85
|
+
| "Mais" present | yes | yes | yes | no | no |
|
|
86
|
+
|
|
87
|
+
**The bar re-spends what its own collapse frees.** Rungs 2/4/5 turn 200 + 96 +
|
|
88
|
+
216 of furniture into 44 + 0 + 140, and the split used to be decided once,
|
|
89
|
+
against the UNCOLLAPSED row — so a phone shed all six controls to make room for
|
|
90
|
+
a search box and a counter that were about to shrink, and nobody handed the
|
|
91
|
+
~330px back. It showed as a 428px bar carrying a magnifier, "Mais 6", and a
|
|
92
|
+
225px band of nothing. `computeSplit` now iterates to a fixed point: cheaper
|
|
93
|
+
furniture buys more filters, more filters push the ladder further down, a
|
|
94
|
+
further rung makes the furniture cheaper again, and the budget decreases
|
|
95
|
+
monotonically until it stops moving.
|
|
96
|
+
|
|
97
|
+
The furniture is also priced for what is ACTUALLY on the bar. "Exportar" exists
|
|
98
|
+
only when the host passes an `exportConfig` — several screens put their export
|
|
99
|
+
in the page header instead — and charging for the pair regardless billed the
|
|
100
|
+
filters ~108px for a button that was never rendered.
|
|
101
|
+
|
|
102
|
+
Which control survives on a phone is whichever fits the room left over, so a
|
|
103
|
+
large mobile keeps a cheaper pill where a tablet affords a wider one.
|
|
82
104
|
|
|
83
105
|
**An applied filter is ranked first, not exempt.** Applied controls take the
|
|
84
106
|
visible slots ahead of idle ones and go into "Mais" like anything else when even
|
|
@@ -133,7 +155,12 @@ Two distinct behaviours, and conflating them was a bug:
|
|
|
133
155
|
| --- | --- | --- | --- | --- | --- |
|
|
134
156
|
| Exibir / Exportar | icon | icon | label | label | label |
|
|
135
157
|
| dropdown chevron | no | no | yes | yes | yes |
|
|
136
|
-
| "N de N" counter | hidden |
|
|
158
|
+
| "N de N" counter | hidden | shown | shown | shown | shown |
|
|
159
|
+
|
|
160
|
+
The counter is priced at 56, not the old 96. It was measured against
|
|
161
|
+
"1.234 de 5.678"; a phone reads "3 de 3" and renders at 35px, so the extra
|
|
162
|
+
~60px was coming straight out of the filter budget at exactly the widths where
|
|
163
|
+
that budget is scarcest.
|
|
137
164
|
|
|
138
165
|
A tablet keeps the labels, which is worth stating because it is easy to assume
|
|
139
166
|
otherwise. With every filter already behind "Mais" the row has room to spare
|
|
@@ -176,6 +203,13 @@ leaves the board with a single populated column, which reads as data loss.
|
|
|
176
203
|
of the five widths and asserts the tables above. It is the executable copy of
|
|
177
204
|
this document; if the two disagree, the test is right and this needs updating.
|
|
178
205
|
|
|
206
|
+
`what fits follows the controls, not the width alone` guards the property the
|
|
207
|
+
rest of this document depends on: the counts above are OUTCOMES of pricing one
|
|
208
|
+
fixture against one row, never rules. At a single width it asserts that a table
|
|
209
|
+
declaring cheaper controls keeps more of them, that one long control keeps
|
|
210
|
+
none, and that the same table promotes another as its row grows — three answers
|
|
211
|
+
a breakpoint could not give.
|
|
212
|
+
|
|
179
213
|
Two things it cannot check, because jsdom has no layout engine: actual pixel
|
|
180
214
|
overflow, and how anything looks. Those are verified in a real browser against
|
|
181
215
|
the running Storybook, at 1600 / 1280 / 1024 / 900 / 768 / 600 / 500 / 430 / 390
|
|
@@ -107,8 +107,6 @@ export function DayBoundInput({
|
|
|
107
107
|
// The mask IS the placeholder, so the expected order is on screen before
|
|
108
108
|
// the first keystroke rather than discovered by getting it wrong.
|
|
109
109
|
placeholder="dd/mm/aaaa"
|
|
110
|
-
// A numeric keypad on a phone, while still accepting a paste with slashes.
|
|
111
|
-
inputMode="numeric"
|
|
112
110
|
value={text}
|
|
113
111
|
onChange={(event) => {
|
|
114
112
|
const next = maskBrDate(event.target.value);
|
|
@@ -126,7 +124,28 @@ export function DayBoundInput({
|
|
|
126
124
|
onBlur={() => setText(isoToBr(applied))}
|
|
127
125
|
// The placeholder occupies the space an un-shrunk label would render into.
|
|
128
126
|
InputLabelProps={{ shrink: true }}
|
|
129
|
-
|
|
127
|
+
// `inputMode` HAS to be here rather than on the TextField. Anything
|
|
128
|
+
// TextField does not recognise is spread onto its root `FormControl` div,
|
|
129
|
+
// and `inputMode` is not in that list — so passing it up there put the
|
|
130
|
+
// attribute on a non-editable wrapper, where it means nothing, and the
|
|
131
|
+
// phone went on offering the letter keyboard for a field that only takes
|
|
132
|
+
// digits (the `dd/mm/aaaa` hint sitting under a QWERTY layout). Only the
|
|
133
|
+
// `<input>` this reaches decides which keypad opens.
|
|
134
|
+
//
|
|
135
|
+
// `numeric` and not `tel`: both raise a keypad, but the phone one is a
|
|
136
|
+
// dialler with `+ * #` on it — keys that cannot occur in a date. A paste
|
|
137
|
+
// carrying separators still lands, because the MASK is what filters the
|
|
138
|
+
// text; the keypad only narrows what a thumb can reach for.
|
|
139
|
+
//
|
|
140
|
+
// Autofill off for the same reason: the browser's saved-value strip
|
|
141
|
+
// covers the keypad to suggest names and addresses, none of which are a
|
|
142
|
+
// date, and none of which the mask would keep.
|
|
143
|
+
inputProps={{
|
|
144
|
+
'data-testid': testId,
|
|
145
|
+
maxLength: 10,
|
|
146
|
+
inputMode: 'numeric',
|
|
147
|
+
autoComplete: 'off',
|
|
148
|
+
}}
|
|
130
149
|
sx={{ width: 165 }}
|
|
131
150
|
/>
|
|
132
151
|
);
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded";
|
|
4
|
-
|
|
4
|
+
// Says "display settings" rather than the generic tune sliders it used to
|
|
5
|
+
// share with the "Mais" FILTER trigger — see the note there.
|
|
6
|
+
import DisplaySettingsRoundedIcon from "@mui/icons-material/DisplaySettingsRounded";
|
|
5
7
|
import { Popover } from "@mui/material";
|
|
6
8
|
import { useState } from "react";
|
|
7
9
|
|
|
@@ -267,7 +269,7 @@ function DisplayTrigger({
|
|
|
267
269
|
title={compact ? "Exibir" : undefined}
|
|
268
270
|
>
|
|
269
271
|
<Box component="span" sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}>
|
|
270
|
-
<
|
|
272
|
+
<DisplaySettingsRoundedIcon fontSize="small" />
|
|
271
273
|
{!compact && (
|
|
272
274
|
<Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
|
|
273
275
|
Exibir
|