@duro-app/ui 0.38.0 → 0.39.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": "@duro-app/ui",
3
- "version": "0.38.0",
3
+ "version": "0.39.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.38.0"
57
+ "@duro-app/tokens": "^0.39.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@babel/preset-typescript": "^7.28.0",
@@ -2,6 +2,7 @@ import {css} from 'react-strict-dom'
2
2
  import {colors} from '@duro-app/tokens/tokens/colors.css'
3
3
  import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
4
  import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+ import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
5
6
 
6
7
  export const styles = css.create({
7
8
  base: {
@@ -17,11 +18,20 @@ export const styles = css.create({
17
18
  borderWidth: 1,
18
19
  borderStyle: 'solid',
19
20
  cursor: 'pointer',
20
- transitionProperty: 'background-color, border-color, color, opacity',
21
- transitionDuration: '150ms',
22
- transitionTimingFunction: 'ease',
21
+ transitionProperty: 'background-color, border-color, color, opacity, transform',
22
+ transitionDuration: duration.fast,
23
+ transitionTimingFunction: easing.standard,
23
24
  textDecoration: 'none',
24
25
  whiteSpace: 'nowrap',
26
+ // Tactile press feedback. The scale is suppressed under reduced motion (and
27
+ // duration.fast collapses to 0ms there too), so it's inert for those users.
28
+ transform: {
29
+ default: 'scale(1)',
30
+ ':active': {
31
+ default: 'scale(0.97)',
32
+ '@media (prefers-reduced-motion: reduce)': 'scale(1)',
33
+ },
34
+ },
25
35
  outlineWidth: {
26
36
  default: 0,
27
37
  ':focus-visible': 2,
@@ -3,6 +3,7 @@ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
3
  import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
4
  import {typography} from '@duro-app/tokens/tokens/typography.css'
5
5
  import {shadows} from '@duro-app/tokens/tokens/shadows.css'
6
+ import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
6
7
 
7
8
  export const styles = css.create({
8
9
  base: {
@@ -41,11 +42,16 @@ export const styles = css.create({
41
42
  },
42
43
  cursor: 'pointer',
43
44
  transitionProperty: 'background-color, border-color, transform',
44
- transitionDuration: '150ms',
45
- transitionTimingFunction: 'ease',
45
+ transitionDuration: duration.fast,
46
+ transitionTimingFunction: easing.standard,
47
+ // Lift on hover; suppressed under reduced motion so there's no positional
48
+ // shift (the token duration also collapses to 0ms there).
46
49
  transform: {
47
50
  default: 'translateY(0)',
48
- ':hover': 'translateY(-2px)',
51
+ ':hover': {
52
+ default: 'translateY(-2px)',
53
+ '@media (prefers-reduced-motion: reduce)': 'translateY(0)',
54
+ },
49
55
  },
50
56
  },
51
57
  // Sizes (padding)
@@ -2,12 +2,11 @@ import {css} from 'react-strict-dom'
2
2
  import {colors} from '@duro-app/tokens/tokens/colors.css'
3
3
  import {spacing} from '@duro-app/tokens/tokens/spacing.css'
4
4
  import {shadows} from '@duro-app/tokens/tokens/shadows.css'
5
+ import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
5
6
 
6
- /** Apple-standard ease: aggressive deceleration, feels like "arriving" */
7
- const APPLE_EASE_OUT = 'cubic-bezier(0.32, 0.72, 0, 1)'
8
- const APPLE_EASE_IN = 'cubic-bezier(0.72, 0, 0.68, 0.28)'
9
-
10
- const OPEN_DURATION = '280ms'
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.
11
10
  const CLOSE_DURATION = '220ms'
12
11
 
13
12
  export const styles = css.create({
@@ -24,8 +23,8 @@ export const styles = css.create({
24
23
  from: {width: 0},
25
24
  to: {width: 360},
26
25
  }),
27
- animationDuration: OPEN_DURATION,
28
- animationTimingFunction: APPLE_EASE_OUT,
26
+ animationDuration: duration.slow,
27
+ animationTimingFunction: easing.easeOut,
29
28
  animationFillMode: 'both',
30
29
  },
31
30
  wrapperCloseSm: {
@@ -34,7 +33,7 @@ export const styles = css.create({
34
33
  to: {width: 0},
35
34
  }),
36
35
  animationDuration: CLOSE_DURATION,
37
- animationTimingFunction: APPLE_EASE_IN,
36
+ animationTimingFunction: easing.easeIn,
38
37
  animationFillMode: 'both',
39
38
  },
40
39
 
@@ -44,8 +43,8 @@ export const styles = css.create({
44
43
  from: {width: 0},
45
44
  to: {width: 480},
46
45
  }),
47
- animationDuration: OPEN_DURATION,
48
- animationTimingFunction: APPLE_EASE_OUT,
46
+ animationDuration: duration.slow,
47
+ animationTimingFunction: easing.easeOut,
49
48
  animationFillMode: 'both',
50
49
  },
51
50
  wrapperCloseMd: {
@@ -54,7 +53,7 @@ export const styles = css.create({
54
53
  to: {width: 0},
55
54
  }),
56
55
  animationDuration: CLOSE_DURATION,
57
- animationTimingFunction: APPLE_EASE_IN,
56
+ animationTimingFunction: easing.easeIn,
58
57
  animationFillMode: 'both',
59
58
  },
60
59
 
@@ -87,8 +86,8 @@ export const styles = css.create({
87
86
  opacity: 1,
88
87
  },
89
88
  }),
90
- animationDuration: OPEN_DURATION,
91
- animationTimingFunction: APPLE_EASE_OUT,
89
+ animationDuration: duration.slow,
90
+ animationTimingFunction: easing.easeOut,
92
91
  animationFillMode: 'both',
93
92
  },
94
93
  slideOut: {
@@ -103,7 +102,7 @@ export const styles = css.create({
103
102
  },
104
103
  }),
105
104
  animationDuration: CLOSE_DURATION,
106
- animationTimingFunction: APPLE_EASE_IN,
105
+ animationTimingFunction: easing.easeIn,
107
106
  animationFillMode: 'both',
108
107
  },
109
108
 
@@ -183,7 +182,8 @@ export const styles = css.create({
183
182
  padding: 0,
184
183
  flexShrink: 0,
185
184
  transitionProperty: 'background-color',
186
- transitionDuration: '150ms',
185
+ transitionDuration: duration.fast,
186
+ transitionTimingFunction: easing.standard,
187
187
  },
188
188
 
189
189
  inlineWrapper: {
@@ -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
  }
@@ -33,3 +33,22 @@
33
33
  text-decoration: none;
34
34
  }
35
35
  }
36
+
37
+ /*
38
+ * Reduced-motion safety net. Neutralizes animation/transition timing globally
39
+ * for users who prefer reduced motion, covering every component (dialogs,
40
+ * drawers, detail panels, spinners, …) without touching their per-component
41
+ * tuned animations. Uses 0.01ms (not 0) so animationend/transitionend still
42
+ * fire and any state-driven mount/unmount logic completes. Kept outside
43
+ * `@layer reset` so its `!important` wins regardless of cascade layers.
44
+ */
45
+ @media (prefers-reduced-motion: reduce) {
46
+ *,
47
+ *::before,
48
+ *::after {
49
+ animation-duration: 0.01ms !important;
50
+ animation-iteration-count: 1 !important;
51
+ transition-duration: 0.01ms !important;
52
+ scroll-behavior: auto !important;
53
+ }
54
+ }