@12-apps/ui 1.22.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/ui",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "license": "MIT",
5
5
  "packageManager": "pnpm@9.0.0",
6
6
  "type": "module",
@@ -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
@@ -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
  }