@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,170 @@
1
+ import {type ReactNode, useRef, useCallback} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+ import {ScrollAreaContext, useScrollArea} from './ScrollAreaContext'
5
+ import {useScrollAreaRoot} from './useScrollAreaRoot'
6
+
7
+ // --- Root ---
8
+
9
+ interface RootProps {
10
+ children: ReactNode
11
+ }
12
+
13
+ function Root({children}: RootProps) {
14
+ const ctx = useScrollAreaRoot()
15
+
16
+ return (
17
+ <ScrollAreaContext.Provider value={ctx}>
18
+ <html.div style={styles.root}>{children}</html.div>
19
+ </ScrollAreaContext.Provider>
20
+ )
21
+ }
22
+
23
+ // --- Viewport ---
24
+
25
+ interface ViewportProps {
26
+ children: ReactNode
27
+ maxHeight?: number | string
28
+ }
29
+
30
+ function Viewport({children, maxHeight}: ViewportProps) {
31
+ const {viewportRef} = useScrollArea()
32
+
33
+ return (
34
+ <html.div
35
+ ref={viewportRef}
36
+ style={[styles.viewport, maxHeight != null && styles.viewportMaxHeight(maxHeight)]}
37
+ >
38
+ {children}
39
+ </html.div>
40
+ )
41
+ }
42
+
43
+ // --- Content ---
44
+
45
+ interface ContentProps {
46
+ children: ReactNode
47
+ }
48
+
49
+ function Content({children}: ContentProps) {
50
+ const {contentRef} = useScrollArea()
51
+ return (
52
+ <html.div ref={contentRef} style={styles.content}>
53
+ {children}
54
+ </html.div>
55
+ )
56
+ }
57
+
58
+ // --- Scrollbar ---
59
+
60
+ type ScrollbarOrientation = 'vertical' | 'horizontal'
61
+
62
+ interface ScrollbarProps {
63
+ orientation?: ScrollbarOrientation
64
+ children: ReactNode
65
+ }
66
+
67
+ function Scrollbar({orientation = 'vertical', children}: ScrollbarProps) {
68
+ const {scrolling, scrollHeight, scrollWidth, clientHeight, clientWidth} = useScrollArea()
69
+
70
+ // Hide scrollbar when content fits
71
+ const hasOverflow =
72
+ orientation === 'vertical' ? scrollHeight > clientHeight : scrollWidth > clientWidth
73
+
74
+ if (!hasOverflow) return null
75
+
76
+ return (
77
+ <html.div
78
+ style={[
79
+ styles.scrollbar,
80
+ orientation === 'vertical' ? styles.scrollbarVertical : styles.scrollbarHorizontal,
81
+ scrolling ? styles.scrollbarVisible : styles.scrollbarHidden,
82
+ ]}
83
+ >
84
+ {children}
85
+ </html.div>
86
+ )
87
+ }
88
+
89
+ // --- Thumb ---
90
+
91
+ interface ThumbProps {
92
+ orientation?: ScrollbarOrientation
93
+ }
94
+
95
+ function Thumb({orientation = 'vertical'}: ThumbProps) {
96
+ const {viewportRef, scrollTop, scrollLeft, scrollHeight, scrollWidth, clientHeight, clientWidth} =
97
+ useScrollArea()
98
+ const draggingRef = useRef(false)
99
+ const startPosRef = useRef(0)
100
+ const startScrollRef = useRef(0)
101
+
102
+ const isVertical = orientation === 'vertical'
103
+
104
+ const thumbSizePercent = isVertical
105
+ ? Math.max((clientHeight / scrollHeight) * 100, 10)
106
+ : Math.max((clientWidth / scrollWidth) * 100, 10)
107
+
108
+ const maxScroll = isVertical ? scrollHeight - clientHeight : scrollWidth - clientWidth
109
+ const trackSize = isVertical ? clientHeight : clientWidth
110
+ const thumbPixelSize = (thumbSizePercent / 100) * trackSize
111
+ const scrollOffset = isVertical ? scrollTop : scrollLeft
112
+ const thumbOffset = maxScroll > 0 ? (scrollOffset / maxScroll) * (trackSize - thumbPixelSize) : 0
113
+
114
+ const thumbStyle = isVertical
115
+ ? styles.thumbVertical(`${thumbSizePercent}%`, `translateY(${thumbOffset}px)`)
116
+ : styles.thumbHorizontal(`${thumbSizePercent}%`, `translateX(${thumbOffset}px)`)
117
+
118
+ const handlePointerDown = useCallback(
119
+ (e: React.PointerEvent) => {
120
+ e.preventDefault()
121
+ draggingRef.current = true
122
+ startPosRef.current = isVertical ? e.clientY : e.clientX
123
+ startScrollRef.current = isVertical
124
+ ? (viewportRef.current?.scrollTop ?? 0)
125
+ : (viewportRef.current?.scrollLeft ?? 0)
126
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
127
+ },
128
+ [isVertical, viewportRef],
129
+ )
130
+
131
+ const handlePointerMove = useCallback(
132
+ (e: React.PointerEvent) => {
133
+ if (!draggingRef.current) return
134
+ const vp = viewportRef.current
135
+ if (!vp) return
136
+
137
+ const delta = (isVertical ? e.clientY : e.clientX) - startPosRef.current
138
+ const scrollRatio = maxScroll / (trackSize - thumbPixelSize)
139
+ const scrollDelta = delta * scrollRatio
140
+
141
+ if (isVertical) {
142
+ vp.scrollTop = startScrollRef.current + scrollDelta
143
+ } else {
144
+ vp.scrollLeft = startScrollRef.current + scrollDelta
145
+ }
146
+ },
147
+ [isVertical, maxScroll, trackSize, thumbPixelSize, viewportRef],
148
+ )
149
+
150
+ const handlePointerUp = useCallback(() => {
151
+ draggingRef.current = false
152
+ }, [])
153
+
154
+ return (
155
+ <html.div
156
+ onPointerDown={handlePointerDown}
157
+ onPointerMove={handlePointerMove}
158
+ onPointerUp={handlePointerUp}
159
+ style={[styles.thumb, thumbStyle]}
160
+ />
161
+ )
162
+ }
163
+
164
+ export const ScrollArea = {
165
+ Root,
166
+ Viewport,
167
+ Content,
168
+ Scrollbar,
169
+ Thumb,
170
+ }
@@ -0,0 +1,21 @@
1
+ import {createContext, useContext} from 'react'
2
+
3
+ export interface ScrollAreaContextValue {
4
+ viewportRef: React.RefObject<HTMLDivElement | null>
5
+ contentRef: React.RefObject<HTMLDivElement | null>
6
+ scrollTop: number
7
+ scrollLeft: number
8
+ scrollHeight: number
9
+ scrollWidth: number
10
+ clientHeight: number
11
+ clientWidth: number
12
+ scrolling: boolean
13
+ }
14
+
15
+ export const ScrollAreaContext = createContext<ScrollAreaContextValue | null>(null)
16
+
17
+ export function useScrollArea() {
18
+ const ctx = useContext(ScrollAreaContext)
19
+ if (!ctx) throw new Error('ScrollArea compound components must be used within ScrollArea.Root')
20
+ return ctx
21
+ }
@@ -0,0 +1,81 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {radii} from '@duro-app/tokens/tokens/spacing.css'
4
+
5
+ export const styles = css.create({
6
+ root: {
7
+ position: 'relative',
8
+ overflow: 'hidden',
9
+ },
10
+ viewport: {
11
+ width: '100%',
12
+ height: '100%',
13
+ overflowX: 'auto',
14
+ overflowY: 'auto',
15
+ // Hide native scrollbar
16
+ scrollbarWidth: 'none',
17
+ },
18
+ content: {
19
+ minWidth: '100%',
20
+ minHeight: '100%',
21
+ },
22
+ scrollbar: {
23
+ position: 'absolute',
24
+ zIndex: 1,
25
+ display: 'flex',
26
+ touchAction: 'none',
27
+ userSelect: 'none',
28
+ transitionProperty: 'opacity',
29
+ transitionDuration: '200ms',
30
+ transitionTimingFunction: 'ease',
31
+ },
32
+ scrollbarVertical: {
33
+ top: 0,
34
+ right: 0,
35
+ bottom: 0,
36
+ width: 8,
37
+ flexDirection: 'column',
38
+ paddingTop: 2,
39
+ paddingBottom: 2,
40
+ paddingRight: 2,
41
+ },
42
+ scrollbarHorizontal: {
43
+ left: 0,
44
+ right: 0,
45
+ bottom: 0,
46
+ height: 8,
47
+ flexDirection: 'row',
48
+ paddingLeft: 2,
49
+ paddingRight: 2,
50
+ paddingBottom: 2,
51
+ },
52
+ scrollbarHidden: {
53
+ opacity: 0,
54
+ },
55
+ scrollbarVisible: {
56
+ opacity: 1,
57
+ },
58
+ thumb: {
59
+ position: 'relative',
60
+ flex: 1,
61
+ backgroundColor: {
62
+ default: colors.border,
63
+ ':hover': colors.textMuted,
64
+ },
65
+ borderRadius: radii.full,
66
+ transitionProperty: 'background-color',
67
+ transitionDuration: '150ms',
68
+ },
69
+ // Dynamic styles — simple identifier params only (StyleX constraint)
70
+ viewportMaxHeight: (maxHeight: number | string) => ({
71
+ maxHeight,
72
+ }),
73
+ thumbVertical: (height: string, transform: string) => ({
74
+ height,
75
+ transform,
76
+ }),
77
+ thumbHorizontal: (width: string, transform: string) => ({
78
+ width,
79
+ transform,
80
+ }),
81
+ })
@@ -0,0 +1,72 @@
1
+ import {useState, useCallback, useRef, useEffect} from 'react'
2
+ import type {ScrollAreaContextValue} from './ScrollAreaContext'
3
+
4
+ export function useScrollAreaRoot(): ScrollAreaContextValue {
5
+ const viewportRef = useRef<HTMLDivElement | null>(null)
6
+ const contentRef = useRef<HTMLDivElement | null>(null)
7
+ const [scrollTop, setScrollTop] = useState(0)
8
+ const [scrollLeft, setScrollLeft] = useState(0)
9
+ const [scrollHeight, setScrollHeight] = useState(0)
10
+ const [scrollWidth, setScrollWidth] = useState(0)
11
+ const [clientHeight, setClientHeight] = useState(0)
12
+ const [clientWidth, setClientWidth] = useState(0)
13
+ const [scrolling, setScrolling] = useState(false)
14
+ const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
15
+
16
+ const handleScroll = useCallback(() => {
17
+ const vp = viewportRef.current
18
+ if (!vp) return
19
+
20
+ setScrollTop(vp.scrollTop)
21
+ setScrollLeft(vp.scrollLeft)
22
+ setScrollHeight(vp.scrollHeight)
23
+ setScrollWidth(vp.scrollWidth)
24
+ setClientHeight(vp.clientHeight)
25
+ setClientWidth(vp.clientWidth)
26
+ setScrolling(true)
27
+
28
+ if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current)
29
+ scrollTimerRef.current = setTimeout(() => setScrolling(false), 1000)
30
+ }, [])
31
+
32
+ // Observe viewport size changes
33
+ useEffect(() => {
34
+ const vp = viewportRef.current
35
+ if (!vp) return
36
+
37
+ const observer = new ResizeObserver(() => {
38
+ setScrollHeight(vp.scrollHeight)
39
+ setScrollWidth(vp.scrollWidth)
40
+ setClientHeight(vp.clientHeight)
41
+ setClientWidth(vp.clientWidth)
42
+ })
43
+ observer.observe(vp)
44
+ // Initial measurement
45
+ setScrollHeight(vp.scrollHeight)
46
+ setScrollWidth(vp.scrollWidth)
47
+ setClientHeight(vp.clientHeight)
48
+ setClientWidth(vp.clientWidth)
49
+
50
+ return () => observer.disconnect()
51
+ }, [])
52
+
53
+ // Attach scroll listener directly to ensure we capture it
54
+ useEffect(() => {
55
+ const vp = viewportRef.current
56
+ if (!vp) return
57
+ vp.addEventListener('scroll', handleScroll, {passive: true})
58
+ return () => vp.removeEventListener('scroll', handleScroll)
59
+ }, [handleScroll])
60
+
61
+ return {
62
+ viewportRef,
63
+ contentRef,
64
+ scrollTop,
65
+ scrollLeft,
66
+ scrollHeight,
67
+ scrollWidth,
68
+ clientHeight,
69
+ clientWidth,
70
+ scrolling,
71
+ }
72
+ }
@@ -0,0 +1,144 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, fn} from 'storybook/test'
3
+ import {Select} from './Select'
4
+
5
+ const meta: Meta = {
6
+ title: 'Components/Select',
7
+ }
8
+
9
+ export default meta
10
+ type Story = StoryObj
11
+
12
+ export const Default: Story = {
13
+ render: () => (
14
+ <Select.Root defaultValue="en">
15
+ <Select.Trigger>
16
+ <Select.Value />
17
+ <Select.Icon />
18
+ </Select.Trigger>
19
+ <Select.Popup>
20
+ <Select.Item value="en">
21
+ <Select.ItemText>English</Select.ItemText>
22
+ </Select.Item>
23
+ <Select.Item value="fr">
24
+ <Select.ItemText>Francais</Select.ItemText>
25
+ </Select.Item>
26
+ <Select.Item value="es">
27
+ <Select.ItemText>Espanol</Select.ItemText>
28
+ </Select.Item>
29
+ </Select.Popup>
30
+ </Select.Root>
31
+ ),
32
+ play: async ({canvas}) => {
33
+ const trigger = canvas.getByRole('combobox')
34
+ await expect(trigger).toHaveAttribute('aria-expanded', 'false')
35
+ await expect(trigger).toHaveAttribute('aria-haspopup', 'listbox')
36
+ await expect(trigger).toHaveTextContent(/English/)
37
+ },
38
+ }
39
+
40
+ export const OpenAndSelect: Story = {
41
+ render: () => (
42
+ <Select.Root defaultValue="en">
43
+ <Select.Trigger>
44
+ <Select.Value />
45
+ <Select.Icon />
46
+ </Select.Trigger>
47
+ <Select.Popup>
48
+ <Select.Item value="en">
49
+ <Select.ItemText>English</Select.ItemText>
50
+ </Select.Item>
51
+ <Select.Item value="fr">
52
+ <Select.ItemText>Francais</Select.ItemText>
53
+ </Select.Item>
54
+ <Select.Item value="es">
55
+ <Select.ItemText>Espanol</Select.ItemText>
56
+ </Select.Item>
57
+ </Select.Popup>
58
+ </Select.Root>
59
+ ),
60
+ play: async ({canvas, userEvent}) => {
61
+ const trigger = canvas.getByRole('combobox')
62
+
63
+ await userEvent.click(trigger)
64
+ await expect(trigger).toHaveAttribute('aria-expanded', 'true')
65
+ const options = canvas.getAllByRole('option')
66
+ await expect(options.length).toBe(3)
67
+ await expect(options[0]).toHaveAttribute('aria-selected', 'true')
68
+
69
+ await userEvent.click(options[1])
70
+ await expect(trigger).toHaveTextContent(/Francais/)
71
+ await expect(canvas.queryByRole('listbox')).not.toBeInTheDocument()
72
+ },
73
+ }
74
+
75
+ export const WithPlaceholder: Story = {
76
+ render: () => (
77
+ <Select.Root>
78
+ <Select.Trigger>
79
+ <Select.Value placeholder="Choose a language..." />
80
+ <Select.Icon />
81
+ </Select.Trigger>
82
+ <Select.Popup>
83
+ <Select.Item value="react">
84
+ <Select.ItemText>React</Select.ItemText>
85
+ </Select.Item>
86
+ <Select.Item value="vue">
87
+ <Select.ItemText>Vue</Select.ItemText>
88
+ </Select.Item>
89
+ <Select.Item value="svelte">
90
+ <Select.ItemText>Svelte</Select.ItemText>
91
+ </Select.Item>
92
+ <Select.Item value="angular">
93
+ <Select.ItemText>Angular</Select.ItemText>
94
+ </Select.Item>
95
+ </Select.Popup>
96
+ </Select.Root>
97
+ ),
98
+ play: async ({canvas, userEvent}) => {
99
+ const trigger = canvas.getByRole('combobox')
100
+
101
+ // Shows placeholder when nothing selected
102
+ await expect(trigger).toHaveTextContent(/Choose a language\.\.\./)
103
+
104
+ // Open and select
105
+ await userEvent.click(trigger)
106
+ const options = canvas.getAllByRole('option')
107
+ await expect(options.length).toBe(4)
108
+
109
+ // None selected initially
110
+ for (const opt of options) {
111
+ await expect(opt).toHaveAttribute('aria-selected', 'false')
112
+ }
113
+
114
+ await userEvent.click(canvas.getByText('Vue'))
115
+ await expect(trigger).toHaveTextContent(/Vue/)
116
+ },
117
+ }
118
+
119
+ export const KeyboardNavigation: Story = {
120
+ render: () => (
121
+ <Select.Root>
122
+ <Select.Trigger>
123
+ <Select.Value placeholder="Select..." />
124
+ <Select.Icon />
125
+ </Select.Trigger>
126
+ <Select.Popup>
127
+ <Select.Item value="a">
128
+ <Select.ItemText>Alpha</Select.ItemText>
129
+ </Select.Item>
130
+ <Select.Item value="b">
131
+ <Select.ItemText>Bravo</Select.ItemText>
132
+ </Select.Item>
133
+ <Select.Item value="c">
134
+ <Select.ItemText>Charlie</Select.ItemText>
135
+ </Select.Item>
136
+ </Select.Popup>
137
+ </Select.Root>
138
+ ),
139
+ play: async ({canvas, userEvent}) => {
140
+ const trigger = canvas.getByRole('combobox')
141
+ await userEvent.click(trigger)
142
+ await expect(canvas.getByRole('listbox')).toBeInTheDocument()
143
+ },
144
+ }
@@ -0,0 +1,183 @@
1
+ import {type ReactNode, useRef, useId, useEffect} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+ import {SelectContext, useSelect} from './SelectContext'
5
+ import {useSelectRoot} from './useSelectRoot'
6
+
7
+ // --- Root ---
8
+ interface RootProps {
9
+ name?: string
10
+ defaultValue?: string
11
+ value?: string
12
+ onValueChange?: (value: string | null) => void
13
+ initialLabels?: Record<string, string>
14
+ children: ReactNode
15
+ }
16
+
17
+ function Root({name, defaultValue, value, onValueChange, initialLabels, children}: RootProps) {
18
+ const {ctx, rootRef} = useSelectRoot({defaultValue, value, onValueChange, initialLabels})
19
+
20
+ return (
21
+ <SelectContext.Provider value={ctx}>
22
+ <html.div ref={rootRef} style={styles.root}>
23
+ {name && <html.input type="hidden" name={name} value={ctx.value ?? ''} />}
24
+ {children}
25
+ </html.div>
26
+ </SelectContext.Provider>
27
+ )
28
+ }
29
+
30
+ // --- Trigger ---
31
+ function Trigger({children}: {children: ReactNode}) {
32
+ const {open, toggle, listboxId, highlightedId, triggerRef} = useSelect()
33
+ const localRef = useRef<HTMLButtonElement>(null)
34
+
35
+ // Sync local ref to context triggerRef
36
+ useEffect(() => {
37
+ triggerRef.current = localRef.current
38
+ })
39
+
40
+ return (
41
+ <html.button
42
+ ref={localRef}
43
+ type="button"
44
+ role={'combobox' as 'listbox'}
45
+ onClick={toggle}
46
+ aria-expanded={open}
47
+ aria-haspopup="listbox"
48
+ aria-controls={open ? listboxId : undefined}
49
+ aria-activedescendant={highlightedId ?? undefined}
50
+ style={styles.trigger}
51
+ >
52
+ {children}
53
+ </html.button>
54
+ )
55
+ }
56
+
57
+ // --- Value ---
58
+ function Value({placeholder}: {placeholder?: string}) {
59
+ const {value, labels} = useSelect()
60
+ const display = value ? (labels[value] ?? value) : null
61
+
62
+ return (
63
+ <html.span style={display ? styles.value : styles.placeholder}>
64
+ {display ?? placeholder}
65
+ </html.span>
66
+ )
67
+ }
68
+
69
+ // --- Icon ---
70
+ function Icon({children}: {children?: ReactNode}) {
71
+ return (
72
+ <html.span style={styles.icon}>
73
+ {children ?? (
74
+ <svg
75
+ width="12"
76
+ height="12"
77
+ viewBox="0 0 24 24"
78
+ fill="none"
79
+ stroke="currentColor"
80
+ strokeWidth="2"
81
+ strokeLinecap="round"
82
+ strokeLinejoin="round"
83
+ aria-hidden="true"
84
+ >
85
+ <path d="M6 9l6 6 6-6" />
86
+ </svg>
87
+ )}
88
+ </html.span>
89
+ )
90
+ }
91
+
92
+ // --- Popup ---
93
+ function Popup({children}: {children: ReactNode}) {
94
+ const {open, close, listboxId} = useSelect()
95
+
96
+ return (
97
+ <>
98
+ {open && <html.div style={styles.backdrop} onClick={close} />}
99
+ <html.div
100
+ id={listboxId}
101
+ role="listbox"
102
+ aria-hidden={!open}
103
+ style={[styles.popup, !open && styles.hidden]}
104
+ >
105
+ {children}
106
+ </html.div>
107
+ </>
108
+ )
109
+ }
110
+
111
+ // --- Item ---
112
+ interface ItemProps {
113
+ value: string
114
+ children: ReactNode
115
+ }
116
+
117
+ function Item({value: itemValue, children}: ItemProps) {
118
+ const {
119
+ value: selectedValue,
120
+ setValue,
121
+ close,
122
+ registerLabel,
123
+ highlightedId,
124
+ setHighlightedId,
125
+ registerItem,
126
+ } = useSelect()
127
+ const id = useId()
128
+ const ref = useRef<HTMLDivElement>(null)
129
+ const isSelected = selectedValue === itemValue
130
+ const isHighlighted = highlightedId === id
131
+
132
+ // Register label from DOM text content (works with both string and JSX children)
133
+ useEffect(() => {
134
+ const el = ref.current
135
+ if (!el) return
136
+ const text = el.textContent
137
+ if (text) registerLabel(itemValue, text)
138
+ }, [itemValue, registerLabel])
139
+
140
+ useEffect(() => {
141
+ const el = ref.current
142
+ if (!el) return
143
+ return registerItem(id, itemValue, el)
144
+ }, [id, itemValue, registerItem])
145
+
146
+ const handleClick = () => {
147
+ setValue(itemValue)
148
+ close()
149
+ }
150
+
151
+ return (
152
+ <html.div
153
+ ref={ref}
154
+ id={id}
155
+ role="option"
156
+ aria-selected={isSelected}
157
+ onClick={handleClick}
158
+ onPointerEnter={() => setHighlightedId(id)}
159
+ style={[
160
+ styles.item,
161
+ isSelected && styles.itemSelected,
162
+ isHighlighted && styles.itemHighlighted,
163
+ ]}
164
+ >
165
+ {children}
166
+ </html.div>
167
+ )
168
+ }
169
+
170
+ // --- ItemText ---
171
+ function ItemText({children}: {children: ReactNode}) {
172
+ return <html.span>{children}</html.span>
173
+ }
174
+
175
+ export const Select = {
176
+ Root,
177
+ Trigger,
178
+ Value,
179
+ Icon,
180
+ Popup,
181
+ Item,
182
+ ItemText,
183
+ }
@@ -0,0 +1,24 @@
1
+ import {createContext, useContext} from 'react'
2
+
3
+ export interface SelectContextValue {
4
+ open: boolean
5
+ toggle: () => void
6
+ close: () => void
7
+ value: string | null
8
+ setValue: (value: string) => void
9
+ labels: Record<string, string>
10
+ registerLabel: (value: string, label: string) => void
11
+ listboxId: string
12
+ highlightedId: string | null
13
+ setHighlightedId: (id: string | null) => void
14
+ registerItem: (id: string, value: string, element: HTMLElement) => () => void
15
+ triggerRef: React.RefObject<HTMLButtonElement | null>
16
+ }
17
+
18
+ export const SelectContext = createContext<SelectContextValue | null>(null)
19
+
20
+ export function useSelect() {
21
+ const ctx = useContext(SelectContext)
22
+ if (!ctx) throw new Error('Select compound components must be used within Select.Root')
23
+ return ctx
24
+ }