@duro-app/ui 0.12.1 → 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.
- package/dist/components/PageShell/PageShell.d.ts +15 -0
- package/dist/components/PageShell/PageShell.d.ts.map +1 -0
- package/dist/components/PageShell/index.d.ts +3 -0
- package/dist/components/PageShell/index.d.ts.map +1 -0
- package/dist/components/PageShell/styles.css.d.ts +41 -0
- package/dist/components/PageShell/styles.css.d.ts.map +1 -0
- package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3283 -3434
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/components/Alert/Alert.stories.tsx +76 -0
- package/src/components/Alert/Alert.tsx +45 -0
- package/src/components/Alert/styles.css.ts +50 -0
- package/src/components/Badge/Badge.stories.tsx +94 -0
- package/src/components/Badge/Badge.tsx +21 -0
- package/src/components/Badge/styles.css.ts +51 -0
- package/src/components/Button/Button.stories.tsx +130 -0
- package/src/components/Button/Button.tsx +48 -0
- package/src/components/Button/styles.css.ts +107 -0
- package/src/components/Callout/Callout.stories.tsx +97 -0
- package/src/components/Callout/Callout.tsx +39 -0
- package/src/components/Callout/index.ts +1 -0
- package/src/components/Callout/styles.css.ts +45 -0
- package/src/components/Card/Card.stories.tsx +119 -0
- package/src/components/Card/Card.tsx +35 -0
- package/src/components/Card/styles.css.ts +67 -0
- package/src/components/Checkbox/Checkbox.stories.tsx +88 -0
- package/src/components/Checkbox/Checkbox.tsx +73 -0
- package/src/components/Checkbox/styles.css.ts +57 -0
- package/src/components/Cluster/Cluster.stories.tsx +92 -0
- package/src/components/Cluster/Cluster.tsx +43 -0
- package/src/components/Cluster/styles.css.ts +25 -0
- package/src/components/EmptyState/EmptyState.stories.tsx +54 -0
- package/src/components/EmptyState/EmptyState.tsx +19 -0
- package/src/components/EmptyState/styles.css.ts +25 -0
- package/src/components/Field/Field.stories.tsx +92 -0
- package/src/components/Field/Field.tsx +80 -0
- package/src/components/Field/FieldContext.ts +14 -0
- package/src/components/Field/styles.css.ts +25 -0
- package/src/components/Fieldset/Fieldset.stories.tsx +85 -0
- package/src/components/Fieldset/Fieldset.tsx +48 -0
- package/src/components/Fieldset/index.ts +1 -0
- package/src/components/Fieldset/styles.css.ts +33 -0
- package/src/components/Grid/Grid.stories.tsx +107 -0
- package/src/components/Grid/Grid.tsx +41 -0
- package/src/components/Grid/styles.css.ts +25 -0
- package/src/components/Heading/Heading.tsx +48 -0
- package/src/components/Heading/styles.css.ts +26 -0
- package/src/components/Icon/Icon.tsx +168 -0
- package/src/components/Icon/index.ts +2 -0
- package/src/components/Inline/Inline.stories.tsx +88 -0
- package/src/components/Inline/Inline.tsx +45 -0
- package/src/components/Inline/styles.css.ts +27 -0
- package/src/components/Input/Input.stories.tsx +89 -0
- package/src/components/Input/Input.tsx +77 -0
- package/src/components/Input/styles.css.ts +60 -0
- package/src/components/InputGroup/InputGroup.stories.tsx +119 -0
- package/src/components/InputGroup/InputGroup.tsx +60 -0
- package/src/components/InputGroup/InputGroupContext.ts +11 -0
- package/src/components/InputGroup/styles.css.ts +61 -0
- package/src/components/LinkButton/LinkButton.stories.tsx +91 -0
- package/src/components/LinkButton/LinkButton.tsx +42 -0
- package/src/components/LinkButton/styles.css.ts +56 -0
- package/src/components/Menu/Menu.stories.tsx +146 -0
- package/src/components/Menu/Menu.tsx +151 -0
- package/src/components/Menu/MenuContext.ts +20 -0
- package/src/components/Menu/styles.css.ts +89 -0
- package/src/components/Menu/useMenuRoot.ts +136 -0
- package/src/components/PageShell/PageShell.tsx +45 -0
- package/src/components/PageShell/index.ts +2 -0
- package/src/components/PageShell/styles.css.ts +26 -0
- package/src/components/ScrollArea/ScrollArea.stories.tsx +82 -0
- package/src/components/ScrollArea/ScrollArea.tsx +170 -0
- package/src/components/ScrollArea/ScrollAreaContext.ts +21 -0
- package/src/components/ScrollArea/styles.css.ts +81 -0
- package/src/components/ScrollArea/useScrollAreaRoot.ts +72 -0
- package/src/components/Select/Select.stories.tsx +144 -0
- package/src/components/Select/Select.tsx +183 -0
- package/src/components/Select/SelectContext.ts +24 -0
- package/src/components/Select/styles.css.ts +97 -0
- package/src/components/Select/useSelectRoot.ts +178 -0
- package/src/components/SideNav/SideNav.stories.tsx +77 -0
- package/src/components/SideNav/SideNav.tsx +172 -0
- package/src/components/SideNav/SideNavContext.ts +18 -0
- package/src/components/SideNav/styles.css.ts +95 -0
- package/src/components/Spinner/Spinner.stories.tsx +59 -0
- package/src/components/Spinner/Spinner.tsx +24 -0
- package/src/components/Spinner/styles.css.ts +47 -0
- package/src/components/Stack/Stack.stories.tsx +103 -0
- package/src/components/Stack/Stack.tsx +33 -0
- package/src/components/Stack/styles.css.ts +21 -0
- package/src/components/StatusIcon/StatusIcon.stories.tsx +81 -0
- package/src/components/StatusIcon/StatusIcon.tsx +24 -0
- package/src/components/StatusIcon/styles.css.ts +27 -0
- package/src/components/Switch/Switch.stories.tsx +88 -0
- package/src/components/Switch/Switch.tsx +78 -0
- package/src/components/Switch/styles.css.ts +71 -0
- package/src/components/Table/Table.stories.tsx +308 -0
- package/src/components/Table/Table.tsx +179 -0
- package/src/components/Table/styles.css.ts +97 -0
- package/src/components/Tabs/Tabs.stories.tsx +142 -0
- package/src/components/Tabs/Tabs.tsx +210 -0
- package/src/components/Tabs/TabsContext.ts +20 -0
- package/src/components/Tabs/styles.css.ts +98 -0
- package/src/components/Tabs/useTabsRoot.ts +42 -0
- package/src/components/Text/Text.tsx +52 -0
- package/src/components/Text/styles.css.ts +57 -0
- package/src/components/Textarea/Textarea.stories.tsx +80 -0
- package/src/components/Textarea/Textarea.tsx +50 -0
- package/src/components/Textarea/styles.css.ts +56 -0
- package/src/components/ThemeProvider/ThemeProvider.stories.tsx +163 -0
- package/src/components/ThemeProvider/ThemeProvider.tsx +33 -0
- package/src/components/Toggle/Toggle.stories.tsx +84 -0
- package/src/components/Toggle/Toggle.tsx +85 -0
- package/src/components/Toggle/styles.css.ts +66 -0
- package/src/components/ToggleGroup/ToggleGroup.stories.tsx +159 -0
- package/src/components/ToggleGroup/ToggleGroup.tsx +63 -0
- package/src/components/ToggleGroup/ToggleGroupContext.ts +18 -0
- package/src/components/ToggleGroup/styles.css.ts +17 -0
- package/src/components/Tooltip/Tooltip.stories.tsx +127 -0
- package/src/components/Tooltip/Tooltip.tsx +97 -0
- package/src/components/Tooltip/styles.css.ts +56 -0
- package/src/docs/Spacing.mdx +80 -0
- package/src/docs/Spacing.stories.tsx +202 -0
- package/src/docs/Typography.mdx +93 -0
- package/src/docs/Typography.stories.tsx +211 -0
- package/src/docs/helpers.tsx +135 -0
- package/src/hooks/useContainerQuery.ts +54 -0
- package/src/hooks/useControllableValue.ts +18 -0
- package/src/index.ts +56 -0
- 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
|
+
}
|