@12-apps/ui 1.6.3 → 1.7.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 (32) hide show
  1. package/package.json +4 -4
  2. package/src/components/data-display/DataViews/DataViewsTableBase.tsx +1 -1
  3. package/src/components/data-display/DataViews/RESPONSIVE-BEHAVIOUR.md +183 -0
  4. package/src/components/data-display/DataViews/data-views-bar-controls.tsx +119 -0
  5. package/src/components/data-display/DataViews/data-views-display-panel.tsx +48 -22
  6. package/src/components/data-display/DataViews/data-views-display-tabs.tsx +64 -31
  7. package/src/components/data-display/DataViews/data-views-export.tsx +2 -1
  8. package/src/components/data-display/DataViews/data-views-glyphs.tsx +16 -0
  9. package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +5 -1
  10. package/src/components/data-display/DataViews/data-views-grid-parts.tsx +20 -14
  11. package/src/components/data-display/DataViews/data-views-inline-bar.tsx +134 -155
  12. package/src/components/data-display/DataViews/data-views-layout-context.tsx +21 -0
  13. package/src/components/data-display/DataViews/data-views-more-fields.tsx +291 -0
  14. package/src/components/data-display/DataViews/data-views-more-filters.tsx +151 -199
  15. package/src/components/data-display/DataViews/data-views-overflow-costs.ts +100 -0
  16. package/src/components/data-display/DataViews/data-views-overflow.ts +89 -73
  17. package/src/components/data-display/DataViews/data-views-preset-chips.tsx +100 -0
  18. package/src/components/data-display/DataViews/data-views-range-pill.tsx +27 -11
  19. package/src/components/data-display/DataViews/data-views-range-presets.ts +121 -0
  20. package/src/components/data-display/DataViews/data-views-range-types.ts +94 -0
  21. package/src/components/data-display/DataViews/data-views-scope-tabs.tsx +57 -0
  22. package/src/components/data-display/DataViews/data-views-scopes.tsx +2 -2
  23. package/src/components/data-display/DataViews/data-views-search.tsx +19 -1
  24. package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +4 -0
  25. package/src/components/data-display/DataViews/data-views-toolbar.tsx +31 -21
  26. package/src/components/data-display/DataViews/data-views-types.ts +14 -47
  27. package/src/components/data-display/DataViews/index.ts +1 -0
  28. package/src/components/data-display/DataViews/pedidos.server.ts +118 -0
  29. package/src/components/data-display/DataViews/use-data-views-state.ts +1 -1
  30. package/src/components/layout/ContentToolbar/ContentToolbar.tsx +16 -1
  31. package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +9 -1
  32. package/src/components/layout/ContentToolbar/MultiSelectTriggers.tsx +39 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/ui",
3
- "version": "1.6.3",
3
+ "version": "1.7.0",
4
4
  "license": "MIT",
5
5
  "packageManager": "pnpm@9.0.0",
6
6
  "type": "module",
@@ -194,7 +194,7 @@
194
194
  "react-dom": "^18.0.0 || ^19.0.0"
195
195
  },
196
196
  "dependencies": {
197
- "@12-apps/forms-core": "^1.5.3",
197
+ "@12-apps/forms-core": "^1.6.0",
198
198
  "@emotion/react": "^11.14.0",
199
199
  "@emotion/styled": "^11.14.0",
200
200
  "@monaco-editor/react": "^4.7.0",
@@ -209,8 +209,8 @@
209
209
  "recharts": "^3.5.1"
210
210
  },
