@12-apps/ui 1.6.0 → 1.6.2
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 +4 -4
- package/src/components/data-display/DataViews/data-views-day-input.tsx +133 -0
- package/src/components/data-display/DataViews/data-views-number-input.tsx +157 -0
- package/src/components/data-display/DataViews/data-views-range-pill.tsx +22 -31
- package/src/components/data-display/DataViews/data-views-range-values.ts +32 -1
- package/src/components/utility/InstallPrompt/InstallPrompt.earlyCapture.ts +117 -0
- package/src/components/utility/InstallPrompt/InstallPrompt.md +52 -4
- package/src/components/utility/InstallPrompt/InstallPrompt.types.ts +19 -0
- package/src/components/utility/InstallPrompt/index.ts +8 -0
- package/src/components/utility/InstallPrompt/usePwaInstall.ts +39 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/ui",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
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.
|
|
197
|
+
"@12-apps/forms-core": "^1.5.2",
|
|
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.
|
|
213
|
-
"@12-apps/typescript-config": "^1.6.
|
|
212
|
+
"@12-apps/eslint-config": "^1.6.2",
|
|
213
|
+
"@12-apps/typescript-config": "^1.6.2",
|
|
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,
|
|
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
|
|
38
|
-
*
|
|
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
|
-
|
|
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
|
-
<
|
|
56
|
-
size="small"
|
|
57
|
-
type={isDay ? 'date' : 'number'}
|
|
63
|
+
<NumberBoundInput
|
|
58
64
|
label={label}
|
|
59
|
-
value={
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { BeforeInstallPromptEvent, PwaInstallStash } from './InstallPrompt.types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Holds `beforeinstallprompt` from before the app exists.
|
|
5
|
+
*
|
|
6
|
+
* `usePwaInstall` cannot attach its own listener early enough, and no React
|
|
7
|
+
* hook can. Chromium fires the event once, during initial page load, the
|
|
8
|
+
* moment it has a manifest and a registered worker — which in a real app is
|
|
9
|
+
* before hydration, and often before the chunk containing the component has
|
|
10
|
+
* even been downloaded. A listener registered in an effect is not late by
|
|
11
|
+
* milliseconds; it can be late by entire navigations. Nothing calls
|
|
12
|
+
* `preventDefault()` in time, the browser keeps the event, and the page can
|
|
13
|
+
* never ask again.
|
|
14
|
+
*
|
|
15
|
+
* So the capture has to live outside React, and the host has to install it as
|
|
16
|
+
* the very first thing the document does. Two supported ways, in order of
|
|
17
|
+
* reliability:
|
|
18
|
+
*
|
|
19
|
+
* 1. An inline `<script>` in `<head>`, which beats a deferred module bundle:
|
|
20
|
+
*
|
|
21
|
+
* <script>
|
|
22
|
+
* window.addEventListener('beforeinstallprompt', function (e) {
|
|
23
|
+
* e.preventDefault();
|
|
24
|
+
* var s = (window.__pwaInstall = window.__pwaInstall || {});
|
|
25
|
+
* s.event = e; s.firedAt = Date.now();
|
|
26
|
+
* window.dispatchEvent(new Event('pwa-install-available'));
|
|
27
|
+
* });
|
|
28
|
+
* </script>
|
|
29
|
+
*
|
|
30
|
+
* 2. `capturePwaInstallEvent()` as the first statement of the entry module,
|
|
31
|
+
* before `createRoot`. Simpler to wire and correct in most apps, but a
|
|
32
|
+
* module is still deferred, so a very early event can outrun it.
|
|
33
|
+
*
|
|
34
|
+
* Both write the same shape, so the hook does not care which was used.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Property on `window` holding the captured event. Shared with the inline snippet. */
|
|
38
|
+
export const PWA_INSTALL_STASH_KEY = '__pwaInstall';
|
|
39
|
+
|
|
40
|
+
/** Fired after a capture, so an app already running can adopt the event. */
|
|
41
|
+
export const PWA_INSTALL_AVAILABLE_EVENT = 'pwa-install-available';
|
|
42
|
+
|
|
43
|
+
type StashHost = Window & { [PWA_INSTALL_STASH_KEY]?: PwaInstallStash };
|
|
44
|
+
|
|
45
|
+
/** The stash, or `null` when no capture ever ran (SSR, or a host that skipped wiring). */
|
|
46
|
+
export const readPwaInstallStash = (): PwaInstallStash | null => {
|
|
47
|
+
if (typeof window === 'undefined') return null;
|
|
48
|
+
return (window as StashHost)[PWA_INSTALL_STASH_KEY] ?? null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const ensureStash = (): PwaInstallStash => {
|
|
52
|
+
const host = window as StashHost;
|
|
53
|
+
host[PWA_INSTALL_STASH_KEY] ??= { event: null, firedAt: null, installedAt: null };
|
|
54
|
+
return host[PWA_INSTALL_STASH_KEY];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The teardown for the capture currently running, so `resetPwaInstallStash`
|
|
59
|
+
* can unwind completely. Held here rather than on the stash because it must
|
|
60
|
+
* survive the stash being deleted, and because it must never be serialised
|
|
61
|
+
* into the diagnostic payload.
|
|
62
|
+
*/
|
|
63
|
+
let activeTeardown: (() => void) | null = null;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Starts capturing. Returns a teardown, and is safe to call twice — a second
|
|
67
|
+
* call is a no-op rather than a duplicate listener, so a host that wires both
|
|
68
|
+
* the inline snippet and this function does not double-handle the event.
|
|
69
|
+
*/
|
|
70
|
+
export const capturePwaInstallEvent = (): (() => void) => {
|
|
71
|
+
if (typeof window === 'undefined') return () => undefined;
|
|
72
|
+
|
|
73
|
+
const stash = ensureStash();
|
|
74
|
+
if (stash.capturing === true) return () => undefined;
|
|
75
|
+
stash.capturing = true;
|
|
76
|
+
|
|
77
|
+
const onBeforeInstall = (event: Event): void => {
|
|
78
|
+
// The line the whole component depends on. Without it the browser owns the
|
|
79
|
+
// event, shows at most its own mini-infobar, and discards the handle.
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
stash.event = event as BeforeInstallPromptEvent;
|
|
82
|
+
stash.firedAt = Date.now();
|
|
83
|
+
window.dispatchEvent(new Event(PWA_INSTALL_AVAILABLE_EVENT));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const onInstalled = (): void => {
|
|
87
|
+
stash.installedAt = Date.now();
|
|
88
|
+
stash.event = null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
window.addEventListener('beforeinstallprompt', onBeforeInstall);
|
|
92
|
+
window.addEventListener('appinstalled', onInstalled);
|
|
93
|
+
|
|
94
|
+
const teardown = () => {
|
|
95
|
+
window.removeEventListener('beforeinstallprompt', onBeforeInstall);
|
|
96
|
+
window.removeEventListener('appinstalled', onInstalled);
|
|
97
|
+
stash.capturing = false;
|
|
98
|
+
if (activeTeardown === teardown) activeTeardown = null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
activeTeardown = teardown;
|
|
102
|
+
return teardown;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Returns the page to its pre-capture state: listeners removed, stash gone.
|
|
107
|
+
*
|
|
108
|
+
* Exists for tests and stories, which need a clean page per case. It unwinds
|
|
109
|
+
* the listeners as well as the data, because a leaked capture from a previous
|
|
110
|
+
* case would go on suppressing events that the next case expects to observe.
|
|
111
|
+
*/
|
|
112
|
+
export const resetPwaInstallStash = (): void => {
|
|
113
|
+
if (typeof window === 'undefined') return;
|
|
114
|
+
activeTeardown?.();
|
|
115
|
+
activeTeardown = null;
|
|
116
|
+
delete (window as StashHost)[PWA_INSTALL_STASH_KEY];
|
|
117
|
+
};
|
|
@@ -85,6 +85,54 @@ raised from a timer or an effect.
|
|
|
85
85
|
All copy defaults to English and every string is a prop — this package is
|
|
86
86
|
locale-neutral and the consuming app supplies its own wording.
|
|
87
87
|
|
|
88
|
+
## Wiring the capture
|
|
89
|
+
|
|
90
|
+
**Required on Chromium.** Without it this component can never offer an install,
|
|
91
|
+
and it will fail silently — no error, no warning, just an affordance that never
|
|
92
|
+
appears.
|
|
93
|
+
|
|
94
|
+
Chromium fires `beforeinstallprompt` once, during page load, as soon as it has a
|
|
95
|
+
manifest and a registered worker. That is before hydration, and in a code-split
|
|
96
|
+
app it can be before the chunk holding this component has been downloaded at
|
|
97
|
+
all. No React hook can attach a listener in time, and the event is not reissued
|
|
98
|
+
on request. So the capture has to run outside React, and first.
|
|
99
|
+
|
|
100
|
+
Paste this into `<head>`, above the app bundle. A classic inline script is the
|
|
101
|
+
only thing guaranteed to beat a deferred module:
|
|
102
|
+
|
|
103
|
+
```html
|
|
104
|
+
<script>
|
|
105
|
+
(function () {
|
|
106
|
+
var stash = (window.__pwaInstall = { event: null, firedAt: null, installedAt: null });
|
|
107
|
+
window.addEventListener('beforeinstallprompt', function (event) {
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
stash.event = event;
|
|
110
|
+
stash.firedAt = Date.now();
|
|
111
|
+
window.dispatchEvent(new Event('pwa-install-available'));
|
|
112
|
+
});
|
|
113
|
+
window.addEventListener('appinstalled', function () {
|
|
114
|
+
stash.installedAt = Date.now();
|
|
115
|
+
stash.event = null;
|
|
116
|
+
});
|
|
117
|
+
})();
|
|
118
|
+
</script>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Or call `capturePwaInstallEvent()` as the first statement of your entry module,
|
|
122
|
+
before `createRoot`. Simpler to wire, but a module is still deferred, so a very
|
|
123
|
+
early event can outrun it. Doing both is safe — the capture is idempotent.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { capturePwaInstallEvent } from '@12-apps/ui/utility/InstallPrompt';
|
|
127
|
+
|
|
128
|
+
capturePwaInstallEvent();
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`readPwaInstallStash()` exposes what was captured (`event`, `firedAt`,
|
|
132
|
+
`installedAt`), which is worth logging when the prompt does not appear:
|
|
133
|
+
`firedAt` set with no visible affordance means something downstream suppressed
|
|
134
|
+
it, whereas an absent stash means the wiring above is missing.
|
|
135
|
+
|
|
88
136
|
## Hook API
|
|
89
137
|
|
|
90
138
|
`usePwaInstall(options)` returns:
|
|
@@ -107,7 +155,7 @@ locale-neutral and the consuming app supplies its own wording.
|
|
|
107
155
|
- **Storage failures are swallowed.** `localStorage` throws rather than returning
|
|
108
156
|
`null` in Safari private mode and wherever cookies are blocked; an uncaught throw
|
|
109
157
|
would break the page on exactly the browsers this component serves.
|
|
110
|
-
- **A missed
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
158
|
+
- **A missed event does NOT recover.** Chromium fires `beforeinstallprompt` once
|
|
159
|
+
and will not reissue it on request, so a listener that attaches on mount has
|
|
160
|
+
already lost. This is why "Wiring the capture" above is a requirement rather
|
|
161
|
+
than an optimisation.
|
|
@@ -15,6 +15,25 @@ export interface BeforeInstallPromptEvent extends Event {
|
|
|
15
15
|
prompt: () => Promise<void>;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* What the pre-React capture left on `window` for the hook to pick up.
|
|
20
|
+
*
|
|
21
|
+
* This is the handoff between code that runs before the app exists and code
|
|
22
|
+
* that runs inside it. Written either by `capturePwaInstallEvent()` or by the
|
|
23
|
+
* equivalent inline snippet in the host's HTML, and read on mount — the event
|
|
24
|
+
* has almost always fired long before any component is alive to hear it.
|
|
25
|
+
*/
|
|
26
|
+
export interface PwaInstallStash {
|
|
27
|
+
/** The held event, or `null` if none has fired or it was already spent. */
|
|
28
|
+
event: BeforeInstallPromptEvent | null;
|
|
29
|
+
/** When it was captured. Diagnostic: proves the capture ran and the browser offered. */
|
|
30
|
+
firedAt: number | null;
|
|
31
|
+
/** When `appinstalled` was seen, if it arrived before the app mounted. */
|
|
32
|
+
installedAt: number | null;
|
|
33
|
+
/** Guards against a host wiring both the inline snippet and the function. */
|
|
34
|
+
capturing?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
18
37
|
/**
|
|
19
38
|
* How — or whether — this browser can install the app.
|
|
20
39
|
*
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
export { InstallPrompt } from './InstallPrompt';
|
|
2
2
|
export { usePwaInstall } from './usePwaInstall';
|
|
3
|
+
export {
|
|
4
|
+
capturePwaInstallEvent,
|
|
5
|
+
readPwaInstallStash,
|
|
6
|
+
resetPwaInstallStash,
|
|
7
|
+
PWA_INSTALL_AVAILABLE_EVENT,
|
|
8
|
+
PWA_INSTALL_STASH_KEY,
|
|
9
|
+
} from './InstallPrompt.earlyCapture';
|
|
3
10
|
export type {
|
|
4
11
|
BeforeInstallPromptEvent,
|
|
5
12
|
InstallOutcome,
|
|
6
13
|
InstallPlatform,
|
|
7
14
|
InstallPromptProps,
|
|
8
15
|
PwaInstallState,
|
|
16
|
+
PwaInstallStash,
|
|
9
17
|
UsePwaInstallOptions,
|
|
10
18
|
} from './InstallPrompt.types';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
|
|
3
|
+
import { PWA_INSTALL_AVAILABLE_EVENT, readPwaInstallStash } from './InstallPrompt.earlyCapture';
|
|
3
4
|
import {
|
|
4
5
|
DEFAULT_DISMISS_DAYS,
|
|
5
6
|
DEFAULT_STORAGE_KEY,
|
|
@@ -50,7 +51,9 @@ const useInstalledState = (onInstalled?: () => void) => {
|
|
|
50
51
|
}, [onInstalled]);
|
|
51
52
|
|
|
52
53
|
useEffect(() => {
|
|
53
|
-
|
|
54
|
+
// `appinstalled` can also land before the app mounts, so the stash is
|
|
55
|
+
// consulted for the same reason the deferred event is.
|
|
56
|
+
setIsInstalled(isStandaloneDisplay() || readPwaInstallStash()?.installedAt != null);
|
|
54
57
|
|
|
55
58
|
const markInstalled = () => {
|
|
56
59
|
setIsInstalled(true);
|
|
@@ -65,29 +68,54 @@ const useInstalledState = (onInstalled?: () => void) => {
|
|
|
65
68
|
};
|
|
66
69
|
|
|
67
70
|
/**
|
|
68
|
-
*
|
|
71
|
+
* Holds the deferred `beforeinstallprompt` event.
|
|
69
72
|
*
|
|
70
|
-
* The
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
73
|
+
* The stash written by `capturePwaInstallEvent` is the PRIMARY source, not a
|
|
74
|
+
* fallback. Chromium fires the event once, during initial page load; a
|
|
75
|
+
* listener attached in an effect runs after hydration and, when the component
|
|
76
|
+
* is code-split, after a download that may not happen until a later route. In
|
|
77
|
+
* a real browser it essentially never catches the event, and the event is not
|
|
78
|
+
* reissued on request — so a hook that relies on its own listener reports
|
|
79
|
+
* "cannot install" forever on the one platform that can.
|
|
80
|
+
*
|
|
81
|
+
* The live listeners below therefore cover only the narrow case they can
|
|
82
|
+
* actually serve: a browser re-evaluating installability while the app is
|
|
83
|
+
* already running, and a host that never wired the capture at all.
|
|
75
84
|
*/
|
|
76
85
|
const useDeferredPrompt = () => {
|
|
77
86
|
const deferredRef = useRef<BeforeInstallPromptEvent | null>(null);
|
|
78
87
|
const [promptReady, setPromptReady] = useState(false);
|
|
79
88
|
|
|
80
89
|
useEffect(() => {
|
|
90
|
+
const hold = (event: BeforeInstallPromptEvent) => {
|
|
91
|
+
deferredRef.current = event;
|
|
92
|
+
setPromptReady(true);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Whatever fired before this component existed.
|
|
96
|
+
const held = readPwaInstallStash()?.event;
|
|
97
|
+
if (held) hold(held);
|
|
98
|
+
|
|
99
|
+
// The capture rang the bell after mount: adopt what it is holding.
|
|
100
|
+
const adopt = () => {
|
|
101
|
+
const fresh = readPwaInstallStash()?.event;
|
|
102
|
+
if (fresh) hold(fresh);
|
|
103
|
+
};
|
|
104
|
+
|
|
81
105
|
const capture = (event: Event) => {
|
|
82
106
|
// Without preventDefault the browser owns the event and the handle dies
|
|
83
|
-
// with it
|
|
107
|
+
// with it. The capture has normally done this already; repeating it is
|
|
108
|
+
// harmless and keeps the hook correct on a host that skipped wiring.
|
|
84
109
|
event.preventDefault();
|
|
85
|
-
|
|
86
|
-
setPromptReady(true);
|
|
110
|
+
hold(event as BeforeInstallPromptEvent);
|
|
87
111
|
};
|
|
88
112
|
|
|
113
|
+
window.addEventListener(PWA_INSTALL_AVAILABLE_EVENT, adopt);
|
|
89
114
|
window.addEventListener('beforeinstallprompt', capture);
|
|
90
|
-
return () =>
|
|
115
|
+
return () => {
|
|
116
|
+
window.removeEventListener(PWA_INSTALL_AVAILABLE_EVENT, adopt);
|
|
117
|
+
window.removeEventListener('beforeinstallprompt', capture);
|
|
118
|
+
};
|
|
91
119
|
}, []);
|
|
92
120
|
|
|
93
121
|
const clearPrompt = useCallback(() => {
|