@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,97 @@
|
|
|
1
|
+
import { AppShell, Box, Burger, Group } from '@mantine/core'
|
|
2
|
+
|
|
3
|
+
import { Wordmark } from './Wordmark'
|
|
4
|
+
|
|
5
|
+
/** A largura da coluna, em px. Igual nos sete painéis. */
|
|
6
|
+
export const SIDEBAR_WIDTH = 300
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A moldura de todo painel: barra no celular, coluna lateral, palco.
|
|
10
|
+
*
|
|
11
|
+
* VEIO DE SETE CÓPIAS IDÊNTICAS. Cada painel repetia o mesmo `<AppShell>` com
|
|
12
|
+
* os mesmos números — inclusive o comentário que explica o `height` responsivo,
|
|
13
|
+
* copiado literalmente nos sete, que é a prova de que viajou por `cp`.
|
|
14
|
+
*
|
|
15
|
+
* O QUE FICA AQUI é a moldura. O que NÃO fica é a composição de cada produto:
|
|
16
|
+
* providers, boot de identidade, stores, quotas e modais continuam no painel,
|
|
17
|
+
* porque é onde vivem as decisões dele. A fronteira é essa — a moldura não sabe
|
|
18
|
+
* o que é um projeto.
|
|
19
|
+
*
|
|
20
|
+
* A ALTURA DO HEADER É 0 NO DESKTOP E 56 NO CELULAR, e isso não é estilo:
|
|
21
|
+
* `hiddenFrom` sozinho remove o elemento por CSS, mas o `AppShell` continua
|
|
22
|
+
* reservando os 56px de deslocamento no topo da coluna e do conteúdo — uma
|
|
23
|
+
* faixa vazia acima da marca. A altura responsiva zera o deslocamento onde o
|
|
24
|
+
* header não existe.
|
|
25
|
+
*
|
|
26
|
+
* O PALCO não usa o py de 80–120 da landing: lá ele separa seções de uma
|
|
27
|
+
* narrativa; aqui há uma tela só, com a coluna fixa ao lado. A respiração vira
|
|
28
|
+
* o padding do container (px 48 · pt 40 · pb 80 · maw 1280), e o ritmo interno
|
|
29
|
+
* vira o espaço entre blocos.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} productName Nome do produto, para a marca na barra mobile.
|
|
32
|
+
* @param {boolean} mobileOpened Estado da gaveta no celular.
|
|
33
|
+
* @param {Function} onToggleMobile Abre/fecha a gaveta.
|
|
34
|
+
* @param {node} sidebar A coluna. Cada produto monta a sua.
|
|
35
|
+
* @param {node} children O palco — normalmente o `<Outlet>`.
|
|
36
|
+
*/
|
|
37
|
+
export function DashboardFrame({ productName, mobileOpened, onToggleMobile, sidebar, children }) {
|
|
38
|
+
return (
|
|
39
|
+
<AppShell
|
|
40
|
+
header={{ height: { base: 56, sm: 0 }, offset: true }}
|
|
41
|
+
navbar={{
|
|
42
|
+
width: SIDEBAR_WIDTH,
|
|
43
|
+
breakpoint: 'sm',
|
|
44
|
+
collapsed: { mobile: !mobileOpened },
|
|
45
|
+
}}
|
|
46
|
+
padding={0}
|
|
47
|
+
transitionDuration={250}
|
|
48
|
+
transitionTimingFunction="ease"
|
|
49
|
+
>
|
|
50
|
+
{/*
|
|
51
|
+
A barra só existe no celular: no desktop a coluna já está visível,
|
|
52
|
+
e mais um header seria cromo duplicado.
|
|
53
|
+
*/}
|
|
54
|
+
<AppShell.Header
|
|
55
|
+
withBorder
|
|
56
|
+
hiddenFrom="sm"
|
|
57
|
+
>
|
|
58
|
+
<Group
|
|
59
|
+
h="100%"
|
|
60
|
+
px="md"
|
|
61
|
+
gap="md"
|
|
62
|
+
wrap="nowrap"
|
|
63
|
+
>
|
|
64
|
+
<Burger
|
|
65
|
+
opened={mobileOpened}
|
|
66
|
+
onClick={onToggleMobile}
|
|
67
|
+
size="sm"
|
|
68
|
+
aria-label="Menu"
|
|
69
|
+
/>
|
|
70
|
+
<Wordmark
|
|
71
|
+
name={productName}
|
|
72
|
+
size={20}
|
|
73
|
+
/>
|
|
74
|
+
</Group>
|
|
75
|
+
</AppShell.Header>
|
|
76
|
+
|
|
77
|
+
<AppShell.Navbar
|
|
78
|
+
withBorder={false}
|
|
79
|
+
p={0}
|
|
80
|
+
>
|
|
81
|
+
{sidebar}
|
|
82
|
+
</AppShell.Navbar>
|
|
83
|
+
|
|
84
|
+
<AppShell.Main>
|
|
85
|
+
<Box
|
|
86
|
+
mx="auto"
|
|
87
|
+
px={{ base: 'md', md: 48 }}
|
|
88
|
+
pt={{ base: 24, md: 40 }}
|
|
89
|
+
pb={80}
|
|
90
|
+
maw={1280}
|
|
91
|
+
>
|
|
92
|
+
{children}
|
|
93
|
+
</Box>
|
|
94
|
+
</AppShell.Main>
|
|
95
|
+
</AppShell>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Box, Card, Text } from '@mantine/core'
|
|
2
|
+
import { useMediaQuery } from '@mantine/hooks'
|
|
3
|
+
import { templateFor, ROW_PADDING, HEAD_PADDING } from './table-grid'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The system's table — CSS Grid, not <Table>.
|
|
7
|
+
*
|
|
8
|
+
* Grid because the spec asks for two things that table layout makes hard:
|
|
9
|
+
* proportional columns (`2.4fr 1fr 1fr 40px`) and the WHOLE row clickable. Grid
|
|
10
|
+
* solves both without fighting display:table.
|
|
11
|
+
*
|
|
12
|
+
* Spec (§Table):
|
|
13
|
+
* container Card p={0} withBorder · overflow hidden
|
|
14
|
+
* header bg gray.1 · padding 12px 24px · borderBottom 1px gray.2
|
|
15
|
+
* header cell fz 10 · fw 800 · gray.5 · uppercase · ls 1.5px
|
|
16
|
+
* row padding 14px 24px · borderBottom 1px gray.2 (except the last)
|
|
17
|
+
* cell fz 13 · gray.7 · lh 1.4 — the "data" role
|
|
18
|
+
* hover background gray.1 · transition 120ms
|
|
19
|
+
* highlight background gray.1 + 2px black hairline on the left (inset)
|
|
20
|
+
* — `quietSelection` keeps only the background, for when the
|
|
21
|
+
* highlight is a state of the data and not the row you clicked
|
|
22
|
+
*
|
|
23
|
+
* Config-driven per Law 4: `columns` describes the columns with data
|
|
24
|
+
* (key/label/width), and each column brings its own `render`. The consumer
|
|
25
|
+
* never injects the table's structure — it only says what each cell shows.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const HEAD = { fz: 10, fw: 800, c: 'gray.5', tt: 'uppercase', ls: '1.5px', lh: 1.4 }
|
|
29
|
+
|
|
30
|
+
export function DataTable({ columns, rows, getRowKey, onRowClick, isRowSelected, quietSelection, getRowLabel, toolbar, footer, emptyState, loading, skeleton }) {
|
|
31
|
+
const clickable = typeof onRowClick === 'function'
|
|
32
|
+
|
|
33
|
+
/*
|
|
34
|
+
* A column with `hideBelow` disappears on phones — and really disappears:
|
|
35
|
+
* hiding it via CSS would leave the grid track reserved, and an empty gash
|
|
36
|
+
* would remain in its place. JS is what decides: below 48em the hidden
|
|
37
|
+
* columns drop out of the list, so the grid is never even generated with
|
|
38
|
+
* them.
|
|
39
|
+
*/
|
|
40
|
+
const wide = useMediaQuery('(min-width: 48em)', true)
|
|
41
|
+
const visible = wide ? columns : columns.filter(col => !col.hideBelow)
|
|
42
|
+
const template = templateFor(visible)
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<Card
|
|
46
|
+
p={0}
|
|
47
|
+
style={{ overflow: 'hidden' }}
|
|
48
|
+
>
|
|
49
|
+
{toolbar}
|
|
50
|
+
|
|
51
|
+
<Box
|
|
52
|
+
bg="gray.1"
|
|
53
|
+
style={{
|
|
54
|
+
display: 'grid',
|
|
55
|
+
gridTemplateColumns: template,
|
|
56
|
+
gap: 16,
|
|
57
|
+
alignItems: 'center',
|
|
58
|
+
padding: HEAD_PADDING,
|
|
59
|
+
borderBottom: 'var(--rlg-border)',
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{visible.map(col =>
|
|
63
|
+
col.label ? (
|
|
64
|
+
<Text
|
|
65
|
+
key={col.key}
|
|
66
|
+
{...HEAD}
|
|
67
|
+
ta={col.align}
|
|
68
|
+
>
|
|
69
|
+
{col.label}
|
|
70
|
+
</Text>
|
|
71
|
+
) : (
|
|
72
|
+
<Box key={col.key} />
|
|
73
|
+
),
|
|
74
|
+
)}
|
|
75
|
+
</Box>
|
|
76
|
+
|
|
77
|
+
{/* First load: the skeleton has the SAME grid as the table, so the
|
|
78
|
+
layout doesn't jump when the data arrives (Law 3). A refetch with
|
|
79
|
+
data already visible doesn't flash a skeleton over it — the
|
|
80
|
+
consumer decides, passing `loading` only the first time. */}
|
|
81
|
+
{loading && skeleton}
|
|
82
|
+
|
|
83
|
+
{!loading && rows.length === 0 && emptyState}
|
|
84
|
+
|
|
85
|
+
{!loading &&
|
|
86
|
+
rows.map((row, i) => {
|
|
87
|
+
const selected = isRowSelected?.(row) || undefined
|
|
88
|
+
const last = i === rows.length - 1
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<Box
|
|
92
|
+
key={getRowKey(row)}
|
|
93
|
+
data-selected={selected}
|
|
94
|
+
onClick={clickable ? () => onRowClick(row) : undefined}
|
|
95
|
+
// The row is the target: it has to announce itself to the
|
|
96
|
+
// keyboard and the screen reader as the button it actually is.
|
|
97
|
+
onKeyDown={
|
|
98
|
+
clickable
|
|
99
|
+
? e => {
|
|
100
|
+
if (e.target !== e.currentTarget) return
|
|
101
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
102
|
+
e.preventDefault()
|
|
103
|
+
onRowClick(row)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
: undefined
|
|
107
|
+
}
|
|
108
|
+
onMouseEnter={clickable ? e => (e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-1)') : undefined}
|
|
109
|
+
onMouseLeave={clickable ? e => (e.currentTarget.style.backgroundColor = selected ? 'var(--mantine-color-gray-1)' : '') : undefined}
|
|
110
|
+
tabIndex={clickable ? 0 : undefined}
|
|
111
|
+
role={clickable ? 'button' : undefined}
|
|
112
|
+
aria-pressed={clickable && isRowSelected ? Boolean(selected) : undefined}
|
|
113
|
+
aria-label={clickable ? getRowLabel?.(row) : undefined}
|
|
114
|
+
style={{
|
|
115
|
+
display: 'grid',
|
|
116
|
+
gridTemplateColumns: template,
|
|
117
|
+
gap: 16,
|
|
118
|
+
alignItems: 'center',
|
|
119
|
+
padding: ROW_PADDING,
|
|
120
|
+
cursor: clickable ? 'pointer' : undefined,
|
|
121
|
+
borderBottom: last ? 'none' : 'var(--rlg-border)',
|
|
122
|
+
transition: 'background-color 120ms ease',
|
|
123
|
+
/* Selected: black hairline on the left. Not a strong
|
|
124
|
+
background color, which would compete with the hover.
|
|
125
|
+
|
|
126
|
+
`quietSelection` keeps the background and drops the
|
|
127
|
+
hairline. The 2px mark was designed for
|
|
128
|
+
SELECTION — the row you just clicked — and it
|
|
129
|
+
weighs too much when the highlight is a
|
|
130
|
+
permanent state of the data (the live version,
|
|
131
|
+
which already announces itself with the badge on
|
|
132
|
+
the row). */
|
|
133
|
+
backgroundColor: selected ? 'var(--mantine-color-gray-1)' : undefined,
|
|
134
|
+
boxShadow: selected && !quietSelection ? 'inset 2px 0 0 var(--mantine-color-gray-9)' : undefined,
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
{visible.map(col => (
|
|
138
|
+
<Box
|
|
139
|
+
key={col.key}
|
|
140
|
+
miw={0}
|
|
141
|
+
style={col.align ? { textAlign: col.align } : undefined}
|
|
142
|
+
>
|
|
143
|
+
{col.render(row)}
|
|
144
|
+
</Box>
|
|
145
|
+
))}
|
|
146
|
+
</Box>
|
|
147
|
+
)
|
|
148
|
+
})}
|
|
149
|
+
|
|
150
|
+
{footer}
|
|
151
|
+
</Card>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Box, Button, Group, Modal, Stack, Text, TextInput, Title } from '@mantine/core'
|
|
3
|
+
import { IconAlertTriangle, IconArrowRight } from '@tabler/icons-react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The destructive modal — one shape for the seven panels.
|
|
7
|
+
*
|
|
8
|
+
* Specified in `design-system/10-modal-de-exclusao.md`. This is the executable
|
|
9
|
+
* half: the spec says WHY, the component is WHAT, and having only one of them
|
|
10
|
+
* is how the two drift apart.
|
|
11
|
+
*
|
|
12
|
+
* WHAT THE AUDIT FOUND
|
|
13
|
+
*
|
|
14
|
+
* Fifteen destructive modals across seven panels, and no two alike. Four
|
|
15
|
+
* families of chrome (`padding={0}` bare vs Mantine's `title` prop vs a
|
|
16
|
+
* `ThemeIcon` in the header vs nothing), the destructive button on the left in
|
|
17
|
+
* two and on the right in thirteen, four ways of comparing the typed string
|
|
18
|
+
* (case-sensitive, case-insensitive, trimmed on one side, trimmed on both), and
|
|
19
|
+
* the target repeated in the label AND the placeholder in nine of them.
|
|
20
|
+
*
|
|
21
|
+
* None of that variety encoded a difference in the product. It encoded the
|
|
22
|
+
* order the screens were written in.
|
|
23
|
+
*
|
|
24
|
+
* THE ONE THING THIS COMPONENT REFUSES TO PARAMETERIZE
|
|
25
|
+
*
|
|
26
|
+
* The row icon. It is `IconArrowRight`, always, and there is no prop for it.
|
|
27
|
+
* When it was a choice, three panels picked thematic glyphs — a key for OAuth,
|
|
28
|
+
* a globe for a domain, a clock for history — and each of those repeats in a
|
|
29
|
+
* picture what the sentence beside it already says, turning a list into four
|
|
30
|
+
* marks competing for the same eye. The glyph MARKS a row; it does not describe
|
|
31
|
+
* it. Making it a prop is how it stops being a rule.
|
|
32
|
+
*
|
|
33
|
+
* WHEN NOT TO REACH FOR THIS
|
|
34
|
+
*
|
|
35
|
+
* The escalation has three steps and this is the top one. A reversible action
|
|
36
|
+
* (disable, archive, unlink) takes a plain button. An irreversible action whose
|
|
37
|
+
* TARGET is unambiguous takes `HoldToConfirmIcon` — holding proves intent,
|
|
38
|
+
* which is the whole question when there is only one row it could be. This
|
|
39
|
+
* modal exists for the case where the target itself can be confused with its
|
|
40
|
+
* neighbours, because only typing an identifier answers "this one?".
|
|
41
|
+
*
|
|
42
|
+
* Do not stack the hold and the typing. Two panels do, and the second gate adds
|
|
43
|
+
* friction without adding information: the modal is already the escalation.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The title's size follows the LENGTH OF THE DATA, not the viewport.
|
|
48
|
+
*
|
|
49
|
+
* A panel title is fixed at 28px because the panel's width is controlled. That
|
|
50
|
+
* reasoning holds for a title someone WROTE. It breaks for a title that IS an
|
|
51
|
+
* identifier, because its length is not ours to choose — the e-mails in one
|
|
52
|
+
* Auth application run from 22 to 56 characters, and half pass 30.
|
|
53
|
+
*
|
|
54
|
+
* Past 40 the identifier leaves the title slot entirely. Shrinking it was the
|
|
55
|
+
* first attempt and it failed in a way worth recording: at 18px mono a
|
|
56
|
+
* 56-character address STILL wrapped, and it broke mid-word
|
|
57
|
+
* (`…myinfrast / ructure`) while the confirmation line below, at 13px, fit the
|
|
58
|
+
* same string whole. The title had become the worse copy of something the modal
|
|
59
|
+
* already showed intact one block down.
|
|
60
|
+
*
|
|
61
|
+
* So above 40 the heading becomes the sentence that says what is about to
|
|
62
|
+
* happen, and the identifier appears once — whole, in mono, directly above the
|
|
63
|
+
* field that asks for it.
|
|
64
|
+
*
|
|
65
|
+
* Truncating with an ellipsis is never the answer: this string is what someone
|
|
66
|
+
* checks character by character before reproducing it.
|
|
67
|
+
*/
|
|
68
|
+
export function deleteTitleScale(value) {
|
|
69
|
+
const length = (value ?? '').length
|
|
70
|
+
|
|
71
|
+
if (length <= 24) return { fz: { base: 22, sm: 28 }, asTitle: true }
|
|
72
|
+
if (length <= 40) return { fz: { base: 18, sm: 22 }, asTitle: true }
|
|
73
|
+
|
|
74
|
+
return { fz: { base: 18, sm: 20 }, asTitle: false }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {object} props
|
|
79
|
+
* @param {boolean} props.opened
|
|
80
|
+
* @param {Function} props.onClose
|
|
81
|
+
* @param {Function} props.onConfirm Receives the confirmed target string.
|
|
82
|
+
* @param {string} props.target The identifier to be typed. Required.
|
|
83
|
+
* @param {string} props.context Subtitle: who · what · where.
|
|
84
|
+
* @param {string} props.headline Title when `target` is too long to be one.
|
|
85
|
+
* @param {string[]} props.consequences One sentence per row. Includes the caveat.
|
|
86
|
+
* @param {string} props.confirmLabel Button verb — names the resource.
|
|
87
|
+
* @param {string} props.prompt Sentence above the target line.
|
|
88
|
+
* @param {boolean} props.loading
|
|
89
|
+
* @param {boolean} props.caseSensitive See `matches` below.
|
|
90
|
+
* @param {string} props.eyebrow
|
|
91
|
+
*/
|
|
92
|
+
export function DeleteModal({
|
|
93
|
+
opened,
|
|
94
|
+
onClose,
|
|
95
|
+
onConfirm,
|
|
96
|
+
target,
|
|
97
|
+
context,
|
|
98
|
+
headline = 'Confirmar exclusão',
|
|
99
|
+
consequences = [],
|
|
100
|
+
confirmLabel = 'Excluir',
|
|
101
|
+
prompt = 'Digite o texto abaixo para confirmar',
|
|
102
|
+
loading = false,
|
|
103
|
+
caseSensitive = false,
|
|
104
|
+
eyebrow = 'Zona de risco',
|
|
105
|
+
}) {
|
|
106
|
+
const [typed, setTyped] = useState('')
|
|
107
|
+
|
|
108
|
+
const value = (target ?? '').trim()
|
|
109
|
+
const scale = deleteTitleScale(value)
|
|
110
|
+
|
|
111
|
+
/*
|
|
112
|
+
* `trim()` always; the case follows the DATA.
|
|
113
|
+
*
|
|
114
|
+
* A stray space — a paste, a phone's autocorrect — is not an attempt to
|
|
115
|
+
* delete something else. The capital letter is the judgement call, so it is
|
|
116
|
+
* the caller's: an e-mail is case-insensitive by RFC and the worker stores
|
|
117
|
+
* it lowercased, so refusing `Maciel@…` would be rigour against someone
|
|
118
|
+
* typing their own address correctly. A project id or a slug is not, and
|
|
119
|
+
* there `caseSensitive` keeps the comparison exact.
|
|
120
|
+
*
|
|
121
|
+
* `value.length > 0` stops an empty field from matching an absent target
|
|
122
|
+
* and arming the button.
|
|
123
|
+
*/
|
|
124
|
+
const entered = typed.trim()
|
|
125
|
+
const matches = value.length > 0 && (caseSensitive ? entered === value : entered.toLowerCase() === value.toLowerCase())
|
|
126
|
+
const canDelete = matches && !loading
|
|
127
|
+
|
|
128
|
+
const confirm = () => {
|
|
129
|
+
if (canDelete) onConfirm(value)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<Modal
|
|
134
|
+
opened={opened}
|
|
135
|
+
// Closing mid-request does not cancel the deletion — it only hides
|
|
136
|
+
// the result of it.
|
|
137
|
+
onClose={loading ? () => {} : onClose}
|
|
138
|
+
size={520}
|
|
139
|
+
padding={0}
|
|
140
|
+
withCloseButton={false}
|
|
141
|
+
closeOnClickOutside={!loading}
|
|
142
|
+
closeOnEscape={!loading}
|
|
143
|
+
>
|
|
144
|
+
<Stack
|
|
145
|
+
gap={0}
|
|
146
|
+
p={{ base: 24, md: 32 }}
|
|
147
|
+
>
|
|
148
|
+
<Group
|
|
149
|
+
gap={8}
|
|
150
|
+
wrap="nowrap"
|
|
151
|
+
align="center"
|
|
152
|
+
>
|
|
153
|
+
<IconAlertTriangle
|
|
154
|
+
size={13}
|
|
155
|
+
stroke={2.5}
|
|
156
|
+
style={{ color: 'var(--mantine-color-red-6)', flex: 'none' }}
|
|
157
|
+
/>
|
|
158
|
+
<Text
|
|
159
|
+
fz={10}
|
|
160
|
+
fw={800}
|
|
161
|
+
c="red.6"
|
|
162
|
+
tt="uppercase"
|
|
163
|
+
ls="1.5px"
|
|
164
|
+
>
|
|
165
|
+
{eyebrow}
|
|
166
|
+
</Text>
|
|
167
|
+
</Group>
|
|
168
|
+
|
|
169
|
+
{/*
|
|
170
|
+
* The TARGET is the title, and the subtitle says what kind of
|
|
171
|
+
* thing it is — until the target is too long to be a title, and
|
|
172
|
+
* then the two swap jobs.
|
|
173
|
+
*
|
|
174
|
+
* The identifier, not the human name: names repeat inside one
|
|
175
|
+
* account, and the identifier is the string to be typed below.
|
|
176
|
+
*/}
|
|
177
|
+
<Title
|
|
178
|
+
order={2}
|
|
179
|
+
fw={900}
|
|
180
|
+
fz={scale.fz}
|
|
181
|
+
lh={1.15}
|
|
182
|
+
mt={12}
|
|
183
|
+
ls="-0.04em"
|
|
184
|
+
c="gray.9"
|
|
185
|
+
/*
|
|
186
|
+
* `break-all` only where the title IS the identifier. On the
|
|
187
|
+
* long case the title is prose, and `break-all` would split
|
|
188
|
+
* it mid-word — the rule exists for a string with no spaces
|
|
189
|
+
* to break at.
|
|
190
|
+
*/
|
|
191
|
+
style={scale.asTitle ? { wordBreak: 'break-all' } : undefined}
|
|
192
|
+
>
|
|
193
|
+
{scale.asTitle ? value : headline}
|
|
194
|
+
</Title>
|
|
195
|
+
|
|
196
|
+
{context ? (
|
|
197
|
+
<Text
|
|
198
|
+
fz={12}
|
|
199
|
+
c="gray.6"
|
|
200
|
+
ff="var(--mantine-font-family-monospace)"
|
|
201
|
+
mt={6}
|
|
202
|
+
>
|
|
203
|
+
{context}
|
|
204
|
+
</Text>
|
|
205
|
+
) : null}
|
|
206
|
+
|
|
207
|
+
{consequences.length > 0 ? (
|
|
208
|
+
<Box mt={20}>
|
|
209
|
+
<Stack gap={12}>
|
|
210
|
+
{consequences.map(label => (
|
|
211
|
+
<Group
|
|
212
|
+
key={label}
|
|
213
|
+
gap={10}
|
|
214
|
+
wrap="nowrap"
|
|
215
|
+
align="flex-start"
|
|
216
|
+
>
|
|
217
|
+
<IconArrowRight
|
|
218
|
+
size={14}
|
|
219
|
+
stroke={1.5}
|
|
220
|
+
style={{ color: 'var(--mantine-color-gray-4)', flex: 'none', marginTop: 3 }}
|
|
221
|
+
/>
|
|
222
|
+
<Text
|
|
223
|
+
fz={13}
|
|
224
|
+
c="gray.6"
|
|
225
|
+
lh={1.5}
|
|
226
|
+
>
|
|
227
|
+
{label}
|
|
228
|
+
</Text>
|
|
229
|
+
</Group>
|
|
230
|
+
))}
|
|
231
|
+
</Stack>
|
|
232
|
+
</Box>
|
|
233
|
+
) : null}
|
|
234
|
+
|
|
235
|
+
{/*
|
|
236
|
+
* No rule before the confirmation — the footer's is the only
|
|
237
|
+
* one. Two hairlines inside 520px cut the modal into three
|
|
238
|
+
* strips and the second lands on nothing: typing the target and
|
|
239
|
+
* pressing the button are one gesture, not two sections.
|
|
240
|
+
*/}
|
|
241
|
+
<Box mt={28}>
|
|
242
|
+
{/*
|
|
243
|
+
* The instruction is prose; the TARGET gets its own line.
|
|
244
|
+
*
|
|
245
|
+
* Embedded mid-sentence, a long identifier breaks the phrase
|
|
246
|
+
* across two lines and the eye has to reassemble it around
|
|
247
|
+
* the very string it is copying.
|
|
248
|
+
*
|
|
249
|
+
* And this is the LAST place it appears. It used to be here
|
|
250
|
+
* AND in the placeholder — the same string twice, with the
|
|
251
|
+
* placeholder truncated by the input's width. A cut-off copy
|
|
252
|
+
* of what must be typed exactly teaches the error.
|
|
253
|
+
*/}
|
|
254
|
+
<Text
|
|
255
|
+
fz={13}
|
|
256
|
+
c="gray.7"
|
|
257
|
+
mb={6}
|
|
258
|
+
>
|
|
259
|
+
{prompt}
|
|
260
|
+
</Text>
|
|
261
|
+
<Text
|
|
262
|
+
fz={13}
|
|
263
|
+
fw={800}
|
|
264
|
+
c="gray.9"
|
|
265
|
+
ff="var(--mantine-font-family-monospace)"
|
|
266
|
+
mb={8}
|
|
267
|
+
// One click selects the whole identifier. Copying is not
|
|
268
|
+
// cheating: the protection is against deleting the WRONG
|
|
269
|
+
// row, and pasted text is the right one.
|
|
270
|
+
style={{ wordBreak: 'break-all', userSelect: 'all' }}
|
|
271
|
+
>
|
|
272
|
+
{value}
|
|
273
|
+
</Text>
|
|
274
|
+
|
|
275
|
+
<TextInput
|
|
276
|
+
value={typed}
|
|
277
|
+
onChange={event => setTyped(event.currentTarget.value)}
|
|
278
|
+
aria-label={`${prompt}: ${value}`}
|
|
279
|
+
// An identifier is data, not prose: the keyboard's
|
|
280
|
+
// helpers get in the way of reproducing an exact string.
|
|
281
|
+
autoComplete="off"
|
|
282
|
+
autoCorrect="off"
|
|
283
|
+
autoCapitalize="off"
|
|
284
|
+
spellCheck={false}
|
|
285
|
+
data-autofocus
|
|
286
|
+
disabled={loading}
|
|
287
|
+
/*
|
|
288
|
+
* Monospace on the INPUT only. `ff` on the component
|
|
289
|
+
* lands on the wrapper and takes the prose with it.
|
|
290
|
+
*/
|
|
291
|
+
styles={{ input: { fontFamily: 'var(--mantine-font-family-monospace)' } }}
|
|
292
|
+
onKeyDown={event => {
|
|
293
|
+
// Enter completes what the field already authorized.
|
|
294
|
+
// Typing IS the confirmation; routing the person to
|
|
295
|
+
// the mouse afterwards is friction with no
|
|
296
|
+
// protective role.
|
|
297
|
+
if (event.key === 'Enter') confirm()
|
|
298
|
+
}}
|
|
299
|
+
/>
|
|
300
|
+
</Box>
|
|
301
|
+
</Stack>
|
|
302
|
+
|
|
303
|
+
{/*
|
|
304
|
+
* The footer is a band of its own and carries ONLY the buttons. It
|
|
305
|
+
* is also the modal's only rule: above it everything that is read
|
|
306
|
+
* and typed, below it the act.
|
|
307
|
+
*
|
|
308
|
+
* Destructive on the LEFT — §Formulários: "Submit à direita;
|
|
309
|
+
* destrutivo à esquerda". The submit position is reserved for what
|
|
310
|
+
* moves forward, and this does the opposite.
|
|
311
|
+
*/}
|
|
312
|
+
<Box
|
|
313
|
+
px={{ base: 24, md: 32 }}
|
|
314
|
+
py={20}
|
|
315
|
+
style={{ borderTop: 'var(--rlg-border)' }}
|
|
316
|
+
>
|
|
317
|
+
<Group
|
|
318
|
+
justify="space-between"
|
|
319
|
+
gap={8}
|
|
320
|
+
>
|
|
321
|
+
<Button
|
|
322
|
+
color="red.7"
|
|
323
|
+
onClick={confirm}
|
|
324
|
+
disabled={!canDelete}
|
|
325
|
+
loading={loading}
|
|
326
|
+
>
|
|
327
|
+
{confirmLabel}
|
|
328
|
+
</Button>
|
|
329
|
+
<Button
|
|
330
|
+
variant="subtle"
|
|
331
|
+
color="gray.7"
|
|
332
|
+
onClick={onClose}
|
|
333
|
+
disabled={loading}
|
|
334
|
+
>
|
|
335
|
+
Cancelar
|
|
336
|
+
</Button>
|
|
337
|
+
</Group>
|
|
338
|
+
</Box>
|
|
339
|
+
</Modal>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Box, Button, Stack, Text } from '@mantine/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Empty is not an error — it's a screen that doesn't have content yet.
|
|
5
|
+
*
|
|
6
|
+
* Spec (§States): Stack align center py 56 · title fz 10 fw 800 gray.4
|
|
7
|
+
* uppercase ls 1.5px + subtitle + action.
|
|
8
|
+
*
|
|
9
|
+
* Two anatomies, the same shape: "no data at all" and "the search found
|
|
10
|
+
* nothing", the latter quoting the term — whoever searched needs to see what
|
|
11
|
+
* was searched for.
|
|
12
|
+
*/
|
|
13
|
+
export function EmptyState({ title, description, actionLabel, onAction }) {
|
|
14
|
+
return (
|
|
15
|
+
<Stack
|
|
16
|
+
align="center"
|
|
17
|
+
gap={8}
|
|
18
|
+
py={56}
|
|
19
|
+
px="md"
|
|
20
|
+
>
|
|
21
|
+
<Text
|
|
22
|
+
fz={10}
|
|
23
|
+
fw={800}
|
|
24
|
+
c="gray.4"
|
|
25
|
+
tt="uppercase"
|
|
26
|
+
ls="1.5px"
|
|
27
|
+
ta="center"
|
|
28
|
+
>
|
|
29
|
+
{title}
|
|
30
|
+
</Text>
|
|
31
|
+
{description && (
|
|
32
|
+
<Text
|
|
33
|
+
fz={13}
|
|
34
|
+
c="gray.6"
|
|
35
|
+
lh={1.5}
|
|
36
|
+
ta="center"
|
|
37
|
+
maw={420}
|
|
38
|
+
>
|
|
39
|
+
{description}
|
|
40
|
+
</Text>
|
|
41
|
+
)}
|
|
42
|
+
{actionLabel && onAction && (
|
|
43
|
+
<Box mt={8}>
|
|
44
|
+
<Button
|
|
45
|
+
variant="outline"
|
|
46
|
+
color="gray.9"
|
|
47
|
+
onClick={onAction}
|
|
48
|
+
>
|
|
49
|
+
{actionLabel}
|
|
50
|
+
</Button>
|
|
51
|
+
</Box>
|
|
52
|
+
)}
|
|
53
|
+
</Stack>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Text } from '@mantine/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* O rótulo que nomeia um grupo na coluna — "Projetos", "Instâncias", "Conta".
|
|
5
|
+
*
|
|
6
|
+
* Seis cópias, e a única diferença real entre elas era o `lineClamp` que o
|
|
7
|
+
* Functions não tinha. Ele entra: um nome de grupo que quebra em duas linhas
|
|
8
|
+
* empurra a lista inteira, e nenhum destes rótulos precisa de duas linhas.
|
|
9
|
+
*
|
|
10
|
+
* O tamanho de 10px com `lts` de 1.5px é o papel de RÓTULO do sistema Zen —
|
|
11
|
+
* ele anuncia a seção sem competir com o conteúdo dela.
|
|
12
|
+
*/
|
|
13
|
+
export function GroupLabel({ children }) {
|
|
14
|
+
return (
|
|
15
|
+
<Text
|
|
16
|
+
fz={10}
|
|
17
|
+
fw={800}
|
|
18
|
+
c="gray.4"
|
|
19
|
+
tt="uppercase"
|
|
20
|
+
px={12}
|
|
21
|
+
mb={4}
|
|
22
|
+
lineClamp={1}
|
|
23
|
+
lts="1.5px"
|
|
24
|
+
>
|
|
25
|
+
{children}
|
|
26
|
+
</Text>
|
|
27
|
+
)
|
|
28
|
+
}
|