@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,142 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
3
+ import {Tabs} from './Tabs'
4
+
5
+ const meta: Meta = {
6
+ title: 'Components/Tabs',
7
+ }
8
+
9
+ export default meta
10
+ type Story = StoryObj
11
+
12
+ export const Default: Story = {
13
+ render: () => (
14
+ <Tabs.Root defaultValue="overview">
15
+ <Tabs.List>
16
+ <Tabs.Tab value="overview">Overview</Tabs.Tab>
17
+ <Tabs.Tab value="security">Security</Tabs.Tab>
18
+ <Tabs.Tab value="network">Network</Tabs.Tab>
19
+ </Tabs.List>
20
+ <Tabs.Panel value="overview">Overview panel content</Tabs.Panel>
21
+ <Tabs.Panel value="security">Security panel content</Tabs.Panel>
22
+ <Tabs.Panel value="network">Network panel content</Tabs.Panel>
23
+ </Tabs.Root>
24
+ ),
25
+ play: async ({canvas, userEvent}) => {
26
+ const tablist = canvas.getByRole('tablist')
27
+ await expect(tablist).toBeInTheDocument()
28
+ await expect(tablist).toHaveAttribute('aria-orientation', 'horizontal')
29
+
30
+ const tabs = canvas.getAllByRole('tab')
31
+ await expect(tabs.length).toBe(3)
32
+
33
+ // Initial state: first tab selected
34
+ await expect(tabs[0]).toHaveAttribute('aria-selected', 'true')
35
+ await expect(tabs[1]).toHaveAttribute('aria-selected', 'false')
36
+ await expect(tabs[2]).toHaveAttribute('aria-selected', 'false')
37
+ await expect(canvas.getByRole('tabpanel')).toHaveTextContent('Overview panel content')
38
+
39
+ // Click second tab
40
+ await userEvent.click(tabs[1])
41
+ await expect(tabs[0]).toHaveAttribute('aria-selected', 'false')
42
+ await expect(tabs[1]).toHaveAttribute('aria-selected', 'true')
43
+ await expect(canvas.getByRole('tabpanel')).toHaveTextContent('Security panel content')
44
+
45
+ // Click third tab
46
+ await userEvent.click(tabs[2])
47
+ await expect(tabs[2]).toHaveAttribute('aria-selected', 'true')
48
+ await expect(canvas.getByRole('tabpanel')).toHaveTextContent('Network panel content')
49
+ },
50
+ }
51
+
52
+ export const KeyboardNavigation: Story = {
53
+ render: () => (
54
+ <Tabs.Root defaultValue="overview">
55
+ <Tabs.List>
56
+ <Tabs.Tab value="overview">Overview</Tabs.Tab>
57
+ <Tabs.Tab value="security">Security</Tabs.Tab>
58
+ <Tabs.Tab value="network">Network</Tabs.Tab>
59
+ </Tabs.List>
60
+ <Tabs.Panel value="overview">Overview content</Tabs.Panel>
61
+ <Tabs.Panel value="security">Security content</Tabs.Panel>
62
+ <Tabs.Panel value="network">Network content</Tabs.Panel>
63
+ </Tabs.Root>
64
+ ),
65
+ play: async ({canvas, userEvent}) => {
66
+ const tabs = canvas.getAllByRole('tab')
67
+
68
+ // Focus first tab then navigate with ArrowRight
69
+ await userEvent.click(tabs[0])
70
+ await expect(tabs[0]).toHaveAttribute('aria-selected', 'true')
71
+
72
+ await userEvent.keyboard('{ArrowRight}')
73
+ await expect(tabs[1]).toHaveFocus()
74
+
75
+ await userEvent.keyboard('{ArrowRight}')
76
+ await expect(tabs[2]).toHaveFocus()
77
+
78
+ // Wrap around
79
+ await userEvent.keyboard('{ArrowRight}')
80
+ await expect(tabs[0]).toHaveFocus()
81
+
82
+ // Home and End
83
+ await userEvent.keyboard('{End}')
84
+ await expect(tabs[2]).toHaveFocus()
85
+
86
+ await userEvent.keyboard('{Home}')
87
+ await expect(tabs[0]).toHaveFocus()
88
+ },
89
+ }
90
+
91
+ export const WithDisabledTab: Story = {
92
+ render: () => (
93
+ <Tabs.Root defaultValue="overview">
94
+ <Tabs.List>
95
+ <Tabs.Tab value="overview">Overview</Tabs.Tab>
96
+ <Tabs.Tab value="security">Security</Tabs.Tab>
97
+ <Tabs.Tab value="settings" disabled>
98
+ Settings
99
+ </Tabs.Tab>
100
+ </Tabs.List>
101
+ <Tabs.Panel value="overview">Overview content</Tabs.Panel>
102
+ <Tabs.Panel value="security">Security content</Tabs.Panel>
103
+ <Tabs.Panel value="settings">Settings content</Tabs.Panel>
104
+ </Tabs.Root>
105
+ ),
106
+ play: async ({canvas}) => {
107
+ const tabs = canvas.getAllByRole('tab')
108
+ await expect(tabs[2]).toHaveAttribute('aria-disabled', 'true')
109
+ await expect(tabs[2]).toHaveAttribute('aria-selected', 'false')
110
+ },
111
+ }
112
+
113
+ export const Vertical: Story = {
114
+ render: () => (
115
+ <Tabs.Root defaultValue="general" orientation="vertical">
116
+ <Tabs.List>
117
+ <Tabs.Tab value="general">General</Tabs.Tab>
118
+ <Tabs.Tab value="security">Security</Tabs.Tab>
119
+ <Tabs.Tab value="network">Network</Tabs.Tab>
120
+ <Tabs.Tab value="advanced">Advanced</Tabs.Tab>
121
+ </Tabs.List>
122
+ <Tabs.Panel value="general">General settings content</Tabs.Panel>
123
+ <Tabs.Panel value="security">Security settings content</Tabs.Panel>
124
+ <Tabs.Panel value="network">Network settings content</Tabs.Panel>
125
+ <Tabs.Panel value="advanced">Advanced settings content</Tabs.Panel>
126
+ </Tabs.Root>
127
+ ),
128
+ play: async ({canvas, userEvent}) => {
129
+ const tablist = canvas.getByRole('tablist')
130
+ await expect(tablist).toHaveAttribute('aria-orientation', 'vertical')
131
+
132
+ const tabs = canvas.getAllByRole('tab')
133
+
134
+ // Vertical tabs use ArrowDown/ArrowUp
135
+ await userEvent.click(tabs[0])
136
+ await userEvent.keyboard('{ArrowDown}')
137
+ await expect(tabs[1]).toHaveFocus()
138
+
139
+ await userEvent.keyboard('{ArrowUp}')
140
+ await expect(tabs[0]).toHaveFocus()
141
+ },
142
+ }
@@ -0,0 +1,210 @@
1
+ import {type ReactNode, useRef, useId, useEffect} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+ import {TabsContext, useTabs} from './TabsContext'
5
+ import {useTabsRoot} from './useTabsRoot'
6
+
7
+ // --- Root ---
8
+
9
+ interface RootProps {
10
+ children: ReactNode
11
+ value?: string
12
+ defaultValue?: string
13
+ onValueChange?: (value: string) => void
14
+ orientation?: 'horizontal' | 'vertical'
15
+ }
16
+
17
+ function Root({
18
+ children,
19
+ value,
20
+ defaultValue,
21
+ onValueChange,
22
+ orientation = 'horizontal',
23
+ }: RootProps) {
24
+ const ctx = useTabsRoot({value, defaultValue, onValueChange, orientation})
25
+
26
+ return (
27
+ <TabsContext.Provider value={ctx}>
28
+ <html.div style={[styles.root, orientation === 'vertical' && styles.rootVertical]}>
29
+ {children}
30
+ </html.div>
31
+ </TabsContext.Provider>
32
+ )
33
+ }
34
+
35
+ // --- List ---
36
+
37
+ interface ListProps {
38
+ children: ReactNode
39
+ }
40
+
41
+ function List({children}: ListProps) {
42
+ const {orientation, activeValue, onSelect, tabsRef, orderRef} = useTabs()
43
+ const listRef = useRef<HTMLDivElement>(null)
44
+
45
+ useEffect(() => {
46
+ const el = listRef.current
47
+ if (!el) return
48
+
49
+ function handleKeyDown(this: HTMLElement, e: KeyboardEvent) {
50
+ const order = orderRef.current
51
+ const tabs = tabsRef.current
52
+ if (order.length === 0) return
53
+ const listEl = this
54
+
55
+ const prevKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp'
56
+ const nextKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown'
57
+
58
+ let targetValue: string | null = null
59
+
60
+ switch (e.key) {
61
+ case nextKey: {
62
+ e.preventDefault()
63
+ const currentIdx = activeValue ? order.indexOf(activeValue) : -1
64
+ for (let i = 1; i <= order.length; i++) {
65
+ const idx = (currentIdx + i) % order.length
66
+ const val = order[idx]
67
+ if (!tabs.get(val)) {
68
+ targetValue = val
69
+ break
70
+ }
71
+ }
72
+ break
73
+ }
74
+ case prevKey: {
75
+ e.preventDefault()
76
+ const currentIdx = activeValue ? order.indexOf(activeValue) : 0
77
+ for (let i = 1; i <= order.length; i++) {
78
+ const idx = (currentIdx - i + order.length) % order.length
79
+ const val = order[idx]
80
+ if (!tabs.get(val)) {
81
+ targetValue = val
82
+ break
83
+ }
84
+ }
85
+ break
86
+ }
87
+ case 'Home': {
88
+ e.preventDefault()
89
+ for (const val of order) {
90
+ if (!tabs.get(val)) {
91
+ targetValue = val
92
+ break
93
+ }
94
+ }
95
+ break
96
+ }
97
+ case 'End': {
98
+ e.preventDefault()
99
+ for (let i = order.length - 1; i >= 0; i--) {
100
+ if (!tabs.get(order[i])) {
101
+ targetValue = order[i]
102
+ break
103
+ }
104
+ }
105
+ break
106
+ }
107
+ }
108
+
109
+ if (targetValue) {
110
+ onSelect(targetValue)
111
+ // Focus the newly activated tab button
112
+ const tabEl = listEl.querySelector(
113
+ `[data-tab-value="${targetValue}"]`,
114
+ ) as HTMLElement | null
115
+ tabEl?.focus()
116
+ }
117
+ }
118
+
119
+ el.addEventListener('keydown', handleKeyDown)
120
+ return () => el.removeEventListener('keydown', handleKeyDown)
121
+ }, [orientation, activeValue, onSelect, tabsRef, orderRef])
122
+
123
+ return (
124
+ <html.div
125
+ ref={listRef}
126
+ role="tablist"
127
+ aria-orientation={orientation}
128
+ style={[styles.list, orientation === 'vertical' && styles.listVertical]}
129
+ >
130
+ {children}
131
+ </html.div>
132
+ )
133
+ }
134
+
135
+ // --- Tab ---
136
+
137
+ interface TabProps {
138
+ value: string
139
+ disabled?: boolean
140
+ children: ReactNode
141
+ }
142
+
143
+ function Tab({value, disabled = false, children}: TabProps) {
144
+ const {activeValue, onSelect, orientation, registerTab} = useTabs()
145
+ const isActive = activeValue === value
146
+ const tabId = useId()
147
+ const panelId = `${tabId}-panel`
148
+
149
+ useEffect(() => {
150
+ return registerTab(value, disabled)
151
+ }, [value, disabled, registerTab])
152
+
153
+ const handleClick = () => {
154
+ if (!disabled) {
155
+ onSelect(value)
156
+ }
157
+ }
158
+
159
+ return (
160
+ <html.button
161
+ type="button"
162
+ role="tab"
163
+ id={tabId}
164
+ aria-selected={isActive}
165
+ aria-controls={panelId}
166
+ aria-disabled={disabled || undefined}
167
+ data-tab-value={value}
168
+ tabIndex={isActive ? 0 : -1}
169
+ onClick={handleClick}
170
+ style={[
171
+ styles.tab,
172
+ orientation === 'vertical' && styles.tabVertical,
173
+ isActive &&
174
+ (orientation === 'vertical' ? styles.tabActiveVertical : styles.tabActiveHorizontal),
175
+ disabled && styles.tabDisabled,
176
+ ]}
177
+ >
178
+ {children}
179
+ </html.button>
180
+ )
181
+ }
182
+
183
+ // --- Panel ---
184
+
185
+ interface PanelProps {
186
+ value: string
187
+ children: ReactNode
188
+ }
189
+
190
+ function Panel({value, children}: PanelProps) {
191
+ const {activeValue, orientation} = useTabs()
192
+
193
+ if (activeValue !== value) return null
194
+
195
+ return (
196
+ <html.div
197
+ role="tabpanel"
198
+ style={[styles.panel, orientation === 'vertical' && styles.panelVertical]}
199
+ >
200
+ {children}
201
+ </html.div>
202
+ )
203
+ }
204
+
205
+ export const Tabs = {
206
+ Root,
207
+ List,
208
+ Tab,
209
+ Panel,
210
+ }
@@ -0,0 +1,20 @@
1
+ import {createContext, useContext} from 'react'
2
+
3
+ export type Orientation = 'horizontal' | 'vertical'
4
+
5
+ export interface TabsContextValue {
6
+ activeValue: string | null
7
+ onSelect: (value: string) => void
8
+ orientation: Orientation
9
+ registerTab: (value: string, disabled: boolean) => () => void
10
+ tabsRef: React.RefObject<Map<string, boolean>>
11
+ orderRef: React.RefObject<string[]>
12
+ }
13
+
14
+ export const TabsContext = createContext<TabsContextValue | null>(null)
15
+
16
+ export function useTabs() {
17
+ const ctx = useContext(TabsContext)
18
+ if (!ctx) throw new Error('Tabs compound components must be used within Tabs.Root')
19
+ return ctx
20
+ }
@@ -0,0 +1,98 @@
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
+ },
11
+ rootVertical: {
12
+ flexDirection: 'row',
13
+ },
14
+ list: {
15
+ display: 'flex',
16
+ flexDirection: 'row',
17
+ borderBottomWidth: 1,
18
+ borderBottomStyle: 'solid',
19
+ borderBottomColor: colors.border,
20
+ gap: spacing.xs,
21
+ },
22
+ listVertical: {
23
+ flexDirection: 'column',
24
+ borderBottomWidth: 0,
25
+ borderRightWidth: 1,
26
+ borderRightStyle: 'solid',
27
+ borderRightColor: colors.border,
28
+ gap: 0,
29
+ },
30
+ tab: {
31
+ display: 'inline-flex',
32
+ alignItems: 'center',
33
+ justifyContent: 'center',
34
+ paddingTop: spacing.sm,
35
+ paddingBottom: spacing.sm,
36
+ paddingLeft: spacing.md,
37
+ paddingRight: spacing.md,
38
+ fontFamily: typography.fontFamily,
39
+ fontSize: typography.fontSizeSm,
40
+ fontWeight: typography.fontWeightMedium,
41
+ color: {
42
+ default: colors.textMuted,
43
+ ':hover': colors.text,
44
+ },
45
+ backgroundColor: 'transparent',
46
+ borderWidth: 0,
47
+ borderBottomWidth: 2,
48
+ borderBottomStyle: 'solid',
49
+ borderBottomColor: 'transparent',
50
+ cursor: 'pointer',
51
+ transitionProperty: 'color, border-color',
52
+ transitionDuration: '150ms',
53
+ transitionTimingFunction: 'ease',
54
+ outlineWidth: {
55
+ default: 0,
56
+ ':focus-visible': 2,
57
+ },
58
+ outlineStyle: {
59
+ default: 'none',
60
+ ':focus-visible': 'solid',
61
+ },
62
+ outlineColor: {
63
+ default: 'transparent',
64
+ ':focus-visible': colors.accent,
65
+ },
66
+ outlineOffset: {
67
+ default: 0,
68
+ ':focus-visible': -2,
69
+ },
70
+ },
71
+ tabVertical: {
72
+ justifyContent: 'flex-start',
73
+ borderBottomWidth: 0,
74
+ borderRightWidth: 2,
75
+ borderRightStyle: 'solid',
76
+ borderRightColor: 'transparent',
77
+ },
78
+ tabActiveHorizontal: {
79
+ color: colors.text,
80
+ borderBottomColor: colors.accent,
81
+ },
82
+ tabActiveVertical: {
83
+ color: colors.text,
84
+ borderRightColor: colors.accent,
85
+ },
86
+ tabDisabled: {
87
+ opacity: 0.5,
88
+ cursor: 'not-allowed',
89
+ color: colors.textMuted,
90
+ },
91
+ panel: {
92
+ paddingTop: spacing.md,
93
+ },
94
+ panelVertical: {
95
+ paddingTop: 0,
96
+ paddingLeft: spacing.md,
97
+ },
98
+ })
@@ -0,0 +1,42 @@
1
+ import {useCallback, useRef} from 'react'
2
+ import {useControllableValue} from '../../hooks/useControllableValue'
3
+ import type {Orientation, TabsContextValue} from './TabsContext'
4
+
5
+ interface UseTabsRootOptions {
6
+ value?: string
7
+ defaultValue?: string
8
+ onValueChange?: (value: string) => void
9
+ orientation?: Orientation
10
+ }
11
+
12
+ export function useTabsRoot({
13
+ value: controlledValue,
14
+ defaultValue,
15
+ onValueChange,
16
+ orientation = 'horizontal',
17
+ }: UseTabsRootOptions): TabsContextValue {
18
+ const [activeValue, onSelect] = useControllableValue<string | null>(
19
+ controlledValue,
20
+ defaultValue ?? null,
21
+ onValueChange
22
+ ? (v) => {
23
+ if (v !== null) onValueChange(v)
24
+ }
25
+ : undefined,
26
+ )
27
+ const tabsRef = useRef(new Map<string, boolean>())
28
+ const orderRef = useRef<string[]>([])
29
+
30
+ const registerTab = useCallback((value: string, disabled: boolean) => {
31
+ tabsRef.current.set(value, disabled)
32
+ if (!orderRef.current.includes(value)) {
33
+ orderRef.current.push(value)
34
+ }
35
+ return () => {
36
+ tabsRef.current.delete(value)
37
+ orderRef.current = orderRef.current.filter((v) => v !== value)
38
+ }
39
+ }, [])
40
+
41
+ return {activeValue, onSelect, orientation, registerTab, tabsRef, orderRef}
42
+ }
@@ -0,0 +1,52 @@
1
+ import type {ReactNode} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {typePresets} from '@duro-app/tokens/tokens/type-presets.css'
4
+ import {styles} from './styles.css'
5
+
6
+ export type TextVariant = 'bodySm' | 'bodyMd' | 'bodyLg' | 'caption' | 'label' | 'code' | 'overline'
7
+ export type TextColor = 'default' | 'muted' | 'accent' | 'error' | 'success' | 'warning'
8
+
9
+ interface TextProps {
10
+ variant?: TextVariant
11
+ color?: TextColor
12
+ weight?: 'normal' | 'medium' | 'semibold' | 'bold'
13
+ align?: 'start' | 'center' | 'end'
14
+ truncate?: boolean
15
+ as?: 'span' | 'p' | 'div'
16
+ children: ReactNode
17
+ }
18
+
19
+ const weightMap = {
20
+ normal: styles.weightNormal,
21
+ medium: styles.weightMedium,
22
+ semibold: styles.weightSemibold,
23
+ bold: styles.weightBold,
24
+ } as const
25
+
26
+ const alignMap = {
27
+ start: styles.alignStart,
28
+ center: styles.alignCenter,
29
+ end: styles.alignEnd,
30
+ } as const
31
+
32
+ export function Text({
33
+ variant = 'bodyMd',
34
+ color = 'default',
35
+ weight,
36
+ align,
37
+ truncate,
38
+ as = 'span',
39
+ children,
40
+ }: TextProps) {
41
+ const style = [
42
+ typePresets[variant],
43
+ styles[color],
44
+ weight && weightMap[weight],
45
+ align && alignMap[align],
46
+ truncate && styles.truncate,
47
+ ]
48
+
49
+ if (as === 'p') return <html.p style={style}>{children}</html.p>
50
+ if (as === 'div') return <html.div style={style}>{children}</html.div>
51
+ return <html.span style={style}>{children}</html.span>
52
+ }
@@ -0,0 +1,57 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
4
+
5
+ export const styles = css.create({
6
+ // Colors
7
+ default: {
8
+ color: colors.text,
9
+ },
10
+ muted: {
11
+ color: colors.textMuted,
12
+ },
13
+ accent: {
14
+ color: colors.accent,
15
+ },
16
+ error: {
17
+ color: colors.errorText,
18
+ },
19
+ success: {
20
+ color: colors.successText,
21
+ },
22
+ warning: {
23
+ color: colors.warningText,
24
+ },
25
+
26
+ // Weight overrides
27
+ weightNormal: {
28
+ fontWeight: typography.fontWeightNormal,
29
+ },
30
+ weightMedium: {
31
+ fontWeight: typography.fontWeightMedium,
32
+ },
33
+ weightSemibold: {
34
+ fontWeight: typography.fontWeightSemibold,
35
+ },
36
+ weightBold: {
37
+ fontWeight: typography.fontWeightBold,
38
+ },
39
+
40
+ // Alignment
41
+ alignStart: {
42
+ textAlign: 'start',
43
+ },
44
+ alignCenter: {
45
+ textAlign: 'center',
46
+ },
47
+ alignEnd: {
48
+ textAlign: 'end',
49
+ },
50
+
51
+ // Truncate
52
+ truncate: {
53
+ overflow: 'hidden',
54
+ textOverflow: 'ellipsis',
55
+ whiteSpace: 'nowrap',
56
+ },
57
+ })
@@ -0,0 +1,80 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, fn} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {Textarea} from './Textarea'
5
+
6
+ const meta: Meta<typeof Textarea> = {
7
+ title: 'Components/Textarea',
8
+ component: Textarea,
9
+ args: {
10
+ onChange: fn(),
11
+ },
12
+ argTypes: {
13
+ variant: {
14
+ control: 'select',
15
+ options: ['default', 'error'],
16
+ },
17
+ disabled: {control: 'boolean'},
18
+ rows: {control: 'number'},
19
+ },
20
+ }
21
+
22
+ export default meta
23
+ type Story = StoryObj<typeof Textarea>
24
+
25
+ export const Default: Story = {
26
+ args: {placeholder: 'Enter your message...'},
27
+ play: async ({canvas, userEvent, args}) => {
28
+ const textarea = canvas.getByPlaceholderText('Enter your message...')
29
+ await expect(textarea).toBeInTheDocument()
30
+ await expect(textarea).toBeEnabled()
31
+
32
+ await userEvent.type(textarea, 'Hello world')
33
+ await expect(textarea).toHaveValue('Hello world')
34
+ await expect(args.onChange).toHaveBeenCalled()
35
+ },
36
+ }
37
+
38
+ export const Error: Story = {
39
+ args: {variant: 'error', placeholder: 'Invalid content'},
40
+ play: async ({canvas}) => {
41
+ const textarea = canvas.getByPlaceholderText('Invalid content')
42
+ await expect(textarea).toHaveAttribute('aria-invalid', 'true')
43
+ },
44
+ }
45
+
46
+ export const Disabled: Story = {
47
+ args: {placeholder: 'Disabled', disabled: true},
48
+ play: async ({canvas}) => {
49
+ await expect(canvas.getByPlaceholderText('Disabled')).toBeDisabled()
50
+ },
51
+ }
52
+
53
+ export const CustomRows: Story = {
54
+ args: {placeholder: 'Large textarea', rows: 8},
55
+ play: async ({canvas}) => {
56
+ const textarea = canvas.getByPlaceholderText('Large textarea')
57
+ await expect(textarea).toHaveAttribute('rows', '8')
58
+ },
59
+ }
60
+
61
+ const stackStyles = css.create({
62
+ stack: {display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 320},
63
+ })
64
+
65
+ export const AllVariants: Story = {
66
+ render: () => (
67
+ <html.div style={stackStyles.stack}>
68
+ <Textarea placeholder="Default textarea" />
69
+ <Textarea variant="error" placeholder="Error textarea" />
70
+ <Textarea placeholder="Disabled" disabled />
71
+ </html.div>
72
+ ),
73
+ play: async ({canvas}) => {
74
+ const textareas = canvas.getAllByRole('textbox')
75
+ await expect(textareas.length).toBe(3)
76
+
77
+ const disabled = canvas.getByPlaceholderText('Disabled')
78
+ await expect(disabled).toBeDisabled()
79
+ },
80
+ }