@ciromaciel/dashboard-kit 1.9.1 → 1.11.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/package.json +1 -1
- package/src/components/ColorSchemeToggle.jsx +13 -4
- package/src/components/DataTable.jsx +7 -6
- package/src/components/DeleteModal.jsx +20 -12
- package/src/components/HoldToConfirm.jsx +37 -9
- package/src/components/MenuItem.jsx +1 -1
- package/src/components/ResourceItem.jsx +1 -1
- package/src/components/SectionCard.jsx +22 -3
- package/src/components/notify-theme.js +3 -3
- package/src/components/undoable.jsx +131 -0
- package/src/index.js +1 -0
- package/src/theme.js +13 -9
- package/src/tokens.css +83 -10
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ActionIcon, Tooltip, useMantineColorScheme } from '@mantine/core'
|
|
2
|
-
import { useMounted } from '@mantine/hooks'
|
|
2
|
+
import { useColorScheme, useMounted } from '@mantine/hooks'
|
|
3
3
|
import { IconDeviceDesktop, IconMoon, IconSun } from '@tabler/icons-react'
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -11,6 +11,12 @@ import { IconDeviceDesktop, IconMoon, IconSun } from '@tabler/icons-react'
|
|
|
11
11
|
* twice: the tooltip and the accessible name both read "Tema: Claro. Mudar
|
|
12
12
|
* para Escuro." — what it is now, and what one click does.
|
|
13
13
|
*
|
|
14
|
+
* The cycle follows the device (14/10/2026): from Automático the first click
|
|
15
|
+
* goes to the theme OPPOSITE the device's, so every click from Automático
|
|
16
|
+
* changes the screen. Before, on a light device, Automático → Claro changed
|
|
17
|
+
* nothing visible. The tooltip opens on focus and touch too, not only on
|
|
18
|
+
* hover — keyboard and touch users see the state as well.
|
|
19
|
+
*
|
|
14
20
|
* The icon waits for the mount. Mantine reads the stored choice from
|
|
15
21
|
* localStorage on the client only; a prerendered page would otherwise paint the
|
|
16
22
|
* server's icon and swap it on hydration.
|
|
@@ -18,22 +24,25 @@ import { IconDeviceDesktop, IconMoon, IconSun } from '@tabler/icons-react'
|
|
|
18
24
|
* The same component lives in the components-kit for the sites; the two must
|
|
19
25
|
* move together.
|
|
20
26
|
*/
|
|
21
|
-
const NEXT = { auto: 'light', light: 'dark', dark: 'auto' }
|
|
22
27
|
const NAME = { auto: 'Automático', light: 'Claro', dark: 'Escuro' }
|
|
23
28
|
const ICON = { auto: IconDeviceDesktop, light: IconSun, dark: IconMoon }
|
|
24
29
|
|
|
25
30
|
export function ColorSchemeToggle({ size = 36, ...props }) {
|
|
26
31
|
const { colorScheme, setColorScheme } = useMantineColorScheme()
|
|
27
32
|
const isMounted = useMounted()
|
|
33
|
+
const system = useColorScheme('light')
|
|
28
34
|
const current = isMounted ? colorScheme : 'auto'
|
|
35
|
+
const opposite = system === 'dark' ? 'light' : 'dark'
|
|
36
|
+
const next = { auto: opposite, [opposite]: system, [system]: 'auto' }[current] ?? 'auto'
|
|
29
37
|
const Icon = ICON[current]
|
|
30
|
-
const label = `Tema: ${NAME[current]}. Mudar para ${NAME[
|
|
38
|
+
const label = `Tema: ${NAME[current]}. Mudar para ${NAME[next]}.`
|
|
31
39
|
|
|
32
40
|
return (
|
|
33
41
|
<Tooltip
|
|
34
42
|
label={label}
|
|
35
43
|
position="top"
|
|
36
44
|
withArrow={false}
|
|
45
|
+
events={{ hover: true, focus: true, touch: true }}
|
|
37
46
|
>
|
|
38
47
|
<ActionIcon
|
|
39
48
|
data-color-scheme-toggle
|
|
@@ -42,7 +51,7 @@ export function ColorSchemeToggle({ size = 36, ...props }) {
|
|
|
42
51
|
size={size}
|
|
43
52
|
radius={0}
|
|
44
53
|
aria-label={label}
|
|
45
|
-
onClick={() => setColorScheme(
|
|
54
|
+
onClick={() => setColorScheme(next)}
|
|
46
55
|
{...props}
|
|
47
56
|
>
|
|
48
57
|
<Icon
|
|
@@ -12,11 +12,12 @@ import { templateFor, ROW_PADDING, HEAD_PADDING } from './table-grid'
|
|
|
12
12
|
*
|
|
13
13
|
* Spec (§Table):
|
|
14
14
|
* container Card p={0} withBorder · overflow hidden
|
|
15
|
-
* header bg
|
|
15
|
+
* header bg --rlg-head (3% of the ink: a step above any surface, in both
|
|
16
|
+
* themes — gray.1 sank below a card in dark mode) · padding 12px 24px
|
|
16
17
|
* header cell fz 10 · fw 800 · gray.5 · uppercase · ls 1.5px
|
|
17
18
|
* row padding 14px 24px · borderBottom 1px gray.2 (except the last)
|
|
18
19
|
* cell fz 13 · gray.7 · lh 1.4 — the "data" role
|
|
19
|
-
* hover
|
|
20
|
+
* hover --rlg-hover (6% of the ink) · transition 120ms
|
|
20
21
|
* highlight background gray.1 + 2px black hairline on the left (inset)
|
|
21
22
|
* — `quietSelection` keeps only the background, for when the
|
|
22
23
|
* highlight is a state of the data and not the row you clicked
|
|
@@ -80,7 +81,7 @@ export function DataTable({ columns, rows, getRowKey, onRowClick, isRowSelected,
|
|
|
80
81
|
|
|
81
82
|
{!stacked && (
|
|
82
83
|
<Box
|
|
83
|
-
bg="
|
|
84
|
+
bg="var(--rlg-head)"
|
|
84
85
|
style={{
|
|
85
86
|
display: 'grid',
|
|
86
87
|
gridTemplateColumns: template,
|
|
@@ -125,7 +126,7 @@ export function DataTable({ columns, rows, getRowKey, onRowClick, isRowSelected,
|
|
|
125
126
|
const clickable = typeof onRowClick === 'function' || hasDetail
|
|
126
127
|
const activate = () => (typeof onRowClick === 'function' ? onRowClick(row) : toggle(key))
|
|
127
128
|
const last = i === rows.length - 1
|
|
128
|
-
const rest = tone === 'broken' ? 'var(--mantine-color-red-0)' : selected ? 'var(--
|
|
129
|
+
const rest = tone === 'broken' ? 'var(--mantine-color-red-0)' : selected ? 'var(--rlg-hover)' : ''
|
|
129
130
|
|
|
130
131
|
return (
|
|
131
132
|
<Fragment key={key}>
|
|
@@ -146,7 +147,7 @@ export function DataTable({ columns, rows, getRowKey, onRowClick, isRowSelected,
|
|
|
146
147
|
}
|
|
147
148
|
: undefined
|
|
148
149
|
}
|
|
149
|
-
onMouseEnter={clickable ? e => (e.currentTarget.style.backgroundColor = 'var(--
|
|
150
|
+
onMouseEnter={clickable ? e => (e.currentTarget.style.backgroundColor = 'var(--rlg-hover)') : undefined}
|
|
150
151
|
onMouseLeave={clickable ? e => (e.currentTarget.style.backgroundColor = rest) : undefined}
|
|
151
152
|
tabIndex={clickable ? 0 : undefined}
|
|
152
153
|
role={clickable ? 'button' : undefined}
|
|
@@ -193,7 +194,7 @@ export function DataTable({ columns, rows, getRowKey, onRowClick, isRowSelected,
|
|
|
193
194
|
</Box>
|
|
194
195
|
{open && (
|
|
195
196
|
<Box
|
|
196
|
-
bg="
|
|
197
|
+
bg="var(--rlg-head)"
|
|
197
198
|
style={{ borderBottom: last ? 'none' : 'var(--rlg-border)' }}
|
|
198
199
|
>
|
|
199
200
|
{detail}
|
|
@@ -313,36 +313,44 @@ export function DeleteModal({
|
|
|
313
313
|
* is also the modal's only rule: above it everything that is read
|
|
314
314
|
* and typed, below it the act.
|
|
315
315
|
*
|
|
316
|
-
*
|
|
317
|
-
* destrutivo à esquerda".
|
|
318
|
-
*
|
|
316
|
+
* Until 14/10/2026 the destructive button sat on the LEFT, filled,
|
|
317
|
+
* by the forms rule "destrutivo à esquerda". That rule is for a
|
|
318
|
+
* form whose main act is saving; here deleting IS the act, and the
|
|
319
|
+
* alert rule applies instead.
|
|
319
320
|
*/}
|
|
320
321
|
<Box
|
|
321
322
|
px={{ base: 24, md: 32 }}
|
|
322
323
|
py={20}
|
|
323
324
|
style={{ borderTop: 'var(--rlg-border)' }}
|
|
324
325
|
>
|
|
326
|
+
{/* Cancel on the leading side, the destructive act on the
|
|
327
|
+
trailing one, outlined — Apple's alert rule, decided with
|
|
328
|
+
the Ciro on 14/10/2026. The destructive button keeps its
|
|
329
|
+
colour (it says what kind of act this is) but not the fill
|
|
330
|
+
of a primary action: deleting is never the step the screen
|
|
331
|
+
pushes toward. */}
|
|
325
332
|
<Group
|
|
326
|
-
justify="
|
|
333
|
+
justify="flex-end"
|
|
327
334
|
gap={8}
|
|
328
335
|
>
|
|
336
|
+
<Button
|
|
337
|
+
variant="subtle"
|
|
338
|
+
color="gray.7"
|
|
339
|
+
onClick={onClose}
|
|
340
|
+
disabled={loading}
|
|
341
|
+
>
|
|
342
|
+
Cancelar
|
|
343
|
+
</Button>
|
|
329
344
|
<Button
|
|
330
345
|
className="rlg-danger"
|
|
331
346
|
color="red.7"
|
|
347
|
+
variant="outline"
|
|
332
348
|
onClick={confirm}
|
|
333
349
|
disabled={!canDelete}
|
|
334
350
|
loading={loading}
|
|
335
351
|
>
|
|
336
352
|
{confirmLabel}
|
|
337
353
|
</Button>
|
|
338
|
-
<Button
|
|
339
|
-
variant="subtle"
|
|
340
|
-
color="gray.7"
|
|
341
|
-
onClick={onClose}
|
|
342
|
-
disabled={loading}
|
|
343
|
-
>
|
|
344
|
-
Cancelar
|
|
345
|
-
</Button>
|
|
346
354
|
</Group>
|
|
347
355
|
</Box>
|
|
348
356
|
</Modal>
|
|
@@ -24,6 +24,10 @@ function useHold(onConfirm, holdMs) {
|
|
|
24
24
|
const [progress, setProgress] = useState(0)
|
|
25
25
|
const timer = useRef(null)
|
|
26
26
|
const holding = useRef(false)
|
|
27
|
+
// Marks a real press (mouse, finger, key) so the click that follows it is
|
|
28
|
+
// not mistaken for an assistive-technology activation. A ref: the button
|
|
29
|
+
// re-renders every tick while held, and a plain object would reset.
|
|
30
|
+
const pressed = useRef(false)
|
|
27
31
|
|
|
28
32
|
const stop = useCallback(() => {
|
|
29
33
|
if (timer.current) clearInterval(timer.current)
|
|
@@ -53,22 +57,39 @@ function useHold(onConfirm, holdMs) {
|
|
|
53
57
|
// If the component leaves the screen mid-hold, the interval dies with it.
|
|
54
58
|
useEffect(() => stop, [stop])
|
|
55
59
|
|
|
56
|
-
return { progress, start, stop }
|
|
60
|
+
return { progress, start, stop, pressed }
|
|
57
61
|
}
|
|
58
62
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Handlers shared by both variants: pointer, touch and keyboard — and the
|
|
65
|
+
* assistive-technology path.
|
|
66
|
+
*
|
|
67
|
+
* A screen reader (VoiceOver, TalkBack) or Switch Control does not hold a key:
|
|
68
|
+
* it sends one synthesized `click`, with no keydown or mousedown before it.
|
|
69
|
+
* Until 14/10/2026 that click did nothing, so a person using one could never
|
|
70
|
+
* delete. Now a click that arrives WITHOUT a hold in progress asks a second
|
|
71
|
+
* time, in the browser's own dialog — which every assistive technology reads —
|
|
72
|
+
* and confirms only on yes. Apple's rule for hard-to-recover actions under
|
|
73
|
+
* assistive access: confirm twice. A mouse, finger or key hold never reaches
|
|
74
|
+
* that path, because each of them marks the press before the click.
|
|
75
|
+
*/
|
|
76
|
+
function holdHandlers(start, stop, disabled, { pressed, onConfirm, confirmText }) {
|
|
61
77
|
if (disabled) return {}
|
|
78
|
+
const press = fn => e => {
|
|
79
|
+
pressed.current = true
|
|
80
|
+
fn(e)
|
|
81
|
+
}
|
|
62
82
|
return {
|
|
63
|
-
onMouseDown: start,
|
|
83
|
+
onMouseDown: press(start),
|
|
64
84
|
onMouseUp: stop,
|
|
65
85
|
onMouseLeave: stop,
|
|
66
|
-
onTouchStart: start,
|
|
86
|
+
onTouchStart: press(start),
|
|
67
87
|
onTouchEnd: stop,
|
|
68
88
|
onTouchCancel: stop,
|
|
69
89
|
onKeyDown: e => {
|
|
70
90
|
if (e.key === ' ' || e.key === 'Enter') {
|
|
71
91
|
e.preventDefault()
|
|
92
|
+
pressed.current = true
|
|
72
93
|
start()
|
|
73
94
|
}
|
|
74
95
|
},
|
|
@@ -76,6 +97,13 @@ function holdHandlers(start, stop, disabled) {
|
|
|
76
97
|
if (e.key === ' ' || e.key === 'Enter') stop()
|
|
77
98
|
},
|
|
78
99
|
onBlur: stop,
|
|
100
|
+
onClick: () => {
|
|
101
|
+
if (pressed.current) {
|
|
102
|
+
pressed.current = false
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (typeof window !== 'undefined' && window.confirm(confirmText)) onConfirm()
|
|
106
|
+
},
|
|
79
107
|
}
|
|
80
108
|
}
|
|
81
109
|
|
|
@@ -84,7 +112,7 @@ function holdHandlers(start, stop, disabled) {
|
|
|
84
112
|
* The ring grows over the icon itself.
|
|
85
113
|
*/
|
|
86
114
|
export function HoldToConfirmIcon({ icon, label, onConfirm, disabled, color = 'red.6', holdMs = HOLD_MS }) {
|
|
87
|
-
const { progress, start, stop } = useHold(onConfirm, holdMs)
|
|
115
|
+
const { progress, start, stop, pressed } = useHold(onConfirm, holdMs)
|
|
88
116
|
const active = progress > 0
|
|
89
117
|
|
|
90
118
|
return (
|
|
@@ -109,7 +137,7 @@ export function HoldToConfirmIcon({ icon, label, onConfirm, disabled, color = 'r
|
|
|
109
137
|
color={active ? color : 'gray'}
|
|
110
138
|
aria-label={label}
|
|
111
139
|
disabled={disabled}
|
|
112
|
-
{...holdHandlers(start, stop, disabled)}
|
|
140
|
+
{...holdHandlers(start, stop, disabled, { pressed, onConfirm, confirmText: `${label}? Não dá para desfazer.` })}
|
|
113
141
|
>
|
|
114
142
|
{icon}
|
|
115
143
|
</ActionIcon>
|
|
@@ -123,7 +151,7 @@ export function HoldToConfirmIcon({ icon, label, onConfirm, disabled, color = 'r
|
|
|
123
151
|
* to say what is going to happen and how much is left.
|
|
124
152
|
*/
|
|
125
153
|
export function HoldToConfirmButton({ children, holdingLabel, onConfirm, disabled, loading, leftSection, holdMs = HOLD_MS, ...props }) {
|
|
126
|
-
const { progress, start, stop } = useHold(onConfirm, holdMs)
|
|
154
|
+
const { progress, start, stop, pressed } = useHold(onConfirm, holdMs)
|
|
127
155
|
const active = progress > 0
|
|
128
156
|
const remaining = Math.ceil(((100 - progress) / 100) * (holdMs / 1000))
|
|
129
157
|
|
|
@@ -134,7 +162,7 @@ export function HoldToConfirmButton({ children, holdingLabel, onConfirm, disable
|
|
|
134
162
|
disabled={disabled}
|
|
135
163
|
loading={loading}
|
|
136
164
|
leftSection={leftSection}
|
|
137
|
-
{...holdHandlers(start, stop, disabled || loading)}
|
|
165
|
+
{...holdHandlers(start, stop, disabled || loading, { pressed, onConfirm, confirmText: `${typeof children === 'string' ? children : 'Confirmar'}? Não dá para desfazer.` })}
|
|
138
166
|
{...props}
|
|
139
167
|
>
|
|
140
168
|
{active ? (
|
|
@@ -33,7 +33,7 @@ export function MenuItem({ icon: Icon, label, onClick }) {
|
|
|
33
33
|
transition: 'background 120ms ease',
|
|
34
34
|
}}
|
|
35
35
|
onMouseEnter={e => {
|
|
36
|
-
e.currentTarget.style.background = 'var(--
|
|
36
|
+
e.currentTarget.style.background = 'var(--rlg-hover)'
|
|
37
37
|
}}
|
|
38
38
|
onMouseLeave={e => {
|
|
39
39
|
e.currentTarget.style.background = 'transparent'
|
|
@@ -50,7 +50,7 @@ export function ResourceItem({ icon: Icon, title, subtitle, tone = 'muted', mono
|
|
|
50
50
|
maxWidth: '100%',
|
|
51
51
|
}}
|
|
52
52
|
onMouseEnter={e => {
|
|
53
|
-
e.currentTarget.style.background = 'var(--
|
|
53
|
+
e.currentTarget.style.background = 'var(--rlg-hover)'
|
|
54
54
|
}}
|
|
55
55
|
onMouseLeave={e => {
|
|
56
56
|
e.currentTarget.style.background = 'transparent'
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { cloneElement, isValidElement, useId } from 'react'
|
|
1
2
|
import { Box, Card, Divider, Group, Text, Title } from '@mantine/core'
|
|
2
3
|
import { useMediaQuery } from '@mantine/hooks'
|
|
3
4
|
|
|
@@ -61,8 +62,10 @@ export function SectionCard({ eyebrow, title, lead, action, children }) {
|
|
|
61
62
|
{eyebrow}
|
|
62
63
|
</Text>
|
|
63
64
|
)}
|
|
65
|
+
{/* h2: it comes right under the page's h1 (PageHeader). It was h3,
|
|
66
|
+
which skipped a level for screen readers. The size stays. */}
|
|
64
67
|
<Title
|
|
65
|
-
order={
|
|
68
|
+
order={2}
|
|
66
69
|
fz={18}
|
|
67
70
|
fw={800}
|
|
68
71
|
lh={1.2}
|
|
@@ -94,6 +97,18 @@ export function SectionCard({ eyebrow, title, lead, action, children }) {
|
|
|
94
97
|
)
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The control is named by the row's label. The label is a Text next to the
|
|
102
|
+
* control, not a <label>, so without this a screen reader announced the input
|
|
103
|
+
* with no name at all (23 settings screens). A control that already has a
|
|
104
|
+
* label or aria-label keeps it.
|
|
105
|
+
*/
|
|
106
|
+
function labelled(control, labelId) {
|
|
107
|
+
if (!labelId || !isValidElement(control)) return control
|
|
108
|
+
if (control.props['aria-label'] || control.props['aria-labelledby'] || control.props.label) return control
|
|
109
|
+
return cloneElement(control, { 'aria-labelledby': labelId })
|
|
110
|
+
}
|
|
111
|
+
|
|
97
112
|
/**
|
|
98
113
|
* One row of the block: label on the left, control on the right.
|
|
99
114
|
*
|
|
@@ -102,6 +117,7 @@ export function SectionCard({ eyebrow, title, lead, action, children }) {
|
|
|
102
117
|
* hairline would double up with the Card's frame.
|
|
103
118
|
*/
|
|
104
119
|
export function Field({ label, help, control, last = false, align = 'center' }) {
|
|
120
|
+
const labelId = useId()
|
|
105
121
|
/*
|
|
106
122
|
* Two columns become one on phones.
|
|
107
123
|
*
|
|
@@ -125,6 +141,7 @@ export function Field({ label, help, control, last = false, align = 'center' })
|
|
|
125
141
|
>
|
|
126
142
|
<Box miw={0}>
|
|
127
143
|
<Text
|
|
144
|
+
id={labelId}
|
|
128
145
|
fz={13}
|
|
129
146
|
fw={700}
|
|
130
147
|
c="gray.9"
|
|
@@ -145,7 +162,7 @@ export function Field({ label, help, control, last = false, align = 'center' })
|
|
|
145
162
|
</Text>
|
|
146
163
|
)}
|
|
147
164
|
</Box>
|
|
148
|
-
<Box style={{ justifySelf: side ? 'end' : 'stretch' }}>{control}</Box>
|
|
165
|
+
<Box style={{ justifySelf: side ? 'end' : 'stretch' }}>{labelled(control, label && labelId)}</Box>
|
|
149
166
|
</Box>
|
|
150
167
|
)
|
|
151
168
|
}
|
|
@@ -155,6 +172,7 @@ export function Field({ label, help, control, last = false, align = 'center' })
|
|
|
155
172
|
* doesn't fit on the right: a textarea, a dropzone, a whole table.
|
|
156
173
|
*/
|
|
157
174
|
export function FieldStacked({ label, help, control, last = false }) {
|
|
175
|
+
const labelId = useId()
|
|
158
176
|
return (
|
|
159
177
|
<Box
|
|
160
178
|
px={{ base: 16, sm: 24 }}
|
|
@@ -163,6 +181,7 @@ export function FieldStacked({ label, help, control, last = false }) {
|
|
|
163
181
|
>
|
|
164
182
|
{label && (
|
|
165
183
|
<Text
|
|
184
|
+
id={labelId}
|
|
166
185
|
fz={13}
|
|
167
186
|
fw={700}
|
|
168
187
|
c="gray.9"
|
|
@@ -184,7 +203,7 @@ export function FieldStacked({ label, help, control, last = false }) {
|
|
|
184
203
|
{help}
|
|
185
204
|
</Text>
|
|
186
205
|
)}
|
|
187
|
-
{control}
|
|
206
|
+
{labelled(control, label && labelId)}
|
|
188
207
|
</Box>
|
|
189
208
|
)
|
|
190
209
|
}
|
|
@@ -55,7 +55,7 @@ export const notificationTheme = {
|
|
|
55
55
|
styles: theme => ({
|
|
56
56
|
root: {
|
|
57
57
|
boxShadow: 'none',
|
|
58
|
-
border:
|
|
58
|
+
border: '1px solid var(--mantine-color-gray-2)',
|
|
59
59
|
'--notification-radius': '0',
|
|
60
60
|
},
|
|
61
61
|
icon: {
|
|
@@ -79,11 +79,11 @@ export const notificationTheme = {
|
|
|
79
79
|
fontWeight: 800,
|
|
80
80
|
fontSize: '13px',
|
|
81
81
|
letterSpacing: '-0.02em',
|
|
82
|
-
color:
|
|
82
|
+
color: 'var(--mantine-color-gray-9)',
|
|
83
83
|
},
|
|
84
84
|
description: {
|
|
85
85
|
fontSize: '13px',
|
|
86
|
-
color:
|
|
86
|
+
color: 'var(--mantine-color-gray-6)',
|
|
87
87
|
},
|
|
88
88
|
}),
|
|
89
89
|
},
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Button, Group, Text } from '@mantine/core'
|
|
2
|
+
import { notifications } from '@mantine/notifications'
|
|
3
|
+
|
|
4
|
+
import { notify } from './notify'
|
|
5
|
+
|
|
6
|
+
/** How long the undo stays on offer. Long enough to read one line and reach the button. */
|
|
7
|
+
export const UNDO_MS = 6000
|
|
8
|
+
|
|
9
|
+
let sequence = 0
|
|
10
|
+
const pending = []
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Delete now, offer Desfazer, send the request only when the offer ends.
|
|
14
|
+
*
|
|
15
|
+
* Apple's rule (decided with the Ciro on 14/10/2026): for an action people
|
|
16
|
+
* perform often, undo beats confirmation. A dialog or a hold is friction paid on
|
|
17
|
+
* every delete to protect the rare mistake; undo charges nothing and still
|
|
18
|
+
* catches the mistake — after it happens, when the person can see it.
|
|
19
|
+
*
|
|
20
|
+
* Nothing is sent to the server while the offer stands. The item leaves the
|
|
21
|
+
* screen (`onHide`), the request (`onCommit`) waits `ms`, and Desfazer only puts
|
|
22
|
+
* the item back (`onRestore`). If the request fails, the item comes back too and
|
|
23
|
+
* the error stays on screen.
|
|
24
|
+
*
|
|
25
|
+
* ⌘Z / Ctrl+Z undoes the latest pending delete, outside text fields — the
|
|
26
|
+
* shortcut every Apple app gives undo.
|
|
27
|
+
*
|
|
28
|
+
* Closing the toast keeps the delete and sends it at once. Leaving the page
|
|
29
|
+
* sends every pending delete (`pagehide`).
|
|
30
|
+
*
|
|
31
|
+
* NOT for credentials or connections: revoking a key or a webhook has to take
|
|
32
|
+
* effect at once, and those keep the hold. See `10-modal-de-exclusao.md`.
|
|
33
|
+
*
|
|
34
|
+
* @param {object} options
|
|
35
|
+
* @param {string} options.message What left, in a few words: "Campo excluído".
|
|
36
|
+
* @param {Function} options.onCommit Sends the delete. May return a promise.
|
|
37
|
+
* @param {Function} [options.onHide] Takes the item off the screen.
|
|
38
|
+
* @param {Function} [options.onRestore] Puts it back.
|
|
39
|
+
* @param {number} [options.ms]
|
|
40
|
+
* @returns {{ undo: Function, commit: Function }}
|
|
41
|
+
*/
|
|
42
|
+
export function undoable({ message, onCommit, onHide, onRestore, ms = UNDO_MS }) {
|
|
43
|
+
const id = `rlg-undo-${++sequence}`
|
|
44
|
+
let isSettled = false
|
|
45
|
+
|
|
46
|
+
const settle = () => {
|
|
47
|
+
isSettled = true
|
|
48
|
+
clearTimeout(timer)
|
|
49
|
+
const index = pending.indexOf(entry)
|
|
50
|
+
if (index >= 0) pending.splice(index, 1)
|
|
51
|
+
notifications.hide(id)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const commit = async () => {
|
|
55
|
+
if (isSettled) return
|
|
56
|
+
settle()
|
|
57
|
+
try {
|
|
58
|
+
await onCommit()
|
|
59
|
+
} catch (error) {
|
|
60
|
+
onRestore?.()
|
|
61
|
+
notify.error('Não excluiu', error?.message || 'Tente de novo.')
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const undo = () => {
|
|
66
|
+
if (isSettled) return
|
|
67
|
+
settle()
|
|
68
|
+
onRestore?.()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const entry = { undo, commit }
|
|
72
|
+
onHide?.()
|
|
73
|
+
pending.push(entry)
|
|
74
|
+
listen()
|
|
75
|
+
const timer = setTimeout(commit, ms)
|
|
76
|
+
|
|
77
|
+
notifications.show({
|
|
78
|
+
id,
|
|
79
|
+
color: 'gray',
|
|
80
|
+
autoClose: false,
|
|
81
|
+
onClose: commit,
|
|
82
|
+
message: (
|
|
83
|
+
<Group
|
|
84
|
+
justify="space-between"
|
|
85
|
+
wrap="nowrap"
|
|
86
|
+
gap={12}
|
|
87
|
+
>
|
|
88
|
+
<Text
|
|
89
|
+
fz={13}
|
|
90
|
+
c="inherit"
|
|
91
|
+
>
|
|
92
|
+
{message}
|
|
93
|
+
</Text>
|
|
94
|
+
<Button
|
|
95
|
+
size="compact-sm"
|
|
96
|
+
variant="subtle"
|
|
97
|
+
color="gray.9"
|
|
98
|
+
onClick={undo}
|
|
99
|
+
>
|
|
100
|
+
Desfazer
|
|
101
|
+
</Button>
|
|
102
|
+
</Group>
|
|
103
|
+
),
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
return entry
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let isListening = false
|
|
110
|
+
|
|
111
|
+
function listen() {
|
|
112
|
+
if (isListening || typeof window === 'undefined') return
|
|
113
|
+
isListening = true
|
|
114
|
+
|
|
115
|
+
window.addEventListener('keydown', event => {
|
|
116
|
+
if (!(event.metaKey || event.ctrlKey) || event.shiftKey || event.key.toLowerCase() !== 'z') return
|
|
117
|
+
if (pending.length === 0 || isEditable(event.target)) return
|
|
118
|
+
event.preventDefault()
|
|
119
|
+
pending.at(-1).undo()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
window.addEventListener('pagehide', () => {
|
|
123
|
+
for (const entry of [...pending]) entry.commit()
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A text field keeps its own ⌘Z: it undoes typing, not a delete. */
|
|
128
|
+
function isEditable(target) {
|
|
129
|
+
if (!target || typeof target.closest !== 'function') return false
|
|
130
|
+
return Boolean(target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]'))
|
|
131
|
+
}
|
package/src/index.js
CHANGED
|
@@ -64,6 +64,7 @@ export { DangerZone } from './components/DangerZone'
|
|
|
64
64
|
export { ZenLabel } from './components/ZenLabel'
|
|
65
65
|
export { Tag } from './components/Tag'
|
|
66
66
|
export { notify } from './components/notify'
|
|
67
|
+
export { undoable, UNDO_MS } from './components/undoable'
|
|
67
68
|
export { NOTIFICATIONS_PROPS, NOTIFY_DESCRIPTION_CSS, notificationTheme } from './components/notify-theme'
|
|
68
69
|
export { panelTheme } from './theme'
|
|
69
70
|
export { Wordmark } from './components/Wordmark'
|
package/src/theme.js
CHANGED
|
@@ -19,6 +19,10 @@ import { notificationTheme } from './components/notify-theme'
|
|
|
19
19
|
* same as the components-kit's; the two files must move together.
|
|
20
20
|
*/
|
|
21
21
|
export const panelTheme = createTheme({
|
|
22
|
+
// Mantine's own transitions (Modal, Menu, Popover, Notification) follow
|
|
23
|
+
// "Reduzir movimento" too, not only ours.
|
|
24
|
+
respectReducedMotion: true,
|
|
25
|
+
|
|
22
26
|
fontFamily: 'Montserrat, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
|
23
27
|
fontFamilyMonospace: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
|
24
28
|
|
|
@@ -272,7 +276,7 @@ export const panelTheme = createTheme({
|
|
|
272
276
|
defaultProps: { size: 'sm', radius: '0', inputWrapperOrder: ['label', 'input', 'description', 'error'] },
|
|
273
277
|
styles: (theme, props) => ({
|
|
274
278
|
label: { marginBottom: rem(6), fontWeight: 700, fontSize: rem(13) },
|
|
275
|
-
input: props.variant === 'unstyled' ? {} : { border: `1px solid var(--mantine-color-gray-
|
|
279
|
+
input: props.variant === 'unstyled' ? {} : { border: `1px solid var(--mantine-color-gray-3)` },
|
|
276
280
|
}),
|
|
277
281
|
},
|
|
278
282
|
/**
|
|
@@ -290,35 +294,35 @@ export const panelTheme = createTheme({
|
|
|
290
294
|
defaultProps: { size: 'sm', radius: '0', inputWrapperOrder: ['label', 'input', 'description', 'error'] },
|
|
291
295
|
styles: (theme, props) => ({
|
|
292
296
|
label: { marginBottom: rem(6), fontWeight: 700, fontSize: rem(13) },
|
|
293
|
-
input: props.variant === 'unstyled' ? {} : { border: `1px solid var(--mantine-color-gray-
|
|
297
|
+
input: props.variant === 'unstyled' ? {} : { border: `1px solid var(--mantine-color-gray-3)` },
|
|
294
298
|
}),
|
|
295
299
|
},
|
|
296
300
|
Textarea: {
|
|
297
301
|
defaultProps: { size: 'sm', radius: '0', inputWrapperOrder: ['label', 'input', 'description', 'error'] },
|
|
298
302
|
styles: (theme, props) => ({
|
|
299
303
|
label: { marginBottom: rem(6), fontWeight: 700, fontSize: rem(13) },
|
|
300
|
-
input: props.variant === 'unstyled' ? {} : { border: `1px solid var(--mantine-color-gray-
|
|
304
|
+
input: props.variant === 'unstyled' ? {} : { border: `1px solid var(--mantine-color-gray-3)` },
|
|
301
305
|
}),
|
|
302
306
|
},
|
|
303
307
|
Select: {
|
|
304
308
|
defaultProps: { size: 'sm', radius: '0', inputWrapperOrder: ['label', 'input', 'description', 'error'] },
|
|
305
309
|
styles: (theme, props) => ({
|
|
306
310
|
label: { marginBottom: rem(6), fontWeight: 700, fontSize: rem(13) },
|
|
307
|
-
input: props.variant === 'unstyled' ? {} : { border: `1px solid var(--mantine-color-gray-
|
|
311
|
+
input: props.variant === 'unstyled' ? {} : { border: `1px solid var(--mantine-color-gray-3)` },
|
|
308
312
|
}),
|
|
309
313
|
},
|
|
310
314
|
NumberInput: {
|
|
311
315
|
defaultProps: { size: 'sm', radius: '0', inputWrapperOrder: ['label', 'input', 'description', 'error'] },
|
|
312
316
|
styles: theme => ({
|
|
313
317
|
label: { marginBottom: rem(6), fontWeight: 700, fontSize: rem(13) },
|
|
314
|
-
input: { border: `1px solid var(--mantine-color-gray-
|
|
318
|
+
input: { border: `1px solid var(--mantine-color-gray-3)` },
|
|
315
319
|
}),
|
|
316
320
|
},
|
|
317
321
|
Autocomplete: {
|
|
318
322
|
defaultProps: { size: 'sm', radius: '0', inputWrapperOrder: ['label', 'input', 'description', 'error'] },
|
|
319
323
|
styles: theme => ({
|
|
320
324
|
label: { marginBottom: rem(6), fontWeight: 700, fontSize: rem(13) },
|
|
321
|
-
input: { border: `1px solid var(--mantine-color-gray-
|
|
325
|
+
input: { border: `1px solid var(--mantine-color-gray-3)` },
|
|
322
326
|
}),
|
|
323
327
|
},
|
|
324
328
|
/**
|
|
@@ -343,7 +347,7 @@ export const panelTheme = createTheme({
|
|
|
343
347
|
styles: theme => ({
|
|
344
348
|
label: { marginBottom: rem(6), fontWeight: 700, fontSize: rem(13) },
|
|
345
349
|
input: {
|
|
346
|
-
border: `1px solid var(--mantine-color-gray-
|
|
350
|
+
border: `1px solid var(--mantine-color-gray-3)`,
|
|
347
351
|
minHeight: rem(36),
|
|
348
352
|
display: 'flex',
|
|
349
353
|
alignItems: 'center',
|
|
@@ -356,7 +360,7 @@ export const panelTheme = createTheme({
|
|
|
356
360
|
defaultProps: { size: 'sm', radius: '0', inputWrapperOrder: ['label', 'input', 'description', 'error'] },
|
|
357
361
|
styles: theme => ({
|
|
358
362
|
label: { marginBottom: rem(6), fontWeight: 700, fontSize: rem(13) },
|
|
359
|
-
input: { border: `1px solid var(--mantine-color-gray-
|
|
363
|
+
input: { border: `1px solid var(--mantine-color-gray-3)` },
|
|
360
364
|
}),
|
|
361
365
|
},
|
|
362
366
|
SegmentedControl: {
|
|
@@ -382,7 +386,7 @@ export const panelTheme = createTheme({
|
|
|
382
386
|
fontSize: rem(10),
|
|
383
387
|
fontWeight: 700,
|
|
384
388
|
color: "var(--mantine-color-gray-5)",
|
|
385
|
-
backgroundColor:
|
|
389
|
+
backgroundColor: 'var(--mantine-color-white)',
|
|
386
390
|
border: `1px solid var(--mantine-color-gray-2)`,
|
|
387
391
|
borderBottomColor: "var(--mantine-color-gray-2)",
|
|
388
392
|
boxShadow: 'none',
|
package/src/tokens.css
CHANGED
|
@@ -90,23 +90,41 @@
|
|
|
90
90
|
* runtime, after this sheet.
|
|
91
91
|
*/
|
|
92
92
|
@media (prefers-contrast: more) {
|
|
93
|
+
/* Contraste aumentado: a rampa inteira desloca e continua em ORDEM — cada
|
|
94
|
+
degrau mais forte que o anterior —, e a borda passa de 3:1 sobre a base,
|
|
95
|
+
o cartão e o menu. Vermelho e verde ganham a sua variante. Até
|
|
96
|
+
14/10/2026 o corpo ficava mais forte que o dado e a borda seguia abaixo
|
|
97
|
+
de 3:1. */
|
|
93
98
|
:root:root:root[data-mantine-color-scheme='dark'] {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
--mantine-color-gray-2: #3a444d;
|
|
97
|
-
--mantine-color-gray-3: #4b5563;
|
|
99
|
+
--mantine-color-gray-2: #6b7582;
|
|
100
|
+
--mantine-color-gray-3: #7b8591;
|
|
98
101
|
--mantine-color-gray-4: #b6bec8;
|
|
99
|
-
--mantine-color-gray-5: #
|
|
100
|
-
--mantine-color-gray-6: #
|
|
102
|
+
--mantine-color-gray-5: #c4cad2;
|
|
103
|
+
--mantine-color-gray-6: #d3d8de;
|
|
104
|
+
--mantine-color-gray-7: #e5e7eb;
|
|
105
|
+
--mantine-color-gray-8: #f3f4f6;
|
|
106
|
+
--mantine-color-gray-9: #ffffff;
|
|
107
|
+
--mantine-color-red-6: #ffa8a8;
|
|
108
|
+
--mantine-color-red-7: #ffc9c9;
|
|
109
|
+
--mantine-color-red-filled: #ffa8a8;
|
|
110
|
+
--mantine-color-green-6: #4ade80;
|
|
111
|
+
--mantine-color-green-7: #86efac;
|
|
101
112
|
}
|
|
102
113
|
|
|
103
114
|
:root:root[data-mantine-color-scheme='light'],
|
|
104
115
|
:root:root:not([data-mantine-color-scheme]) {
|
|
105
|
-
--mantine-color-gray-2: #
|
|
106
|
-
--mantine-color-gray-3: #
|
|
116
|
+
--mantine-color-gray-2: #878e99;
|
|
117
|
+
--mantine-color-gray-3: #7b838e;
|
|
107
118
|
--mantine-color-gray-4: #4b5563;
|
|
108
|
-
--mantine-color-gray-5: #
|
|
119
|
+
--mantine-color-gray-5: #414a57;
|
|
109
120
|
--mantine-color-gray-6: #374151;
|
|
121
|
+
--mantine-color-gray-7: #252d38;
|
|
122
|
+
--mantine-color-gray-8: #1a222b;
|
|
123
|
+
--mantine-color-gray-9: #0b0f12;
|
|
124
|
+
--mantine-color-red-6: #c92a2a;
|
|
125
|
+
--mantine-color-red-7: #a61e1e;
|
|
126
|
+
--mantine-color-green-6: #15803d;
|
|
127
|
+
--mantine-color-green-7: #166534;
|
|
110
128
|
}
|
|
111
129
|
}
|
|
112
130
|
|
|
@@ -166,7 +184,7 @@
|
|
|
166
184
|
--mantine-color-gray-1: #12181d; /* palco alternado, hover */
|
|
167
185
|
--mantine-color-gray-2: #222a31; /* filete */
|
|
168
186
|
--mantine-color-gray-3: #2f3840; /* borda forte, desabilitado */
|
|
169
|
-
--mantine-color-gray-4: #
|
|
187
|
+
--mantine-color-gray-4: #8a94a0; /* label caixa alta — 6,3:1 na base, 4,7:1 no menu */
|
|
170
188
|
--mantine-color-gray-5: #98a1ac; /* legenda — 7,4:1 */
|
|
171
189
|
--mantine-color-gray-6: #b6bec8; /* corpo — 10,3:1 */
|
|
172
190
|
--mantine-color-gray-7: #d3d8de; /* dado — 13,4:1 */
|
|
@@ -233,3 +251,58 @@
|
|
|
233
251
|
:root[data-mantine-color-scheme='dark'] img[data-invert-dark]:not([data-keep-colors]) {
|
|
234
252
|
filter: invert(1) hue-rotate(180deg);
|
|
235
253
|
}
|
|
254
|
+
|
|
255
|
+
/* ===========================================================================
|
|
256
|
+
* Revisão de 14/10/2026 contra as diretrizes da Apple (16-apple.md).
|
|
257
|
+
* =========================================================================== */
|
|
258
|
+
|
|
259
|
+
/* O realce de linha e de item funciona em QUALQUER superfície. Era gray.1 —
|
|
260
|
+
no escuro, um degrau da base: dentro de um cartão ou de um menu, o item
|
|
261
|
+
destacado afundava abaixo da própria superfície. Uma tinta de 6% do preto
|
|
262
|
+
do sistema escurece no claro e clareia no escuro, sobre o que estiver
|
|
263
|
+
embaixo. */
|
|
264
|
+
:root {
|
|
265
|
+
--rlg-hover: color-mix(in srgb, var(--mantine-color-gray-9) 6%, transparent);
|
|
266
|
+
--rlg-head: color-mix(in srgb, var(--mantine-color-gray-9) 3%, transparent);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* Alvo de toque também em notebook com tela de toque (que se declara
|
|
270
|
+
"ponteiro fino") e em mais controles: item de navegação, caixa de seleção,
|
|
271
|
+
chave, abas, paginação, menu. */
|
|
272
|
+
@media (any-pointer: coarse) {
|
|
273
|
+
.mantine-Button-root,
|
|
274
|
+
.mantine-ActionIcon-root,
|
|
275
|
+
.mantine-CloseButton-root,
|
|
276
|
+
.mantine-Burger-root,
|
|
277
|
+
.mantine-NavLink-root,
|
|
278
|
+
.mantine-Tabs-tab,
|
|
279
|
+
.mantine-Pagination-control,
|
|
280
|
+
.mantine-Menu-item,
|
|
281
|
+
.mantine-SegmentedControl-control {
|
|
282
|
+
min-height: 44px;
|
|
283
|
+
}
|
|
284
|
+
.mantine-ActionIcon-root,
|
|
285
|
+
.mantine-CloseButton-root,
|
|
286
|
+
.mantine-Burger-root,
|
|
287
|
+
.mantine-Pagination-control {
|
|
288
|
+
min-width: 44px;
|
|
289
|
+
}
|
|
290
|
+
/* O selo sobe para 11px no toque: 10px fica abaixo do mínimo da Apple
|
|
291
|
+
para texto em tela de celular. */
|
|
292
|
+
[data-tag] {
|
|
293
|
+
font-size: 11px !important;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* Menos movimento, sem perder o esmaecimento. A Apple pede que deslocamento
|
|
298
|
+
vire esmaecimento, não que tudo pare: animações em laço param, a rolagem
|
|
299
|
+
deixa de ser suave, e o que é cor e opacidade continua. */
|
|
300
|
+
@media (prefers-reduced-motion: reduce) {
|
|
301
|
+
*,
|
|
302
|
+
*::before,
|
|
303
|
+
*::after {
|
|
304
|
+
animation-iteration-count: 1 !important;
|
|
305
|
+
animation-duration: 0.01ms !important;
|
|
306
|
+
scroll-behavior: auto !important;
|
|
307
|
+
}
|
|
308
|
+
}
|