@duro-app/ui 0.30.0 → 0.32.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/ActionBar/ActionBar.stories.d.ts +2 -0
- package/dist/components/ActionBar/ActionBar.stories.d.ts.map +1 -1
- package/dist/components/CheckboxGroup/CheckboxGroup.d.ts.map +1 -1
- package/dist/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/components/Combobox/ComboboxContext.d.ts +1 -0
- package/dist/components/Combobox/ComboboxContext.d.ts.map +1 -1
- package/dist/components/Combobox/styles.css.d.ts +7 -6
- package/dist/components/Combobox/styles.css.d.ts.map +1 -1
- package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -1
- package/dist/components/Dialog/styles.css.d.ts +4 -4
- package/dist/components/Dialog/styles.css.d.ts.map +1 -1
- package/dist/components/ThemeProvider/ThemeProvider.d.ts +1 -0
- package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2086 -2006
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/ActionBar/ActionBar.stories.tsx +2 -0
- package/src/components/CheckboxGroup/CheckboxGroup.tsx +1 -0
- package/src/components/Combobox/Combobox.stories.tsx +2 -2
- package/src/components/Combobox/Combobox.tsx +45 -5
- package/src/components/Combobox/ComboboxContext.ts +1 -0
- package/src/components/Combobox/styles.css.ts +13 -6
- package/src/components/Combobox/useComboboxRoot.ts +18 -3
- package/src/components/Dialog/Dialog.tsx +1 -1
- package/src/components/Dialog/styles.css.ts +4 -12
- package/src/components/Drawer/Drawer.tsx +1 -1
- package/src/components/ThemeProvider/ThemeProvider.tsx +28 -2
- package/src/index.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
}
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@duro-app/tokens": "^0.
|
|
56
|
+
"@duro-app/tokens": "^0.32.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -58,11 +58,11 @@ export const WithDefaultValue: Story = {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
function ControlledDemo() {
|
|
61
|
-
const [value, setValue] = useState<string |
|
|
61
|
+
const [value, setValue] = useState<string | undefined>(undefined)
|
|
62
62
|
|
|
63
63
|
return (
|
|
64
64
|
<div style={{display: 'flex', flexDirection: 'column', gap: 16}}>
|
|
65
|
-
<Combobox.Root value={value} onValueChange={setValue}>
|
|
65
|
+
<Combobox.Root value={value} onValueChange={(v) => setValue(v ?? undefined)}>
|
|
66
66
|
<Combobox.Input placeholder="Pick a role...">
|
|
67
67
|
<Combobox.Trigger />
|
|
68
68
|
</Combobox.Input>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {type ReactNode, useRef, useId, useEffect, useState} from 'react'
|
|
1
|
+
import {type ReactNode, useRef, useId, useEffect, useLayoutEffect, useState} from 'react'
|
|
2
|
+
import {createPortal} from 'react-dom'
|
|
2
3
|
import {html} from 'react-strict-dom'
|
|
3
4
|
import {styles} from './styles.css'
|
|
4
5
|
import {ComboboxContext, useCombobox} from './ComboboxContext'
|
|
5
6
|
import {useComboboxRoot} from './useComboboxRoot'
|
|
7
|
+
import {usePortalMount} from '../ThemeProvider/ThemeProvider'
|
|
6
8
|
|
|
7
9
|
// --- Root ---
|
|
8
10
|
interface RootProps {
|
|
@@ -129,16 +131,54 @@ function Trigger({children}: {children?: ReactNode}) {
|
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
// --- Popup ---
|
|
134
|
+
// Rendered into the ThemeProvider portal mount and positioned with viewport
|
|
135
|
+
// coordinates so it escapes ancestor `overflow: hidden` / `transform`
|
|
136
|
+
// containing blocks (notably Dialog and Drawer).
|
|
132
137
|
function Popup({children}: {children: ReactNode}) {
|
|
133
|
-
const {open, listboxId} = useCombobox()
|
|
138
|
+
const {open, listboxId, rootRef} = useCombobox()
|
|
139
|
+
const mount = usePortalMount()
|
|
140
|
+
const [coords, setCoords] = useState<{top: number; left: number; width: number} | null>(null)
|
|
134
141
|
|
|
135
|
-
|
|
142
|
+
// Measure the root each time the popup opens, and re-measure on scroll/resize
|
|
143
|
+
// so the dropdown follows the input. Capture-phase scroll listener catches
|
|
144
|
+
// scroll on any ancestor (e.g. Dialog body).
|
|
145
|
+
useLayoutEffect(() => {
|
|
146
|
+
if (!open) {
|
|
147
|
+
setCoords(null)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
const update = () => {
|
|
151
|
+
const el = rootRef.current
|
|
152
|
+
if (!el) return
|
|
153
|
+
const rect = el.getBoundingClientRect()
|
|
154
|
+
// Position immediately below the input with a small gap. `xs` token = 4px.
|
|
155
|
+
setCoords({top: rect.bottom + 4, left: rect.left, width: rect.width})
|
|
156
|
+
}
|
|
157
|
+
update()
|
|
158
|
+
window.addEventListener('resize', update)
|
|
159
|
+
window.addEventListener('scroll', update, true)
|
|
160
|
+
return () => {
|
|
161
|
+
window.removeEventListener('resize', update)
|
|
162
|
+
window.removeEventListener('scroll', update, true)
|
|
163
|
+
}
|
|
164
|
+
}, [open, rootRef])
|
|
136
165
|
|
|
137
|
-
return
|
|
138
|
-
|
|
166
|
+
if (!open || !coords) return null
|
|
167
|
+
|
|
168
|
+
const node = (
|
|
169
|
+
<html.div
|
|
170
|
+
id={listboxId}
|
|
171
|
+
role="listbox"
|
|
172
|
+
style={[styles.popup, styles.popupPosition(coords.top, coords.left, coords.width)]}
|
|
173
|
+
>
|
|
139
174
|
{children}
|
|
140
175
|
</html.div>
|
|
141
176
|
)
|
|
177
|
+
|
|
178
|
+
// SSR / pre-mount fallback: render inline (will be invisible under
|
|
179
|
+
// overflow:hidden parents but won't crash). Once the mount is available,
|
|
180
|
+
// portal into it.
|
|
181
|
+
return mount ? createPortal(node, mount) : node
|
|
142
182
|
}
|
|
143
183
|
|
|
144
184
|
// --- Item ---
|
|
@@ -15,6 +15,7 @@ export interface ComboboxContextValue {
|
|
|
15
15
|
setHighlightedId: (id: string | null) => void
|
|
16
16
|
registerItem: (id: string, value: string, element: HTMLElement) => () => void
|
|
17
17
|
inputRef: React.RefObject<HTMLInputElement | null>
|
|
18
|
+
rootRef: React.RefObject<HTMLDivElement | null>
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export const ComboboxContext = createContext<ComboboxContextValue | null>(null)
|
|
@@ -63,11 +63,10 @@ export const styles = css.create({
|
|
|
63
63
|
zIndex: 49,
|
|
64
64
|
},
|
|
65
65
|
popup: {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
marginTop: spacing.xs,
|
|
66
|
+
// Position is `fixed` and the top/left/width are applied inline by Popup
|
|
67
|
+
// (computed from the root's bounding rect) so the dropdown can escape any
|
|
68
|
+
// ancestor with `overflow: hidden` or `transform` (e.g. inside a Dialog).
|
|
69
|
+
position: 'fixed',
|
|
71
70
|
backgroundColor: colors.bgCard,
|
|
72
71
|
borderWidth: 1,
|
|
73
72
|
borderStyle: 'solid',
|
|
@@ -78,7 +77,9 @@ export const styles = css.create({
|
|
|
78
77
|
paddingBottom: spacing.xs,
|
|
79
78
|
maxHeight: 200,
|
|
80
79
|
overflowY: 'auto',
|
|
81
|
-
|
|
80
|
+
// Re-enable pointer events: the portal mount is pointer-events: none so
|
|
81
|
+
// clicks fall through except on the popup itself.
|
|
82
|
+
pointerEvents: 'auto',
|
|
82
83
|
},
|
|
83
84
|
item: {
|
|
84
85
|
display: 'flex',
|
|
@@ -116,4 +117,10 @@ export const styles = css.create({
|
|
|
116
117
|
hidden: {
|
|
117
118
|
display: 'none',
|
|
118
119
|
},
|
|
120
|
+
// Dynamic position — applied at runtime from the input's bounding rect.
|
|
121
|
+
popupPosition: (top: number, left: number, width: number) => ({
|
|
122
|
+
top,
|
|
123
|
+
left,
|
|
124
|
+
width,
|
|
125
|
+
}),
|
|
119
126
|
})
|
|
@@ -190,12 +190,26 @@ export function useComboboxRoot({
|
|
|
190
190
|
item?.element.scrollIntoView({block: 'nearest'})
|
|
191
191
|
}, [highlightedId])
|
|
192
192
|
|
|
193
|
-
//
|
|
193
|
+
// Reconcile input text with the committed value once the popup closes.
|
|
194
|
+
// Three cases when transitioning from open → closed:
|
|
195
|
+
// - inputValue is empty: user cleared the field, treat that as
|
|
196
|
+
// "remove the selection" so the consumer's onValueChange fires
|
|
197
|
+
// with null and downstream UI (e.g. dependent fields) can reset.
|
|
198
|
+
// - inputValue doesn't match the selected label: user typed something
|
|
199
|
+
// but didn't pick anything; restore the label so the visible text
|
|
200
|
+
// and the committed value stay consistent.
|
|
201
|
+
// - inputValue equals the selected label: nothing to do.
|
|
194
202
|
useEffect(() => {
|
|
195
|
-
if (
|
|
203
|
+
if (open) return
|
|
204
|
+
if (value === null) return
|
|
205
|
+
if (inputValue === '') {
|
|
206
|
+
setValue(null)
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
if (labels[value] && inputValue !== labels[value]) {
|
|
196
210
|
setInputValue(labels[value])
|
|
197
211
|
}
|
|
198
|
-
}, [value, labels,
|
|
212
|
+
}, [open, value, labels, inputValue, setInputValue, setValue])
|
|
199
213
|
|
|
200
214
|
const ctx: ComboboxContextValue = useMemo(
|
|
201
215
|
() => ({
|
|
@@ -213,6 +227,7 @@ export function useComboboxRoot({
|
|
|
213
227
|
setHighlightedId,
|
|
214
228
|
registerItem,
|
|
215
229
|
inputRef,
|
|
230
|
+
rootRef,
|
|
216
231
|
}),
|
|
217
232
|
[
|
|
218
233
|
open,
|
|
@@ -13,9 +13,7 @@ export const styles = css.create({
|
|
|
13
13
|
zIndex: 1000,
|
|
14
14
|
},
|
|
15
15
|
backdropOpen: {
|
|
16
|
-
opacity:
|
|
17
|
-
default: 1,
|
|
18
|
-
},
|
|
16
|
+
opacity: 1,
|
|
19
17
|
animationName: css.keyframes({
|
|
20
18
|
from: {opacity: 0},
|
|
21
19
|
to: {opacity: 1},
|
|
@@ -25,9 +23,7 @@ export const styles = css.create({
|
|
|
25
23
|
animationFillMode: 'both',
|
|
26
24
|
},
|
|
27
25
|
backdropClosing: {
|
|
28
|
-
opacity:
|
|
29
|
-
default: 0,
|
|
30
|
-
},
|
|
26
|
+
opacity: 0,
|
|
31
27
|
animationName: css.keyframes({
|
|
32
28
|
from: {opacity: 1},
|
|
33
29
|
to: {opacity: 0},
|
|
@@ -67,9 +63,7 @@ export const styles = css.create({
|
|
|
67
63
|
borderColor: colors.border,
|
|
68
64
|
},
|
|
69
65
|
popupOpen: {
|
|
70
|
-
opacity:
|
|
71
|
-
default: 1,
|
|
72
|
-
},
|
|
66
|
+
opacity: 1,
|
|
73
67
|
animationName: css.keyframes({
|
|
74
68
|
from: {
|
|
75
69
|
opacity: 0,
|
|
@@ -85,9 +79,7 @@ export const styles = css.create({
|
|
|
85
79
|
animationFillMode: 'both',
|
|
86
80
|
},
|
|
87
81
|
popupClosing: {
|
|
88
|
-
opacity:
|
|
89
|
-
default: 0,
|
|
90
|
-
},
|
|
82
|
+
opacity: 0,
|
|
91
83
|
animationName: css.keyframes({
|
|
92
84
|
from: {
|
|
93
85
|
opacity: 1,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {type ReactNode} from 'react'
|
|
1
|
+
import {createContext, type ReactNode, useContext, useState} from 'react'
|
|
2
2
|
import {css, html} from 'react-strict-dom'
|
|
3
3
|
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
4
4
|
import {lightTheme, lightShadows} from '@duro-app/tokens/themes/light.css'
|
|
@@ -21,15 +21,41 @@ const styles = css.create({
|
|
|
21
21
|
display: 'contents',
|
|
22
22
|
color: colors.text,
|
|
23
23
|
},
|
|
24
|
+
// Sibling overlay where floating popups (Combobox, Menu, Tooltip) portal to.
|
|
25
|
+
// Lives inside ThemeProvider so theme tokens cascade. Fixed + inset 0 so
|
|
26
|
+
// popups can position freely with viewport coords; pointer-events: none lets
|
|
27
|
+
// clicks fall through except where children re-enable them.
|
|
28
|
+
portalMount: {
|
|
29
|
+
position: 'fixed',
|
|
30
|
+
top: 0,
|
|
31
|
+
left: 0,
|
|
32
|
+
right: 0,
|
|
33
|
+
bottom: 0,
|
|
34
|
+
pointerEvents: 'none',
|
|
35
|
+
// Above Dialog (1001) so popups inside dialogs render on top.
|
|
36
|
+
zIndex: 1100,
|
|
37
|
+
},
|
|
24
38
|
})
|
|
25
39
|
|
|
26
40
|
// react-strict-dom's style prop rejects Theme<VarGroup<{named keys}>> because the
|
|
27
41
|
// concrete VarGroup lacks the generic index signature. This is a known typing gap.
|
|
28
42
|
type DivStyle = Parameters<typeof html.div>[0]['style']
|
|
29
43
|
|
|
44
|
+
const PortalMountContext = createContext<HTMLElement | null>(null)
|
|
45
|
+
|
|
46
|
+
export function usePortalMount(): HTMLElement | null {
|
|
47
|
+
return useContext(PortalMountContext)
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
export function ThemeProvider({theme = 'dark', children}: ThemeProviderProps) {
|
|
31
51
|
const overrides = themeMap[theme]
|
|
32
52
|
const themeStyles = [overrides?.[0], overrides?.[1], styles.root] as DivStyle
|
|
53
|
+
const [mount, setMount] = useState<HTMLElement | null>(null)
|
|
33
54
|
|
|
34
|
-
return
|
|
55
|
+
return (
|
|
56
|
+
<html.div data-theme={theme} style={themeStyles}>
|
|
57
|
+
<PortalMountContext.Provider value={mount}>{children}</PortalMountContext.Provider>
|
|
58
|
+
<html.div ref={setMount} data-portal-mount style={styles.portalMount} />
|
|
59
|
+
</html.div>
|
|
60
|
+
)
|
|
35
61
|
}
|
package/src/index.ts
CHANGED
|
@@ -50,7 +50,11 @@ export {Table, type TableVariant, type TableSize} from './components/Table/Table
|
|
|
50
50
|
// Table.Pagination, Table.SortIndicator, Table.ColumnFilter for styled integration
|
|
51
51
|
export {Tabs} from './components/Tabs/Tabs'
|
|
52
52
|
export {Textarea, type TextareaVariant} from './components/Textarea/Textarea'
|
|
53
|
-
export {
|
|
53
|
+
export {
|
|
54
|
+
ThemeProvider,
|
|
55
|
+
usePortalMount,
|
|
56
|
+
type ThemeName,
|
|
57
|
+
} from './components/ThemeProvider/ThemeProvider'
|
|
54
58
|
export {Text, type TextVariant, type TextColor} from './components/Text/Text'
|
|
55
59
|
export {Toggle, type ToggleSize} from './components/Toggle/Toggle'
|
|
56
60
|
export {ToggleGroup} from './components/ToggleGroup/ToggleGroup'
|