@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.
Files changed (38) hide show
  1. package/README.md +40 -0
  2. package/package.json +46 -0
  3. package/src/auth-labels.js +77 -0
  4. package/src/components/ActiveResource.jsx +68 -0
  5. package/src/components/AuthLoader.jsx +66 -0
  6. package/src/components/AuthScreen.jsx +32 -0
  7. package/src/components/CardSkeleton.jsx +82 -0
  8. package/src/components/CommandCenter.css +57 -0
  9. package/src/components/CommandCenter.jsx +185 -0
  10. package/src/components/DashboardFrame.jsx +97 -0
  11. package/src/components/DataTable.jsx +153 -0
  12. package/src/components/DeleteModal.jsx +341 -0
  13. package/src/components/EmptyState.jsx +55 -0
  14. package/src/components/GroupLabel.jsx +28 -0
  15. package/src/components/HoldToConfirm.jsx +151 -0
  16. package/src/components/ListSkeleton.jsx +55 -0
  17. package/src/components/MenuItem.jsx +56 -0
  18. package/src/components/Metric.jsx +178 -0
  19. package/src/components/MoreResults.jsx +137 -0
  20. package/src/components/NavItem.jsx +57 -0
  21. package/src/components/PageHeader.jsx +124 -0
  22. package/src/components/PlanBanner.jsx +88 -0
  23. package/src/components/ResourceItem.jsx +93 -0
  24. package/src/components/SectionCard.jsx +212 -0
  25. package/src/components/SidebarShell.jsx +271 -0
  26. package/src/components/SupportModal.jsx +269 -0
  27. package/src/components/TableSkeleton.jsx +43 -0
  28. package/src/components/TableToolbar.jsx +136 -0
  29. package/src/components/Terminal.jsx +217 -0
  30. package/src/components/Wordmark.jsx +52 -0
  31. package/src/components/ZenLabel.jsx +30 -0
  32. package/src/components/notify-theme.js +103 -0
  33. package/src/components/notify.js +65 -0
  34. package/src/components/table-grid.js +27 -0
  35. package/src/hooks/useExclusiveModal.js +49 -0
  36. package/src/hooks/usePaginatedList.js +206 -0
  37. package/src/index.js +99 -0
  38. 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
+ }