@duro-app/ui 0.37.2 → 0.38.1

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.
Files changed (30) hide show
  1. package/dist/components/SideNav/SideNav.d.ts.map +1 -1
  2. package/dist/components/SideNav/SideNav.stories.d.ts +1 -0
  3. package/dist/components/SideNav/SideNav.stories.d.ts.map +1 -1
  4. package/dist/components/SideNav/SideNavContext.d.ts +4 -0
  5. package/dist/components/SideNav/SideNavContext.d.ts.map +1 -1
  6. package/dist/components/Toast/Toast.meta.d.ts +3 -0
  7. package/dist/components/Toast/Toast.meta.d.ts.map +1 -0
  8. package/dist/components/Toast/Toast.stories.d.ts +9 -0
  9. package/dist/components/Toast/Toast.stories.d.ts.map +1 -0
  10. package/dist/components/Toast/ToastProvider.d.ts +25 -0
  11. package/dist/components/Toast/ToastProvider.d.ts.map +1 -0
  12. package/dist/components/Toast/index.d.ts +3 -0
  13. package/dist/components/Toast/index.d.ts.map +1 -0
  14. package/dist/components/Toast/styles.css.d.ts +110 -0
  15. package/dist/components/Toast/styles.css.d.ts.map +1 -0
  16. package/dist/index.css +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2698 -2462
  20. package/dist/index.js.map +1 -1
  21. package/package.json +2 -2
  22. package/src/components/SideNav/SideNav.stories.tsx +22 -0
  23. package/src/components/SideNav/SideNav.tsx +31 -18
  24. package/src/components/SideNav/SideNavContext.ts +4 -0
  25. package/src/components/Toast/Toast.meta.ts +34 -0
  26. package/src/components/Toast/Toast.stories.tsx +81 -0
  27. package/src/components/Toast/ToastProvider.tsx +189 -0
  28. package/src/components/Toast/index.ts +2 -0
  29. package/src/components/Toast/styles.css.ts +115 -0
  30. 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.37.2",
3
+ "version": "0.38.1",
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.37.2"
57
+ "@duro-app/tokens": "^0.38.1"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@babel/preset-typescript": "^7.28.0",
@@ -58,6 +58,28 @@ export const Collapsed: Story = {
58
58
  },
59
59
  }
60
60
 
61
+ // Regression: a `defaultExpanded` group that ALSO contains the active item used
62
+ // to be toggled twice on mount (once by defaultExpanded, once by auto-expand of
63
+ // the active group) and end up collapsed, hiding the active item. It must stay
64
+ // open.
65
+ export const DefaultExpandedWithActiveItem: Story = {
66
+ render: (args) => (
67
+ <SideNav.Root {...args} defaultValue="security">
68
+ <SideNav.Group label="Settings" defaultExpanded>
69
+ <SideNav.Item value="profile">Profile</SideNav.Item>
70
+ <SideNav.Item value="security">Security</SideNav.Item>
71
+ </SideNav.Group>
72
+ </SideNav.Root>
73
+ ),
74
+ play: async ({canvas}) => {
75
+ const trigger = canvas.getByText('Settings')
76
+ await expect(trigger).toHaveAttribute('aria-expanded', 'true')
77
+ const active = canvas.getByText('Security')
78
+ await expect(active).toBeInTheDocument()
79
+ await expect(active).toHaveAttribute('aria-current', 'page')
80
+ },
81
+ }
82
+
61
83
  export const Interactive: Story = {
62
84
  render: (args) => (
63
85
  <SideNav.Root {...args}>
@@ -38,6 +38,19 @@ function Root({children, value: controlledValue, defaultValue, onValueChange}: R
38
38
  })
39
39
  }, [])
40
40
 
