@duro-app/ui 0.12.2 → 0.14.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 (134) hide show
  1. package/dist/components/PageShell/PageShell.d.ts +15 -0
  2. package/dist/components/PageShell/PageShell.d.ts.map +1 -0
  3. package/dist/components/PageShell/index.d.ts +3 -0
  4. package/dist/components/PageShell/index.d.ts.map +1 -0
  5. package/dist/components/PageShell/styles.css.d.ts +41 -0
  6. package/dist/components/PageShell/styles.css.d.ts.map +1 -0
  7. package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
  8. package/dist/index.css +1 -1
  9. package/dist/index.d.ts +1 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3283 -3434
  12. package/dist/index.js.map +1 -1
  13. package/package.json +4 -4
  14. package/src/components/Alert/Alert.stories.tsx +76 -0
  15. package/src/components/Alert/Alert.tsx +45 -0
  16. package/src/components/Alert/styles.css.ts +50 -0
  17. package/src/components/Badge/Badge.stories.tsx +94 -0
  18. package/src/components/Badge/Badge.tsx +21 -0
  19. package/src/components/Badge/styles.css.ts +51 -0
  20. package/src/components/Button/Button.stories.tsx +130 -0
  21. package/src/components/Button/Button.tsx +48 -0
  22. package/src/components/Button/styles.css.ts +107 -0
  23. package/src/components/Callout/Callout.stories.tsx +97 -0
  24. package/src/components/Callout/Callout.tsx +39 -0
  25. package/src/components/Callout/index.ts +1 -0
  26. package/src/components/Callout/styles.css.ts +45 -0
  27. package/src/components/Card/Card.stories.tsx +119 -0
  28. package/src/components/Card/Card.tsx +35 -0
  29. package/src/components/Card/styles.css.ts +67 -0
  30. package/src/components/Checkbox/Checkbox.stories.tsx +88 -0
  31. package/src/components/Checkbox/Checkbox.tsx +73 -0
  32. package/src/components/Checkbox/styles.css.ts +57 -0
  33. package/src/components/Cluster/Cluster.stories.tsx +92 -0
  34. package/src/components/Cluster/Cluster.tsx +43 -0
  35. package/src/components/Cluster/styles.css.ts +25 -0
  36. package/src/components/EmptyState/EmptyState.stories.tsx +54 -0
  37. package/src/components/EmptyState/EmptyState.tsx +19 -0
  38. package/src/components/EmptyState/styles.css.ts +25 -0
  39. package/src/components/Field/Field.stories.tsx +92 -0
  40. package/src/components/Field/Field.tsx +80 -0
  41. package/src/components/Field/FieldContext.ts +14 -0
  42. package/src/components/Field/styles.css.ts +25 -0
  43. package/src/components/Fieldset/Fieldset.stories.tsx +85 -0
  44. package/src/components/Fieldset/Fieldset.tsx +48 -0
  45. package/src/components/Fieldset/index.ts +1 -0
  46. package/src/components/Fieldset/styles.css.ts +33 -0
  47. package/src/components/Grid/Grid.stories.tsx +107 -0
  48. package/src/components/Grid/Grid.tsx +41 -0
  49. package/src/components/Grid/styles.css.ts +25 -0
  50. package/src/components/Heading/Heading.tsx +48 -0
  51. package/src/components/Heading/styles.css.ts +26 -0
  52. package/src/components/Icon/Icon.tsx +168 -0
  53. package/src/components/Icon/index.ts +2 -0
  54. package/src/components/Inline/Inline.stories.tsx +88 -0
  55. package/src/components/Inline/Inline.tsx +45 -0
  56. package/src/components/Inline/styles.css.ts +27 -0
  57. package/src/components/Input/Input.stories.tsx +89 -0
  58. package/src/components/Input/Input.tsx +77 -0
  59. package/src/components/Input/styles.css.ts +60 -0
  60. package/src/components/InputGroup/InputGroup.stories.tsx +119 -0
  61. package/src/components/InputGroup/InputGroup.tsx +60 -0
  62. package/src/components/InputGroup/InputGroupContext.ts +11 -0
  63. package/src/components/InputGroup/styles.css.ts +61 -0
  64. package/src/components/LinkButton/LinkButton.stories.tsx +91 -0
  65. package/src/components/LinkButton/LinkButton.tsx +42 -0
  66. package/src/components/LinkButton/styles.css.ts +56 -0
  67. package/src/components/Menu/Menu.stories.tsx +146 -0
  68. package/src/components/Menu/Menu.tsx +151 -0
  69. package/src/components/Menu/MenuContext.ts +20 -0
  70. package/src/components/Menu/styles.css.ts +89 -0
  71. package/src/components/Menu/useMenuRoot.ts +136 -0
  72. package/src/components/PageShell/PageShell.tsx +45 -0
  73. package/src/components/PageShell/index.ts +2 -0
  74. package/src/components/PageShell/styles.css.ts +26 -0
  75. package/src/components/ScrollArea/ScrollArea.stories.tsx +82 -0
  76. package/src/components/ScrollArea/ScrollArea.tsx +170 -0
  77. package/src/components/ScrollArea/ScrollAreaContext.ts +21 -0
  78. package/src/components/ScrollArea/styles.css.ts +81 -0
  79. package/src/components/ScrollArea/useScrollAreaRoot.ts +72 -0
  80. package/src/components/Select/Select.stories.tsx +144 -0
  81. package/src/components/Select/Select.tsx +183 -0
  82. package/src/components/Select/SelectContext.ts +24 -0
  83. package/src/components/Select/styles.css.ts +97 -0
  84. package/src/components/Select/useSelectRoot.ts +178 -0
  85. package/src/components/SideNav/SideNav.stories.tsx +77 -0
  86. package/src/components/SideNav/SideNav.tsx +172 -0
  87. package/src/components/SideNav/SideNavContext.ts +18 -0
  88. package/src/components/SideNav/styles.css.ts +95 -0
  89. package/src/components/Spinner/Spinner.stories.tsx +59 -0
  90. package/src/components/Spinner/Spinner.tsx +24 -0
  91. package/src/components/Spinner/styles.css.ts +47 -0
  92. package/src/components/Stack/Stack.stories.tsx +103 -0
  93. package/src/components/Stack/Stack.tsx +33 -0
  94. package/src/components/Stack/styles.css.ts +21 -0
  95. package/src/components/StatusIcon/StatusIcon.stories.tsx +81 -0
  96. package/src/components/StatusIcon/StatusIcon.tsx +24 -0
  97. package/src/components/StatusIcon/styles.css.ts +27 -0
  98. package/src/components/Switch/Switch.stories.tsx +88 -0
  99. package/src/components/Switch/Switch.tsx +78 -0
  100. package/src/components/Switch/styles.css.ts +71 -0
  101. package/src/components/Table/Table.stories.tsx +308 -0
  102. package/src/components/Table/Table.tsx +179 -0
  103. package/src/components/Table/styles.css.ts +97 -0
  104. package/src/components/Tabs/Tabs.stories.tsx +142 -0
  105. package/src/components/Tabs/Tabs.tsx +210 -0
  106. package/src/components/Tabs/TabsContext.ts +20 -0
  107. package/src/components/Tabs/styles.css.ts +98 -0
  108. package/src/components/Tabs/useTabsRoot.ts +42 -0
  109. package/src/components/Text/Text.tsx +52 -0
  110. package/src/components/Text/styles.css.ts +57 -0
  111. package/src/components/Textarea/Textarea.stories.tsx +80 -0
  112. package/src/components/Textarea/Textarea.tsx +50 -0
  113. package/src/components/Textarea/styles.css.ts +56 -0
  114. package/src/components/ThemeProvider/ThemeProvider.stories.tsx +163 -0
  115. package/src/components/ThemeProvider/ThemeProvider.tsx +33 -0
  116. package/src/components/Toggle/Toggle.stories.tsx +84 -0
  117. package/src/components/Toggle/Toggle.tsx +85 -0
  118. package/src/components/Toggle/styles.css.ts +66 -0
  119. package/src/components/ToggleGroup/ToggleGroup.stories.tsx +159 -0
  120. package/src/components/ToggleGroup/ToggleGroup.tsx +63 -0
  121. package/src/components/ToggleGroup/ToggleGroupContext.ts +18 -0
  122. package/src/components/ToggleGroup/styles.css.ts +17 -0
  123. package/src/components/Tooltip/Tooltip.stories.tsx +127 -0
  124. package/src/components/Tooltip/Tooltip.tsx +97 -0
  125. package/src/components/Tooltip/styles.css.ts +56 -0
  126. package/src/docs/Spacing.mdx +80 -0
  127. package/src/docs/Spacing.stories.tsx +202 -0
  128. package/src/docs/Typography.mdx +93 -0
  129. package/src/docs/Typography.stories.tsx +211 -0
  130. package/src/docs/helpers.tsx +135 -0
  131. package/src/hooks/useContainerQuery.ts +54 -0
  132. package/src/hooks/useControllableValue.ts +18 -0
  133. package/src/index.ts +56 -0
  134. package/src/stubs/assets-registry.ts +3 -0
