@duro-app/ui 0.37.1 → 0.38.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/dist/components/Toast/Toast.meta.d.ts +3 -0
- package/dist/components/Toast/Toast.meta.d.ts.map +1 -0
- package/dist/components/Toast/Toast.stories.d.ts +9 -0
- package/dist/components/Toast/Toast.stories.d.ts.map +1 -0
- package/dist/components/Toast/ToastProvider.d.ts +25 -0
- package/dist/components/Toast/ToastProvider.d.ts.map +1 -0
- package/dist/components/Toast/index.d.ts +3 -0
- package/dist/components/Toast/index.d.ts.map +1 -0
- package/dist/components/Toast/styles.css.d.ts +110 -0
- package/dist/components/Toast/styles.css.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2093 -1862
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/Toast/Toast.meta.ts +34 -0
- package/src/components/Toast/Toast.stories.tsx +81 -0
- package/src/components/Toast/ToastProvider.tsx +189 -0
- package/src/components/Toast/index.ts +2 -0
- package/src/components/Toast/styles.css.ts +115 -0
- package/src/index.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
}
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@duro-app/tokens": "^0.
|
|
57
|
+
"@duro-app/tokens": "^0.38.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Transient, self-dismissing notification for the RESULT of an action (saved, sent, revoked, failed). Mount <ToastProvider> once at the app root and call useToast().toast() from anywhere. Toasts stack and portal above dialogs.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Confirming an action succeeded (e.g. "Grant revoked")',
|
|
8
|
+
'Reporting an action failed, with the reason',
|
|
9
|
+
'Reversible actions that benefit from an inline Undo',
|
|
10
|
+
],
|
|
11
|
+
whenNotToUse: [
|
|
12
|
+
'In-form validation errors — use Field.Error or an inline Alert',
|
|
13
|
+
'Persistent block-level messages — use Callout',
|
|
14
|
+
'Confirming a destructive action before it runs — use ConfirmDialog / Dialog',
|
|
15
|
+
],
|
|
16
|
+
relatedTo: [
|
|
17
|
+
{
|
|
18
|
+
component: 'Alert',
|
|
19
|
+
relationship: 'Alert for inline/in-form status; Toast for action results',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
component: 'ConfirmDialog',
|
|
23
|
+
relationship: 'ConfirmDialog gates a destructive action; Toast reports the outcome',
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
example: `function Example() {
|
|
27
|
+
const {toast} = useToast()
|
|
28
|
+
return (
|
|
29
|
+
<Button onClick={() => toast({variant: 'success', message: 'Grant revoked', action: {label: 'Undo', onClick: restore}})}>
|
|
30
|
+
Revoke
|
|
31
|
+
</Button>
|
|
32
|
+
)
|
|
33
|
+
}`,
|
|
34
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect, userEvent, waitFor} from 'storybook/test'
|
|
3
|
+
import {css, html} from 'react-strict-dom'
|
|
4
|
+
import {ToastProvider, useToast} from './ToastProvider'
|
|
5
|
+
|
|
6
|
+
const s = css.create({
|
|
7
|
+
row: {display: 'flex', flexDirection: 'row', gap: 8, flexWrap: 'wrap'},
|
|
8
|
+
btn: {padding: 8, borderRadius: 6, borderWidth: 1, borderStyle: 'solid', cursor: 'pointer'},
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
function Triggers() {
|
|
12
|
+
const {toast} = useToast()
|
|
13
|
+
return (
|
|
14
|
+
<html.div style={s.row}>
|
|
15
|
+
<html.button
|
|
16
|
+
style={s.btn}
|
|
17
|
+
onClick={() => toast({variant: 'success', message: 'Grant revoked', duration: 0})}
|
|
18
|
+
>
|
|
19
|
+
Show success
|
|
20
|
+
</html.button>
|
|
21
|
+
<html.button
|
|
22
|
+
style={s.btn}
|
|
23
|
+
onClick={() =>
|
|
24
|
+
toast({variant: 'error', message: 'Couldn’t revoke: SMTP down', duration: 0})
|
|
25
|
+
}
|
|
26
|
+
>
|
|
27
|
+
Show error
|
|
28
|
+
</html.button>
|
|
29
|
+
<html.button
|
|
30
|
+
style={s.btn}
|
|
31
|
+
onClick={() =>
|
|
32
|
+
toast({
|
|
33
|
+
variant: 'success',
|
|
34
|
+
message: 'Mapping deleted',
|
|
35
|
+
duration: 0,
|
|
36
|
+
action: {label: 'Undo', onClick: () => {}},
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
>
|
|
40
|
+
Show with undo
|
|
41
|
+
</html.button>
|
|
42
|
+
</html.div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const meta: Meta<typeof ToastProvider> = {
|
|
47
|
+
title: 'Components/Toast',
|
|
48
|
+
component: ToastProvider,
|
|
49
|
+
render: () => (
|
|
50
|
+
<ToastProvider>
|
|
51
|
+
<Triggers />
|
|
52
|
+
</ToastProvider>
|
|
53
|
+
),
|
|
54
|
+
}
|
|
55
|
+
export default meta
|
|
56
|
+
type Story = StoryObj<typeof ToastProvider>
|
|
57
|
+
|
|
58
|
+
export const Success: Story = {
|
|
59
|
+
play: async ({canvas}) => {
|
|
60
|
+
await userEvent.click(canvas.getByText('Show success'))
|
|
61
|
+
const toast = await waitFor(() => canvas.getByRole('status'))
|
|
62
|
+
await expect(toast).toHaveTextContent('Grant revoked')
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const Error: Story = {
|
|
67
|
+
play: async ({canvas}) => {
|
|
68
|
+
await userEvent.click(canvas.getByText('Show error'))
|
|
69
|
+
const toast = await waitFor(() => canvas.getByRole('alert'))
|
|
70
|
+
await expect(toast).toHaveTextContent('Couldn’t revoke: SMTP down')
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const Dismiss: Story = {
|
|
75
|
+
play: async ({canvas}) => {
|
|
76
|
+
await userEvent.click(canvas.getByText('Show success'))
|
|
77
|
+
await waitFor(() => canvas.getByRole('status'))
|
|
78
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Dismiss'}))
|
|
79
|
+
await waitFor(() => expect(canvas.queryByRole('status')).not.toBeInTheDocument())
|
|
80
|
+
},
|
|
81
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useSyncExternalStore,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react'
|
|
11
|
+
import {createPortal} from 'react-dom'
|
|
12
|
+
import {html} from 'react-strict-dom'
|
|
13
|
+
import {Icon, type IconName} from '../Icon'
|
|
14
|
+
import {usePortalMount} from '../ThemeProvider/ThemeProvider'
|
|
15
|
+
import {styles} from './styles.css'
|
|
16
|
+
|
|
17
|
+
export type ToastVariant = 'success' | 'error' | 'info'
|
|
18
|
+
|
|
19
|
+
export interface ToastAction {
|
|
20
|
+
label: string
|
|
21
|
+
onClick: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ToastOptions {
|
|
25
|
+
/** Visual + semantic tone. Errors announce assertively and linger longer. */
|
|
26
|
+
variant?: ToastVariant
|
|
27
|
+
message: ReactNode
|
|
28
|
+
/** Optional single action, e.g. Undo. Runs, then dismisses the toast. */
|
|
29
|
+
action?: ToastAction
|
|
30
|
+
/** Auto-dismiss delay in ms. `0` (or negative) keeps it until dismissed. */
|
|
31
|
+
duration?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ToastContextValue {
|
|
35
|
+
/** Show a toast; returns its id so it can be dismissed programmatically. */
|
|
36
|
+
toast: (opts: ToastOptions) => string
|
|
37
|
+
dismiss: (id: string) => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ToastEntry {
|
|
41
|
+
id: string
|
|
42
|
+
variant: ToastVariant
|
|
43
|
+
message: ReactNode
|
|
44
|
+
action?: ToastAction
|
|
45
|
+
duration: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ToastStore {
|
|
49
|
+
subscribe: (cb: () => void) => () => void
|
|
50
|
+
getSnapshot: () => readonly ToastEntry[]
|
|
51
|
+
add: (opts: ToastOptions) => string
|
|
52
|
+
remove: (id: string) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DEFAULT_DURATION: Record<ToastVariant, number> = {
|
|
56
|
+
success: 5000,
|
|
57
|
+
info: 5000,
|
|
58
|
+
error: 8000,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createToastStore(): ToastStore {
|
|
62
|
+
let entries: ToastEntry[] = []
|
|
63
|
+
let seq = 0
|
|
64
|
+
const listeners = new Set<() => void>()
|
|
65
|
+
const notify = () => listeners.forEach((l) => l())
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
subscribe: (cb) => {
|
|
69
|
+
listeners.add(cb)
|
|
70
|
+
return () => listeners.delete(cb)
|
|
71
|
+
},
|
|
72
|
+
getSnapshot: () => entries,
|
|
73
|
+
add: (opts) => {
|
|
74
|
+
const variant = opts.variant ?? 'info'
|
|
75
|
+
const id = `toast-${++seq}`
|
|
76
|
+
const duration = opts.duration ?? DEFAULT_DURATION[variant]
|
|
77
|
+
entries = [...entries, {id, variant, message: opts.message, action: opts.action, duration}]
|
|
78
|
+
notify()
|
|
79
|
+
return id
|
|
80
|
+
},
|
|
81
|
+
remove: (id) => {
|
|
82
|
+
const next = entries.filter((e) => e.id !== id)
|
|
83
|
+
if (next.length !== entries.length) {
|
|
84
|
+
entries = next
|
|
85
|
+
notify()
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const variantIcon: Record<ToastVariant, IconName> = {
|
|
92
|
+
success: 'check-circle-filled',
|
|
93
|
+
error: 'x-circle-filled',
|
|
94
|
+
info: 'info-circle-filled',
|
|
95
|
+
}
|
|
96
|
+
const variantIconStyle = {
|
|
97
|
+
success: styles.iconSuccess,
|
|
98
|
+
error: styles.iconError,
|
|
99
|
+
info: styles.iconInfo,
|
|
100
|
+
} as const
|
|
101
|
+
|
|
102
|
+
const ToastContext = createContext<ToastContextValue | null>(null)
|
|
103
|
+
|
|
104
|
+
export function useToast(): ToastContextValue {
|
|
105
|
+
const ctx = useContext(ToastContext)
|
|
106
|
+
if (!ctx) throw new Error('useToast must be used within a <ToastProvider>')
|
|
107
|
+
return ctx
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function ToastItem({entry, onDismiss}: {entry: ToastEntry; onDismiss: (id: string) => void}) {
|
|
111
|
+
const {id, variant, message, action, duration} = entry
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (duration <= 0) return
|
|
115
|
+
const timer = setTimeout(() => onDismiss(id), duration)
|
|
116
|
+
return () => clearTimeout(timer)
|
|
117
|
+
}, [id, duration, onDismiss])
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<html.div
|
|
121
|
+
role={variant === 'error' ? 'alert' : 'status'}
|
|
122
|
+
aria-live={variant === 'error' ? 'assertive' : 'polite'}
|
|
123
|
+
style={[styles.toast, styles[variant]]}
|
|
124
|
+
>
|
|
125
|
+
<html.div style={[styles.iconWrap, variantIconStyle[variant]]}>
|
|
126
|
+
<Icon name={variantIcon[variant]} size={18} />
|
|
127
|
+
</html.div>
|
|
128
|
+
<html.div style={styles.content}>
|
|
129
|
+
<html.div style={styles.message}>{message}</html.div>
|
|
130
|
+
{action ? (
|
|
131
|
+
<html.button
|
|
132
|
+
type="button"
|
|
133
|
+
style={styles.action}
|
|
134
|
+
onClick={() => {
|
|
135
|
+
action.onClick()
|
|
136
|
+
onDismiss(id)
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
{action.label}
|
|
140
|
+
</html.button>
|
|
141
|
+
) : null}
|
|
142
|
+
</html.div>
|
|
143
|
+
<html.button
|
|
144
|
+
type="button"
|
|
145
|
+
aria-label="Dismiss"
|
|
146
|
+
style={styles.closeBtn}
|
|
147
|
+
onClick={() => onDismiss(id)}
|
|
148
|
+
>
|
|
149
|
+
<Icon name="x-circle" size={16} />
|
|
150
|
+
</html.button>
|
|
151
|
+
</html.div>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function ToastProvider({children}: {children: ReactNode}) {
|
|
156
|
+
const storeRef = useRef<ToastStore>(null)
|
|
157
|
+
if (storeRef.current === null) {
|
|
158
|
+
storeRef.current = createToastStore()
|
|
159
|
+
}
|
|
160
|
+
const store = storeRef.current
|
|
161
|
+
|
|
162
|
+
const entries = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
|
|
163
|
+
const mount = usePortalMount()
|
|
164
|
+
|
|
165
|
+
const value = useMemo<ToastContextValue>(
|
|
166
|
+
() => ({
|
|
167
|
+
toast: (opts) => store.add(opts),
|
|
168
|
+
dismiss: (id) => store.remove(id),
|
|
169
|
+
}),
|
|
170
|
+
[store],
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const dismiss = useCallback((id: string) => store.remove(id), [store])
|
|
174
|
+
|
|
175
|
+
const region = (
|
|
176
|
+
<html.div role="region" aria-label="Notifications" style={styles.region}>
|
|
177
|
+
{entries.map((entry) => (
|
|
178
|
+
<ToastItem key={entry.id} entry={entry} onDismiss={dismiss} />
|
|
179
|
+
))}
|
|
180
|
+
</html.div>
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<ToastContext.Provider value={value}>
|
|
185
|
+
{children}
|
|
186
|
+
{mount ? createPortal(region, mount) : region}
|
|
187
|
+
</ToastContext.Provider>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
+
import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
|
+
import {typography} from '@duro-app/tokens/tokens/typography.css'
|
|
5
|
+
import {shadows} from '@duro-app/tokens/tokens/shadows.css'
|
|
6
|
+
|
|
7
|
+
const enter = css.keyframes({
|
|
8
|
+
from: {opacity: 0, transform: 'translateY(10px)'},
|
|
9
|
+
to: {opacity: 1, transform: 'translateY(0)'},
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
export const styles = css.create({
|
|
13
|
+
// Fixed, viewport-anchored stack. Portaled into the ThemeProvider mount so it
|
|
14
|
+
// layers above dialogs; the container itself is click-through, each toast
|
|
15
|
+
// re-enables pointer events.
|
|
16
|
+
region: {
|
|
17
|
+
position: 'fixed',
|
|
18
|
+
bottom: spacing.lg,
|
|
19
|
+
left: 0,
|
|
20
|
+
right: 0,
|
|
21
|
+
display: 'flex',
|
|
22
|
+
flexDirection: 'column',
|
|
23
|
+
alignItems: 'center',
|
|
24
|
+
gap: spacing.sm,
|
|
25
|
+
paddingLeft: spacing.md,
|
|
26
|
+
paddingRight: spacing.md,
|
|
27
|
+
pointerEvents: 'none',
|
|
28
|
+
},
|
|
29
|
+
toast: {
|
|
30
|
+
pointerEvents: 'auto',
|
|
31
|
+
boxSizing: 'border-box',
|
|
32
|
+
width: '100%',
|
|
33
|
+
maxWidth: 440,
|
|
34
|
+
display: 'flex',
|
|
35
|
+
flexDirection: 'row',
|
|
36
|
+
alignItems: 'flex-start',
|
|
37
|
+
gap: spacing.sm,
|
|
38
|
+
padding: spacing.md,
|
|
39
|
+
borderRadius: radii.sm,
|
|
40
|
+
borderWidth: 1,
|
|
41
|
+
borderStyle: 'solid',
|
|
42
|
+
borderColor: colors.border,
|
|
43
|
+
borderLeftWidth: 3,
|
|
44
|
+
backgroundColor: colors.bgCard,
|
|
45
|
+
boxShadow: shadows.lg,
|
|
46
|
+
color: colors.text,
|
|
47
|
+
fontSize: typography.fontSizeSm,
|
|
48
|
+
lineHeight: typography.lineHeight,
|
|
49
|
+
animationName: {
|
|
50
|
+
default: enter,
|
|
51
|
+
'@media (prefers-reduced-motion: reduce)': 'none',
|
|
52
|
+
},
|
|
53
|
+
animationDuration: '160ms',
|
|
54
|
+
animationTimingFunction: 'ease-out',
|
|
55
|
+
},
|
|
56
|
+
success: {borderLeftColor: colors.successBorder},
|
|
57
|
+
error: {borderLeftColor: colors.errorBorder},
|
|
58
|
+
info: {borderLeftColor: colors.infoBorder},
|
|
59
|
+
|
|
60
|
+
iconWrap: {
|
|
61
|
+
flexShrink: 0,
|
|
62
|
+
display: 'flex',
|
|
63
|
+
alignItems: 'center',
|
|
64
|
+
justifyContent: 'center',
|
|
65
|
+
paddingTop: 1,
|
|
66
|
+
},
|
|
67
|
+
iconSuccess: {color: colors.successText},
|
|
68
|
+
iconError: {color: colors.errorText},
|
|
69
|
+
iconInfo: {color: colors.infoText},
|
|
70
|
+
|
|
71
|
+
content: {
|
|
72
|
+
flex: 1,
|
|
73
|
+
minWidth: 0,
|
|
74
|
+
display: 'flex',
|
|
75
|
+
flexDirection: 'column',
|
|
76
|
+
gap: 2,
|
|
77
|
+
},
|
|
78
|
+
message: {
|
|
79
|
+
color: colors.text,
|
|
80
|
+
},
|
|
81
|
+
action: {
|
|
82
|
+
alignSelf: 'flex-start',
|
|
83
|
+
marginTop: 2,
|
|
84
|
+
padding: 0,
|
|
85
|
+
borderWidth: 0,
|
|
86
|
+
backgroundColor: 'transparent',
|
|
87
|
+
color: colors.accent,
|
|
88
|
+
fontSize: typography.fontSizeSm,
|
|
89
|
+
fontWeight: typography.fontWeightMedium,
|
|
90
|
+
textDecorationLine: 'underline',
|
|
91
|
+
cursor: 'pointer',
|
|
92
|
+
outlineStyle: {default: 'none', ':focus-visible': 'solid'},
|
|
93
|
+
outlineWidth: 2,
|
|
94
|
+
outlineColor: colors.accent,
|
|
95
|
+
outlineOffset: 2,
|
|
96
|
+
borderRadius: radii.sm,
|
|
97
|
+
},
|
|
98
|
+
closeBtn: {
|
|
99
|
+
flexShrink: 0,
|
|
100
|
+
display: 'flex',
|
|
101
|
+
alignItems: 'center',
|
|
102
|
+
justifyContent: 'center',
|
|
103
|
+
padding: 2,
|
|
104
|
+
marginTop: -1,
|
|
105
|
+
borderWidth: 0,
|
|
106
|
+
backgroundColor: 'transparent',
|
|
107
|
+
color: {default: colors.textMuted, ':hover': colors.text},
|
|
108
|
+
cursor: 'pointer',
|
|
109
|
+
borderRadius: radii.sm,
|
|
110
|
+
outlineStyle: {default: 'none', ':focus-visible': 'solid'},
|
|
111
|
+
outlineWidth: 2,
|
|
112
|
+
outlineColor: colors.accent,
|
|
113
|
+
outlineOffset: 1,
|
|
114
|
+
},
|
|
115
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,14 @@ export {Combobox} from './components/Combobox/Combobox'
|
|
|
40
40
|
export {Select} from './components/Select/Select'
|
|
41
41
|
export {SideNav} from './components/SideNav/SideNav'
|
|
42
42
|
export {Spinner, type SpinnerSize} from './components/Spinner/Spinner'
|
|
43
|
+
export {
|
|
44
|
+
ToastProvider,
|
|
45
|
+
useToast,
|
|
46
|
+
type ToastVariant,
|
|
47
|
+
type ToastOptions,
|
|
48
|
+
type ToastAction,
|
|
49
|
+
type ToastContextValue,
|
|
50
|
+
} from './components/Toast'
|
|
43
51
|
export {
|
|
44
52
|
StatusIcon,
|
|
45
53
|
type StatusIconName,
|