211
211
  "devDependencies": {
212
- "@12-apps/eslint-config": "^1.6.3",
213
- "@12-apps/typescript-config": "^1.6.3",
212
+ "@12-apps/eslint-config": "^1.7.0",
213
+ "@12-apps/typescript-config": "^1.7.0",
214
214
  "@playwright/test": "^1.55.0",
215
215
  "@storybook/addon-docs": "^9.1.10",
216
216
  "@storybook/addon-links": "^9.1.10",
@@ -83,7 +83,7 @@ export interface DataViewsTableBaseProps<T extends Record<string, unknown>> {
83
83
  board?: BoardConfig<T>;
84
84
  /** Opt-in "Lista" layout — one full-width, entity-rendered row per record (FUT-733). */
85
85
  renderListRow?: (row: T, selection: DataViewCardSelection) => React.ReactNode;
86
- /** The page-level partition rendered as tabs above the toolbar (server mode only). */
86
+ /** The page-level partition rendered as tabs under the toolbar (server mode only). */
87
87
  scopes?: ScopeConfig[];
88
88
  /** The row field the scopes partition by — used only to reject a pill over the same field. */
89
89
  scopeFieldId?: string;
@@ -0,0 +1,183 @@
1
+ # The DataViews toolbar, at every width
2
+
3
+ The toolbar degrades through a **measured ladder**, not breakpoints. Nothing in
4
+ `data-views-overflow.ts` reads a media query: a `ResizeObserver` reports the
5
+ toolbar row's width, every control is priced, and the ladder sheds one rung at a
6
+ time until the row fits.
7
+
8
+ That is deliberate and worth restating, because it is what makes this document
9
+ approximate. Pages declare different numbers of filters with different label
10
+ lengths, and the same page collapses at a different width in another language. A
11
+ shared breakpoint is wrong for at least one table by construction.
12
+
13
+ **So the widths below are the ones the tests pin, not thresholds in the code.**
14
+ They are named for the device class they stand in for, and the behaviour column
15
+ is what the six-control Pedidos table does there. A two-filter table degrades
16
+ later; an eight-filter one degrades sooner. Both are correct.
17
+
18
+ | class | width | stands for |
19
+ | --- | --- | --- |
20
+ | small mobile | 320 | iPhone SE, the floor we support |
21
+ | large mobile | 430 | iPhone Pro Max |
22
+ | tablet | 768 | iPad portrait |
23
+ | small desktop | 1280 | laptop |
24
+ | large desktop | 1600 | external monitor |
25
+
26
+ **These are toolbar ROW widths, not viewport widths.** The `ResizeObserver`
27
+ watches the row, and the row is narrower than the window by whatever the page
28
+ puts around it — around 48px in the Pedidos shell. So a 430px row is roughly a
29
+ 478px phone, and the same page at a 430px *viewport* sits one rung further down
30
+ than this table shows. The tests drive the row directly, which is the only
31
+ number the ladder ever sees.
32
+
33
+ ---
34
+
35
+ ## The one rule that outranks the rest
36
+
37
+ **The toolbar is one line and never scrolls sideways.** Not the row, not the
38
+ filter cluster, not the "Mais" panel, not the document. At any width, with any
39
+ combination of filters applied, with the search open or closed.
40
+
41
+ Everything below exists to keep that true. When a change breaks it, the fix is
42
+ another rung on the ladder — never a scrollbar, and never letting controls paint
43
+ outside the toolbar.
44
+
45
+ ---
46
+
47
+ ## The ladder
48
+
49
+ Cheapest loss first. Each rung is taken only if the one before it did not free
50
+ enough room.
51
+
52
+ | # | rung | flag | what goes |
53
+ | --- | --- | --- | --- |
54
+ | 1 | filter controls move into "Mais" | `inline` / `overflow` | one control at a time, idle ones first |
55
+ | 2 | Exibir / Exportar drop their labels | `compactControls` | the text, and the dropdown chevron with it |
56
+ | 3 | the search box shrinks | — | none; it is `flex: 1` and CSS does it |
57
+ | 4 | the search collapses to a magnifier | `searchCollapsed` | the box |
58
+ | 5 | the "N de N" counter is dropped | `counterHidden` | the count |
59
+ | 6 | "Limpar" leaves the bar | `clearAllHidden` | the button — it survives in the panel footer |
60
+
61
+ Rung order is not arbitrary. **A control leaves the bar early if it has somewhere
62
+ else to be, and late if it does not.** "Limpar" goes before the magnifier because
63
+ the "Mais" panel footer still carries "Limpar todos os filtros"; the magnifier
64
+ and "Mais" go last because nothing else on screen can stand in for them.
65
+
66
+ The counter goes before the search because it is the only thing left that
67
+ *reports* rather than *does* — an operator can act without knowing the total,
68
+ but not without a way to search.
69
+
70
+ ---
71
+
72
+ ## Behaviour by class
73
+
74
+ Read this as "what the Pedidos table does", not "what the code hard-codes".
75
+
76
+ ### Filters
77
+
78
+ | | small mobile | large mobile | tablet | small desktop | large desktop |
79
+ | --- | --- | --- | --- | --- | --- |
80
+ | controls on the bar | 0 | 0 | 0 | 1–2 | 3+ |
81
+ | "Mais" present | yes | yes | yes | yes | only if something overflowed |
82
+
83
+ **An applied filter is ranked first, not exempt.** Applied controls take the
84
+ visible slots ahead of idle ones and go into "Mais" like anything else when even
85
+ those run out. The earlier rule exempted them outright, and on a phone four
86
+ applied pills then claimed more width than the row had: the bar painted past its
87
+ own edge and the pills were reachable only by scrolling it sideways.
88
+
89
+ Hiding one is safe *because the badge says so* — see below. A control scrolled
90
+ off-screen carries no signal at all, which is the failure the exemption was
91
+ written to prevent and did not.
92
+
93
+ ### The "Mais" badge
94
+
95
+ | state | badge shows | tone |
96
+ | --- | --- | --- |
97
+ | overflowed fields, none applied | count of hidden **fields** | neutral |
98
+ | any overflowed field applied | count of **applied** ones | filled, primary |
99
+
100
+ A neutral "3" would make *three filters you have not used* and *three filters
101
+ narrowing this list* look identical. On a phone, where every filter is in there,
102
+ that badge is the only thing on screen saying the list is filtered at all.
103
+
104
+ ### Search
105
+
106
+ | | small mobile | large mobile | tablet | small desktop | large desktop |
107
+ | --- | --- | --- | --- | --- | --- |
108
+ | resting state | magnifier | magnifier | box | box | box |
109
+ | on expanding the magnifier | **takes over** the cluster | shares the row | — | — | — |
110
+
111
+ The share/takeover boundary sits inside the "large mobile" band and moves with
112
+ the table: it is a comparison against what the box would actually be left with,
113
+ so a page with four short filter labels shares where Pedidos, with six longer
114
+ ones, takes over. Measured on the real Pedidos screen, takeover begins at a
115
+ 500px viewport and sharing resumes at 600px.
116
+
117
+ Two distinct behaviours, and conflating them was a bug:
118
+
119
+ - **Shrink** (`fill`) — an expanded box drops its usual 200px floor and takes
120
+ whatever the cluster has. That floor is right for a box that lives on the row
121
+ permanently; it is wrong for one expanded into a cluster the ladder sized for
122
+ an icon, where insisting on it made the row scroll (154px of overhang at
123
+ 320px, 84px at 390px, 42px at 500px).
124
+ - **Takeover** (`searchTakeover`) — the filters stand down and the box owns the
125
+ cluster, with a ✕ to leave. Only where shrinking has run out of road and what
126
+ is left would be too narrow to read back. On a large phone there is room to
127
+ share, and evicting the filters there cost the operator their filters for
128
+ nothing.
129
+
130
+ ### The right-hand controls
131
+
132
+ | | small mobile | large mobile | tablet | small desktop | large desktop |
133
+ | --- | --- | --- | --- | --- | --- |
134
+ | Exibir / Exportar | icon | icon | label | label | label |
135
+ | dropdown chevron | no | no | yes | yes | yes |
136
+ | "N de N" counter | hidden | hidden | shown | shown | shown |
137
+
138
+ A tablet keeps the labels, which is worth stating because it is easy to assume
139
+ otherwise. With every filter already behind "Mais" the row has room to spare
140
+ there, and rung 2 is taken only when the search would otherwise fall below its
141
+ floor. The ladder spends the width it has rather than degrading on a schedule —
142
+ that is the whole point of measuring.
143
+
144
+ The chevron goes with the label. A bare icon already reads as a button, and the
145
+ ~24px each buys is the difference between a row that fits and one that scrolls.
146
+ It is dropped from Exibir, Exportar **and** "Mais" together — one chevron left
147
+ among three identical buttons reads as a defect.
148
+
149
+ ### Clear all
150
+
151
+ | | small mobile | large mobile | tablet | small desktop | large desktop |
152
+ | --- | --- | --- | --- | --- | --- |
153
+ | on the bar | no | no | icon | icon | icon + "Limpar" |
154
+ | in the panel footer | yes | yes | yes | yes | yes |
155
+
156
+ Present only while something is applied — a dead button is worse than none — and
157
+ last in the cluster, because it is destructive and must never appear where a
158
+ filter control was a moment ago.
159
+
160
+ It is styled flat: no border, no fill, muted until hovered. Every other control
161
+ on the row is an outlined pill because it *opens* something; this one is an
162
+ escape hatch, and giving it the same weight made it read as a sixth filter.
163
+
164
+ ### Scope tabs
165
+
166
+ Below the toolbar, above the rows they narrow. Hidden entirely when the board
167
+ layout groups by the same field the scopes partition by — a column per situação
168
+ and a tab per situação are the same partition offered twice, and picking one tab
169
+ leaves the board with a single populated column, which reads as data loss.
170
+
171
+ ---
172
+
173
+ ## Where this is tested
174
+
175
+ `__tests__/data-views-responsive.test.tsx` drives a fake `ResizeObserver` at each
176
+ of the five widths and asserts the tables above. It is the executable copy of
177
+ this document; if the two disagree, the test is right and this needs updating.
178
+
179
+ Two things it cannot check, because jsdom has no layout engine: actual pixel
180
+ overflow, and how anything looks. Those are verified in a real browser against
181
+ the running Storybook, at 1600 / 1280 / 1024 / 900 / 768 / 600 / 500 / 430 / 390
182
+ / 360 / 320, measured on the toolbar row, the filter cluster's own scroll box,
183
+ and the document.
@@ -0,0 +1,119 @@
1
+ "use client";
2
+
3
+ /**
4
+ * THE TWO BARE AFFORDANCES ON THE FILTER BAR.
5
+ *
6
+ * Neither opens anything, which is what sets them apart from every other
7
+ * control on that row and why they are styled flat rather than as outlined
8
+ * pills: one clears the filters, one leaves a search takeover. Giving them a
9
+ * pill's weight made "Limpar" read as a sixth filter.
10
+ *
11
+ * Their own module because `data-views-inline-bar` outgrew the file-size gate.
12
+ */
13
+ import CloseRoundedIcon from "@mui/icons-material/CloseRounded";
14
+ import FilterAltOffRoundedIcon from "@mui/icons-material/FilterAltOffRounded";
15
+ import { Tooltip } from "@mui/material";
16
+
17
+ import { Box } from "../../../mui/Box";
18
+
19
+ /**
20
+ * "Limpar" — one gesture back to the unfiltered list.
21
+ *
22
+ * Present ONLY while something is applied, and last in the cluster, because it
23
+ * is destructive and must never be where a filter control was a moment ago.
24
+ * Each pill already carries its own ✕; this is the other job — six applied
25
+ * filters is six ✕ hunts, and the operator who wants "show me everything
26
+ * again" has no way to say it.
27
+ *
28
+ * Loses its label on the same rung Exibir/Exportar lose theirs, and keeps a
29
+ * tooltip when it does: it is one of the two controls (with the search) whose
30
+ * icon has to carry the whole meaning on a phone.
31
+ */
32
+ export function ClearAllControl({
33
+ onClearAll,
34
+ compact,
35
+ testIdPrefix,
36
+ }: {
37
+ onClearAll: () => void;
38
+ compact: boolean;
39
+ testIdPrefix: string;
40
+ }): React.JSX.Element {
41
+ // No border, no fill. Every other control on this row is an outlined pill
42
+ // because it OPENS something; this one is an escape hatch, and giving it the
43
+ // same weight made it read as a sixth filter. Quiet until hovered.
44
+ const button = (
45
+ <Box
46
+ component="button"
47
+ type="button"
48
+ onClick={onClearAll}
49
+ data-testid={`${testIdPrefix}-clear-all`}
50
+ aria-label="Limpar filtros"
51
+ sx={{
52
+ display: "inline-flex",
53
+ alignItems: "center",
54
+ gap: 0.5,
55
+ height: 34,
56
+ px: compact ? 0.75 : 1,
57
+ border: 0,
58
+ borderRadius: 1,
59
+ bgcolor: "transparent",
60
+ cursor: "pointer",
61
+ font: "inherit",
62
+ fontSize: "0.8125rem",
63
+ whiteSpace: "nowrap",
64
+ color: "text.secondary",
65
+ "&:hover": { color: "text.primary", bgcolor: "action.hover" },
66
+ }}
67
+ >
68
+ <FilterAltOffRoundedIcon sx={{ fontSize: 16 }} />
69
+ {!compact && <Box component="span">Limpar</Box>}
70
+ </Box>
71
+ );
72
+ return compact ? <Tooltip title="Limpar filtros">{button}</Tooltip> : button;
73
+ }
74
+
75
+ /**
76
+ * The way out of a search takeover.
77
+ *
78
+ * A phone has no Escape key, and without this the takeover is a one-way door
79
+ * onto a bar with no filters on it.
80
+
81
+ /**
82
+ * The way out of a search takeover.
83
+ *
84
+ * A phone has no Escape key, and without this the takeover is a one-way door
85
+ * onto a bar with no filters on it.
86
+ */
87
+ export function CloseSearchControl({
88
+ onClose,
89
+ testIdPrefix,
90
+ }: {
91
+ onClose: () => void;
92
+ testIdPrefix: string;
93
+ }): React.JSX.Element {
94
+ return (
95
+ <Tooltip title="Fechar busca">
96
+ <Box
97
+ component="button"
98
+ type="button"
99
+ onClick={onClose}
100
+ aria-label="Fechar busca"
101
+ data-testid={`${testIdPrefix}-search-close`}
102
+ sx={{
103
+ display: "inline-flex",
104
+ alignItems: "center",
105
+ height: 34,
106
+ px: 0.75,
107
+ border: 0,
108
+ borderRadius: 1,
109
+ bgcolor: "transparent",
110
+ cursor: "pointer",
111
+ color: "text.secondary",
112
+ "&:hover": { color: "text.primary", bgcolor: "action.hover" },
113
+ }}
114
+ >
115
+ <CloseRoundedIcon sx={{ fontSize: 18 }} />
116
+ </Box>
117
+ </Tooltip>
118
+ );
119
+ }
@@ -244,6 +244,48 @@ function ViewsBody({
244
244
  return <ViewsList handlers={nav} onOpenActions={onOpenActions} testIdPrefix={testIdPrefix} />;
245
245
  }
246
246
 
247
+ /** The "Exibir" button, with the dot that says the view has unsaved changes. */
248
+ function DisplayTrigger({
249
+ dirty,
250
+ compact,
251
+ onOpen,
252
+ testIdPrefix,
253
+ }: {
254
+ dirty: boolean;
255
+ compact: boolean;
256
+ onOpen: (anchor: HTMLElement) => void;
257
+ testIdPrefix: string;
258
+ }): React.JSX.Element {
259
+ return (
260
+ <Button
261
+ variant="outline"
262
+ size="sm"
263
+ color="neutral"
264
+ onClick={(event) => onOpen(event.currentTarget as HTMLElement)}
265
+ dataTestId={`${testIdPrefix}-display-trigger`}
266
+ aria-label="Exibir"
267
+ title={compact ? "Exibir" : undefined}
268
+ >
269
+ <Box component="span" sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}>
270
+ <TuneRoundedIcon fontSize="small" />
271
+ {!compact && (
272
+ <Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
273
+ Exibir
274
+ </Box>
275
+ )}
276
+ {dirty && (
277
+ <Box component="span" sx={{ height: 6, width: 6, borderRadius: "50%", bgcolor: "primary.main" }} />
278
+ )}
279
+ {/* The chevron goes with the label. Once the control is a bare icon it
280
+ is already unmistakably a button, and on the rung where that happens
281
+ the ~24px it costs is the difference between a row that fits and one
282
+ that scrolls. */}
283
+ {!compact && <KeyboardArrowDownRoundedIcon fontSize="small" />}
284
+ </Box>
285
+ </Button>
286
+ );
287
+ }
288
+
247
289
  /**
248
290
  * The toolbar's "Exibir" control: one button, one popover, three tabs, and the
249
291
  * view bracketing all of it.
@@ -271,28 +313,12 @@ export function DataViewsDisplayPanel<T extends Record<string, unknown>>({
271
313
  };
272
314
  return (
273
315
  <>
274
- <Button
275
- variant="outline"
276
- size="sm"
277
- color="neutral"
278
- onClick={(event) => setAnchor(event.currentTarget as HTMLElement)}
279
- dataTestId={`${testIdPrefix}-display-trigger`}
280
- aria-label="Exibir"
281
- title={compact ? "Exibir" : undefined}
282
- >
283
- <Box component="span" sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}>
284
- <TuneRoundedIcon fontSize="small" />
285
- {!compact && (
286
- <Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
287
- Exibir
288
- </Box>
289
- )}
290
- {view?.dirty && (
291
- <Box component="span" sx={{ height: 6, width: 6, borderRadius: "50%", bgcolor: "primary.main" }} />
292
- )}
293
- <KeyboardArrowDownRoundedIcon fontSize="small" />
294
- </Box>
295
- </Button>
316
+ <DisplayTrigger
317
+ dirty={Boolean(view?.dirty)}
318
+ compact={Boolean(compact)}
319
+ onOpen={setAnchor}
320
+ testIdPrefix={testIdPrefix}
321
+ />
296
322
  <Popover
297
323
  open={Boolean(anchor)}
298
324
  anchorEl={anchor}
@@ -9,6 +9,7 @@ import { Text } from "../../typography/Text";
9
9
 
10
10
  import {
11
11
  BoardGlyph,
12
+ ColumnsGlyph,
12
13
  GridGlyph,
13
14
  ListGlyph,
14
15
  RowsGlyph,
@@ -219,15 +220,52 @@ const LAYOUT_HINTS: Record<DataViewsLayout, string> = {
219
220
  board: "Colunas por etapa, para acompanhar o que está onde.",
220
221
  };
221
222
 
223
+ /**
224
+ * What density MEANS in each layout — same three values, three different
225
+ * questions. A table's is how tall its rows are, a card grid's is how many fit
226
+ * on a line, and a board's is how wide its columns are. The heading and the
227
+ * tile labels change with the layout because "Alta" over a board is meaningless
228
+ * where "Larga" is not.
229
+ */
230
+ const DENSITY_HEADINGS: Record<DataViewsLayout, string> = {
231
+ table: "Altura das linhas",
232
+ list: "Altura das linhas",
233
+ cards: "Cards por linha",
234
+ board: "Largura das colunas",
235
+ };
236
+
237
+ const DENSITY_LABELS: Record<DataViewsLayout, Record<DataViewsDensity, string>> = {
238
+ table: { compact: "Baixa", cozy: "Média", comfortable: "Alta" },
239
+ list: { compact: "Baixa", cozy: "Média", comfortable: "Alta" },
240
+ cards: { compact: "Muitos", cozy: "Médio", comfortable: "Poucos" },
241
+ board: { compact: "Estreita", cozy: "Média", comfortable: "Larga" },
242
+ };
243
+
222
244
  /** The density tiles, phrased for whichever layout is on screen. */
223
- function densityTiles(cards: boolean): { value: DataViewsDensity; label: string; gap: number; columns: number }[] {
245
+ function densityTiles(
246
+ layout: DataViewsLayout,
247
+ ): { value: DataViewsDensity; label: string; gap: number; columns: number }[] {
248
+ const labels = DENSITY_LABELS[layout];
224
249
  return [
225
- { value: "compact", label: cards ? "Muitos" : "Baixa", gap: 2, columns: 4 },
226
- { value: "cozy", label: cards ? "Médio" : "Média", gap: 5, columns: 3 },
227
- { value: "comfortable", label: cards ? "Poucos" : "Alta", gap: 8, columns: 2 },
250
+ { value: "compact", label: labels.compact, gap: 2, columns: 4 },
251
+ { value: "cozy", label: labels.cozy, gap: 5, columns: 3 },
252
+ { value: "comfortable", label: labels.comfortable, gap: 8, columns: 2 },
228
253
  ];
229
254
  }
230
255
 
256
+ /** The preview for one density tile, in the terms that layout's density is in. */
257
+ function densityGlyph(
258
+ layout: DataViewsLayout,
259
+ tile: { gap: number; columns: number },
260
+ active: boolean,
261
+ ): React.JSX.Element {
262
+ if (layout === "cards") return <GridGlyph n={tile.columns} active={active} />;
263
+ // A wider column means FEWER of them across the same board, so the
264
+ // comfortable end draws two fat columns and the compact end four thin ones.
265
+ if (layout === "board") return <ColumnsGlyph n={tile.columns} active={active} />;
266
+ return <RowsGlyph gap={tile.gap} active={active} />;
267
+ }
268
+
231
269
  /** The glyph for a layout tile. */
232
270
  function layoutGlyph(layout: DataViewsLayout, active: boolean): React.JSX.Element {
233
271
  if (layout === "table") return <TableGlyph active={active} />;
@@ -257,7 +295,6 @@ export function DisplayTab({ testIdPrefix }: { testIdPrefix: string }): React.JS
257
295
  ...(canUseCards ? (["cards"] as const) : []),
258
296
  ...(canUseBoard ? (["board"] as const) : []),
259
297
  ];
260
- const cards = layout === "cards";
261
298
  return (
262
299
  <Box sx={{ p: 1 }}>
263
300
  <Text variant="caption" as="p">
@@ -290,32 +327,28 @@ export function DisplayTab({ testIdPrefix }: { testIdPrefix: string }): React.JS
290
327
  {LAYOUT_HINTS[layout]}
291
328
  </Box>
292
329
  </Text>
293
- {layout !== "board" && (
294
- <>
295
- <Text variant="caption" as="p">
296
- <Box component="span" sx={{ px: 0.5, color: "text.secondary" }}>
297
- {cards ? "Cards por linha" : "Altura das linhas"}
298
- </Box>
299
- </Text>
300
- <Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 0.75, mt: 0.5 }}>
301
- {densityTiles(cards).map((tile) => (
302
- <Tile
303
- key={tile.value}
304
- active={density === tile.value}
305
- label={tile.label}
306
- onClick={() => setDensity(tile.value)}
307
- testId={`${testIdPrefix}-density-${tile.value}`}
308
- >
309
- {cards ? (
310
- <GridGlyph n={tile.columns} active={density === tile.value} />
311
- ) : (
312
- <RowsGlyph gap={tile.gap} active={density === tile.value} />
313
- )}
314
- </Tile>
315
- ))}
316
- </Box>
317
- </>
318
- )}
330
+ {/* Every layout gets this, the board included. It used to be hidden on
331
+ Quadro, which left the format tile switching to a view with no sizing
332
+ control at all — and the section disappearing on select reads as a
333
+ control that broke rather than one that does not apply. */}
334
+ <Text variant="caption" as="p">
335
+ <Box component="span" sx={{ px: 0.5, color: "text.secondary" }}>
336
+ {DENSITY_HEADINGS[layout]}
337
+ </Box>
338
+ </Text>
339
+ <Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 0.75, mt: 0.5 }}>
340
+ {densityTiles(layout).map((tile) => (
341
+ <Tile
342
+ key={tile.value}
343
+ active={density === tile.value}
344
+ label={tile.label}
345
+ onClick={() => setDensity(tile.value)}
346
+ testId={`${testIdPrefix}-density-${tile.value}`}
347
+ >
348
+ {densityGlyph(layout, tile, density === tile.value)}
349
+ </Tile>
350
+ ))}
351
+ </Box>
319
352
  {!canUseBoard && (
320
353
  <Text variant="caption" as="p">
321
354
  <Box component="span" sx={{ px: 0.5, pt: 1.25, display: "block", color: "text.disabled" }}>
@@ -116,7 +116,8 @@ function ExportTrigger({
116
116
  Exportar
117
117
  </Box>
118
118
  )}
119
- <KeyboardArrowDownRoundedIcon fontSize="small" />
119
+ {/* Dropped with the label — see the note on the Exibir trigger. */}
120
+ {!compact && <KeyboardArrowDownRoundedIcon fontSize="small" />}
120
121
  </Box>
121
122
  </Button>
122
123
  );
@@ -87,6 +87,22 @@ export function GridGlyph({ n = 3, active }: { n?: number; active: boolean }): R
87
87
  );
