@12-apps/ui 1.6.1 → 1.6.3

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.6.1",
3
+ "version": "1.6.3",
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.1",
197
+ "@12-apps/forms-core": "^1.5.3",
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.1",
213
- "@12-apps/typescript-config": "^1.6.1",
212
+ "@12-apps/eslint-config": "^1.6.3",
213
+ "@12-apps/typescript-config": "^1.6.3",
214
214
  "@playwright/test": "^1.55.0",
215
215
  "@storybook/addon-docs": "^9.1.10",
216
216
  "@storybook/addon-links": "^9.1.10",
@@ -0,0 +1,133 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * A day bound as a MASKED `dd/mm/aaaa` text field, replacing `<input type="date">`.
5
+ *
6
+ * The native control is three separate segments wearing one box. Typing a date
7
+ * means filling a segment, being jumped to the next, and starting over — and
8
+ * which segment comes first is the BROWSER's locale, not ours, so the same
9
+ * field reads `dd/mm/aaaa` for one merchant and `mm/dd/yyyy` for the next with
10
+ * nothing in our code saying which. There is no way to type a date as one
11
+ * continuous run of digits, which is how people actually type dates.
12
+ *
13
+ * A mask gives that back: eight digits straight through, separators inserted as
14
+ * you go, backspace walking back through them.
15
+ *
16
+ * Measured, driving both controls in Chromium with the same eight keystrokes
17
+ * (`01072026`, one `keydown` at a time):
18
+ *
19
+ * native → field ends EMPTY, no filter applied, 0 requests
20
+ * masked → field reads `01/07/2026`, filter applied, 1 request
21
+ *
22
+ * The native result is the whole complaint: you type a date and nothing lands.
23
+ *
24
+ * The second half of the fix is WHEN the bound is written. A partially-typed
25
+ * year is itself a valid date — `2026` passes through `0002`, `0020`, `0202` on
26
+ * the way — so committing on every keystroke would send the grid off to fetch
27
+ * years nobody meant. A bound is written only once the text is a WHOLE, real
28
+ * date, which is what keeps the run above at one request rather than four.
29
+ */
30
+ import { TextField } from '@mui/material';
31
+ import React, { useEffect, useState } from 'react';
32
+
33
+ /** `AAAA-MM-DD` (the wire) → `dd/mm/aaaa` (what the merchant reads). */
34
+ export function isoToBr(iso: string): string {
35
+ const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
36
+ return parts ? `${parts[3]}/${parts[2]}/${parts[1]}` : '';
37
+ }
38
+
39
+ /**
40
+ * `dd/mm/aaaa` → `AAAA-MM-DD`, but ONLY for a day that exists. `31/02/2026`
41
+ * parses fine as three numbers and is not a date, and `Date` would silently
42
+ * roll it into March rather than reject it — so the round trip through UTC is
43
+ * the check: if the month or day comes back changed, the input was not a day.
44
+ */
45
+ export function brToIso(text: string): string {
46
+ const parts = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(text);
47
+ if (!parts) return '';
48
+ const [, dd, mm, yyyy] = parts;
49
+ const day = Number(dd);
50
+ const month = Number(mm);
51
+ const year = Number(yyyy);
52
+ if (year < 1000) return '';
53
+ const probe = new Date(Date.UTC(year, month - 1, day));
54
+ if (probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) return '';
55
+ return `${yyyy}-${mm}-${dd}`;
56
+ }
57
+
58
+ /**
59
+ * Digits only, capped at eight, with the separators put back as you type.
60
+ *
61
+ * Deriving the whole string from the digits — rather than editing the text in
62
+ * place — is what makes backspace behave. Four digits render as `06/08`, not
63
+ * `06/08/`, so deleting the first digit of the year removes the trailing
64
+ * separator with it instead of leaving a dead keystroke that changes nothing.
65
+ */
66
+ export function maskBrDate(raw: string): string {
67
+ const digits = raw.replace(/\D/g, '').slice(0, 8);
68
+ if (digits.length <= 2) return digits;
69
+ if (digits.length <= 4) return `${digits.slice(0, 2)}/${digits.slice(2)}`;
70
+ return `${digits.slice(0, 2)}/${digits.slice(2, 4)}/${digits.slice(4)}`;
71
+ }
72
+
73
+ /**
74
+ * One end of a day window. The BOUND stays `AAAA-MM-DD` — the form the backend's
75
+ * inclusive day comparison already speaks, and the form the chip label reads —
76
+ * so only the typing surface changes.
77
+ */
78
+ export function DayBoundInput({
79
+ label,
80
+ value,
81
+ onChange,
82
+ testId,
83
+ }: {
84
+ label: string;
85
+ /** The applied bound, `AAAA-MM-DD`, or undefined when this end is open. */
86
+ value: string | undefined;
87
+ onChange: (bound: string | undefined) => void;
88
+ testId: string;
89
+ }): React.JSX.Element {
90
+ const applied = value ?? '';
91
+ const [text, setText] = useState(() => isoToBr(applied));
92
+
93
+ // The bound is the APPLIED filter; `text` is what is being typed. Re-sync only
94
+ // when the applied value moved somewhere the text does not already mean — a
95
+ // "Limpar", a saved view, the other pill resetting the pair. Without that
96
+ // guard the field's own write bounces straight back mid-edit and resets the
97
+ // caret, which is the native control's worst habit reproduced by hand.
98
+ useEffect(() => {
99
+ setText((current) => (brToIso(current) === applied ? current : isoToBr(applied)));
100
+ }, [applied]);
101
+
102
+ return (
103
+ <TextField
104
+ size="small"
105
+ type="text"
106
+ label={label}
107
+ // The mask IS the placeholder, so the expected order is on screen before
108
+ // the first keystroke rather than discovered by getting it wrong.
109
+ placeholder="dd/mm/aaaa"
110
+ // A numeric keypad on a phone, while still accepting a paste with slashes.
111
+ inputMode="numeric"
112
+ value={text}
113
+ onChange={(event) => {
114
+ const next = maskBrDate(event.target.value);
115
+ setText(next);
116
+ // Emptying clears the bound. A COMPLETE day applies it. A partial one
117
+ // does NEITHER — it leaves whatever is applied alone, so typing is never
118
+ // fought and a half-typed year never reaches the backend.
119
+ if (next === '') return onChange(undefined);
120
+ const iso = brToIso(next);
121
+ if (iso) onChange(iso);
122
+ }}
123
+ // Leaving a half-typed (or impossible) date on screen would show a filter
124
+ // the list is not actually using. On blur the field snaps back to what IS
125
+ // applied; a complete date already equals it, so this only ever tidies.
126
+ onBlur={() => setText(isoToBr(applied))}
127
+ // The placeholder occupies the space an un-shrunk label would render into.
128
+ InputLabelProps={{ shrink: true }}
129
+ inputProps={{ 'data-testid': testId, maxLength: 10 }}
130
+ sx={{ width: 165 }}
131
+ />
132
+ );
133
+ }
@@ -0,0 +1,157 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * A numeric bound typed the way Brazilians write numbers, replacing
5
+ * `<input type="number">`.
6
+ *
7
+ * The native control parses in the BROWSER's locale, and it does not fail
8
+ * loudly when the text does not match — it drops what it cannot read and keeps
9
+ * the rest. Typing `50,00` into it, measured in Chromium:
10
+ *
11
+ * digitado "50,00" -> campo "5000" -> filtro R$ 5.000
12
+ * digitado "17,50" -> campo "1750" -> filtro R$ 1.750
13
+ * digitado "50.00" -> campo "500" -> filtro R$ 500
14
+ *
15
+ * The comma is deleted and the digits close ranks, so the merchant asks for
16
+ * fifty reais and gets a filter a HUNDRED times larger. The list empties, the
17
+ * chip reads `Valor: ≥ R$ 5000`, and nothing on screen connects that to the
18
+ * `50,00` they typed. It reads as "the value filter is broken", which is how it
19
+ * was reported.
20
+ *
21
+ * So the field parses pt-BR itself: `,` is the decimal separator and `.` groups
22
+ * thousands, which is what `R$ 1.234,56` means to the person reading it. A
23
+ * plain `50` still works, and so does `50.00` — a dot with exactly two digits
24
+ * after it is far more likely to be a decimal typed on a numeric keypad than a
25
+ * thousands group, and reading it as `5000` is the very failure this replaces.
26
+ */
27
+ import { Box, TextField } from '@mui/material';
28
+ import React, { useEffect, useState } from 'react';
29
+
30
+ /** Everything this field lets through: digits and the two separators. */
31
+ const ALLOWED = /[^\d.,]/g;
32
+
33
+ /**
34
+ * pt-BR text → number, or `undefined` when it is not one (yet).
35
+ *
36
+ * `.` is a thousands group and `,` is the decimal point, EXCEPT when a lone dot
37
+ * is followed by one or two digits and no comma is present — `50.00` and `1.5`
38
+ * are decimals somebody typed, while `1.234` is a thousand.
39
+ */
40
+ export function parsePtBrNumber(text: string): number | undefined {
41
+ const cleaned = text.replace(ALLOWED, '');
42
+ if (cleaned === '') return undefined;
43
+ const hasComma = cleaned.includes(',');
44
+ const dots = cleaned.split('.').length - 1;
45
+ const lastDot = cleaned.lastIndexOf('.');
46
+ const dotIsDecimal =
47
+ !hasComma && dots === 1 && cleaned.length - lastDot - 1 > 0 && cleaned.length - lastDot - 1 <= 2;
48
+ const normalized = dotIsDecimal
49
+ ? cleaned
50
+ : cleaned.replace(/\./g, '').replace(',', '.');
51
+ const amount = Number(normalized);
52
+ return Number.isFinite(amount) ? amount : undefined;
53
+ }
54
+
55
+ /**
56
+ * A stored bound → the text the merchant reads, with a comma for the decimal
57
+ * point.
58
+ *
59
+ * It takes a STRING too, and that is not defensive typing: a range restored
60
+ * from the URL arrives as one (`?totalCents_gte=5000`). Narrowing to `number`
61
+ * and dropping the rest emptied the field on every reload — and then the next
62
+ * keystroke wrote a bound built from nothing.
63
+ */
64
+ export function formatPtBrNumber(amount: number | string | undefined): string {
65
+ if (amount == null || amount === '') return '';
66
+ return String(amount).replace('.', ',');
67
+ }
68
+
69
+ /** The same, as a number — `undefined` when the bound is unset or unusable. */
70
+ function boundAsNumber(amount: number | string | undefined): number | undefined {
71
+ if (amount == null || amount === '') return undefined;
72
+ const parsed = typeof amount === 'number' ? amount : Number(amount);
73
+ return Number.isFinite(parsed) ? parsed : undefined;
74
+ }
75
+
76
+ /**
77
+ * One end of a numeric range. The BOUND stays a plain `number` — nothing
78
+ * downstream learns that the typing surface changed.
79
+ */
80
+ export function NumberBoundInput({
81
+ label,
82
+ value,
83
+ unit,
84
+ onChange,
85
+ testId,
86
+ }: {
87
+ label: string;
88
+ /** The applied bound. A range restored from the URL hands this over as a string. */
89
+ value: number | string | undefined;
90
+ /** Rendered as a start adornment ("R$"), not parsed out of the text. */
91
+ unit?: string;
92
+ onChange: (bound: number | undefined) => void;
93
+ testId: string;
94
+ }): React.JSX.Element {
95
+ const applied = boundAsNumber(value);
96
+ const [text, setText] = useState(() => formatPtBrNumber(value));
97
+
98
+ // Re-sync only when the applied bound moved somewhere the text does not
99
+ // already mean — a "Limpar", a saved view. Comparing through the PARSE rather
100
+ // than the string keeps `50,` and `50,00` from being rewritten mid-edit.
101
+ useEffect(() => {
102
+ setText((current) => (parsePtBrNumber(current) === applied ? current : formatPtBrNumber(applied)));
103
+ }, [applied]);
104
+
105
+ return (
106
+ <TextField
107
+ size="small"
108
+ type="text"
109
+ 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
+ value={text}
114
+ onChange={(event) => {
115
+ // Reject the characters rather than silently dropping them AFTER
116
+ // parsing — a letter should not move the number.
117
+ const next = event.target.value.replace(ALLOWED, '');
118
+ setText(next);
119
+ const amount = parsePtBrNumber(next);
120
+ // A bare separator (`50,`) parses to nothing yet; leave the applied
121
+ // bound alone rather than clearing it half-way through a decimal.
122
+ if (next === '') return onChange(undefined);
123
+ if (amount !== undefined) onChange(amount);
124
+ }}
125
+ // Blur TIDIES, it does not rewrite. Text that already means the applied
126
+ // bound is left exactly as typed — `20`, `17,50` and `1.234,56` all stay
127
+ // — and only something that does not (`50,,`) snaps back.
128
+ //
129
+ // Padding the field to the chip's precision was tried and is worse than
130
+ // the mismatch it closed: settling `20` to `20,00` means the next edit
131
+ // starts from text the merchant did not write, so appending a digit to
132
+ // make it 200 produces `20,000`, which parses back to 20. The filter then
133
+ // ignores the keystroke, which is exactly the "não funciona" this whole
134
+ // control exists to end. An input must hold what was typed into it; the
135
+ // CHIP is where the applied window gets written in full.
136
+ onBlur={() =>
137
+ setText((current) =>
138
+ parsePtBrNumber(current) === applied ? current : formatPtBrNumber(applied),
139
+ )
140
+ }
141
+ InputLabelProps={{ shrink: true }}
142
+ inputProps={{ 'data-testid': testId }}
143
+ InputProps={
144
+ unit
145
+ ? {
146
+ startAdornment: (
147
+ <Box component="span" sx={{ mr: 0.5, color: 'text.secondary' }}>
148
+ {unit}
149
+ </Box>
150
+ ),
151
+ }
152
+ : undefined
153
+ }
154
+ sx={{ width: 140 }}
155
+ />
156
+ );
157
+ }
@@ -13,10 +13,12 @@
13
13
  */
