@duro-app/ui 0.52.0 → 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.
Files changed (105) hide show
  1. package/dist/{Table-xlMa_Kqe.js → Table-P-UHkEqJ.js} +333 -329
  2. package/dist/Table-P-UHkEqJ.js.map +1 -0
  3. package/dist/components/ActionBar/ActionBar.d.ts +6 -4
  4. package/dist/components/ActionBar/ActionBar.d.ts.map +1 -1
  5. package/dist/components/ButtonGroup/ButtonGroup.d.ts +3 -1
  6. package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -1
  7. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
  8. package/dist/components/Cluster/Cluster.d.ts +2 -2
  9. package/dist/components/Cluster/Cluster.d.ts.map +1 -1
  10. package/dist/components/Cluster/Cluster.stories.d.ts.map +1 -1
  11. package/dist/components/ColorMode/ColorMode.d.ts +3 -2
  12. package/dist/components/ColorMode/ColorMode.d.ts.map +1 -1
  13. package/dist/components/Combobox/Combobox.stories.d.ts.map +1 -1
  14. package/dist/components/ConfirmDialog/ConfirmDialog.d.ts +1 -1
  15. package/dist/components/ConfirmDialog/ConfirmDialog.d.ts.map +1 -1
  16. package/dist/components/DetailPanel/DetailPanel.d.ts +3 -2
  17. package/dist/components/DetailPanel/DetailPanel.d.ts.map +1 -1
  18. package/dist/components/DetailPanel/styles.css.d.ts +15 -3
  19. package/dist/components/DetailPanel/styles.css.d.ts.map +1 -1
  20. package/dist/components/Dialog/Dialog.d.ts +3 -2
  21. package/dist/components/Dialog/Dialog.d.ts.map +1 -1
  22. package/dist/components/Dialog/styles.css.d.ts +16 -4
  23. package/dist/components/Dialog/styles.css.d.ts.map +1 -1
  24. package/dist/components/Drawer/Drawer.d.ts +3 -2
  25. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  26. package/dist/components/Drawer/styles.css.d.ts +16 -4
  27. package/dist/components/Drawer/styles.css.d.ts.map +1 -1
  28. package/dist/components/Fieldset/Fieldset.d.ts +2 -1
  29. package/dist/components/Fieldset/Fieldset.d.ts.map +1 -1
  30. package/dist/components/Grid/Grid.d.ts +2 -2
  31. package/dist/components/Grid/Grid.d.ts.map +1 -1
  32. package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
  33. package/dist/components/Icon/Icon.d.ts +5 -3
  34. package/dist/components/Icon/Icon.d.ts.map +1 -1
  35. package/dist/components/Icon/index.d.ts +1 -1
  36. package/dist/components/Icon/index.d.ts.map +1 -1
  37. package/dist/components/Inline/Inline.d.ts +2 -2
  38. package/dist/components/Inline/Inline.d.ts.map +1 -1
  39. package/dist/components/Inline/Inline.stories.d.ts.map +1 -1
  40. package/dist/components/Stack/Stack.d.ts +2 -1
  41. package/dist/components/Stack/Stack.d.ts.map +1 -1
  42. package/dist/components/Stack/Stack.stories.d.ts.map +1 -1
  43. package/dist/components/StatusIcon/StatusIcon.d.ts +2 -2
  44. package/dist/components/StatusIcon/StatusIcon.d.ts.map +1 -1
  45. package/dist/components/Table/Table.d.ts +1 -7
  46. package/dist/components/Table/Table.d.ts.map +1 -1
  47. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  48. package/dist/components/Tabs/TabsContext.d.ts +2 -1
  49. package/dist/components/Tabs/TabsContext.d.ts.map +1 -1
  50. package/dist/components/ToggleGroup/ToggleGroupContext.d.ts +2 -1
  51. package/dist/components/ToggleGroup/ToggleGroupContext.d.ts.map +1 -1
  52. package/dist/components/VirtualTable/VirtualTable.d.ts +2 -1
  53. package/dist/components/VirtualTable/VirtualTable.d.ts.map +1 -1
  54. package/dist/hooks/useContainerQuery.d.ts +3 -2
  55. package/dist/hooks/useContainerQuery.d.ts.map +1 -1
  56. package/dist/index.css +1 -1
  57. package/dist/index.js +2270 -2177
  58. package/dist/index.js.map +1 -1
  59. package/dist/shared/types.d.ts +2 -0
  60. package/dist/shared/types.d.ts.map +1 -0
  61. package/dist/table.js +137 -139
  62. package/dist/table.js.map +1 -1
  63. package/package.json +3 -2
  64. package/src/components/ActionBar/ActionBar.tsx +10 -5
  65. package/src/components/Alert/Alert.tsx +2 -2
  66. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +3 -2
  67. package/src/components/ButtonGroup/ButtonGroup.tsx +5 -2
  68. package/src/components/Callout/Callout.tsx +2 -2
  69. package/src/components/Cluster/Cluster.stories.tsx +2 -1
  70. package/src/components/Cluster/Cluster.tsx +3 -3
  71. package/src/components/ColorMode/ColorMode.tsx +4 -4
  72. package/src/components/Combobox/Combobox.stories.tsx +7 -3
  73. package/src/components/ConfirmDialog/ConfirmDialog.tsx +1 -3
  74. package/src/components/DetailPanel/DetailPanel.tsx +23 -6
  75. package/src/components/DetailPanel/styles.css.ts +14 -7
  76. package/src/components/Dialog/Dialog.tsx +26 -5
  77. package/src/components/Dialog/styles.css.ts +12 -4
  78. package/src/components/Drawer/Drawer.tsx +17 -4
  79. package/src/components/Drawer/styles.css.ts +12 -4
  80. package/src/components/EmptyState/EmptyState.meta.ts +1 -1
  81. package/src/components/EmptyState/EmptyState.stories.tsx +2 -2
  82. package/src/components/Fieldset/Fieldset.tsx +3 -2
  83. package/src/components/Grid/Grid.stories.tsx +2 -1
  84. package/src/components/Grid/Grid.tsx +3 -3
  85. package/src/components/Icon/Icon.meta.ts +3 -3
  86. package/src/components/Icon/Icon.tsx +7 -2
  87. package/src/components/Icon/index.ts +1 -1
  88. package/src/components/Inline/Inline.stories.tsx +2 -1
  89. package/src/components/Inline/Inline.tsx +3 -3
  90. package/src/components/SideNav/SideNav.meta.ts +3 -3
  91. package/src/components/SideNav/SideNav.stories.tsx +8 -8
  92. package/src/components/Stack/Stack.stories.tsx +3 -2
  93. package/src/components/Stack/Stack.tsx +3 -2
  94. package/src/components/StatusIcon/StatusIcon.stories.tsx +1 -1
  95. package/src/components/StatusIcon/StatusIcon.tsx +3 -3
  96. package/src/components/Table/Table.stories.tsx +29 -32
  97. package/src/components/Table/Table.tsx +0 -13
  98. package/src/components/Tabs/TabsContext.ts +2 -1
  99. package/src/components/Toast/ToastProvider.tsx +2 -2
  100. package/src/components/Toggle/Toggle.meta.ts +1 -1
  101. package/src/components/ToggleGroup/ToggleGroupContext.ts +2 -1
  102. package/src/components/VirtualTable/VirtualTable.tsx +6 -5
  103. package/src/hooks/useContainerQuery.ts +10 -3
  104. package/src/shared/types.ts +3 -0
  105. package/dist/Table-xlMa_Kqe.js.map +0 -1