@@ -0,0 +1,97 @@
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
+ export const styles = css.create({
8
+ root: {
9
+ position: 'relative',
10
+ display: 'inline-flex',
11
+ },
12
+ trigger: {
13
+ display: 'inline-flex',
14
+ alignItems: 'center',
15
+ justifyContent: 'space-between',
16
+ gap: spacing.sm,
17
+ paddingTop: spacing.sm,
18
+ paddingBottom: spacing.sm,
19
+ paddingLeft: spacing.md,
20
+ paddingRight: spacing.md,
21
+ fontFamily: typography.fontFamily,
22
+ fontSize: typography.fontSizeSm,
23
+ color: colors.text,
24
+ backgroundColor: colors.bgCard,
25
+ borderWidth: 1,
26
+ borderStyle: 'solid',
27
+ borderColor: {
28
+ default: colors.border,
29
+ ':hover': colors.accent,
30
+ },
31
+ borderRadius: radii.sm,
32
+ cursor: 'pointer',
33
+ transitionProperty: 'border-color',
34
+ transitionDuration: '150ms',
35
+ },
36
+ value: {
37
+ color: colors.text,
38
+ },
39
+ placeholder: {
40
+ color: colors.textMuted,
41
+ },
42
+ icon: {
43
+ display: 'flex',
44
+ alignItems: 'center',
45
+ color: colors.textMuted,
46
+ },
47
+ backdrop: {
48
+ position: 'fixed',
49
+ top: 0,
50
+ left: 0,
51
+ right: 0,
52
+ bottom: 0,
53
+ zIndex: 49,
54
+ },
55
+ popup: {
56
+ position: 'absolute',
57
+ top: '100%',
58
+ left: 0,
59
+ marginTop: spacing.xs,
60
+ backgroundColor: colors.bgCard,
61
+ borderWidth: 1,
62
+ borderStyle: 'solid',
63
+ borderColor: colors.border,
64
+ borderRadius: radii.sm,
65
+ boxShadow: shadows.md,
66
+ paddingTop: spacing.xs,
67
+ paddingBottom: spacing.xs,
68
+ minWidth: 120,
69
+ zIndex: 50,
70
+ },
71
+ item: {
72
+ display: 'flex',
73
+ alignItems: 'center',
74
+ paddingTop: spacing.sm,
75
+ paddingBottom: spacing.sm,
76
+ paddingLeft: spacing.md,
77
+ paddingRight: spacing.md,
78
+ fontSize: typography.fontSizeSm,
79
+ fontFamily: typography.fontFamily,
80
+ color: colors.text,
81
+ borderRadius: radii.sm,
82
+ cursor: 'pointer',
83
+ backgroundColor: 'transparent',
84
+ transitionProperty: 'background-color',
85
+ transitionDuration: '150ms',
86
+ },
87
+ itemSelected: {
88
+ color: colors.accent,
89
+ fontWeight: typography.fontWeightMedium,
90
+ },
91
+ itemHighlighted: {
92
+ backgroundColor: colors.bgCardHover,
93
+ },
94
+ hidden: {
95
+ display: 'none',
96
+ },
97
+ })
@@ -0,0 +1,178 @@
1
+ import {useState, useCallback, useMemo, useRef, useId, useEffect} from 'react'
2
+ import {useControllableValue} from '../../hooks/useControllableValue'
3
+ import type {SelectContextValue} from './SelectContext'
4
+
5
+ interface UseSelectRootOptions {
6
+ defaultValue?: string
7
+ value?: string
8
+ onValueChange?: (value: string | null) => void
9
+ initialLabels?: Record<string, string>
10
+ }
11
+
12
+ export function useSelectRoot({
13
+ defaultValue,
14
+ value: controlledValue,
15
+ onValueChange,
16
+ initialLabels,
17
+ }: UseSelectRootOptions) {
18
+ const [value, setValue] = useControllableValue<string | null>(
19
+ controlledValue,
20
+ defaultValue ?? null,
21
+ onValueChange,
22
+ )
23
+ const [open, setOpen] = useState(false)
24
+ const [labels, setLabels] = useState<Record<string, string>>(initialLabels ?? {})
25
+ const [highlightedId, setHighlightedId] = useState<string | null>(null)
26
+ const listboxId = useId()
27
+ const rootRef = useRef<HTMLDivElement>(null)
28
+ const triggerRef = useRef<HTMLButtonElement | null>(null)
29
+ const itemsRef = useRef(new Map<string, {value: string; element: HTMLElement}>())
30
+ const orderRef = useRef<string[]>([])
31
+
32
+ const close = useCallback(() => {
33
+ setOpen(false)
34
+ setHighlightedId(null)
35
+ triggerRef.current?.focus()
36
+ }, [])
37
+
38
+ const toggle = useCallback(() => {
39
+ setOpen((prev) => {
40
+ if (!prev) {
41
+ const items = itemsRef.current
42
+ const order = orderRef.current
43
+ let found: string | null = null
44
+ for (const id of order) {
45
+ const item = items.get(id)
46
+ if (item && item.value === value) {
47
+ found = id
48
+ break
49
+ }
50
+ }
51
+ setHighlightedId(found ?? (order.length > 0 ? order[0] : null))
52
+ } else {
53
+ setHighlightedId(null)
54
+ }
55
+ return !prev
56
+ })
57
+ }, [value])
58
+
59
+ const registerLabel = useCallback((v: string, label: string) => {
60
+ setLabels((prev) => {
61
+ if (prev[v] === label) return prev
62
+ return {...prev, [v]: label}
63
+ })
64
+ }, [])
65
+
66
+ const registerItem = useCallback((id: string, itemValue: string, element: HTMLElement) => {
67
+ itemsRef.current.set(id, {value: itemValue, element})
68
+ const map = itemsRef.current
69
+ const ids = [...map.keys()]
70
+ ids.sort((a, b) => {
71
+ const elA = map.get(a)?.element
72
+ const elB = map.get(b)?.element
73
+ if (!elA || !elB) return 0
74
+ return elA.compareDocumentPosition(elB) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
75
+ })
76
+ orderRef.current = ids
77
+ return () => {
78
+ itemsRef.current.delete(id)
79
+ orderRef.current = orderRef.current.filter((i) => i !== id)
80
+ }
81
+ }, [])
82
+
83
+ // Native keydown for full KeyboardEvent access (preventDefault)
84
+ useEffect(() => {
85
+ const root = rootRef.current
86
+ if (!root || !open) return
87
+
88
+ function handleKeyDown(e: KeyboardEvent) {
89
+ const order = orderRef.current
90
+ const items = itemsRef.current
91
+ if (order.length === 0) return
92
+
93
+ switch (e.key) {
94
+ case 'ArrowDown': {
95
+ e.preventDefault()
96
+ setHighlightedId((prev) => {
97
+ const idx = prev ? order.indexOf(prev) : -1
98
+ return order[(idx + 1) % order.length]
99
+ })
100
+ break
101
+ }
102
+ case 'ArrowUp': {
103
+ e.preventDefault()
104
+ setHighlightedId((prev) => {
105
+ const idx = prev ? order.indexOf(prev) : 0
106
+ return order[(idx - 1 + order.length) % order.length]
107
+ })
108
+ break
109
+ }
110
+ case 'Home': {
111
+ e.preventDefault()
112
+ setHighlightedId(order[0])
113
+ break
114
+ }
115
+ case 'End': {
116
+ e.preventDefault()
117
+ setHighlightedId(order[order.length - 1])
118
+ break
119
+ }
120
+ case 'Enter':
121
+ case ' ': {
122
+ e.preventDefault()
123
+ setHighlightedId((prev) => {
124
+ if (prev) {
125
+ const item = items.get(prev)
126
+ if (item) {
127
+ setValue(item.value)
128
+ close()
129
+ }
130
+ }
131
+ return prev
132
+ })
133
+ break
134
+ }
135
+ case 'Escape':
136
+ case 'Tab': {
137
+ close()
138
+ break
139
+ }
140
+ }
141
+ }
142
+
143
+ root.addEventListener('keydown', handleKeyDown)
144
+ return () => root.removeEventListener('keydown', handleKeyDown)
145
+ }, [open, close, setValue])
146
+
147
+ const ctx: SelectContextValue = useMemo(
148
+ () => ({
149
+ open,
150
+ toggle,
151
+ close,
152
+ value,
153
+ setValue,
154
+ labels,
155
+ registerLabel,
156
+ listboxId,
157
+ highlightedId,
158
+ setHighlightedId,
159
+ registerItem,
160
+ triggerRef,
161
+ }),
162
+ [
163
+ open,
164
+ toggle,
165
+ close,
166
+ value,
167
+ setValue,
168
+ labels,
169
+ registerLabel,
170
+ listboxId,
171
+ highlightedId,
172
+ setHighlightedId,
173
+ registerItem,
174
+ ],
175
+ )
176
+
177
+ return {ctx, rootRef}
178
+ }
@@ -0,0 +1,77 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, fn} from 'storybook/test'
3
+ import {SideNav} from './SideNav'
4
+
5
+ const meta: Meta<typeof SideNav.Root> = {
6
+ title: 'Components/SideNav',
7
+ component: SideNav.Root,
8
+ args: {
9
+ onValueChange: fn(),
10
+ },
11
+ }
12
+
13
+ export default meta
14
+ type Story = StoryObj<typeof SideNav.Root>
15
+
16
+ export const Default: Story = {
17
+ render: (args) => (
18
+ <SideNav.Root {...args} defaultValue="dashboard">
19
+ <SideNav.Group label="Overview" defaultExpanded>
20
+ <SideNav.Item value="dashboard">Dashboard</SideNav.Item>
21
+ <SideNav.Item value="analytics">Analytics</SideNav.Item>
22
+ </SideNav.Group>
23
+ <SideNav.Group label="Settings">
24
+ <SideNav.Item value="profile">Profile</SideNav.Item>
25
+ <SideNav.Item value="security">Security</SideNav.Item>
26
+ <SideNav.Item value="notifications">Notifications</SideNav.Item>
27
+ </SideNav.Group>
28
+ </SideNav.Root>
29
+ ),
30
+ play: async ({canvas}) => {
31
+ const nav = canvas.getByRole('navigation')
32
+ await expect(nav).toBeInTheDocument()
33
+
34
+ const activeItem = canvas.getByText('Dashboard')
35
+ await expect(activeItem).toHaveAttribute('aria-current', 'page')
36
+ },
37
+ }
38
+
39
+ export const Collapsed: Story = {
40
+ render: (args) => (
41
+ <SideNav.Root {...args}>
42
+ <SideNav.Group label="Section A">
43
+ <SideNav.Item value="a1">Item A1</SideNav.Item>
44
+ <SideNav.Item value="a2">Item A2</SideNav.Item>
45
+ </SideNav.Group>
46
+ <SideNav.Group label="Section B">
47
+ <SideNav.Item value="b1">Item B1</SideNav.Item>
48
+ </SideNav.Group>
49
+ </SideNav.Root>
50
+ ),
51
+ play: async ({canvas, userEvent}) => {
52
+ const trigger = canvas.getByText('Section A')
53
+ await expect(trigger).toHaveAttribute('aria-expanded', 'false')
54
+
55
+ await userEvent.click(trigger)
56
+ await expect(trigger).toHaveAttribute('aria-expanded', 'true')
57
+ await expect(canvas.getByText('Item A1')).toBeInTheDocument()
58
+ },
59
+ }
60
+
61
+ export const Interactive: Story = {
62
+ render: (args) => (
63
+ <SideNav.Root {...args}>
64
+ <SideNav.Group label="Pages" defaultExpanded>
65
+ <SideNav.Item value="home">Home</SideNav.Item>
66
+ <SideNav.Item value="about">About</SideNav.Item>
67
+ <SideNav.Item value="contact">Contact</SideNav.Item>
68
+ </SideNav.Group>
69
+ </SideNav.Root>
70
+ ),
71
+ play: async ({canvas, userEvent, args}) => {
72
+ const aboutItem = canvas.getByText('About')
73
+ await userEvent.click(aboutItem)
74
+ await expect(args.onValueChange).toHaveBeenCalledWith('about')
75
+ await expect(aboutItem).toHaveAttribute('aria-current', 'page')
76
+ },
77
+ }
@@ -0,0 +1,172 @@
1
+ import {type ReactNode, useState, useCallback, useRef, useEffect} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+ import {useControllableValue} from '../../hooks/useControllableValue'
5
+ import {SideNavContext, useSideNav} from './SideNavContext'
6
+
7
+ // --- Root ---
8
+
9
+ interface RootProps {
10
+ children: ReactNode
11
+ value?: string
12
+ defaultValue?: string
13
+ onValueChange?: (value: string) => void
14
+ }
15
+
16
+ function Root({children, value: controlledValue, defaultValue, onValueChange}: RootProps) {
17
+ const [activeValue, onSelect] = useControllableValue<string | null>(
18
+ controlledValue,
19
+ defaultValue ?? null,
20
+ onValueChange
21
+ ? (v) => {
22
+ if (v !== null) onValueChange(v)
23
+ }
24
+ : undefined,
25
+ )
26
+ const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
27
+ const orderRef = useRef<string[]>([])
28
+
29
+ const toggleGroup = useCallback((group: string) => {
30
+ setExpandedGroups((prev) => {
31
+ const next = new Set(prev)
32
+ if (next.has(group)) {
33
+ next.delete(group)
34
+ } else {
35
+ next.add(group)
36
+ }
37
+ return next
38
+ })
39
+ }, [])
40
+
41
+ const registerItem = useCallback((value: string) => {
42
+ if (!orderRef.current.includes(value)) {
43
+ orderRef.current.push(value)
44
+ }
45
+ return () => {
46
+ orderRef.current = orderRef.current.filter((v) => v !== value)
47
+ }
48
+ }, [])
49
+
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
+ return (
61
+ <SideNavContext.Provider
62
+ value={{activeValue, onSelect, expandedGroups, toggleGroup, registerItem, orderRef}}
63
+ >
64
+ <html.nav role="navigation" style={styles.root}>
65
+ {children}
66
+ </html.nav>
67
+ </SideNavContext.Provider>
68
+ )
69
+ }
70
+
71
+ // --- Group ---
72
+
73
+ interface GroupProps {
74
+ children: ReactNode
75
+ label: string
76
+ groupKey?: string
77
+ defaultExpanded?: boolean
78
+ }
79
+
80
+ function Group({children, label, groupKey, defaultExpanded}: GroupProps) {
81
+ const key = groupKey ?? label
82
+ const {expandedGroups, toggleGroup, activeValue} = useSideNav()
83
+ const isExpanded = expandedGroups.has(key)
84
+ const groupRef = useRef<HTMLDivElement>(null)
85
+
86
+ // Auto-expand if this group contains the active item
87
+ useEffect(() => {
88
+ if (!activeValue || expandedGroups.has(key)) return
89
+ const el = groupRef.current
90
+ if (!el) return
91
+ const activeBtn = el.querySelector(`[data-nav-value="${activeValue}"]`)
92
+ if (activeBtn) {
93
+ toggleGroup(key)
94
+ }
95
+ }, [activeValue, key, expandedGroups, toggleGroup])
96
+
97
+ // Expand on first render if defaultExpanded
98
+ useEffect(() => {
99
+ if (defaultExpanded && !expandedGroups.has(key)) {
100
+ toggleGroup(key)
101
+ }
102
+ // Only run on mount
103
+ // eslint-disable-next-line react-hooks/exhaustive-deps
104
+ }, [])
105
+
106
+ const hasActiveChild = (() => {
107
+ if (!activeValue || !groupRef.current) return false
108
+ return !!groupRef.current.querySelector(`[data-nav-value="${activeValue}"]`)
109
+ })()
110
+
111
+ return (
112
+ <html.div ref={groupRef} style={styles.group}>
113
+ <html.button
114
+ type="button"
115
+ onClick={() => toggleGroup(key)}
116
+ style={[styles.groupTrigger, hasActiveChild && styles.groupTriggerActive]}
117
+ aria-expanded={isExpanded}
118
+ >
119
+ <html.span style={[styles.chevron, isExpanded && styles.chevronOpen]}>
120
+ <svg
121
+ width={10}
122
+ height={10}
123
+ viewBox="0 0 24 24"
124
+ fill="none"
125
+ stroke="currentColor"
126
+ strokeWidth={2.5}
127
+ strokeLinecap="round"
128
+ strokeLinejoin="round"
129
+ >
130
+ <path d="M9 18l6-6-6-6" />
131
+ </svg>
132
+ </html.span>
133
+ {label}
134
+ </html.button>
135
+ {isExpanded && children}
136
+ </html.div>
137
+ )
138
+ }
139
+
140
+ // --- Item ---
141
+
142
+ interface ItemProps {
143
+ value: string
144
+ children: ReactNode
145
+ }
146
+
147
+ function Item({value, children}: ItemProps) {
148
+ const {activeValue, onSelect, registerItem} = useSideNav()
149
+ const isActive = activeValue === value
150
+
151
+ useEffect(() => {
152
+ return registerItem(value)
153
+ }, [value, registerItem])
154
+
155
+ return (
156
+ <html.button
157
+ type="button"
158
+ data-nav-value={value}
159
+ onClick={() => onSelect(value)}
160
+ style={[styles.item, isActive && styles.itemActive]}
161
+ aria-current={isActive ? 'page' : undefined}
162
+ >
163
+ {children}
164
+ </html.button>
165
+ )
166
+ }
167
+
168
+ export const SideNav = {
169
+ Root,
170
+ Group,
171
+ Item,
172
+ }
@@ -0,0 +1,18 @@
1
+ import {createContext, useContext} from 'react'
2
+
3
+ export interface SideNavContextValue {
4
+ activeValue: string | null
5
+ onSelect: (value: string) => void
6
+ expandedGroups: Set<string>
7
+ toggleGroup: (group: string) => void
8
+ registerItem: (value: string) => () => void
9
+ orderRef: React.RefObject<string[]>
10
+ }
11
+
12
+ export const SideNavContext = createContext<SideNavContextValue | null>(null)
13
+
14
+ export function useSideNav() {
15
+ const ctx = useContext(SideNavContext)
16
+ if (!ctx) throw new Error('SideNav compound components must be used within SideNav.Root')
17
+ return ctx
18
+ }
@@ -0,0 +1,95 @@
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
+
6
+ export const styles = css.create({
7
+ root: {
8
+ display: 'flex',
9
+ flexDirection: 'column',
10
+ gap: 0,
11
+ },
12
+ group: {
13
+ display: 'flex',
14
+ flexDirection: 'column',
15
+ },
16
+ groupTrigger: {
17
+ display: 'flex',
18
+ alignItems: 'center',
19
+ gap: spacing.xs,
20
+ paddingTop: spacing.sm,
21
+ paddingBottom: spacing.sm,
22
+ paddingLeft: spacing.md,
23
+ paddingRight: spacing.md,
24
+ fontFamily: typography.fontFamily,
25
+ fontSize: typography.fontSizeXs,
26
+ fontWeight: typography.fontWeightSemibold,
27
+ textTransform: 'uppercase' as const,
28
+ letterSpacing: '0.05em',
29
+ color: {
30
+ default: colors.textMuted,
31
+ ':hover': colors.text,
32
+ },
33
+ backgroundColor: 'transparent',
34
+ borderWidth: 0,
35
+ cursor: 'pointer',
36
+ transitionProperty: 'color',
37
+ transitionDuration: '150ms',
38
+ transitionTimingFunction: 'ease',
39
+ },
40
+ groupTriggerActive: {
41
+ color: colors.text,
42
+ },
43
+ chevron: {
44
+ display: 'inline-flex',
45
+ alignItems: 'center',
46
+ transitionProperty: 'transform',
47
+ transitionDuration: '150ms',
48
+ transitionTimingFunction: 'ease',
49
+ },
50
+ chevronOpen: {
51
+ transform: 'rotate(90deg)',
52
+ },
53
+ item: {
54
+ display: 'flex',
55
+ alignItems: 'center',
56
+ paddingTop: '6px',
57
+ paddingBottom: '6px',
58
+ paddingLeft: spacing.lg,
59
+ paddingRight: spacing.md,
60
+ fontFamily: typography.fontFamily,
61
+ fontSize: typography.fontSizeSm,
62
+ fontWeight: typography.fontWeightNormal,
63
+ color: {
64
+ default: colors.textMuted,
65
+ ':hover': colors.text,
66
+ },
67
+ backgroundColor: {
68
+ default: 'transparent',
69
+ ':hover': colors.bgCardHover,
70
+ },
71
+ borderWidth: 0,
72
+ borderRadius: radii.sm,
73
+ cursor: 'pointer',
74
+ transitionProperty: 'color, background-color',
75
+ transitionDuration: '150ms',
76
+ transitionTimingFunction: 'ease',
77
+ textAlign: 'left' as const,
78
+ outlineWidth: {
79
+ default: 0,
80
+ ':focus-visible': 2,
81
+ },
82
+ outlineStyle: {
83
+ default: 'none',
84
+ ':focus-visible': 'solid',
85
+ },
86
+ outlineColor: {
87
+ default: 'transparent',
88
+ ':focus-visible': colors.accent,
89
+ },
90
+ },
91
+ itemActive: {
92
+ color: colors.accent,
93
+ fontWeight: typography.fontWeightMedium,
94
+ },
95
+ })
@@ -0,0 +1,59 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {Spinner} from './Spinner'
5
+
6
+ const meta: Meta<typeof Spinner> = {
7
+ title: 'Components/Spinner',
8
+ component: Spinner,
9
+ argTypes: {
10
+ size: {
11
+ control: 'select',
12
+ options: ['sm', 'md', 'lg'],
13
+ },
14
+ },
15
+ }
16
+
17
+ export default meta
18
+ type Story = StoryObj<typeof Spinner>
19
+
20
+ export const Default: Story = {
21
+ play: async ({canvas}) => {
22
+ const status = canvas.getByRole('status')
23
+ await expect(status).toBeInTheDocument()
24
+ await expect(status).toHaveTextContent('Loading')
25
+ },
26
+ }
27
+
28
+ export const Small: Story = {
29
+ args: {size: 'sm'},
30
+ }
31
+
32
+ export const Large: Story = {
33
+ args: {size: 'lg'},
34
+ }
35
+
36
+ export const CustomLabel: Story = {
37
+ args: {label: 'Saving...'},
38
+ play: async ({canvas}) => {
39
+ await expect(canvas.getByRole('status')).toHaveTextContent('Saving...')
40
+ },
41
+ }
42
+
43
+ const rowStyles = css.create({
44
+ row: {display: 'flex', gap: 24, alignItems: 'center'},
45
+ })
46
+
47
+ export const AllSizes: Story = {
48
+ render: () => (
49
+ <html.div style={rowStyles.row}>
50
+ <Spinner size="sm" />
51
+ <Spinner size="md" />
52
+ <Spinner size="lg" />
53
+ </html.div>
54
+ ),
55
+ play: async ({canvas}) => {
56
+ const spinners = canvas.getAllByRole('status')
57
+ await expect(spinners.length).toBe(3)
58
+ },
59
+ }