14
14
  import CloseIcon from '@mui/icons-material/Close';
15
15
  import ChevronDownIcon from '@mui/icons-material/KeyboardArrowDown';
16
- import { Box, Button, Popover, TextField, Typography } from '@mui/material';
16
+ import { Box, Button, Popover, Typography } from '@mui/material';
17
17
  import React, { useState } from 'react';
18
18
 
19
19
  import { TableFilter } from '../../layout/TableFilter';
20
+ import { DayBoundInput } from './data-views-day-input';
21
+ import { NumberBoundInput } from './data-views-number-input';
20
22
  import {
21
23
  isRangeInverted,
22
24
  isRangeSet,
@@ -27,15 +29,12 @@ import type { RangeFieldConfig, RangeValue } from './data-views-types';
27
29
 
28
30
 
29
31
 
30
- /** A bound as the input's string value ('' when unset). */
31
- function inputValue(bound: number | string | undefined): string {
32
- return bound == null ? '' : String(bound);
33
- }
34
-
35
32
  /**
36
33
  * One end of the range. A day keeps its `AAAA-MM-DD` string verbatim (the form
37
- * both the native picker and the backend speak); a number parses, and an
38
- * unparseable/blank entry CLEARS the bound rather than writing `NaN`.
34
+ * both the field and the backend speak) and is typed through the masked
35
+ * `dd/mm/aaaa` field rather than a native date input — see
36
+ * {@link DayBoundInput} for why. A number parses, and an unparseable/blank
37
+ * entry CLEARS the bound rather than writing `NaN`.
39
38
  */
40
39
  function BoundField<T extends Record<string, unknown>>({
41
40
  field,
@@ -50,30 +49,23 @@ function BoundField<T extends Record<string, unknown>>({
50
49
  onChange: (bound: number | string | undefined) => void;
51
50
  testId: string;
52
51
  }): React.JSX.Element {
53
- const isDay = field.kind === 'day';
52
+ if (field.kind === 'day') {
53
+ return (
54
+ <DayBoundInput
55
+ label={label}
56
+ value={value == null ? undefined : String(value)}
57
+ onChange={onChange}
58
+ testId={testId}
59
+ />
60
+ );
61
+ }
54
62
  return (
55
- <TextField
56
- size="small"
57
- type={isDay ? 'date' : 'number'}
63
+ <NumberBoundInput
58
64
  label={label}
59
- value={inputValue(value)}
60
- onChange={(event) => {
61
- const raw = event.target.value;
62
- if (raw === '') return onChange(undefined);
63
- if (isDay) return onChange(raw);
64
- const amount = Number(raw);
65
- return onChange(Number.isNaN(amount) ? undefined : amount);
66
- }}
67
- // A native date input ALWAYS paints its `dd/mm/aaaa` placeholder, and a
68
- // start adornment occupies the same space — so a label left to MUI's
69
- // default (shrink only once filled) renders ON TOP of it.
70
- InputLabelProps={{ shrink: true }}
71
- inputProps={{
72
- 'data-testid': testId,
73
- ...(isDay ? {} : { step: field.step ?? 1, min: 0 }),
74
- }}
75
- InputProps={!isDay && field.unit ? { startAdornment: <Box component="span" sx={{ mr: 0.5, color: 'text.secondary' }}>{field.unit}</Box> } : undefined}
76
- sx={{ width: isDay ? 165 : 140 }}
65
+ value={value}
66
+ unit={field.unit}
67
+ onChange={onChange}
68
+ testId={testId}
77
69
  />
78
70
  );
79
71
  }
@@ -338,7 +330,6 @@ export function PanelRangeField<T extends Record<string, unknown>>({
338
330
  <TableFilter.RangeField
339
331
  label={field.label}
340
332
  unit={field.unit}
341
- step={field.step}
342
333
  value={numericRange(value)}
343
334
  onChange={(range) => onChange(range)}
344
335
  testId={testId}
@@ -18,13 +18,44 @@ function shortDay(day: string): string {
18
18
  const [, month, date] = day.split('-');
19
19
  return month && date ? `${date}/${month}` : day;
20
20
  }
21
+ /**
22
+ * How many decimals a bound is WRITTEN with, taken from the field's own `step`.
23
+ *
24
+ * `step` is already the field's statement about its precision — money declares
25
+ * `0.01` — so nothing new has to be configured for a chip to know that R$ 17,50
26
+ * has two decimals and a quantity has none.
27
+ */
28
+ function decimalsFor(step: number | undefined): number {
29
+ if (!step || Number.isInteger(step)) return 0;
30
+ return String(step).split('.')[1]?.length ?? 0;
31
+ }
32
+
33
+ /**
34
+ * A numeric bound as the merchant writes it: decimal COMMA, thousands grouped.
35
+ *
36
+ * `String(17.5)` reads `17.5` — a decimal point in a currency that has none —
37
+ * and padding it to the field's own precision is what makes `R$ 17,50` instead
38
+ * of `R$ 17,5`. Shared with the input's blur so the chip and the field it was
39
+ * typed into can never disagree, which matters because the chip is the ONLY
40
+ * place the applied window is stated once the popover closes.
41
+ */
42
+ export function formatBoundNumber(amount: number, step?: number): string {
43
+ const decimals = decimalsFor(step);
44
+ return new Intl.NumberFormat('pt-BR', {
45
+ minimumFractionDigits: decimals,
46
+ maximumFractionDigits: Math.max(decimals, 2),
47
+ }).format(amount);
48
+ }
49
+
21
50
  /** One bound, rendered for the chip: money in reais, a day as `DD/MM`. */
22
51
  function boundLabel<T extends Record<string, unknown>>(
23
52
  field: RangeFieldConfig<T>,
24
53
  bound: number | string,
25
54
  ): string {
26
55
  if (field.kind === 'day') return shortDay(String(bound));
27
- return field.unit ? `${field.unit} ${bound}` : String(bound);
56
+ const amount = typeof bound === 'number' ? bound : Number(bound);
57
+ const text = Number.isFinite(amount) ? formatBoundNumber(amount, field.step) : String(bound);
58
+ return field.unit ? `${field.unit} ${text}` : text;
28
59
  }
29
60
  /**
30
61
  * The chip text for an applied range: "Data: 01/07–31/07" when both ends are