@@ -10,10 +10,18 @@ import {
10
10
  useRef,
11
11
  } from 'react'
12
12
  import {html} from 'react-strict-dom'
13
+ import {DURATION_MS, type DurationToken} from '@duro-app/tokens/keys'
13
14
  import {styles} from './styles.css'
14
15
 
15
16
  // --- Types ---
16
17
 
18
+ const closeDurationMap = {
19
+ instant: styles.closeDurationInstant,
20
+ fast: styles.closeDurationFast,
21
+ base: styles.closeDurationBase,
22
+ slow: styles.closeDurationSlow,
23
+ } as const satisfies Record<DurationToken, unknown>
24
+
17
25
  export type DetailPanelSize = 'sm' | 'md'
18
26
 
19
27
  // --- Context ---
@@ -21,6 +29,7 @@ export type DetailPanelSize = 'sm' | 'md'
21
29
  interface DetailPanelContextValue {
22
30
  present: boolean
23
31
  closing: boolean
32
+ closeDuration: DurationToken
24
33
  requestOpen: () => void
25
34
  requestClose: () => void
26
35
  titleId: string
@@ -48,8 +57,8 @@ interface RootProps {
48
57
  defaultOpen?: boolean
49
58
  /** Called when open state changes */
50
59
  onOpenChange?: (open: boolean) => void
51
- /** Duration of the close animation in ms. Default: 220 */
52
- closeAnimationDuration?: number
60
+ /** Motion token for the close animation. Default: 'base' (200ms) */
61
+ closeAnimationDuration?: DurationToken
53
62
  }
54
63
 
55
64
  function Root({
@@ -57,7 +66,7 @@ function Root({
57
66
  open: controlledOpen,
58
67
  defaultOpen = false,
59
68
  onOpenChange,
60
- closeAnimationDuration = 220,
69
+ closeAnimationDuration = 'base',
61
70
  }: RootProps) {
62
71
  const isControlled = controlledOpen !== undefined
63
72
  const [internalOpen, setInternalOpen] = useState(defaultOpen)
@@ -83,7 +92,7 @@ function Root({
83
92
  setClosing(false)
84
93
  setPresent(false)
85
94
  closingTimerRef.current = null
86
- }, closeAnimationDuration)
95
+ }, DURATION_MS[closeAnimationDuration])
87
96
  }
88
97
  }, [isOpen, present, closing, closeAnimationDuration])
