@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,124 @@
|
|
|
1
|
+
import { Group, Stack, Text, Title } from '@mantine/core'
|
|
2
|
+
import { useMediaQuery } from '@mantine/hooks'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The canonical pair in dashboard form: a small label pressed against a heavy
|
|
6
|
+
* title.
|
|
7
|
+
*
|
|
8
|
+
* Two differences from the stage — the title is 36px (the dashboard has a
|
|
9
|
+
* controlled width, so it drops to 28 only on phones, where 36 would break a
|
|
10
|
+
* project name across three lines) and the row gains an actions zone on the
|
|
11
|
+
* right.
|
|
12
|
+
*
|
|
13
|
+
* Spec (§Page header):
|
|
14
|
+
* crumb fz 10 · fw 800 · gray.4 · uppercase · ls 1.5px — where you are
|
|
15
|
+
* title fz 36 · fw 900 · lh 1 · ls -0.04em — what this screen is
|
|
16
|
+
* subtitle fz 15 · fw 500 · gray.6 · lh 1.5 — what you can do
|
|
17
|
+
* actions Group gap md, on the right — ONE primary button
|
|
18
|
+
*
|
|
19
|
+
* One primary button per screen. If there are two equally important actions,
|
|
20
|
+
* one of them isn't.
|
|
21
|
+
*/
|
|
22
|
+
export function PageHeader({ crumb, title, subtitle, actions, order = 1, mb = 32 }) {
|
|
23
|
+
const side = useMediaQuery('(min-width: 48em)')
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* The crumb on project screens is assembled as `Product · ${project.name}`.
|
|
27
|
+
* On first load the name hasn't arrived yet (ProjectShell hands over a
|
|
28
|
+
* provisional project, with the id from the URL and an empty name), and the
|
|
29
|
+
* interpolation would leave a separator dangling at the end of the line — a
|
|
30
|
+
* " · " pointing at nothing. Trimming it here is worth more than repeating
|
|
31
|
+
* the condition on every screen: the header already owns the crumb's
|
|
32
|
+
* display rule.
|
|
33
|
+
*/
|
|
34
|
+
const trilha = typeof crumb === 'string' ? crumb.replace(/\s*·\s*$/, '') : crumb
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Group
|
|
38
|
+
justify="space-between"
|
|
39
|
+
align="flex-start"
|
|
40
|
+
/*
|
|
41
|
+
* On phones the actions drop to the line below instead of squeezing
|
|
42
|
+
* the title against the edge.
|
|
43
|
+
*
|
|
44
|
+
* `wrap` does NOT accept a responsive object: the Group passes it
|
|
45
|
+
* raw into the CSS var `--group-wrap`, and a stringified object is
|
|
46
|
+
* not a valid value. That's why the decision comes from
|
|
47
|
+
* `useMediaQuery`.
|
|
48
|
+
*/
|
|
49
|
+
wrap={side ? 'nowrap' : 'wrap'}
|
|
50
|
+
gap={{ base: 16, sm: 24 }}
|
|
51
|
+
mb={mb}
|
|
52
|
+
>
|
|
53
|
+
<Stack
|
|
54
|
+
gap={8}
|
|
55
|
+
/* The `0` in flex-shrink is what forces the wrap: with shrink 1
|
|
56
|
+
the two children shrank until they fit side by side, and the
|
|
57
|
+
title broke one word per line instead of the row wrapping. */
|
|
58
|
+
flex={{ base: '1 0 100%', sm: 1 }}
|
|
59
|
+
miw={0}
|
|
60
|
+
>
|
|
61
|
+
{trilha && (
|
|
62
|
+
<Text
|
|
63
|
+
fz={10}
|
|
64
|
+
fw={800}
|
|
65
|
+
c="gray.4"
|
|
66
|
+
tt="uppercase"
|
|
67
|
+
ls="1.5px"
|
|
68
|
+
lh={1.4}
|
|
69
|
+
>
|
|
70
|
+
{trilha}
|
|
71
|
+
</Text>
|
|
72
|
+
)}
|
|
73
|
+
<Title
|
|
74
|
+
order={order}
|
|
75
|
+
/*
|
|
76
|
+
* THE SIZE COMES FROM THE THEME, not from this line.
|
|
77
|
+
*
|
|
78
|
+
* Mantine's `Title` ignores a responsive `fz`: it always
|
|
79
|
+
* emits `--title-fz: var(--mantine-h1-font-size)`, whatever
|
|
80
|
+
* object you pass. Measured — `{base:28, sm:36}` and
|
|
81
|
+
* `{base:26, sm:32, md:36}` produce identical styling.
|
|
82
|
+
*
|
|
83
|
+
* This matters because three dashboards kept a local copy of
|
|
84
|
+
* this component "because of the different ramp", and the
|
|
85
|
+
* ramp never applied in any of the four. The divergence was
|
|
86
|
+
* only in the source; the screen was always the same.
|
|
87
|
+
*
|
|
88
|
+
* Anyone who wants to change the title scale changes the
|
|
89
|
+
* theme (`headings.sizes.h1`), which is where it actually
|
|
90
|
+
* lives.
|
|
91
|
+
*/
|
|
92
|
+
fw={900}
|
|
93
|
+
lh={1}
|
|
94
|
+
ls="-0.04em"
|
|
95
|
+
c="gray.9"
|
|
96
|
+
style={{ overflowWrap: 'anywhere' }}
|
|
97
|
+
>
|
|
98
|
+
{title}
|
|
99
|
+
</Title>
|
|
100
|
+
{subtitle && (
|
|
101
|
+
<Text
|
|
102
|
+
fz={15}
|
|
103
|
+
fw={500}
|
|
104
|
+
c="gray.6"
|
|
105
|
+
lh={1.5}
|
|
106
|
+
>
|
|
107
|
+
{subtitle}
|
|
108
|
+
</Text>
|
|
109
|
+
)}
|
|
110
|
+
</Stack>
|
|
111
|
+
|
|
112
|
+
{actions && (
|
|
113
|
+
<Group
|
|
114
|
+
gap="md"
|
|
115
|
+
wrap="nowrap"
|
|
116
|
+
flex={{ base: '1 0 100%', sm: 'none' }}
|
|
117
|
+
justify={side ? 'flex-end' : 'flex-start'}
|
|
118
|
+
>
|
|
119
|
+
{actions}
|
|
120
|
+
</Group>
|
|
121
|
+
)}
|
|
122
|
+
</Group>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Alert, Button, Group, Text } from '@mantine/core'
|
|
2
|
+
import { IconAlertTriangle } from '@tabler/icons-react'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The billing banner: shown when the owner has no active plan of this product.
|
|
6
|
+
*
|
|
7
|
+
* WHAT IT DOES NOT DO is the point of it. It does not block the panel, it does
|
|
8
|
+
* not cover the content, and it can never be the reason somebody fails to reach
|
|
9
|
+
* what they already have. The rule across the stack is FREEZE, NOT DISCONNECT:
|
|
10
|
+
* an expired subscription stops the NEXT thing from being created, and leaves
|
|
11
|
+
* everything already created working — including, in Auth's case, the login of
|
|
12
|
+
* end users who never bought anything from us and could not fix it if they
|
|
13
|
+
* wanted to.
|
|
14
|
+
*
|
|
15
|
+
* That is why the copy says what STILL WORKS before what does not. The first
|
|
16
|
+
* question of anyone who sees this banner is "did my users just go down?", and
|
|
17
|
+
* an answer that opens with the refusal makes them assume the worst.
|
|
18
|
+
*
|
|
19
|
+
* IT TAKES THE STATE, IT DOES NOT FETCH IT. The kit paints; it does not know
|
|
20
|
+
* the address of the Payments. The caller passes what `usePlanNotice(product)`
|
|
21
|
+
* from `@ciromaciel/payments-react` returned, which costs no request of its own —
|
|
22
|
+
* the provider has already loaded it.
|
|
23
|
+
*
|
|
24
|
+
* IT RENDERS NOTHING UNLESS THE STATE IS `none`. `unknown` — still loading, or
|
|
25
|
+
* the call failed — is deliberately silent: dunning somebody because our own
|
|
26
|
+
* billing request has not come back is worse than waiting for it, and it is
|
|
27
|
+
* also what keeps the banner from flashing on every page load.
|
|
28
|
+
*
|
|
29
|
+
* IT IS NOT DISMISSIBLE. A banner with an X is a banner that gets closed once
|
|
30
|
+
* and never seen again, and what it reports does not go away on its own — it
|
|
31
|
+
* goes away when the person pays. The button is the way out.
|
|
32
|
+
*
|
|
33
|
+
* RED, NOT YELLOW. Yellow reads as "look at this when you can"; a lapsed
|
|
34
|
+
* subscription is a wall the person has already hit. The panel's own vocabulary
|
|
35
|
+
* says so: `red.6`/`red.7` is what the kit spends on destructive confirmation,
|
|
36
|
+
* so a viewer who has seen a delete modal already knows this colour means
|
|
37
|
+
* "something is blocked until you act". The light `variant="light"` surface
|
|
38
|
+
* keeps it from shouting over the page it sits on — the banner still must not
|
|
39
|
+
* be the loudest thing on a screen that otherwise works.
|
|
40
|
+
*
|
|
41
|
+
* The button is `red.7` filled with white text, the same pair as the kit's
|
|
42
|
+
* destructive button, so the two never drift apart.
|
|
43
|
+
*
|
|
44
|
+
* @param {'active'|'none'|'unknown'} state What `usePlanNotice` returned.
|
|
45
|
+
* @param {string} message One sentence, in the reader's language. Says what
|
|
46
|
+
* keeps working before what does not.
|
|
47
|
+
* @param {string} [actionLabel]
|
|
48
|
+
* @param {Function} onAction Opens the Billing modal the panel already has.
|
|
49
|
+
*/
|
|
50
|
+
export function PlanBanner({ state, message, actionLabel = 'Regularizar assinatura', onAction }) {
|
|
51
|
+
if (state !== 'none') return null
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Alert
|
|
55
|
+
variant="light"
|
|
56
|
+
color="red.6"
|
|
57
|
+
icon={<IconAlertTriangle size={20} />}
|
|
58
|
+
title="Assinatura necessária"
|
|
59
|
+
mb="lg"
|
|
60
|
+
>
|
|
61
|
+
<Group
|
|
62
|
+
justify="space-between"
|
|
63
|
+
align="flex-start"
|
|
64
|
+
wrap="wrap"
|
|
65
|
+
gap="sm"
|
|
66
|
+
>
|
|
67
|
+
<Text
|
|
68
|
+
size="sm"
|
|
69
|
+
style={{ flex: '1 1 320px' }}
|
|
70
|
+
>
|
|
71
|
+
{message}
|
|
72
|
+
</Text>
|
|
73
|
+
|
|
74
|
+
{/* Responsive `w`, not `fullWidth`: Mantine's `fullWidth` is a
|
|
75
|
+
boolean and does not accept breakpoints, so on a phone the
|
|
76
|
+
button would keep its content width in the corner. */}
|
|
77
|
+
<Button
|
|
78
|
+
size="sm"
|
|
79
|
+
color="red.7"
|
|
80
|
+
onClick={onAction}
|
|
81
|
+
w={{ base: '100%', sm: 'auto' }}
|
|
82
|
+
>
|
|
83
|
+
{actionLabel}
|
|
84
|
+
</Button>
|
|
85
|
+
</Group>
|
|
86
|
+
</Alert>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Box, Text, UnstyledButton } from '@mantine/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Uma linha da lista de recursos na coluna lateral.
|
|
5
|
+
*
|
|
6
|
+
* VEIO DE SEIS CÓPIAS COM QUATRO NOMES: `ProjectItem` (payments, messages,
|
|
7
|
+
* hoster), `InstanceItem` (storage), `ApplicationItem` (auth) e `FunctionItem`
|
|
8
|
+
* (functions). A estrutura era a mesma nas seis — ícone, nome, segunda linha —
|
|
9
|
+
* e o que mudava era só o formato do objeto que cada uma recebia
|
|
10
|
+
* (`project.name`, `tenant.name`, `fn.name`) e o campo da segunda linha.
|
|
11
|
+
*
|
|
12
|
+
* Aqui o componente recebe `title` e `subtitle` já resolvidos. O painel sabe o
|
|
13
|
+
* que é um projeto; o kit não precisa saber.
|
|
14
|
+
*
|
|
15
|
+
* A SEGUNDA LINHA É O QUE DISTINGUE DUAS LINHAS PARECIDAS. No Storage é o
|
|
16
|
+
* ambiente, no Hoster o endereço no ar, no Payments se o projeto consegue
|
|
17
|
+
* cobrar. Sem ela, quatro nomes semelhantes viram quatro linhas iguais — e ela
|
|
18
|
+
* SOME quando não há dado, em vez de mostrar um estado inventado enquanto a
|
|
19
|
+
* resposta não chegou.
|
|
20
|
+
*
|
|
21
|
+
* `minWidth: 0` no botão e no texto não é detalhe: sem ele o conteúdo mais
|
|
22
|
+
* largo que a coluna define a largura mínima e empurra a barra inteira.
|
|
23
|
+
*
|
|
24
|
+
* @param {Function} icon Componente de ícone (tabler).
|
|
25
|
+
* @param {string} title O nome do recurso.
|
|
26
|
+
* @param {string} subtitle A segunda linha. Opcional.
|
|
27
|
+
* @param {'muted'|'alert'} tone `alert` pinta o subtítulo de vermelho — para
|
|
28
|
+
* quando ele É o problema ("SEM GATEWAY").
|
|
29
|
+
* @param {boolean} mono Título monoespaçado, para nome que é identificador.
|
|
30
|
+
* @param {node} trailing O que vai na ponta direita. O Hoster põe o alfinete
|
|
31
|
+
* de fixar; os outros não passam nada.
|
|
32
|
+
* @param {Function} onClick Abrir o recurso.
|
|
33
|
+
*/
|
|
34
|
+
export function ResourceItem({ icon: Icon, title, subtitle, tone = 'muted', mono = false, trailing, onClick }) {
|
|
35
|
+
return (
|
|
36
|
+
<UnstyledButton
|
|
37
|
+
onClick={onClick}
|
|
38
|
+
w="100%"
|
|
39
|
+
px={12}
|
|
40
|
+
py={8}
|
|
41
|
+
style={{
|
|
42
|
+
display: 'flex',
|
|
43
|
+
alignItems: 'center',
|
|
44
|
+
justifyContent: 'flex-start',
|
|
45
|
+
gap: 10,
|
|
46
|
+
borderRadius: 0,
|
|
47
|
+
background: 'transparent',
|
|
48
|
+
transition: 'background 120ms ease',
|
|
49
|
+
minWidth: 0,
|
|
50
|
+
maxWidth: '100%',
|
|
51
|
+
}}
|
|
52
|
+
onMouseEnter={e => {
|
|
53
|
+
e.currentTarget.style.background = 'var(--mantine-color-gray-1)'
|
|
54
|
+
}}
|
|
55
|
+
onMouseLeave={e => {
|
|
56
|
+
e.currentTarget.style.background = 'transparent'
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
<Icon
|
|
60
|
+
size={18}
|
|
61
|
+
stroke={1.5}
|
|
62
|
+
style={{ flex: 'none', color: 'var(--mantine-color-gray-5)' }}
|
|
63
|
+
/>
|
|
64
|
+
|
|
65
|
+
<Box style={{ flex: 1, minWidth: 0, lineHeight: 1.25, textAlign: 'left' }}>
|
|
66
|
+
<Text
|
|
67
|
+
fz={13}
|
|
68
|
+
fw={500}
|
|
69
|
+
c="gray.9"
|
|
70
|
+
lineClamp={1}
|
|
71
|
+
ff={mono ? 'monospace' : undefined}
|
|
72
|
+
>
|
|
73
|
+
{title}
|
|
74
|
+
</Text>
|
|
75
|
+
|
|
76
|
+
{subtitle && (
|
|
77
|
+
<Text
|
|
78
|
+
fz={9}
|
|
79
|
+
fw={800}
|
|
80
|
+
c={tone === 'alert' ? 'red.6' : 'gray.4'}
|
|
81
|
+
tt="uppercase"
|
|
82
|
+
lts="1.5px"
|
|
83
|
+
lineClamp={1}
|
|
84
|
+
>
|
|
85
|
+
{subtitle}
|
|
86
|
+
</Text>
|
|
87
|
+
)}
|
|
88
|
+
</Box>
|
|
89
|
+
|
|
90
|
+
{trailing}
|
|
91
|
+
</UnstyledButton>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { Box, Card, Divider, Group, Text, Title } from '@mantine/core'
|
|
2
|
+
import { useMediaQuery } from '@mantine/hooks'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The dashboard's settings block — the same primitive as the auth dashboard
|
|
6
|
+
* (components/Configurations/Shared.jsx) and the storage one (SettingsView).
|
|
7
|
+
*
|
|
8
|
+
* THE TITLE LIVES INSIDE THE CARD. This is the difference that separates a
|
|
9
|
+
* dashboard from a document, and it's worth writing out in full:
|
|
10
|
+
*
|
|
11
|
+
* The hoster opened each block with a SectionHeader — a "01 · ENVIAR
|
|
12
|
+
* ARQUIVOS" label, a 32px title, a 15px description over two lines — and only
|
|
13
|
+
* then the Card with the content. Each of those headers cost ~135px of height
|
|
14
|
+
* before any data, and two sections per screen spent 30% of the viewport saying
|
|
15
|
+
* the names of things. Worse: with the screen's h1 at 36px and the section's at
|
|
16
|
+
* 32px, nothing declared which of the two was the page title.
|
|
17
|
+
*
|
|
18
|
+
* Here the title is 18px and lives in the Card's own header, right up against
|
|
19
|
+
* the content it names. The ratio against the PageHeader is back to 36:18 — the
|
|
20
|
+
* same as auth — and the whole screen fits where half of it used to.
|
|
21
|
+
*
|
|
22
|
+
* The 01/02/03 numbering went with it. It promises a sequence, and parallel
|
|
23
|
+
* settings are not steps of anything; in auth, numbering only shows up in
|
|
24
|
+
* onboarding, where you really do follow one step after another.
|
|
25
|
+
*/
|
|
26
|
+
export function SectionCard({ eyebrow, title, lead, action, children }) {
|
|
27
|
+
return (
|
|
28
|
+
<Card
|
|
29
|
+
p={0}
|
|
30
|
+
withBorder
|
|
31
|
+
styles={{ root: { overflow: 'hidden' } }}
|
|
32
|
+
>
|
|
33
|
+
<Group
|
|
34
|
+
justify="space-between"
|
|
35
|
+
align="flex-end"
|
|
36
|
+
px={{ base: 16, sm: 24 }}
|
|
37
|
+
py={20}
|
|
38
|
+
gap={16}
|
|
39
|
+
wrap="nowrap"
|
|
40
|
+
>
|
|
41
|
+
<Box
|
|
42
|
+
miw={0}
|
|
43
|
+
flex={1}
|
|
44
|
+
>
|
|
45
|
+
{/* `span` because the eyebrow isn't always text: the domains
|
|
46
|
+
screen passes the status indicator (glyph + word), and a
|
|
47
|
+
<div> inside the <p> that Text generates by default is
|
|
48
|
+
invalid HTML — the browser closes the paragraph on its own
|
|
49
|
+
and the layout breaks. As a span, any content fits. */}
|
|
50
|
+
{eyebrow && (
|
|
51
|
+
<Text
|
|
52
|
+
component="span"
|
|
53
|
+
display="block"
|
|
54
|
+
fz={10}
|
|
55
|
+
fw={800}
|
|
56
|
+
c="gray.4"
|
|
57
|
+
tt="uppercase"
|
|
58
|
+
mb={6}
|
|
59
|
+
ls="1.5px"
|
|
60
|
+
>
|
|
61
|
+
{eyebrow}
|
|
62
|
+
</Text>
|
|
63
|
+
)}
|
|
64
|
+
<Title
|
|
65
|
+
order={3}
|
|
66
|
+
fz={18}
|
|
67
|
+
fw={800}
|
|
68
|
+
lh={1.2}
|
|
69
|
+
ls="-0.02em"
|
|
70
|
+
c="gray.9"
|
|
71
|
+
>
|
|
72
|
+
{title}
|
|
73
|
+
</Title>
|
|
74
|
+
{lead && (
|
|
75
|
+
<Text
|
|
76
|
+
fz={13}
|
|
77
|
+
fw={500}
|
|
78
|
+
c="gray.6"
|
|
79
|
+
lh={1.5}
|
|
80
|
+
mt={6}
|
|
81
|
+
maw={640}
|
|
82
|
+
>
|
|
83
|
+
{lead}
|
|
84
|
+
</Text>
|
|
85
|
+
)}
|
|
86
|
+
</Box>
|
|
87
|
+
{action && <Box flex="none">{action}</Box>}
|
|
88
|
+
</Group>
|
|
89
|
+
|
|
90
|
+
<Divider />
|
|
91
|
+
|
|
92
|
+
<Box>{children}</Box>
|
|
93
|
+
</Card>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One row of the block: label on the left, control on the right.
|
|
99
|
+
*
|
|
100
|
+
* `1fr auto` instead of two equal columns — the text is elastic and the control
|
|
101
|
+
* gets the width it needs. `last` suppresses the final border, otherwise the
|
|
102
|
+
* hairline would double up with the Card's frame.
|
|
103
|
+
*/
|
|
104
|
+
export function Field({ label, help, control, last = false, align = 'center' }) {
|
|
105
|
+
/*
|
|
106
|
+
* Two columns become one on phones.
|
|
107
|
+
*
|
|
108
|
+
* The `auto` column is the control, and `auto` won't shrink below the
|
|
109
|
+
* control's own width: on a 280px input the label was squeezed to zero and
|
|
110
|
+
* the row overflowed anyway. Stacked, the control gets the full width and
|
|
111
|
+
* the label fits again.
|
|
112
|
+
*/
|
|
113
|
+
const side = useMediaQuery('(min-width: 48em)')
|
|
114
|
+
return (
|
|
115
|
+
<Box
|
|
116
|
+
px={{ base: 16, sm: 24 }}
|
|
117
|
+
py={18}
|
|
118
|
+
style={{
|
|
119
|
+
borderBottom: last ? 'none' : 'var(--rlg-border)',
|
|
120
|
+
display: 'grid',
|
|
121
|
+
gridTemplateColumns: side ? '1fr auto' : '1fr',
|
|
122
|
+
gap: side ? 32 : 10,
|
|
123
|
+
alignItems: side ? align : 'stretch',
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
126
|
+
<Box miw={0}>
|
|
127
|
+
<Text
|
|
128
|
+
fz={13}
|
|
129
|
+
fw={700}
|
|
130
|
+
c="gray.9"
|
|
131
|
+
lh={1.3}
|
|
132
|
+
>
|
|
133
|
+
{label}
|
|
134
|
+
</Text>
|
|
135
|
+
{help && (
|
|
136
|
+
<Text
|
|
137
|
+
fz={12}
|
|
138
|
+
fw={500}
|
|
139
|
+
c="gray.6"
|
|
140
|
+
lh={1.5}
|
|
141
|
+
mt={4}
|
|
142
|
+
maw={520}
|
|
143
|
+
>
|
|
144
|
+
{help}
|
|
145
|
+
</Text>
|
|
146
|
+
)}
|
|
147
|
+
</Box>
|
|
148
|
+
<Box style={{ justifySelf: side ? 'end' : 'stretch' }}>{control}</Box>
|
|
149
|
+
</Box>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The same row, with the control at full width below the label — for whatever
|
|
155
|
+
* doesn't fit on the right: a textarea, a dropzone, a whole table.
|
|
156
|
+
*/
|
|
157
|
+
export function FieldStacked({ label, help, control, last = false }) {
|
|
158
|
+
return (
|
|
159
|
+
<Box
|
|
160
|
+
px={{ base: 16, sm: 24 }}
|
|
161
|
+
py={20}
|
|
162
|
+
style={{ borderBottom: last ? 'none' : 'var(--rlg-border)' }}
|
|
163
|
+
>
|
|
164
|
+
{label && (
|
|
165
|
+
<Text
|
|
166
|
+
fz={13}
|
|
167
|
+
fw={700}
|
|
168
|
+
c="gray.9"
|
|
169
|
+
lh={1.3}
|
|
170
|
+
mb={4}
|
|
171
|
+
>
|
|
172
|
+
{label}
|
|
173
|
+
</Text>
|
|
174
|
+
)}
|
|
175
|
+
{help && (
|
|
176
|
+
<Text
|
|
177
|
+
fz={12}
|
|
178
|
+
fw={500}
|
|
179
|
+
c="gray.6"
|
|
180
|
+
lh={1.5}
|
|
181
|
+
mb={12}
|
|
182
|
+
maw={520}
|
|
183
|
+
>
|
|
184
|
+
{help}
|
|
185
|
+
</Text>
|
|
186
|
+
)}
|
|
187
|
+
{control}
|
|
188
|
+
</Box>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A block's action footer — outside the body, over the gray.1 band.
|
|
194
|
+
*
|
|
195
|
+
* It sits inside the Card, not loose below it: the action belongs to the block
|
|
196
|
+
* it saves, and a button floating between two Cards doesn't say which one it
|
|
197
|
+
* answers to.
|
|
198
|
+
*/
|
|
199
|
+
export function CardActions({ children, justify = 'flex-end' }) {
|
|
200
|
+
return (
|
|
201
|
+
<Group
|
|
202
|
+
justify={justify}
|
|
203
|
+
gap={8}
|
|
204
|
+
px={{ base: 16, sm: 24 }}
|
|
205
|
+
py={16}
|
|
206
|
+
bg="gray.1"
|
|
207
|
+
style={{ borderTop: 'var(--rlg-border)' }}
|
|
208
|
+
>
|
|
209
|
+
{children}
|
|
210
|
+
</Group>
|
|
211
|
+
)
|
|
212
|
+
}
|