88
88
  }
89
89
 
90
+ /**
91
+ * Full-height columns, `n` of them — the board's DENSITY, where fewer, wider
92
+ * columns is the comfortable end. Distinct from {@link BoardGlyph}, which
93
+ * stands for the board format itself and shows cards stacked at differing
94
+ * depths; this one is about the columns' width, so the bars are solid.
95
+ */
96
+ export function ColumnsGlyph({ n, active }: { n: number; active: boolean }): React.JSX.Element {
97
+ return (
98
+ <Box sx={{ ...FRAME, gap: "2px" }}>
99
+ {Array.from({ length: n }).map((_, column) => (
100
+ <Box key={column} sx={{ flex: 1, borderRadius: 0.5, bgcolor: barColor(active) }} />
101
+ ))}
102
+ </Box>
103
+ );
104
+ }
105
+
90
106
  /** Three columns of decreasing height — the board. */
91
107
  export function BoardGlyph({ active }: { active: boolean }): React.JSX.Element {
92
108
  return (
@@ -6,6 +6,7 @@ import { Box } from "../../../mui/Box";
6
6
  import {
7
7
  cardMinWidthForZoom,
8
8
  cardScaleForZoom,
9
+ DENSITY_BOARD_SCALE,
9
10
  DENSITY_CARD_COLUMNS,
10
11
  DENSITY_ROW_PADDING,
11
12
  useDataViewsLayout,
@@ -297,7 +298,10 @@ export function GridMain<T extends Record<string, unknown>>({
297
298
  renderCard={renderCard}
298
299
  selectedIds={c.selectedIds}
299
300
  onToggleId={c.toggleId}
300
- cardScale={cardScaleForZoom(zoom)}
301
+ // DENSITY, not zoom: the board's own knob in the Exibição tab is how
302
+ // wide its columns are, and it is the only sizing control the board
303
+ // has since the zoom slider was removed.
304
+ cardScale={DENSITY_BOARD_SCALE[density]}
301
305
  dataTestId={dataTestId}
302
306
  />
303
307
  </Headerless>