@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
@@ -142,6 +142,8 @@ interface GridShellFiltersArgs<T extends Record<string, unknown>> {
142
142
  rangeFields: RangeFieldConfig<T>[];
143
143
  testIdPrefix: string;
144
144
  alwaysShowSearch: boolean;
145
+ /** Is "Exportar" on the bar? Half the right cluster's cost when it is not. */
146
+ hasExport: boolean;
145
147
  }
146
148
 
147
149
  function useGridShellFilters<T extends Record<string, unknown>>({
@@ -151,6 +153,7 @@ function useGridShellFilters<T extends Record<string, unknown>>({
151
153
  rangeFields,
152
154
  testIdPrefix,
153
155
  alwaysShowSearch,
156
+ hasExport,
154
157
  }: GridShellFiltersArgs<T>): {
155
158
  showInline: boolean;
156
159
  useModal: boolean;
@@ -171,6 +174,7 @@ function useGridShellFilters<T extends Record<string, unknown>>({
171
174
  state.pills,
172
175
  c.ranges,
173
176
  openControls > 0,
177
+ hasExport,
174
178
  );
175
179
  // THE BAR RENDERS AT EVERY WIDTH. It used to be swapped for a full-screen
176
180
  // filter MODAL below `lg`, which was the responsive strategy before the
@@ -365,7 +369,15 @@ export function GridShell<T extends Record<string, unknown>>(props: GridShellPro
365
369
  inlineFilters = false,
366
370
  alwaysShowSearch = false,
367
371
  } = props;
368
- const filters = useGridShellFilters({ c, inlineFilters, fields, rangeFields, testIdPrefix, alwaysShowSearch });
372
+ const filters = useGridShellFilters({
373
+ c,
374
+ inlineFilters,
375
+ fields,
376
+ rangeFields,
377
+ testIdPrefix,
378
+ alwaysShowSearch,
379
+ hasExport: props.exportConfig !== undefined,
380
+ });
369
381
  return (
370
382
  <DataViewsLayoutProvider
371
383
  canUseCards={Boolean(renderCard)}
@@ -19,9 +19,7 @@ import { Box } from "../../../mui/Box";
19
19
  import { Text } from "../../typography/Text";
20
20
 
21
21
  import type { OverflowField } from "./data-views-overflow";
22
- import { RangePresets } from "./data-views-preset-chips";
23
22
  import { RangeBounds } from "./data-views-range-pill";
24
- import { presetsFor } from "./data-views-range-presets";
25
23
  import { isRangeSet } from "./data-views-range-values";
26
24
  import type { RangeValue } from "./data-views-types";
27
25
 
@@ -114,10 +112,11 @@ function OverflowPill<T extends Record<string, unknown>>({
114
112
 
115
113
 
116
114
  /**
117
- * One overflowed range: its two bounds, as the same inputs the pill uses.
115
+ * One overflowed range: its presets and its two bounds, as the same controls
116
+ * the pill uses.
118
117
  *
119
- * The bounds DELEGATE to {@link RangeBounds} rather than being built here,
120
- * which is the whole point — this panel used to render a raw
118
+ * The whole control DELEGATES to {@link RangeBounds} rather than being built
119
+ * here, which is the point — this panel used to render a raw
121
120
  * `<input type="date">`, so the masked `dd/mm/aaaa` field existed only while
122
121
  * the filter FITTED on the bar. The moment "Data" overflowed it reverted to the
123
122
  * native control the mask replaced, and a merchant on a narrow screen never saw
@@ -128,6 +127,12 @@ function OverflowPill<T extends Record<string, unknown>>({
128
127
  * native date input reports a wide minimum (its own mask plus the picker
129
128
  * glyph), which is what made two of them refuse to shrink and scrolled the
130
129
  * panel sideways. A text input has no such minimum.
130
+ *
131
+ * `RangeBounds` renders the preset chips ITSELF, above the inputs. This panel
132
+ * must NOT render its own row as well: when the delegation above landed, the
133
+ * chips it used to draw by hand stayed behind, and "Data" showed Hoje/Ontem/
134
+ * Esta semana/Este mês/Este ano twice — two rows, duplicated test ids, and half
135
+ * the panel's height spent saying the same thing (FUT-751).
131
136
  */
132
137
  function OverflowRange<T extends Record<string, unknown>>({
133
138
  field,
@@ -144,24 +149,12 @@ function OverflowRange<T extends Record<string, unknown>>({
144
149
  // set; the guard is for the type, not for a state the caller can reach.
145
150
  if (!field.range) return null;
146
151
  return (
147
- <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
148
- {/* The overflow offers the same one-click windows the pill does. A field
149
- in here is one that had no room on the bar, not one with fewer
150
- controls — otherwise "Data" gains and loses its presets as the window
151
- is resized. */}
152
- <RangePresets
153
- presets={presetsFor(field.range)}
154
- value={value}
155
- onChange={onChange}
156
- testId={`${testIdPrefix}-more-${field.id}`}
157
- />
158
- <RangeBounds
159
- field={field.range}
160
- value={value}
161
- onChange={onChange}
162
- testId={`${testIdPrefix}-more-${field.id}`}
163
- />
164
- </Box>
152
+ <RangeBounds
153
+ field={field.range}
154
+ value={value}
155
+ onChange={onChange}
156
+ testId={`${testIdPrefix}-more-${field.id}`}
157
+ />
165
158
  );
166
159
  }
167
160
 
@@ -1,7 +1,12 @@
1
1
  "use client";
2
2
 
3
3
  import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded";
4
- import TuneRoundedIcon from "@mui/icons-material/TuneRounded";
4
+ // A FUNNEL, not the tune sliders this used to share with "Exibir". The two
5
+ // sit a few px apart on the same bar and do unrelated things — one narrows
6
+ // WHICH rows you see, the other changes HOW they are shown — and with one
7
+ // glyph between them the bar offered the operator no way to tell which was
8
+ // which. On a phone, where both are icon-only, they were indistinguishable.
9
+ import FilterAltRoundedIcon from "@mui/icons-material/FilterAltRounded";
5
10
  import { Popover } from "@mui/material";
6
11
  import { useState } from "react";
7
12
 
@@ -76,7 +81,7 @@ function MoreTrigger({
76
81
  color: applied ? "primary.main" : undefined,
77
82
  }}
78
83
  >
79
- <TuneRoundedIcon fontSize="small" />
84
+ <FilterAltRoundedIcon fontSize="small" />
80
85
  {!compact && (
81
86
  <Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
82
87
  Mais
@@ -23,6 +23,17 @@
23
23
  * plain `50` still works, and so does `50.00` — a dot with exactly two digits
24
24
  * after it is far more likely to be a decimal typed on a numeric keypad than a
25
25
  * thousands group, and reading it as `5000` is the very failure this replaces.
26
+ *
27
+ * WHICH KEYPAD, and where the request has to be made. `inputMode` goes on the
28
+ * `<input>`, via `inputProps` — set on the `TextField` it rides the spread onto
29
+ * the root `FormControl` div, an element nothing types into, so the attribute
30
+ * has no effect and a phone goes on offering letters for a field that accepts
31
+ * none of them. It is `decimal` rather than `numeric` because the numeric pad
32
+ * has no comma on iOS, and the comma is the separator this field exists to
33
+ * accept — `numeric` here would reopen the bug above from the other side, with
34
+ * `17,50` untypeable rather than misread. Autofill is off alongside it: its
35
+ * saved-value strip covers the keypad to offer names and addresses, none of
36
+ * which survive `ALLOWED` anyway.
26
37
  */
27
38
  import { Box, TextField } from '@mui/material';
28
39
  import React, { useEffect, useState } from 'react';
@@ -107,9 +118,6 @@ export function NumberBoundInput({
107
118
  size="small"
108
119
  type="text"
109
120
  label={label}
110
- // Not `numeric`: that keypad has no comma on iOS, which is the separator
111
- // this field exists to accept.
112
- inputMode="decimal"
113
121
  value={text}
114
122
  onChange={(event) => {
115
123
  // Reject the characters rather than silently dropping them AFTER
@@ -139,7 +147,9 @@ export function NumberBoundInput({
139
147
  )
140
148
  }
141
149
  InputLabelProps={{ shrink: true }}
142
- inputProps={{ 'data-testid': testId }}
150
+ // `inputMode` belongs on the `<input>`, never on the TextField — see the
151
+ // keypad note at the top of this file for what goes wrong up there.
152
+ inputProps={{ 'data-testid': testId, inputMode: 'decimal', autoComplete: 'off' }}
143
153
  InputProps={
144
154
  unit
145
155
  ? {
@@ -33,8 +33,15 @@ export function estimateWidth(text: string, extra: number): number {
33
33
  *
34
34
  * Deliberately generous: over-pricing sheds one control too early, which the
35
35
  * "Mais" button absorbs invisibly. Under-pricing breaks the line.
36
+ *
37
+ * The chrome was 76, and generous had turned into wrong. MEASURED on the
38
+ * Pedidos bar: "Pagamento" renders at 123px against an estimate of 144, and
39
+ * "Situação" at 107 against 137 — every pill over-priced by ~22%, which on a
40
+ * phone is the whole difference between one pill on the bar and none. 56 is
41
+ * still above the ~50 measured, so the slack the comment above asks for is
42
+ * intact; it is just slack rather than a second control's worth of budget.
36
43
  */
37
- return Math.round(text.length * 7.6) + 76 + extra;
44
+ return Math.round(text.length * 7.6) + 56 + extra;
38
45
  }
39
46
 
40
47
  /** Is a range bounded at either end? */
@@ -56,20 +63,40 @@ export function pillText<T extends Record<string, unknown>>(field: OverflowField
56
63
  export const RESERVED = {
57
64
  /** The search box never shrinks below this. */
58
65
  search: 200,
59
- /** The "N de N" counter. */
60
- counter: 96,
61
- /** Exibir + Exportar WITH their text labels. */
62
- right: 216,
63
66
  /**
64
- * The same two controls as icons only — step 2 of the ladder.
67
+ * The "N de N" counter.
68
+ *
69
+ * MEASURED at 35px, plus its divider and gap. The old 96 was priced for a
70
+ * counter reading "1.234 de 5.678"; a phone shows "3 de 3", and charging
71
+ * nearly triple its rendered width came straight out of the filter budget.
72
+ */
73
+ counter: 56,
74
+ /**
75
+ * ONE right-hand control (Exibir, or Exportar) WITH its text label.
76
+ *
77
+ * Priced per control rather than for the pair, because the pair is not what
78
+ * every page renders: "Exportar" exists only when the host passes an
79
+ * `exportConfig`, and several pages (Pedidos among them) put their export in
80
+ * the PAGE HEADER instead. Charging 216 there billed the filters for a button
81
+ * that was never on the bar — ~108px of budget spent on nothing, which on a
82
+ * phone is the difference between one filter pill and none.
83
+ */
84
+ displayControl: 108,
85
+ /**
86
+ * The same control as an icon only — step 2 of the ladder.
87
+ *
88
+ * MEASURED at 133px for the PAIR (66 each: a Button's minimum tap target is
89
+ * a good deal wider than the 20px glyph inside it); 70 keeps that total's
90
+ * slack at one control's granularity.
91
+ */
92
+ displayControlCompact: 70,
93
+ /**
94
+ * What step 4 leaves behind: the search as a bare magnifier.
65
95
  *
66
- * MEASURED at 133px for the pair (66 each: a Button's minimum tap target is
67
- * a good deal wider than the 20px glyph inside it), so the old 96 under-priced
68
- * them by a third and the row went on believing it had room it did not.
96
+ * MEASURED at 30px on the Pedidos bar (`…-search-all-collapsed`); 38 keeps a
97
+ * tap target's worth of slack over it.
69
98
  */
70
- rightCompact: 140,
71
- /** What step 4 leaves behind: the search as a bare magnifier. */
72
- searchIcon: 44,
99
+ searchIcon: 38,
73
100
  /**
74
101
  * The narrowest keyword box still worth typing into — below it the operator
75
102
  * cannot read back what they wrote, and the box takes over the cluster
@@ -81,8 +108,17 @@ export const RESERVED = {
81
108
  * while keeping its filters — paying for nothing.
82
109
  */
83
110
  usableSearch: 120,
84
- /** The "Mais" button, only charged when there IS an overflow. */
85
- overflowButton: 104,
111
+ /**
112
+ * The "Mais" button, only charged when there IS an overflow — and priced by
113
+ * the state it is actually in, like the right-hand cluster beside it.
114
+ *
115
+ * MEASURED: 128px with its label, badge and chevron; 69px once the ladder has
116
+ * it down to icon + badge. One number for both was wrong in BOTH directions —
117
+ * it under-priced the wide bar (the row's own failure mode) and over-priced
118
+ * the narrow one by a whole pill.
119
+ */
120
+ overflowButton: 136,
121
+ overflowButtonCompact: 80,
86
122
  /**
87
123
  * "Limpar", only charged when there IS something to clear.
88
124
  *
@@ -92,9 +128,44 @@ export const RESERVED = {
92
128
  * Measured at 64 as an icon; 72 with slack.
93
129
  */
94
130
  clearAll: 72,
95
- /** Gaps + the row's own padding. */
96
- chrome: 64,
131
+ /**
132
+ * The row's own padding, plus the gaps BETWEEN the fixed furniture.
133
+ *
134
+ * Not the per-control gaps — `splitFilters` already adds a `GAP` for every
135
+ * filter it keeps, so counting a fourth one here charged one gap twice.
136
+ * MEASURED: 32px of padding (16 each side) + three 8px gaps between the
137
+ * search, the counter and the right cluster.
138
+ */
139
+ chrome: 56,
97
140
  } as const;
98
141
 
99
142
  /** The gap between two controls. */
100
143
  export const GAP = 8;
144
+
145
+ /** What the right-hand cluster costs, given what the host actually renders. */
146
+ export function rightClusterCost(hasExport: boolean, compact: boolean): number {
147
+ const one = compact ? RESERVED.displayControlCompact : RESERVED.displayControl;
148
+ return hasExport ? one * 2 : one;
149
+ }
150
+
151
+ /**
152
+ * What the toolbar furniture costs at a given rung of the ladder — the budget
153
+ * the filter controls have to fit AROUND.
154
+ *
155
+ * This exists because the two halves used to disagree. `splitFilters` priced
156
+ * the furniture at its widest (search 200 + counter 96 + right 216) and shed
157
+ * every filter to make room; the ladder then collapsed those very controls to
158
+ * 44 + 0 + 140 and nobody handed the ~330px back. On a 428px phone that showed
159
+ * as a bar carrying a magnifier, a "Mais 5" pill, and a band of nothing.
160
+ */
161
+ export function furnitureCost(
162
+ flags: { searchCollapsed: boolean; counterHidden: boolean; compactControls: boolean },
163
+ hasExport: boolean,
164
+ ): number {
165
+ return (
166
+ (flags.searchCollapsed ? RESERVED.searchIcon : RESERVED.search) +
167
+ (flags.counterHidden ? 0 : RESERVED.counter) +
168
+ rightClusterCost(hasExport, flags.compactControls) +
169
+ RESERVED.chrome
170
+ );
171
+ }
@@ -2,7 +2,15 @@
2
2
 
3
3
  import { useEffect, useRef, useState } from "react";
4
4
 
5
- import { estimateWidth, GAP, isRangeSet, pillText, RESERVED } from "./data-views-overflow-costs";
5
+ import {
6
+ estimateWidth,
7
+ furnitureCost,
8
+ GAP,
9
+ isRangeSet,
10
+ pillText,
11
+ RESERVED,
12
+ rightClusterCost,
13
+ } from "./data-views-overflow-costs";
6
14
  import type { FilterFieldConfig, RangeFieldConfig, RangeValue } from "./data-views-types";
7
15
 
8
16
  /**
@@ -154,6 +162,10 @@ function splitFilters<T extends Record<string, unknown>>(
154
162
  pills: Record<string, string[]>,
155
163
  ranges: Record<string, RangeValue>,
156
164
  width: number,
165
+ /** Search + counter + right cluster + chrome, at the rung being considered. */
166
+ furniture: number,
167
+ /** Is the bar already down to icons? "Mais" shrinks with everything else. */
168
+ compact: boolean,
157
169
  ): Split<T> {
158
170
  const none: OverflowField<T>[] = [];
159
171
  const widthOf = (field: OverflowField<T>): number => fieldWidth(field, pills, ranges);
@@ -164,14 +176,15 @@ function splitFilters<T extends Record<string, unknown>>(
164
176
 
165
177
  // "Limpar" rides the end of the cluster whenever anything is applied.
166
178
  const clearCost = active.length > 0 ? RESERVED.clearAll + GAP : 0;
167
- let available =
168
- width - RESERVED.search - RESERVED.counter - RESERVED.right - RESERVED.chrome - clearCost;
179
+ let available = width - furniture - clearCost;
169
180
  if (cost(all) <= available) {
170
181
  return { inline: all, overflow: none, used: cost(all) + clearCost };
171
182
  }
172
183
 
173
- // There IS an overflow, so its button now costs room too.
174
- available -= RESERVED.overflowButton;
184
+ // There IS an overflow, so its button now costs room too — at the width it
185
+ // will actually have, which on a phone is the icon-and-badge one.
186
+ const overflowButton = compact ? RESERVED.overflowButtonCompact : RESERVED.overflowButton;
187
+ available -= overflowButton;
175
188
  // APPLIED FIRST, BUT NOT EXEMPT.
176
189
  //
177
190
  // Applied controls take the visible slots ahead of idle ones — that part was
@@ -198,7 +211,7 @@ function splitFilters<T extends Record<string, unknown>>(
198
211
  return {
199
212
  inline,
200
213
  overflow: all.filter((field) => !keep.has(field.id)),
201
- used: cost(inline) + RESERVED.overflowButton + clearCost,
214
+ used: cost(inline) + overflowButton + clearCost,
202
215
  };
203
216
  }
204
217
 
@@ -224,6 +237,8 @@ function computeSplit<T extends Record<string, unknown>>(
224
237
  pills: Record<string, string[]>,
225
238
  ranges: Record<string, RangeValue>,
226
239
  width: number,
240
+ /** Does the host render "Exportar" beside "Exibir"? Half the cluster if not. */
241
+ hasExport: boolean,
227
242
  ): Omit<OverflowSplit<T>, "barRef"> {
228
243
  // Unmeasured (SSR, or jsdom without a ResizeObserver) ⇒ degrade nothing.
229
244
  if (width === 0) {
@@ -237,12 +252,47 @@ function computeSplit<T extends Record<string, unknown>>(
237
252
  searchTakeover: false,
238
253
  };
239
254
  }
240
- const { inline, overflow, used } = splitFilters(all, pills, ranges, width);
255
+ // Pass 1 prices the furniture at its widest — no rung has been taken yet.
256
+ let split = splitFilters(all, pills, ranges, width, furnitureCost(UNCOLLAPSED, hasExport), false);
257
+ let flags = ladderFlags(split, width, hasExport);
258
+ // RE-SPEND WHAT THE LATER RUNGS FREED.
259
+ //
260
+ // Rungs 2/4/5 turn 200 + 96 + 216 of furniture into 44 + 0 + 140, and until
261
+ // this loop existed nobody handed those ~330px back to the filters: the
262
+ // split was decided once, against the uncollapsed row, so a phone shed every
263
+ // control and then sat with a band of empty bar beside "Mais 5".
264
+ //
265
+ // Settles rather than oscillates, and cannot overfill: a cheaper furniture
266
+ // can only give the filters MORE room, more filter width can only push the
267
+ // ladder FURTHER down, and a further rung can only make the furniture
268
+ // cheaper again — so the budget decreases monotonically and the loop exits
269
+ // the moment it stops moving. The bound is a backstop, not the exit.
270
+ let budget = furnitureCost(UNCOLLAPSED, hasExport);
271
+ for (let pass = 0; pass < 3; pass += 1) {
272
+ const freed = furnitureCost(flags, hasExport);
273
+ if (freed >= budget) break;
274
+ budget = freed;
275
+ split = splitFilters(all, pills, ranges, width, budget, flags.compactControls);
276
+ flags = ladderFlags(split, width, hasExport);
277
+ }
278
+ return { inline: split.inline, overflow: split.overflow, ...flags };
279
+ }
280
+
281
+ /** The widest the furniture ever is — every rung still untaken. */
282
+ const UNCOLLAPSED = { searchCollapsed: false, counterHidden: false, compactControls: false };
283
+
284
+ /** Rungs 2 and 4-6, priced against what the filters actually took. */
285
+ function ladderFlags<T extends Record<string, unknown>>(
286
+ split: Split<T>,
287
+ width: number,
288
+ hasExport: boolean,
289
+ ): Omit<OverflowSplit<T>, "barRef" | "inline" | "overflow"> {
290
+ const { overflow, used } = split;
241
291
  const base = width - used - RESERVED.chrome;
242
292
 
243
293
  // Would the search still make its minimum with the labels on?
244
- const compactControls = base - RESERVED.counter - RESERVED.right < RESERVED.search;
245
- const rightCost = compactControls ? RESERVED.rightCompact : RESERVED.right;
294
+ const compactControls = base - RESERVED.counter - rightClusterCost(hasExport, false) < RESERVED.search;
295
+ const rightCost = rightClusterCost(hasExport, compactControls);
246
296
  // …and with them off?
247
297
  const searchCollapsed = base - RESERVED.counter - rightCost < RESERVED.search;
248
298
  // Everything has collapsed and the row STILL overflows. The controls cannot
@@ -258,26 +308,29 @@ function computeSplit<T extends Record<string, unknown>>(
258
308
  // on there BEING an overflow, because with no panel the footer does not
259
309
  // exist and dropping this would strand the operator.
260
310
  const clearAllHidden = counterHidden && overflow.length > 0;
261
- // What the keyword box would get if the operator expanded the magnifier and
262
- // everything else stayed put. `searchCollapsed` is a different question — it
263
- // asks whether the box fits at its PREFERRED 200px, and is deliberately
264
- // pessimistic — so reusing it to decide the takeover evicted the filters on
265
- // screens where the box had plenty of room to simply shrink.
266
- const searchBoxRoom = base - (counterHidden ? 0 : RESERVED.counter) - rightCost;
311
+ // What the keyword box would get if the operator expanded the magnifier.
312
+ // `searchCollapsed` is a different question — it asks whether the box fits at
313
+ // its PREFERRED 200px, and is deliberately pessimistic — so reusing it to
314
+ // decide the takeover evicted the filters on screens where the box had plenty
315
+ // of room to simply shrink.
316
+ //
317
+ // Asked with the inline filters SHED, because shedding them is exactly what
318
+ // expanding the box does: the row re-splits and they go behind "Mais".
319
+ // Measuring against the resting row instead made a filter argue for its own
320
+ // eviction — now that a large phone keeps one, its width came out of `base`,
321
+ // pushed the box under `usableSearch`, and tipped a row that had ample space
322
+ // to share into a full takeover.
323
+ const overflowButton = compactControls
324
+ ? RESERVED.overflowButtonCompact
325
+ : RESERVED.overflowButton;
326
+ const shedRoom = width - RESERVED.chrome - (overflow.length > 0 ? overflowButton : 0);
327
+ const searchBoxRoom = shedRoom - (counterHidden ? 0 : RESERVED.counter) - rightCost;
267
328
  // Below this a box is too narrow to read what you typed into it, and shrinking
268
329
  // further buys nothing; that is the only point at which taking the whole
269
330
  // cluster is worth losing the filters.
270
331
  const searchTakeover = searchCollapsed && searchBoxRoom < RESERVED.usableSearch;
271
332
 
272
- return {
273
- inline,
274
- overflow,
275
- compactControls,
276
- counterHidden,
277
- searchCollapsed,
278
- clearAllHidden,
279
- searchTakeover,
280
- };
333
+ return { compactControls, counterHidden, searchCollapsed, clearAllHidden, searchTakeover };
281
334
  }
282
335
 
283
336
  /**
@@ -307,12 +360,20 @@ export function useFilterOverflow<T extends Record<string, unknown>>(
307
360
  * that is mid-edit.
308
361
  */
309
362
  frozen = false,
363
+ /** Whether "Exportar" is on the bar — see `rightClusterCost`. */
364
+ hasExport = true,
310
365
  ): OverflowSplit<T> {
311
366
  const { barRef, width } = useBarWidth();
312
- const signature = JSON.stringify({ ids: all.map((field) => field.id), pills, ranges, width });
367
+ const signature = JSON.stringify({
368
+ ids: all.map((field) => field.id),
369
+ pills,
370
+ ranges,
371
+ width,
372
+ hasExport,
373
+ });
313
374
  const cache = useRef<{ signature: string; split: ReturnType<typeof computeSplit<T>> } | null>(null);
314
375
  if (cache.current === null || (!frozen && cache.current.signature !== signature)) {
315
- cache.current = { signature, split: computeSplit(all, pills, ranges, width) };
376
+ cache.current = { signature, split: computeSplit(all, pills, ranges, width, hasExport) };
316
377
  }
317
378
  return { ...cache.current.split, barRef };
318
379
  }
@@ -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 && (
@@ -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 />