@ciromaciel/dashboard-kit 1.10.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciromaciel/dashboard-kit",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "The components every dashboard shares: table, empty state, loading state, destructive confirm, danger zone and wordmark.",
@@ -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
- * Destructive on the LEFT — §Formulários: "Submit à direita;
317
- * destrutivo à esquerda". The submit position is reserved for what
318
- * moves forward, and this does the opposite.
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="space-between"
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>
@@ -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'