@12-apps/ui 1.21.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/ui",
3
- "version": "1.21.0",
3
+ "version": "1.22.0",
4
4
  "license": "MIT",
5
5
  "packageManager": "pnpm@9.0.0",
6
6
  "type": "module",
@@ -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, fontSize: preset.fontSize };
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: variant === 'neon' ? '#00ffff' : theme.palette.text.secondary,
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: variant === 'neon' ? alpha('#00ffff', 0.1) : theme.palette.divider,
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 { ChartDataPoint, ChartProps, ChartSeries } from './Chart.types';
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 key="x" dataKey={props.xAxisKey ?? 'name'} style={ctx.axisStyle} label={props.xAxisLabel} />,
83
- <YAxis key="y" style={ctx.axisStyle} label={props.yAxisLabel} tickFormatter={props.valueFormatter} />,
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
- <CartesianGrid strokeDasharray="3 3" stroke={ctx.gridStroke} />
238
- )}
239
- <XAxis dataKey={props.xAxisKey ?? 'name'} style={ctx.axisStyle} label={props.xAxisLabel} />
240
- <YAxis dataKey={ctx.chartSeries[0]?.dataKey} style={ctx.axisStyle} label={props.yAxisLabel} />
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 key={s.dataKey} dataKey={s.dataKey} name={s.name} fill={color} animationDuration={ctx.animationDuration} />
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 = buildContext(props, theme);
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
  }
@@ -7,6 +7,24 @@
7
7
  */
8
8
  export { BaseCard } from "./base-card";
9
9
  export { BaseListCard, type BaseListCardProps } from "./base-list-card";
10
+ // THE LIST'S HALF OF THE CARD API. `BaseListCard` alone is not usable as
11
+ // designed: its cells are declared by the GROUP and typed by
12
+ // `ListCardCellConfig`, and without these a consumer can only fall back to the
13
+ // named slots and standalone rails — which is the layout the cell config
14
+ // replaced, and gives up the alignment that was the point.
15
+ export {
16
+ ListCardGroup,
17
+ DEFAULT_RAILS,
18
+ RAIL_COUNT,
19
+ type ListRails,
20
+ } from "./list-card-rails";
21
+ export {
22
+ type ListCardCellConfig,
23
+ DEFAULT_CELL_WIDTH,
24
+ } from "./list-card-cells";
25
+ // Published by a drag container so a row can draw the marker; a consumer wiring
26
+ // its own DnD needs the type to build the value.
27
+ export { DropIndicator } from "./data-views-drag";
10
28
  export {
11
29
  DragContainerProvider,
12
30
  useDragItem,
@@ -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: alpha(getStripeColorFromTheme(theme, stripeColor), stripeColor === 'neutral' ? 0.5 : 0.15) } }),
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 && (
@@ -190,6 +190,7 @@ export const Select = React.forwardRef<HTMLDivElement, SelectProps>(
190
190
  pulse,
191
191
  'data-testid': dataTestId,
192
192
  value,
193
+ 'aria-label': ariaLabel,
193
194
  ...props
194
195
  },
195
196
  ref,
@@ -223,6 +224,24 @@ export const Select = React.forwardRef<HTMLDivElement, SelectProps>(
223
224
  data-testid={dataTestId ? `${dataTestId}-select` : 'select'}
224
225
  value={value ?? ''}
225
226
  {...props}
227
+ /*
228
+ * `role="combobox"` sits on MUI's display DIV, not on the hidden
229
+ * native input that `{...props}` reaches — so an `aria-label` passed
230
+ * to this component never landed on the element carrying the role,
231
+ * and MUI's `aria-labelledby` then fell back to that div's OWN id.
232
+ * The control announced as its current value: a filter's field
233
+ * select read "Status" instead of "Filtro 1 — campo".
234
+ *
235
+ * Forwarding it here names the right element. The explicit
236
+ * `undefined` clears that self-referential `aria-labelledby`, which
237
+ * would otherwise win — a labelledby always beats a label in the
238
+ * accessible-name computation.
239
+ */
240
+ SelectDisplayProps={
241
+ ariaLabel === undefined
242
+ ? undefined
243
+ : { 'aria-label': ariaLabel, 'aria-labelledby': undefined }
244
+ }
226
245
  >
227
246
  {renderMenuItems(options, placeholder, dataTestId)}
228
247
  </MuiSelect>
@@ -42,10 +42,17 @@ The Drawer component is perfect for:
42
42
 
43
43
  ### Styling Props
44
44
 
45
- | Prop | Type | Default | Description |
46
- | ----------- | --------- | ------- | -------------------------------- |
47
- | `glass` | `boolean` | `false` | Enable glass/blur effect styling |
48
- | `className` | `string` | - | Additional CSS class names |
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="Close drawer"
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