89
98
 
@@ -154,6 +163,7 @@ function Root({
154
163
  value={{
155
164
  present,
156
165
  closing,
166
+ closeDuration: closeAnimationDuration,
157
167
  requestOpen,
158
168
  requestClose,
159
169
  titleId,
@@ -189,12 +199,18 @@ const wrapperCloseMap = {
189
199
  } as const
190
200
 
191
201
  function Content({children, size = 'sm', label}: ContentProps) {
192
- const {present, closing, labelId, panelRef} = useDetailPanel()
202
+ const {present, closing, closeDuration, labelId, panelRef} = useDetailPanel()
193
203
 
194
204
  if (!present) return null
195
205
 
196
206
  return (
197
- <html.div style={[styles.wrapper, closing ? wrapperCloseMap[size] : wrapperOpenMap[size]]}>
207
+ <html.div
208
+ style={[
209
+ styles.wrapper,
210
+ closing ? wrapperCloseMap[size] : wrapperOpenMap[size],
211
+ closing && closeDurationMap[closeDuration],
212
+ ]}
213
+ >
198
214
  <html.div
199
215
  ref={panelRef}
200
216
  role="complementary"
@@ -205,6 +221,7 @@ function Content({children, size = 'sm', label}: ContentProps) {
205
221
  styles.content,
206
222
  size === 'sm' ? styles.contentSm : styles.contentMd,
207
223
  closing ? styles.slideOut : styles.slideIn,
224
+ closing && closeDurationMap[closeDuration],
208
225
  ]}
209
226
  >
210
227
  {children}
@@ -4,10 +4,9 @@ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
4
4
  import {shadows} from '@duro-app/tokens/tokens/shadows.css'
5
5
  import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
6
6
 
7
- // The "Apple ease" curves and the 280ms open now live in the shared motion
8
- // tokens (easing.easeOut/easeIn, duration.slow). The 220ms close is specific to
9
- // this panel's exit, so it stays local.
10
- const CLOSE_DURATION = '220ms'
7
+ // The "Apple ease" curves and the 280ms open live in the shared motion tokens
8
+ // (easing.easeOut/easeIn, duration.slow). The close is duration.base, and the
9
+ // closeAnimationDuration prop can override it via the closeDuration* styles.
11
10
 
12
11
  export const styles = css.create({
13
12
  // --- Outer wrapper: animates width from 0 → target ---
@@ -32,7 +31,7 @@ export const styles = css.create({
32
31
  from: {width: 360},
33
32
  to: {width: 0},
34
33
  }),
35
- animationDuration: CLOSE_DURATION,
34
+ animationDuration: duration.base,
36
35
  animationTimingFunction: easing.easeIn,
37
36
  animationFillMode: 'both',
38
37
  },
@@ -52,7 +51,7 @@ export const styles = css.create({
52
51
  from: {width: 480},
53
52
  to: {width: 0},
54
53
  }),
55
- animationDuration: CLOSE_DURATION,
54
+ animationDuration: duration.base,
56
55
  animationTimingFunction: easing.easeIn,
57
56
  animationFillMode: 'both',
58
57
  },
@@ -101,11 +100,19 @@ export const styles = css.create({
101
100
  opacity: 0,
102
101
  },
103
102
  }),
104
- animationDuration: CLOSE_DURATION,
103
+ animationDuration: duration.base,
105
104
  animationTimingFunction: easing.easeIn,
106
105
  animationFillMode: 'both',
107
106
  },
108
107
 
108
+ // --- Close-duration overrides ---
109
+ // The closeAnimationDuration prop token drives both these and the unmount
110
+ // timeout, so the exit animation can never race the unmount.
111
+ closeDurationInstant: {animationDuration: duration.instant},
112
+ closeDurationFast: {animationDuration: duration.fast},
113
+ closeDurationBase: {animationDuration: duration.base},
114
+ closeDurationSlow: {animationDuration: duration.slow},
115
+
109
116
  // --- Header ---
110
117
  header: {
111
118
  display: 'flex',
@@ -10,17 +10,26 @@ import {
10
10
  useRef,
11
11
  } from 'react'
12
12
  import {html} from 'react-strict-dom'
13
+ import {DURATION_MS, type DurationToken} from '@duro-app/tokens/keys'
13
14
  import {styles} from './styles.css'
14
15
 
15
16
  // --- Types ---
16
17
 
17
18
  export type DialogSize = 'sm' | 'md' | 'lg'
18
19
 
20
+ const closeDurationMap = {
21
+ instant: styles.closeDurationInstant,
22
+ fast: styles.closeDurationFast,
23
+ base: styles.closeDurationBase,
24
+ slow: styles.closeDurationSlow,
25
+ } as const satisfies Record<DurationToken, unknown>
26
+
19
27
  // --- Context ---
20
28
 
21
29
  interface DialogContextValue {
22
30
  open: boolean
23
31
  closing: boolean
32
+ closeDuration: DurationToken
24
33
  dismissable: boolean
25
34
  requestOpen: () => void
26
35
  requestClose: () => void
@@ -49,8 +58,8 @@ interface RootProps {
49
58
  onOpenChange?: (open: boolean) => void
50
59
  /** Whether clicking backdrop closes the dialog. Default: true */
51
60
  dismissable?: boolean
52
- /** Duration of the close animation in ms. Default: 140 */
53
- closeAnimationDuration?: number
61
+ /** Motion token for the close animation. Default: 'fast' (150ms) */
62
+ closeAnimationDuration?: DurationToken
54
63
  }
55
64
 
56
65
  function Root({
@@ -59,7 +68,7 @@ function Root({
59
68
  defaultOpen = false,
60
69
  onOpenChange,
61
70
  dismissable = true,
62
- closeAnimationDuration = 140,
71
+ closeAnimationDuration = 'fast',
63
72
  }: RootProps) {
64
73
  const isControlled = controlledOpen !== undefined
65
74
  const [internalOpen, setInternalOpen] = useState(defaultOpen)
@@ -89,7 +98,7 @@ function Root({
89
98
  closingTimerRef.current = null
90
99
  if (!isControlled) setInternalOpen(false)
91
100
  onOpenChange?.(false)
92
- }, closeAnimationDuration)
101
+ }, DURATION_MS[closeAnimationDuration])
93
102
  }, [isControlled, onOpenChange, closeAnimationDuration])
94
103
 
95
104
  // If controlled open goes false externally, trigger close animation
@@ -137,6 +146,7 @@ function Root({
137
146
  value={{
138
147
  open: isOpen || closing,
139
148
  closing,
149
+ closeDuration: closeAnimationDuration,
140
150
  dismissable,
141
151
  requestOpen,
142
152
  requestClose,
@@ -174,7 +184,16 @@ interface PortalProps {
174
184
  }
175
185
 
176
186
  function Portal({children, size = 'md'}: PortalProps) {
177
- const {open, closing, dismissable, requestClose, titleId, descriptionId, popupRef} = useDialog()
187
+ const {
188
+ open,
189
+ closing,
190
+ closeDuration,
191
+ dismissable,
192
+ requestClose,
193
+ titleId,
194
+ descriptionId,
195
+ popupRef,
196
+ } = useDialog()
178
197
 
179
198
  const handleBackdropClick = useCallback(() => {
180
199
  if (dismissable) {
@@ -191,6 +210,7 @@ function Portal({children, size = 'md'}: PortalProps) {
191
210
  style={[
192
211
  styles.backdrop,
193
212
  closing && styles.backdropClosing,
213
+ closing && closeDurationMap[closeDuration],
194
214
  !closing && styles.backdropOpen,
195
215
  ]}
196
216
  aria-hidden
@@ -209,6 +229,7 @@ function Portal({children, size = 'md'}: PortalProps) {
209
229
  styles.popup,
210
230
  styles[size],
211
231
  closing && styles.popupClosing,
232
+ closing && closeDurationMap[closeDuration],
212
233
  !closing && styles.popupOpen,
213
234
  ]}
214
235
  >
@@ -19,7 +19,7 @@ export const styles = css.create({
19
19
  from: {opacity: 0},
20
20
  to: {opacity: 1},
21
21
  }),
22
- animationDuration: '180ms',
22
+ animationDuration: duration.base,
23
23
  animationTimingFunction: easing.easeOut,
24
24
  animationFillMode: 'both',
25
25
  },
@@ -29,7 +29,7 @@ export const styles = css.create({
29
29
  from: {opacity: 1},
30
30
  to: {opacity: 0},
31
31
  }),
32
- animationDuration: '140ms',
32
+ animationDuration: duration.fast,
33
33
  animationTimingFunction: easing.easeIn,
34
34
  animationFillMode: 'both',
35
35
  },
@@ -75,7 +75,7 @@ export const styles = css.create({
75
75
  transform: 'scale(1) translateY(0)',
76
76
  },
77
77
  }),
78
- animationDuration: '180ms',
78
+ animationDuration: duration.base,
79
79
  animationTimingFunction: easing.easeOut,
80
80
  animationFillMode: 'both',
81
81
  },
@@ -91,11 +91,19 @@ export const styles = css.create({
91
91
  transform: 'scale(0.97) translateY(8px)',
92
92
  },
93
93
  }),
94
- animationDuration: '140ms',
94
+ animationDuration: duration.fast,
95
95
  animationTimingFunction: easing.easeIn,
96
96
  animationFillMode: 'both',
97
97
  },
98
98
 
99
+ // --- Close-duration overrides ---
100
+ // The closeAnimationDuration prop token drives both these and the unmount
101
+ // timeout, so the exit animation can never race the unmount.
102
+ closeDurationInstant: {animationDuration: duration.instant},
103
+ closeDurationFast: {animationDuration: duration.fast},
104
+ closeDurationBase: {animationDuration: duration.base},
105
+ closeDurationSlow: {animationDuration: duration.slow},
106
+
99
107
  // --- Sizes ---
100
108
  sm: {maxWidth: 400},
101
109
  md: {maxWidth: 520},
@@ -10,6 +10,7 @@ import {
10
10
  useRef,
11
11
  } from 'react'
12
12
  import {html} from 'react-strict-dom'
13
+ import {DURATION_MS, type DurationToken} from '@duro-app/tokens/keys'
13
14
  import {styles} from './styles.css'
14
15
  import {useSwipeDismiss} from './useSwipeDismiss'
15
16
 
@@ -18,11 +19,19 @@ import {useSwipeDismiss} from './useSwipeDismiss'
18
19
  export type DrawerAnchor = 'right' | 'left' | 'bottom'
19
20
  export type DrawerSize = 'sm' | 'md' | 'lg'
20
21
 
22
+ const closeDurationMap = {
23
+ instant: styles.closeDurationInstant,
24
+ fast: styles.closeDurationFast,
25
+ base: styles.closeDurationBase,
26
+ slow: styles.closeDurationSlow,
27
+ } as const satisfies Record<DurationToken, unknown>
28
+
21
29
  // --- Context ---
22
30
 
23
31
  interface DrawerContextValue {
24
32
  open: boolean
25
33
  closing: boolean
34
+ closeDuration: DurationToken
26
35
  anchor: DrawerAnchor
27
36
  dismissable: boolean
28
37
  swipeDismiss: boolean
@@ -59,8 +68,8 @@ interface RootProps {
59
68
  dismissable?: boolean
60
69
  /** Whether swipe gesture dismisses the drawer. Default: true */
61
70
  swipeDismiss?: boolean
62
- /** Duration of the close animation in ms. Default: 160 */
63
- closeAnimationDuration?: number
71
+ /** Motion token for the close animation. Default: 'fast' (150ms) */
72
+ closeAnimationDuration?: DurationToken
64
73
  }
65
74
 
66
75
  function Root({
@@ -71,7 +80,7 @@ function Root({
71
80
  anchor = 'right',
72
81
  dismissable = true,
73
82
  swipeDismiss = true,
74
- closeAnimationDuration = 160,
83
+ closeAnimationDuration = 'fast',
75
84
  }: RootProps) {
76
85
  const isControlled = controlledOpen !== undefined
77
86
  const [internalOpen, setInternalOpen] = useState(defaultOpen)
@@ -101,7 +110,7 @@ function Root({
101
110
  closingTimerRef.current = null
102
111
  if (!isControlled) setInternalOpen(false)
103
112
  onOpenChange?.(false)
104
- }, closeAnimationDuration)
113
+ }, DURATION_MS[closeAnimationDuration])
105
114
  }, [isControlled, onOpenChange, closeAnimationDuration])
106
115
 
107
116
  const requestCloseImmediate = useCallback(() => {
@@ -152,6 +161,7 @@ function Root({
152
161
  value={{
153
162
  open: isOpen || closing,
154
163
  closing,
164
+ closeDuration: closeAnimationDuration,
155
165
  anchor,
156
166
  dismissable,
157
167
  swipeDismiss,
@@ -210,6 +220,7 @@ function Portal({children, size = 'md'}: PortalProps) {
210
220
  const {
211
221
  open,
212
222
  closing,
223
+ closeDuration,
213
224
  anchor,
214
225
  dismissable,
215
226
  swipeDismiss: swipeEnabled,
@@ -245,6 +256,7 @@ function Portal({children, size = 'md'}: PortalProps) {
245
256
  style={[
246
257
  styles.backdrop,
247
258
  closing && styles.backdropClosing,
259
+ closing && closeDurationMap[closeDuration],
248
260
  !closing && styles.backdropOpen,
249
261
  ]}
250
262
  aria-hidden
@@ -266,6 +278,7 @@ function Portal({children, size = 'md'}: PortalProps) {
266
278
  !isHorizontal && styles.panelVertical,
267
279
  anchor === 'left' && styles.panelLeftBorder,
268
280
  closing && slideOutMap[anchor],
281
+ closing && closeDurationMap[closeDuration],
269
282
  !closing && slideInMap[anchor],
270
283
  ]}
271
284
  >
@@ -27,7 +27,7 @@ export const styles = css.create({
27
27
  from: {opacity: 1},
28
28
  to: {opacity: 0},
29
29
  }),
30
- animationDuration: '160ms',
30
+ animationDuration: duration.fast,
31
31
  animationTimingFunction: easing.easeIn,
32
32
  animationFillMode: 'both',
33
33
  },
@@ -114,7 +114,7 @@ export const styles = css.create({
114
114
  from: {transform: 'translateX(0)'},
115
115
  to: {transform: 'translateX(100%)'},
116
116
  }),
117
- animationDuration: '160ms',
117
+ animationDuration: duration.fast,
118
118
  animationTimingFunction: easing.easeIn,
119
119
  animationFillMode: 'both',
120
120
  },
@@ -134,7 +134,7 @@ export const styles = css.create({
134
134
  from: {transform: 'translateX(0)'},
135
135
  to: {transform: 'translateX(-100%)'},
136
136
  }),
137
- animationDuration: '160ms',
137
+ animationDuration: duration.fast,
138
138
  animationTimingFunction: easing.easeIn,
139
139
  animationFillMode: 'both',
140
140
  },
@@ -154,11 +154,19 @@ export const styles = css.create({
154
154
  from: {transform: 'translateY(0)'},
155
155
  to: {transform: 'translateY(100%)'},
156
156
  }),
157
- animationDuration: '160ms',
157
+ animationDuration: duration.fast,
158
158
  animationTimingFunction: easing.easeIn,
159
159
  animationFillMode: 'both',
160
160
  },
161
161
 
162
+ // --- Close-duration overrides ---
163
+ // The closeAnimationDuration prop token drives both these and the unmount
164
+ // timeout, so the exit animation can never race the unmount.
165
+ closeDurationInstant: {animationDuration: duration.instant},
166
+ closeDurationFast: {animationDuration: duration.fast},
167
+ closeDurationBase: {animationDuration: duration.base},
168
+ closeDurationSlow: {animationDuration: duration.slow},
169
+
162
170
  // --- Header ---
163
171
  header: {
164
172
  display: 'flex',
@@ -11,7 +11,7 @@ export const meta: ComponentMeta = {
11
11
  whenNotToUse: ['Loading states — use Spinner', 'Error states — use Alert or Callout'],
12
12
  example: `<Card>
13
13
  <EmptyState
14
- icon={<Icon name="info-circle" size={48} />}
14
+ icon={<Icon name="info-circle" size="xxl" />}
15
15
  message="No results found"
16
16
  action={<Button variant="secondary">Clear filters</Button>}
17
17
  />
@@ -23,7 +23,7 @@ export const Default: Story = {
23
23
  export const WithIcon: Story = {
24
24
  args: {
25
25
  message: 'Nothing to show here',
26
- icon: <StatusIcon name="forbidden" variant="muted" size={48} />,
26
+ icon: <StatusIcon name="forbidden" variant="muted" size="xxl" />,
27
27
  },
28
28
  play: async ({canvas}) => {
29
29
  await expect(canvas.getByText('Nothing to show here')).toBeInTheDocument()
@@ -44,7 +44,7 @@ export const WithAction: Story = {
44
44
  export const WithIconAndAction: Story = {
45
45
  args: {
46
46
  message: 'Your inbox is empty',
47
- icon: <StatusIcon name="check-done" variant="success" size={48} />,
47
+ icon: <StatusIcon name="check-done" variant="success" size="xxl" />,
48
48
  action: <Button variant="primary">Refresh</Button>,
49
49
  },
50
50
  play: async ({canvas}) => {
@@ -1,8 +1,9 @@
1
1
  import type {ReactNode} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
+ import type {SpacingToken} from '@duro-app/tokens/keys'
3
4
  import {styles} from './styles.css'
4
5
 
5
- export type FieldsetGap = 'xs' | 'sm' | 'ms' | 'md' | 'lg' | 'xl'
6
+ export type FieldsetGap = Exclude<SpacingToken, 'xxl' | 'xxxl'>
6
7
 
7
8
  const gapMap = {
8
9
  xs: styles.gapXs,
@@ -11,7 +12,7 @@ const gapMap = {
11
12
  md: styles.gapMd,
12
13
  lg: styles.gapLg,
13
14
  xl: styles.gapXl,
14
- } as const
15
+ } as const satisfies Record<FieldsetGap, unknown>
15
16
 
16
17
  // --- Root ---
17
18
  interface RootProps {
@@ -4,6 +4,7 @@ import {Grid} from './Grid'
4
4
  import {Stack} from '../Stack/Stack'
5
5
  import {colors} from '@duro-app/tokens/tokens/colors.css'
6
6
  import {spacing} from '@duro-app/tokens/tokens/spacing.css'
7
+ import {SPACING_KEYS} from '@duro-app/tokens/keys'
7
8
  import {useContainerQuery} from '../../hooks/useContainerQuery'
8
9
 
9
10
  const meta: Meta<typeof Grid> = {
@@ -12,7 +13,7 @@ const meta: Meta<typeof Grid> = {
12
13
  argTypes: {
13
14
  gap: {
14
15
  control: 'select',
15
- options: ['xs', 'sm', 'ms', 'md', 'lg', 'xl', 'xxl', 'xxxl'],
16
+ options: [...SPACING_KEYS],
16
17
  },
17
18
  columns: {
18
19
  control: 'select',
@@ -1,10 +1,10 @@
1
1
  import type {ReactNode} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
3
  import {styles} from './styles.css'
4
- import type {SpacingKey} from '../Stack/Stack'
4
+ import type {SpacingToken} from '@duro-app/tokens/keys'
5
5
 
6
6
  interface GridProps {
7
- gap?: SpacingKey
7
+ gap?: SpacingToken
8
8
  columns?: 1 | 2 | 3 | 4 | 5 | 6
9
9
  minColumnWidth?: string
10
10
  children: ReactNode
@@ -19,7 +19,7 @@ const gapMap = {
19
19
  xl: styles.gapXl,
20
20
  xxl: styles.gapXxl,
21
21
  xxxl: styles.gapXxxl,
22
- } as const
22
+ } as const satisfies Record<SpacingToken, unknown>
23
23
 
24
24
  const columnsMap = {
25
25
  1: styles.col1,
@@ -16,8 +16,8 @@ export const meta: ComponentMeta = {
16
16
  },
17
17
  ],
18
18
  example: `<Inline gap="sm" align="center">
19
- <Icon name="check-circle" size={20} />
20
- <Icon name="alert-triangle" size={20} />
21
- <Icon name="shield-filled" size={20} />
19
+ <Icon name="check-circle" size="md" />
20
+ <Icon name="alert-triangle" size="md" />
21
+ <Icon name="shield-filled" size="md" />
22
22
  </Inline>`,
23
23
  }
@@ -1,4 +1,7 @@
1
1
  import type {ReactNode} from 'react'
2
+ import {ICON_SIZES, type IconSize} from '@duro-app/tokens/keys'
3
+
4
+ export type {IconSize}
2
5
 
3
6
  export type IconName =
4
7
  | 'x-circle'
@@ -350,10 +353,12 @@ const filledIcons: Partial<Record<IconName, ReactNode>> = {
350
353
 
351
354
  interface IconProps {
352
355
  name: IconName
353
- size?: number
356
+ /** Token from the icon size scale: sm 16, md 18, lg 24, xl 36, xxl 48. */
357
+ size?: IconSize
354
358
  }
355
359
 
356
- export function Icon({name, size = 24}: IconProps) {
360
+ export function Icon({name, size: sizeToken = 'lg'}: IconProps) {
361
+ const size = ICON_SIZES[sizeToken]
357
362
  const filled = filledIcons[name]
358
363
  if (filled) {
359
364
  return (
@@ -1,2 +1,2 @@
1
1
  export {Icon} from './Icon'
2
- export type {IconName} from './Icon'
2
+ export type {IconName, IconSize} from './Icon'
@@ -3,6 +3,7 @@ import {css, html} from 'react-strict-dom'
3
3
  import {Inline} from './Inline'
4
4
  import {colors} from '@duro-app/tokens/tokens/colors.css'
5
5
  import {spacing} from '@duro-app/tokens/tokens/spacing.css'
6
+ import {SPACING_KEYS} from '@duro-app/tokens/keys'
6
7
 
7
8
  const meta: Meta<typeof Inline> = {
8
9
  title: 'Layout/Inline',
@@ -10,7 +11,7 @@ const meta: Meta<typeof Inline> = {
10
11
  argTypes: {
11
12
  gap: {
12
13
  control: 'select',
13
- options: ['xs', 'sm', 'ms', 'md', 'lg', 'xl', 'xxl', 'xxxl'],
14
+ options: [...SPACING_KEYS],
14
15
  },
15
16
  align: {
16
17
  control: 'select',
@@ -1,10 +1,10 @@
1
1
  import type {ReactNode} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
3
  import {styles} from './styles.css'
4
- import type {SpacingKey} from '../Stack/Stack'
4
+ import type {SpacingToken} from '@duro-app/tokens/keys'
5
5
 
6
6
  interface InlineProps {
7
- gap?: SpacingKey
7
+ gap?: SpacingToken
8
8
  align?: 'start' | 'center' | 'end' | 'baseline' | 'stretch'
9
9
  justify?: 'start' | 'center' | 'end' | 'between'
10
10
  children: ReactNode
@@ -19,7 +19,7 @@ const gapMap = {
19
19
  xl: styles.gapXl,
20
20
  xxl: styles.gapXxl,
21
21
  xxxl: styles.gapXxxl,
22
- } as const
22
+ } as const satisfies Record<SpacingToken, unknown>
23
23
 
24
24
  const alignMap = {
25
25
  start: styles.alignStart,
@@ -26,12 +26,12 @@ export const meta: ComponentMeta = {
26
26
  example: `<SideNav.Root defaultValue="identities">
27
27
  {/* Default: always-open sections. Nothing is a click away from being seen. */}
28
28
  <SideNav.Section label="People & access">
29
- <SideNav.Item value="identities" icon={<Icon name="users" size={18} />}>Identities</SideNav.Item>
30
- <SideNav.Item value="grants" icon={<Icon name="key" size={18} />}>Grants</SideNav.Item>
29
+ <SideNav.Item value="identities" icon={<Icon name="users" size="md" />}>Identities</SideNav.Item>
30
+ <SideNav.Item value="grants" icon={<Icon name="key" size="md" />}>Grants</SideNav.Item>
31
31
  </SideNav.Section>
32
32
  {/* Disclosure, earned: rarely visited, so it starts collapsed. */}
33
33
  <SideNav.Group label="Advanced" defaultExpanded={false}>
34
- <SideNav.Item value="plugins" icon={<Icon name="plug" size={18} />}>Plugins</SideNav.Item>
34
+ <SideNav.Item value="plugins" icon={<Icon name="plug" size="md" />}>Plugins</SideNav.Item>
35
35
  </SideNav.Group>
36
36
  </SideNav.Root>`,
37
37
  }
@@ -58,20 +58,20 @@ export const SectionsWithOneDisclosedGroup: Story = {
58
58
  render: (args) => (
59
59
  <SideNav.Root {...args} defaultValue="identities">
60
60
  <SideNav.Section label="People & access">
61
- <SideNav.Item value="identities" icon={<Icon name="users" size={18} />}>
61
+ <SideNav.Item value="identities" icon={<Icon name="users" size="md" />}>
62
62
  Identities
63
63
  </SideNav.Item>
64
- <SideNav.Item value="grants" icon={<Icon name="key" size={18} />}>
64
+ <SideNav.Item value="grants" icon={<Icon name="key" size="md" />}>
65
65
  Grants
66
66
  </SideNav.Item>
67
67
  </SideNav.Section>
68
68
  <SideNav.Section label="Audit">
69
- <SideNav.Item value="audit" icon={<Icon name="file-text" size={18} />}>
69
+ <SideNav.Item value="audit" icon={<Icon name="file-text" size="md" />}>
70
70
  Audit log
71
71
  </SideNav.Item>
72
72
  </SideNav.Section>
73
73
  <SideNav.Group label="Advanced">
74
- <SideNav.Item value="plugins" icon={<Icon name="plug" size={18} />}>
74
+ <SideNav.Item value="plugins" icon={<Icon name="plug" size="md" />}>
75
75
  Plugins
76
76
  </SideNav.Item>
77
77
  </SideNav.Group>
@@ -165,18 +165,18 @@ export const FlatWithIcons: Story = {
165
165
  render: (args) => (
166
166
  <SideNav.Root {...args} defaultValue="grants">
167
167
  <SideNav.Section label="People & access">
168
- <SideNav.Item value="identities" icon={<Icon name="users" size={18} />}>
168
+ <SideNav.Item value="identities" icon={<Icon name="users" size="md" />}>
169
169
  Identities
170
170
  </SideNav.Item>
171
- <SideNav.Item value="grants" icon={<Icon name="key" size={18} />}>
171
+ <SideNav.Item value="grants" icon={<Icon name="key" size="md" />}>
172
172
  Grants
173
173
  </SideNav.Item>
174
174
  </SideNav.Section>
175
175
  <SideNav.Section label="Requests & invites">
176
- <SideNav.Item value="invitations" icon={<Icon name="mail" size={18} />}>
176
+ <SideNav.Item value="invitations" icon={<Icon name="mail" size="md" />}>
177
177
  Invitations
178
178
  </SideNav.Item>
179
- <SideNav.Item value="invites" icon={<Icon name="user-plus" size={18} />}>
179
+ <SideNav.Item value="invites" icon={<Icon name="user-plus" size="md" />}>
180
180
  User Invites
181
181
  </SideNav.Item>
182
182
  </SideNav.Section>