@ciromaciel/dashboard-kit 1.0.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/README.md +40 -0
- package/package.json +46 -0
- package/src/auth-labels.js +77 -0
- package/src/components/ActiveResource.jsx +68 -0
- package/src/components/AuthLoader.jsx +66 -0
- package/src/components/AuthScreen.jsx +32 -0
- package/src/components/CardSkeleton.jsx +82 -0
- package/src/components/CommandCenter.css +57 -0
- package/src/components/CommandCenter.jsx +185 -0
- package/src/components/DashboardFrame.jsx +97 -0
- package/src/components/DataTable.jsx +153 -0
- package/src/components/DeleteModal.jsx +341 -0
- package/src/components/EmptyState.jsx +55 -0
- package/src/components/GroupLabel.jsx +28 -0
- package/src/components/HoldToConfirm.jsx +151 -0
- package/src/components/ListSkeleton.jsx +55 -0
- package/src/components/MenuItem.jsx +56 -0
- package/src/components/Metric.jsx +178 -0
- package/src/components/MoreResults.jsx +137 -0
- package/src/components/NavItem.jsx +57 -0
- package/src/components/PageHeader.jsx +124 -0
- package/src/components/PlanBanner.jsx +88 -0
- package/src/components/ResourceItem.jsx +93 -0
- package/src/components/SectionCard.jsx +212 -0
- package/src/components/SidebarShell.jsx +271 -0
- package/src/components/SupportModal.jsx +269 -0
- package/src/components/TableSkeleton.jsx +43 -0
- package/src/components/TableToolbar.jsx +136 -0
- package/src/components/Terminal.jsx +217 -0
- package/src/components/Wordmark.jsx +52 -0
- package/src/components/ZenLabel.jsx +30 -0
- package/src/components/notify-theme.js +103 -0
- package/src/components/notify.js +65 -0
- package/src/components/table-grid.js +27 -0
- package/src/hooks/useExclusiveModal.js +49 -0
- package/src/hooks/usePaginatedList.js +206 -0
- package/src/index.js +99 -0
- package/src/tokens.css +47 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { ActionIcon, Box, Button, RingProgress, Text, Tooltip } from '@mantine/core'
|
|
3
|
+
|
|
4
|
+
const HOLD_MS = 3000
|
|
5
|
+
const TICK_MS = 50
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Confirmation by holding, not by dialog.
|
|
9
|
+
*
|
|
10
|
+
* An "are you sure?" gets answered on autopilot — the finger is already on its
|
|
11
|
+
* way to OK before the eye reads. Holding requires continuous intent and stays
|
|
12
|
+
* reversible until the last instant: letting go cancels.
|
|
13
|
+
*
|
|
14
|
+
* Spec (§Destructive action): holdMs 3000 · tickMs 50 · RingProgress thickness 2
|
|
15
|
+
* over the icon · cancels on onMouseUp/onMouseLeave/onTouchEnd.
|
|
16
|
+
*
|
|
17
|
+
* ACCESSIBILITY — the spec records this as an "open requirement": the original
|
|
18
|
+
* pattern only responded to mouse and touch, which made deletion inaccessible
|
|
19
|
+
* from the keyboard. Here the same gesture exists on a held Space/Enter: keydown
|
|
20
|
+
* starts it (ignoring the OS autorepeat), keyup cancels. Same duration, same
|
|
21
|
+
* reversibility, same feedback.
|
|
22
|
+
*/
|
|
23
|
+
function useHold(onConfirm, holdMs) {
|
|
24
|
+
const [progress, setProgress] = useState(0)
|
|
25
|
+
const timer = useRef(null)
|
|
26
|
+
const holding = useRef(false)
|
|
27
|
+
|
|
28
|
+
const stop = useCallback(() => {
|
|
29
|
+
if (timer.current) clearInterval(timer.current)
|
|
30
|
+
timer.current = null
|
|
31
|
+
holding.current = false
|
|
32
|
+
setProgress(0)
|
|
33
|
+
}, [])
|
|
34
|
+
|
|
35
|
+
const start = useCallback(() => {
|
|
36
|
+
// The keyboard's autorepeat fires keydown continuously: without this
|
|
37
|
+
// guard, each repeat would restart the count and the hold would never
|
|
38
|
+
// complete.
|
|
39
|
+
if (holding.current) return
|
|
40
|
+
holding.current = true
|
|
41
|
+
|
|
42
|
+
const startedAt = performance.now()
|
|
43
|
+
timer.current = setInterval(() => {
|
|
44
|
+
const pct = Math.min(100, ((performance.now() - startedAt) / holdMs) * 100)
|
|
45
|
+
setProgress(pct)
|
|
46
|
+
if (pct >= 100) {
|
|
47
|
+
stop()
|
|
48
|
+
onConfirm()
|
|
49
|
+
}
|
|
50
|
+
}, TICK_MS)
|
|
51
|
+
}, [holdMs, onConfirm, stop])
|
|
52
|
+
|
|
53
|
+
// If the component leaves the screen mid-hold, the interval dies with it.
|
|
54
|
+
useEffect(() => stop, [stop])
|
|
55
|
+
|
|
56
|
+
return { progress, start, stop }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Handlers shared by both variants: pointer, touch and keyboard. */
|
|
60
|
+
function holdHandlers(start, stop, disabled) {
|
|
61
|
+
if (disabled) return {}
|
|
62
|
+
return {
|
|
63
|
+
onMouseDown: start,
|
|
64
|
+
onMouseUp: stop,
|
|
65
|
+
onMouseLeave: stop,
|
|
66
|
+
onTouchStart: start,
|
|
67
|
+
onTouchEnd: stop,
|
|
68
|
+
onTouchCancel: stop,
|
|
69
|
+
onKeyDown: e => {
|
|
70
|
+
if (e.key === ' ' || e.key === 'Enter') {
|
|
71
|
+
e.preventDefault()
|
|
72
|
+
start()
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
onKeyUp: e => {
|
|
76
|
+
if (e.key === ' ' || e.key === 'Enter') stop()
|
|
77
|
+
},
|
|
78
|
+
onBlur: stop,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Icon variant — for inside tables and lists, where text doesn't fit.
|
|
84
|
+
* The ring grows over the icon itself.
|
|
85
|
+
*/
|
|
86
|
+
export function HoldToConfirmIcon({ icon, label, onConfirm, disabled, color = 'red.6', holdMs = HOLD_MS }) {
|
|
87
|
+
const { progress, start, stop } = useHold(onConfirm, holdMs)
|
|
88
|
+
const active = progress > 0
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<Tooltip label={label}>
|
|
92
|
+
<Box
|
|
93
|
+
pos="relative"
|
|
94
|
+
display="flex"
|
|
95
|
+
style={{ alignItems: 'center', justifyContent: 'center' }}
|
|
96
|
+
>
|
|
97
|
+
{active && (
|
|
98
|
+
<RingProgress
|
|
99
|
+
size={34}
|
|
100
|
+
thickness={2}
|
|
101
|
+
sections={[{ value: progress, color }]}
|
|
102
|
+
pos="absolute"
|
|
103
|
+
style={{ pointerEvents: 'none' }}
|
|
104
|
+
aria-hidden
|
|
105
|
+
/>
|
|
106
|
+
)}
|
|
107
|
+
<ActionIcon
|
|
108
|
+
color={active ? color : 'gray'}
|
|
109
|
+
aria-label={label}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
{...holdHandlers(start, stop, disabled)}
|
|
112
|
+
>
|
|
113
|
+
{icon}
|
|
114
|
+
</ActionIcon>
|
|
115
|
+
</Box>
|
|
116
|
+
</Tooltip>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Text variant with a countdown — for forms and modals, where the button needs
|
|
122
|
+
* to say what is going to happen and how much is left.
|
|
123
|
+
*/
|
|
124
|
+
export function HoldToConfirmButton({ children, holdingLabel, onConfirm, disabled, loading, leftSection, holdMs = HOLD_MS, ...props }) {
|
|
125
|
+
const { progress, start, stop } = useHold(onConfirm, holdMs)
|
|
126
|
+
const active = progress > 0
|
|
127
|
+
const remaining = Math.ceil(((100 - progress) / 100) * (holdMs / 1000))
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<Button
|
|
131
|
+
color="red.6"
|
|
132
|
+
disabled={disabled}
|
|
133
|
+
loading={loading}
|
|
134
|
+
leftSection={leftSection}
|
|
135
|
+
{...holdHandlers(start, stop, disabled || loading)}
|
|
136
|
+
{...props}
|
|
137
|
+
>
|
|
138
|
+
{active ? (
|
|
139
|
+
<Text
|
|
140
|
+
c="gray.6"
|
|
141
|
+
span
|
|
142
|
+
inherit
|
|
143
|
+
>
|
|
144
|
+
{holdingLabel} {remaining}s
|
|
145
|
+
</Text>
|
|
146
|
+
) : (
|
|
147
|
+
children
|
|
148
|
+
)}
|
|
149
|
+
</Button>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Box, Skeleton, Stack } from '@mantine/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A forma da lista de recursos enquanto ela não chegou.
|
|
5
|
+
*
|
|
6
|
+
* Seis cópias, e a única diferença entre elas era o nome — `ListSkeleton` em
|
|
7
|
+
* cinco, `ListaEsqueleto` no que ainda não tinha migrado para inglês.
|
|
8
|
+
*
|
|
9
|
+
* As LARGURAS SÃO IRREGULARES de propósito. Barras do mesmo tamanho leem como
|
|
10
|
+
* uma tabela vazia; larguras diferentes leem como texto que ainda não chegou —
|
|
11
|
+
* que é a verdade. O ciclo de seis valores evita o padrão visível que três ou
|
|
12
|
+
* quatro produziriam numa lista longa.
|
|
13
|
+
*
|
|
14
|
+
* `aria-hidden` porque não há o que anunciar: um leitor de tela lendo seis
|
|
15
|
+
* caixas vazias atrapalha em vez de informar. Quem depende dele recebe o
|
|
16
|
+
* conteúdo quando ele existir.
|
|
17
|
+
*/
|
|
18
|
+
export function ListSkeleton({ rows = 4 }) {
|
|
19
|
+
const widths = ['62%', '48%', '71%', '55%', '66%', '43%']
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Stack
|
|
23
|
+
gap={2}
|
|
24
|
+
w="100%"
|
|
25
|
+
aria-hidden
|
|
26
|
+
>
|
|
27
|
+
{Array.from({ length: rows }).map((_, i) => (
|
|
28
|
+
<Box
|
|
29
|
+
key={i}
|
|
30
|
+
w="100%"
|
|
31
|
+
px={12}
|
|
32
|
+
py={8}
|
|
33
|
+
style={{
|
|
34
|
+
display: 'flex',
|
|
35
|
+
alignItems: 'center',
|
|
36
|
+
justifyContent: 'flex-start',
|
|
37
|
+
gap: 10,
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
<Skeleton
|
|
41
|
+
h={18}
|
|
42
|
+
w={18}
|
|
43
|
+
radius={0}
|
|
44
|
+
style={{ flex: 'none' }}
|
|
45
|
+
/>
|
|
46
|
+
<Skeleton
|
|
47
|
+
h={10}
|
|
48
|
+
w={widths[i % widths.length]}
|
|
49
|
+
radius={0}
|
|
50
|
+
/>
|
|
51
|
+
</Box>
|
|
52
|
+
))}
|
|
53
|
+
</Stack>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { UnstyledButton, Text } from '@mantine/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A linha de um menu — ícone à esquerda, rótulo, fundo no hover.
|
|
5
|
+
*
|
|
6
|
+
* VEIO DE SETE CÓPIAS BYTE-IDÊNTICAS.
|
|
7
|
+
*
|
|
8
|
+
* Este é o caso mais limpo de duplicação da stack: o mesmo `md5` em seis
|
|
9
|
+
* painéis, e o sétimo (Monitors) diferindo APENAS por aspas duplas — um arquivo
|
|
10
|
+
* que escapou do Prettier da casa. Trinta e seis linhas, sete vezes, sem uma
|
|
11
|
+
* decisão de produto no meio.
|
|
12
|
+
*
|
|
13
|
+
* O hover é escrito no DOM em vez de CSS porque a linha vive dentro de um
|
|
14
|
+
* `Popover` do Mantine, que monta fora da árvore de estilos do painel — uma
|
|
15
|
+
* classe declarada no CSS do produto não o alcança.
|
|
16
|
+
*
|
|
17
|
+
* @param {Function} icon Componente de ícone (tabler). Recebe `size` e `stroke`.
|
|
18
|
+
* @param {string} label O que a pessoa lê.
|
|
19
|
+
* @param {Function} onClick O que acontece no clique.
|
|
20
|
+
*/
|
|
21
|
+
export function MenuItem({ icon: Icon, label, onClick }) {
|
|
22
|
+
return (
|
|
23
|
+
<UnstyledButton
|
|
24
|
+
onClick={onClick}
|
|
25
|
+
px={10}
|
|
26
|
+
py={8}
|
|
27
|
+
style={{
|
|
28
|
+
display: 'flex',
|
|
29
|
+
alignItems: 'center',
|
|
30
|
+
gap: 10,
|
|
31
|
+
width: '100%',
|
|
32
|
+
borderRadius: 0,
|
|
33
|
+
transition: 'background 120ms ease',
|
|
34
|
+
}}
|
|
35
|
+
onMouseEnter={e => {
|
|
36
|
+
e.currentTarget.style.background = 'var(--mantine-color-gray-1)'
|
|
37
|
+
}}
|
|
38
|
+
onMouseLeave={e => {
|
|
39
|
+
e.currentTarget.style.background = 'transparent'
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
<Icon
|
|
43
|
+
size={16}
|
|
44
|
+
stroke={1.5}
|
|
45
|
+
style={{ flex: 'none', color: 'var(--mantine-color-gray-5)' }}
|
|
46
|
+
/>
|
|
47
|
+
<Text
|
|
48
|
+
fz={13}
|
|
49
|
+
fw={500}
|
|
50
|
+
c="gray.9"
|
|
51
|
+
>
|
|
52
|
+
{label}
|
|
53
|
+
</Text>
|
|
54
|
+
</UnstyledButton>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Box, Group, Skeleton, Text } from '@mantine/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A number, its label and the note that makes it verifiable.
|
|
5
|
+
*
|
|
6
|
+
* LAW 5, and it is the reason this component exists instead of a big <Text>:
|
|
7
|
+
* 'big metric without a note' is one of the spec's blockers. A bare '99,4%'
|
|
8
|
+
* doesn't say over what period, measured by whom, across how many samples — and
|
|
9
|
+
* an uptime dashboard that asserts without backing is advertising, not
|
|
10
|
+
* instrumentation.
|
|
11
|
+
*
|
|
12
|
+
* The `note` is neither decoration nor a tooltip: it is the lowest rung of the
|
|
13
|
+
* hierarchy (12px, gray.6) and lives right against the number, always visible.
|
|
14
|
+
*
|
|
15
|
+
* NULL NEVER BECOMES ZERO. The manager returns `null` for 'we didn't measure'
|
|
16
|
+
* and a number for 'we measured'. An em dash is the only honest answer to the
|
|
17
|
+
* first case — 100% uptime on a monitor that never ran would be the worst lie
|
|
18
|
+
* this product can tell, and it would show up exactly where trust is highest.
|
|
19
|
+
*
|
|
20
|
+
* ────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
* THIS COMPONENT IS THE SUPERSET OF SIX COPIES, NOT A SEVENTH
|
|
22
|
+
*
|
|
23
|
+
* Before it existed here, the same block lived six times over: `MetricStrip` in
|
|
24
|
+
* storage, payments, functions, hoster and messages, plus an inline
|
|
25
|
+
* `MetricBlock` in the auth Overview. The extraction found three divergences
|
|
26
|
+
* that were NOT styling, and all three went against the first version of this
|
|
27
|
+
* file:
|
|
28
|
+
*
|
|
29
|
+
* 1. THE LABEL COMES AFTER THE NUMBER. All six put the label below; the first
|
|
30
|
+
* version here put it above. With the label on top, the eye reads
|
|
31
|
+
* "USUÁRIOS" before "1.204" — and the number stops being the first impact,
|
|
32
|
+
* which is exactly what this screen exists to produce.
|
|
33
|
+
*
|
|
34
|
+
* 2. `unit` IS CONTENT, not ornament. The Hoster's age formatter returns
|
|
35
|
+
* `{ value: '3', unit: 'h' }`; without the baseline-aligned suffix, "3
|
|
36
|
+
* hours ago" becomes "3". A number without its unit isn't a smaller
|
|
37
|
+
* number: it's a different one.
|
|
38
|
+
*
|
|
39
|
+
* 3. `loading` SWAPS THE NUMBER FOR A SKELETON, and the note goes with it.
|
|
40
|
+
* The note says WHERE the number came from; without the number it is a
|
|
41
|
+
* loose claim about data nobody read. The label stays — it names the
|
|
42
|
+
* metric, and that is true before the answer arrives.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} label - The metric's name. Sits BELOW the number.
|
|
45
|
+
* @param {*} value - The number. `null`/`undefined` become an em dash.
|
|
46
|
+
* @param {string} unit - Baseline-aligned suffix ('h', 'GB', '%').
|
|
47
|
+
* @param {string} note - Where the number came from. Law 5.
|
|
48
|
+
* @param {string} hint - Synonym for `note`. The five local `MetricStrip`
|
|
49
|
+
* copies called this field `hint` in 46 places, and
|
|
50
|
+
* a component that only reads `note` would erase
|
|
51
|
+
* all 46 SILENTLY — which is literally the bug the
|
|
52
|
+
* Storage had (it passed `sub`, the strip read
|
|
53
|
+
* `hint`, and three notes never rendered).
|
|
54
|
+
* Accepting both names costs one line; discovering
|
|
55
|
+
* the erasure costs a production screen lying until
|
|
56
|
+
* someone notices.
|
|
57
|
+
* @param {number} delta - Change against the previous period, in %.
|
|
58
|
+
* @param {boolean} loading - Swaps number and note for a skeleton.
|
|
59
|
+
* @param {string} size - 'xl' on the account picture; everything else follows.
|
|
60
|
+
*/
|
|
61
|
+
export function Metric({ label, value, unit, note, hint, delta, deltaSuffix = '%', betterWhenLower = false, loading = false, size = 'md' }) {
|
|
62
|
+
// `hint` is the name the five panels already used; `note` wins if both arrive.
|
|
63
|
+
const caption = note ?? hint
|
|
64
|
+
|
|
65
|
+
const hasDelta = !loading && delta != null && Number.isFinite(delta) && delta !== 0
|
|
66
|
+
|
|
67
|
+
/*
|
|
68
|
+
* THE CHROMATIC EXCEPTION ONLY MARKS WHAT GOT WORSE, and the default is
|
|
69
|
+
* `false` because most metrics in the stack improve by going up (users,
|
|
70
|
+
* deploys, revenue). The auth painted ANY rise neutral and any drop gray —
|
|
71
|
+
* inheriting that `betterWhenLower = true` would have painted "users grew
|
|
72
|
+
* 12%" as an alert across five dashboards at once.
|
|
73
|
+
*
|
|
74
|
+
* An improvement doesn't get a color: it doesn't ask for action.
|
|
75
|
+
*/
|
|
76
|
+
const worsened = betterWhenLower ? delta > 0 : delta < 0
|
|
77
|
+
|
|
78
|
+
/*
|
|
79
|
+
* `xl` is the size of the account picture, and it is RESPONSIVE out of
|
|
80
|
+
* necessity: 64px on a phone overflows the line and breaks "1.204" in half.
|
|
81
|
+
*
|
|
82
|
+
* The six dashboards had already converged on desktop 64 on their own, with
|
|
83
|
+
* two scales to get there — `{base:40, sm:56, md:64}` in two of them and
|
|
84
|
+
* `{base:48, sm:64}` in three. The difference never had a reason, only
|
|
85
|
+
* copying. We keep the 48 base (the 40 was cramped for no reason) with the
|
|
86
|
+
* intermediate step from the ones that had three.
|
|
87
|
+
*
|
|
88
|
+
* The other sizes serve where the metric ACCOMPANIES something else: next
|
|
89
|
+
* to a table, inside a detail card. There, competing with the content would
|
|
90
|
+
* be noise.
|
|
91
|
+
*/
|
|
92
|
+
const fz = size === 'xl' ? { base: 48, sm: 56, md: 64 } : size === 'lg' ? 36 : size === 'sm' ? 20 : 28
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Box miw={0}>
|
|
96
|
+
{loading ? (
|
|
97
|
+
<Skeleton
|
|
98
|
+
h={size === 'xl' ? 48 : 28}
|
|
99
|
+
w="60%"
|
|
100
|
+
radius={0}
|
|
101
|
+
/>
|
|
102
|
+
) : (
|
|
103
|
+
<Group
|
|
104
|
+
gap={4}
|
|
105
|
+
align="baseline"
|
|
106
|
+
wrap="nowrap"
|
|
107
|
+
>
|
|
108
|
+
<Text
|
|
109
|
+
fz={fz}
|
|
110
|
+
fw={900}
|
|
111
|
+
c="gray.9"
|
|
112
|
+
lh={1}
|
|
113
|
+
ls="-0.04em"
|
|
114
|
+
style={{ overflowWrap: 'anywhere' }}
|
|
115
|
+
>
|
|
116
|
+
{value ?? '—'}
|
|
117
|
+
</Text>
|
|
118
|
+
|
|
119
|
+
{unit && (
|
|
120
|
+
<Text
|
|
121
|
+
fz={20}
|
|
122
|
+
fw={700}
|
|
123
|
+
c="dimmed"
|
|
124
|
+
lh={1}
|
|
125
|
+
>
|
|
126
|
+
{unit}
|
|
127
|
+
</Text>
|
|
128
|
+
)}
|
|
129
|
+
</Group>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
<Text
|
|
133
|
+
fz={11}
|
|
134
|
+
fw={800}
|
|
135
|
+
c="gray.4"
|
|
136
|
+
tt="uppercase"
|
|
137
|
+
ls="1.5px"
|
|
138
|
+
lh={1.4}
|
|
139
|
+
mt={12}
|
|
140
|
+
>
|
|
141
|
+
{label}
|
|
142
|
+
</Text>
|
|
143
|
+
|
|
144
|
+
{!loading && (caption || hasDelta) && (
|
|
145
|
+
<Group
|
|
146
|
+
gap={6}
|
|
147
|
+
mt={4}
|
|
148
|
+
wrap="nowrap"
|
|
149
|
+
align="baseline"
|
|
150
|
+
>
|
|
151
|
+
{hasDelta && (
|
|
152
|
+
<Text
|
|
153
|
+
fz={11}
|
|
154
|
+
fw={800}
|
|
155
|
+
c={worsened ? 'red.6' : 'gray.9'}
|
|
156
|
+
lh={1.5}
|
|
157
|
+
>
|
|
158
|
+
{delta > 0 ? '+' : ''}
|
|
159
|
+
{delta}
|
|
160
|
+
{deltaSuffix}
|
|
161
|
+
</Text>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
{caption && (
|
|
165
|
+
<Text
|
|
166
|
+
fz={12}
|
|
167
|
+
fw={500}
|
|
168
|
+
c="gray.6"
|
|
169
|
+
lh={1.5}
|
|
170
|
+
>
|
|
171
|
+
{caption}
|
|
172
|
+
</Text>
|
|
173
|
+
)}
|
|
174
|
+
</Group>
|
|
175
|
+
)}
|
|
176
|
+
</Box>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import { Box, Button, Group, Loader, Text } from '@mantine/core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The footer of a paginated list: loads the next page when you reach the end.
|
|
6
|
+
*
|
|
7
|
+
* Infinite scroll WITH A BUTTON, not one or the other. The
|
|
8
|
+
* `IntersectionObserver` makes the common case happen on its own — whoever
|
|
9
|
+
* scrolls to the end gets more rows without asking. The button sits below it
|
|
10
|
+
* because pure infinite scroll leaves out anyone navigating by keyboard (there
|
|
11
|
+
* is no way to 'scroll to the observer' with Tab), anyone using a screen reader
|
|
12
|
+
* (the silent load is not announced), and anyone on a window tall enough that
|
|
13
|
+
* the sentinel never leaves the viewport.
|
|
14
|
+
*
|
|
15
|
+
* Both trigger the SAME function. The button is not a fallback that appears
|
|
16
|
+
* when the observer fails: it is always visible, always operable, and the
|
|
17
|
+
* observer merely anticipates the click when the finger is already scrolling.
|
|
18
|
+
*
|
|
19
|
+
* `rootMargin: 200px` fires the load before the footer appears: requesting the
|
|
20
|
+
* page only when the sentinel enters the screen guarantees the user ALWAYS sees
|
|
21
|
+
* the emptiness before the rows. 200px is roughly two table rows — enough time
|
|
22
|
+
* for the response to arrive during the scroll movement.
|
|
23
|
+
*
|
|
24
|
+
* Why we did not replace this with numbered pagination: in a monitoring list the
|
|
25
|
+
* question is 'what is broken?', and the answer is ordered by severity. Numbered
|
|
26
|
+
* pages make sense when there is a stable index to return to ('page 4 of the
|
|
27
|
+
* results'); here the order changes every 30s cycle, and 'page 4' is nowhere.
|
|
28
|
+
* What the person wants is to keep scrolling down.
|
|
29
|
+
*
|
|
30
|
+
* THE NOUN COMES BY PROP. This component was born in Monitors and carried a
|
|
31
|
+
* literal "monitor"/"monitores" in the end-of-list sentence — which made the
|
|
32
|
+
* Payments email log announce "25 monitores · fim da lista". It is the same
|
|
33
|
+
* class of error as the Wordmark without `name`: a component promoted to the kit
|
|
34
|
+
* carrying along a word that belonged to the product it came from.
|
|
35
|
+
*
|
|
36
|
+
* The interface is English; the words it renders are the product's Portuguese
|
|
37
|
+
* copy, and the pluralization rule below is Portuguese grammar on purpose.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} noun - Singular ("envio", "monitor").
|
|
40
|
+
* @param {string} pluralNoun - Plural. Defaults to `noun` + "s", which covers
|
|
41
|
+
* the regular Portuguese case; whoever has an
|
|
42
|
+
* irregular plural passes their own.
|
|
43
|
+
*/
|
|
44
|
+
export function MoreResults({ loading, hasMore, onLoadMore, shown, total, noun = 'item', pluralNoun }) {
|
|
45
|
+
const plural = pluralNoun ?? `${noun}s`
|
|
46
|
+
const sentinel = useRef(null)
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const target = sentinel.current
|
|
50
|
+
// Without `hasMore` there is nothing to observe; without
|
|
51
|
+
// `IntersectionObserver` (old browser, test environment) the button
|
|
52
|
+
// keeps working.
|
|
53
|
+
if (!target || !hasMore || typeof IntersectionObserver === 'undefined') return
|
|
54
|
+
|
|
55
|
+
const obs = new IntersectionObserver(
|
|
56
|
+
entries => {
|
|
57
|
+
if (entries[0]?.isIntersecting) onLoadMore()
|
|
58
|
+
},
|
|
59
|
+
{ rootMargin: '200px' },
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
obs.observe(target)
|
|
63
|
+
return () => obs.disconnect()
|
|
64
|
+
}, [hasMore, onLoadMore])
|
|
65
|
+
|
|
66
|
+
// End of the list: silence would be ambiguous — 'it's over' or 'is it loading?'.
|
|
67
|
+
if (!hasMore) {
|
|
68
|
+
if (!total || shown < 20) return null
|
|
69
|
+
return (
|
|
70
|
+
<Box
|
|
71
|
+
py={16}
|
|
72
|
+
ta="center"
|
|
73
|
+
style={{ borderTop: 'var(--rlg-border)' }}
|
|
74
|
+
>
|
|
75
|
+
<Text
|
|
76
|
+
fz={11}
|
|
77
|
+
fw={800}
|
|
78
|
+
c="gray.4"
|
|
79
|
+
tt="uppercase"
|
|
80
|
+
ls="1.5px"
|
|
81
|
+
>
|
|
82
|
+
{total} {total === 1 ? noun : plural} · fim da lista
|
|
83
|
+
</Text>
|
|
84
|
+
</Box>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<Box
|
|
90
|
+
ref={sentinel}
|
|
91
|
+
py={16}
|
|
92
|
+
style={{ borderTop: 'var(--rlg-border)' }}
|
|
93
|
+
>
|
|
94
|
+
<Group
|
|
95
|
+
justify="center"
|
|
96
|
+
gap={12}
|
|
97
|
+
>
|
|
98
|
+
{loading ? (
|
|
99
|
+
<>
|
|
100
|
+
<Loader size={16} />
|
|
101
|
+
<Text
|
|
102
|
+
fz={11}
|
|
103
|
+
fw={800}
|
|
104
|
+
c="gray.4"
|
|
105
|
+
tt="uppercase"
|
|
106
|
+
ls="1.5px"
|
|
107
|
+
/* `aria-live` because a scroll-triggered load has no
|
|
108
|
+
focus: without this, a screen reader would not know
|
|
109
|
+
the list had grown. */
|
|
110
|
+
aria-live="polite"
|
|
111
|
+
>
|
|
112
|
+
Carregando
|
|
113
|
+
</Text>
|
|
114
|
+
</>
|
|
115
|
+
) : (
|
|
116
|
+
<Button
|
|
117
|
+
variant="default"
|
|
118
|
+
size="xs"
|
|
119
|
+
onClick={onLoadMore}
|
|
120
|
+
>
|
|
121
|
+
Carregar mais
|
|
122
|
+
</Button>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
<Text
|
|
126
|
+
fz={11}
|
|
127
|
+
fw={800}
|
|
128
|
+
c="gray.4"
|
|
129
|
+
tt="uppercase"
|
|
130
|
+
ls="1.5px"
|
|
131
|
+
>
|
|
132
|
+
{shown} de {total}
|
|
133
|
+
</Text>
|
|
134
|
+
</Group>
|
|
135
|
+
</Box>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { NavLink } from '@mantine/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Um item da navegação lateral.
|
|
5
|
+
*
|
|
6
|
+
* VEIO DE SETE CÓPIAS, E O SUPERSET É MENOR QUE A MAIOR DELAS.
|
|
7
|
+
*
|
|
8
|
+
* As sete assinaturas divergiam só no que cada painel precisou acrescentar:
|
|
9
|
+
* `muted` (payments, auth), `disabled` (auth), e `collapsed` (seis). As três
|
|
10
|
+
* primeiras entram aqui; a última NÃO.
|
|
11
|
+
*
|
|
12
|
+
* POR QUE `collapsed` FICOU DE FORA
|
|
13
|
+
*
|
|
14
|
+
* Nenhum painel colapsa a coluna. Os seis declaram `const [collapsed] =
|
|
15
|
+
* useState(false)` e descartam o setter — não existe um `setCollapsed` na
|
|
16
|
+
* stack inteira, e o toggle do Auth está comentado. Eram 262 ocorrências de um
|
|
17
|
+
* estado que nunca muda, mantendo vivos um ramo de largura, um de padding, um
|
|
18
|
+
* de alinhamento e um `<Tooltip>` inteiro que nunca aparece.
|
|
19
|
+
*
|
|
20
|
+
* Se um dia a coluna colapsar de verdade, o lugar de tratar isso é aqui, uma
|
|
21
|
+
* vez — não em seis arquivos que precisam concordar.
|
|
22
|
+
*
|
|
23
|
+
* @param {Function} icon Componente de ícone (tabler).
|
|
24
|
+
* @param {string} label O que a pessoa lê.
|
|
25
|
+
* @param {boolean} active Item da rota atual. Engrossa o rótulo.
|
|
26
|
+
* @param {boolean} muted Secundário: ícone e texto em cinza. Para a nav de conta.
|
|
27
|
+
* @param {boolean} disabled Sem alvo ainda (no Auth, antes de escolher a aplicação).
|
|
28
|
+
* @param {Function} onClick Ignorado quando `disabled`.
|
|
29
|
+
*/
|
|
30
|
+
export function NavItem({ icon: Icon, label, active, muted, disabled, onClick }) {
|
|
31
|
+
return (
|
|
32
|
+
<NavLink
|
|
33
|
+
onClick={disabled ? undefined : onClick}
|
|
34
|
+
disabled={disabled}
|
|
35
|
+
active={active}
|
|
36
|
+
variant="filled"
|
|
37
|
+
color="gray.9"
|
|
38
|
+
label={label}
|
|
39
|
+
w="100%"
|
|
40
|
+
px={12}
|
|
41
|
+
py={9}
|
|
42
|
+
bdrs="0"
|
|
43
|
+
leftSection={
|
|
44
|
+
<Icon
|
|
45
|
+
size={18}
|
|
46
|
+
stroke={1.5}
|
|
47
|
+
style={muted ? { color: 'var(--mantine-color-gray-5)' } : undefined}
|
|
48
|
+
/>
|
|
49
|
+
}
|
|
50
|
+
styles={{
|
|
51
|
+
root: { justifyContent: 'flex-start' },
|
|
52
|
+
section: { marginInlineEnd: 12 },
|
|
53
|
+
label: { fontSize: 13, fontWeight: active ? 800 : 500, color: muted ? 'var(--mantine-color-gray-6)' : undefined },
|
|
54
|
+
}}
|
|
55
|
+
/>
|
|
56
|
+
)
|
|
57
|
+
}
|