41
+ // Idempotent expand — never closes an already-open group. The mount effects
42
+ // in Group (defaultExpanded + auto-expand-active) use this instead of
43
+ // toggleGroup so a group that qualifies on both counts stays open rather than
44
+ // being toggled open-then-closed.
45
+ const expandGroup = useCallback((group: string) => {
46
+ setExpandedGroups((prev) => {
47
+ if (prev.has(group)) return prev
48
+ const next = new Set(prev)
49
+ next.add(group)
50
+ return next
51
+ })
52
+ }, [])
53
+
41
54
  const registerItem = useCallback((value: string) => {
42
55
  if (!orderRef.current.includes(value)) {
43
56
  orderRef.current.push(value)
@@ -47,19 +60,17 @@ function Root({children, value: controlledValue, defaultValue, onValueChange}: R
47
60
  }
48
61
  }, [])
49
62
 
50
- // Auto-expand group containing active value
51
- useEffect(() => {
52
- if (activeValue) {
53
- setExpandedGroups((prev) => {
54
- // We don't know which group it belongs to here — Group handles this
55
- return prev
56
- })
57
- }
58
- }, [activeValue])
59
-
60
63
  return (
61
64
  <SideNavContext.Provider
62
- value={{activeValue, onSelect, expandedGroups, toggleGroup, registerItem, orderRef}}
65
+ value={{
66
+ activeValue,
67
+ onSelect,
68
+ expandedGroups,
69
+ toggleGroup,
70
+ expandGroup,
71
+ registerItem,
72
+ orderRef,
73
+ }}
63
74
  >
64
75
  <html.nav role="navigation" style={styles.root}>
65
76
  {children}
@@ -79,25 +90,27 @@ interface GroupProps {
79
90
 
80
91
  function Group({children, label, groupKey, defaultExpanded}: GroupProps) {
81
92
  const key = groupKey ?? label
82
- const {expandedGroups, toggleGroup, activeValue} = useSideNav()
93
+ const {expandedGroups, toggleGroup, expandGroup, activeValue} = useSideNav()
83
94
  const isExpanded = expandedGroups.has(key)
84
95
  const groupRef = useRef<HTMLDivElement>(null)
85
96
 
86
- // Auto-expand if this group contains the active item
97
+ // Auto-expand if this group contains the active item. Uses expandGroup (not
98
+ // toggleGroup) so it never races the defaultExpanded effect into a closed
99
+ // state when the active item lives in a defaultExpanded group.
87
100
  useEffect(() => {
88
101
  if (!activeValue || expandedGroups.has(key)) return
89
102
  const el = groupRef.current
90
103
  if (!el) return
91
104
  const activeBtn = el.querySelector(`[data-nav-value="${activeValue}"]`)
92
105
  if (activeBtn) {
93
- toggleGroup(key)
106
+ expandGroup(key)
94
107
  }
95
- }, [activeValue, key, expandedGroups, toggleGroup])
108
+ }, [activeValue, key, expandedGroups, expandGroup])
96
109
 
97
- // Expand on first render if defaultExpanded
110
+ // Expand on first render if defaultExpanded (idempotent add, not a toggle).
98
111
  useEffect(() => {
99
- if (defaultExpanded && !expandedGroups.has(key)) {
100
- toggleGroup(key)
112
+ if (defaultExpanded) {
113
+ expandGroup(key)
101
114
  }
102
115
  // Only run on mount
103
116
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -5,6 +5,10 @@ export interface SideNavContextValue {
5
5
  onSelect: (value: string) => void
6
6
  expandedGroups: Set<string>
7
7
  toggleGroup: (group: string) => void
8
+ /** Idempotent expand (add-only) — used by auto-expand/defaultExpanded so a
9
+ * group that qualifies twice (e.g. defaultExpanded AND holding the active
10
+ * item) doesn't get toggled back closed. `toggleGroup` stays for user clicks. */
11
+ expandGroup: (group: string) => void
8
12
  registerItem: (value: string) => () => void
9
13
  orderRef: React.RefObject<string[]>
10
14
  }
@@ -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,2 @@
1
+ export {ToastProvider, useToast} from './ToastProvider'
2
+ export type {ToastVariant, ToastOptions, ToastAction, ToastContextValue} from './ToastProvider'
@@ -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,