@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.
Files changed (22) hide show
  1. package/package.json +1 -1
  2. package/src/components/data-display/Chart/Chart.md +22 -0
  3. package/src/components/data-display/Chart/Chart.types.ts +26 -0
  4. package/src/components/data-display/Chart/chart-axis.ts +118 -0
  5. package/src/components/data-display/Chart/chart-context.ts +75 -0
  6. package/src/components/data-display/Chart/chart-internals.ts +32 -8
  7. package/src/components/data-display/Chart/chart-renderers.tsx +58 -66
  8. package/src/components/data-display/Chart/chart-spec.ts +3 -0
  9. package/src/components/data-display/DataViews/RESPONSIVE-BEHAVIOUR.md +37 -3
  10. package/src/components/data-display/DataViews/data-views-day-input.tsx +22 -3
  11. package/src/components/data-display/DataViews/data-views-display-panel.tsx +4 -2
  12. package/src/components/data-display/DataViews/data-views-grid-parts.tsx +13 -1
  13. package/src/components/data-display/DataViews/data-views-more-fields.tsx +16 -23
  14. package/src/components/data-display/DataViews/data-views-more-filters.tsx +7 -2
  15. package/src/components/data-display/DataViews/data-views-number-input.tsx +14 -4
  16. package/src/components/data-display/DataViews/data-views-overflow-costs.ts +87 -16
  17. package/src/components/data-display/DataViews/data-views-overflow.ts +87 -26
  18. package/src/components/data-display/Table/Table.styles.ts +22 -1
  19. package/src/components/form/Input/Input.tsx +10 -0
  20. package/src/components/layout/Drawer/Drawer.md +11 -4
  21. package/src/components/layout/Drawer/Drawer.tsx +11 -2
  22. package/src/components/layout/Drawer/Drawer.types.ts +20 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/ui",
3
- "version": "1.21.1",
3
+ "version": "1.23.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
  }
@@ -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 | 0 | 0 | 1–2 | 3+ |
81
- | "Mais" present | yes | yes | yes | yes | only if something overflowed |
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 | hidden | shown | shown | shown |
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
- inputProps={{ 'data-testid': testId, maxLength: 10 }}
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
- import TuneRoundedIcon from "@mui/icons-material/TuneRounded";
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
- <TuneRoundedIcon fontSize="small" />
272
+ <DisplaySettingsRoundedIcon fontSize="small" />
271
273
  {!compact && (
272
274
  <Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
273
275
  Exibir