@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.
Files changed (31) hide show
  1. package/dist/components/ActionBar/ActionBar.stories.d.ts +2 -0
  2. package/dist/components/ActionBar/ActionBar.stories.d.ts.map +1 -1
  3. package/dist/components/CheckboxGroup/CheckboxGroup.d.ts.map +1 -1
  4. package/dist/components/Combobox/Combobox.d.ts.map +1 -1
  5. package/dist/components/Combobox/ComboboxContext.d.ts +1 -0
  6. package/dist/components/Combobox/ComboboxContext.d.ts.map +1 -1
  7. package/dist/components/Combobox/styles.css.d.ts +7 -6
  8. package/dist/components/Combobox/styles.css.d.ts.map +1 -1
  9. package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -1
  10. package/dist/components/Dialog/styles.css.d.ts +4 -4
  11. package/dist/components/Dialog/styles.css.d.ts.map +1 -1
  12. package/dist/components/ThemeProvider/ThemeProvider.d.ts +1 -0
  13. package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
  14. package/dist/index.css +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2086 -2006
  18. package/dist/index.js.map +1 -1
  19. package/package.json +2 -2
  20. package/src/components/ActionBar/ActionBar.stories.tsx +2 -0
  21. package/src/components/CheckboxGroup/CheckboxGroup.tsx +1 -0
  22. package/src/components/Combobox/Combobox.stories.tsx +2 -2
  23. package/src/components/Combobox/Combobox.tsx +45 -5
  24. package/src/components/Combobox/ComboboxContext.ts +1 -0
  25. package/src/components/Combobox/styles.css.ts +13 -6
  26. package/src/components/Combobox/useComboboxRoot.ts +18 -3
  27. package/src/components/Dialog/Dialog.tsx +1 -1
  28. package/src/components/Dialog/styles.css.ts +4 -12
  29. package/src/components/Drawer/Drawer.tsx +1 -1
  30. package/src/components/ThemeProvider/ThemeProvider.tsx +28 -2
  31. 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.30.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.30.0"
56
+ "@duro-app/tokens": "^0.32.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@babel/preset-typescript": "^7.28.0",
@@ -9,6 +9,8 @@ import {Table} from '../Table/Table'
9
9
 
10
10
  interface ActionBarStoryArgs {
11
11
  onClearSelection: () => void
12
+ selectedItemCount?: number
13
+ isEmphasized?: boolean
12
14
  }
13
15
 
14
16
  const meta: Meta<ActionBarStoryArgs> = {
@@ -56,6 +56,7 @@ function Root({
56
56
  <CheckboxGroupContext.Provider value={{value, onToggle, disabled}}>
57
57
  <html.div
58
58
  role="group"
59
+ aria-orientation={orientation}
59
60
  style={[styles.root, orientation === 'horizontal' && styles.rootHorizontal]}
60
61
  >
61
62
  {children}
@@ -58,11 +58,11 @@ export const WithDefaultValue: Story = {
58
58
  }
59
59
 
60
60
  function ControlledDemo() {
61
- const [value, setValue] = useState<string | null>(null)
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
- if (!open) return null
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
- <html.div id={listboxId} role="listbox" style={styles.popup}>
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
- position: 'absolute',
67
- top: '100%',
68
- left: 0,
69
- right: 0,
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
- zIndex: 50,
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
- // Sync input value with selected label when value changes externally
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 (value && labels[value] && !open) {
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, open, setInputValue])
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,
@@ -202,7 +202,7 @@ function Portal({children, size = 'md'}: PortalProps) {
202
202
  <html.div
203
203
  ref={popupRef}
204
204
  role="dialog"
205
- aria-modal="true"
205
+ aria-modal={true}
206
206
  aria-labelledby={titleId}
207
207
  aria-describedby={descriptionId}
208
208
  style={[
@@ -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,
@@ -256,7 +256,7 @@ function Portal({children, size = 'md'}: PortalProps) {
256
256
  <html.div
257
257
  ref={panelRef}
258
258
  role="dialog"
259
- aria-modal="true"
259
+ aria-modal={true}
260
260
  aria-labelledby={titleId}
261
261
  aria-describedby={descriptionId}
262
262
  style={[
@@ -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 <html.div style={themeStyles}>{children}</html.div>
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 {ThemeProvider, type ThemeName} from './components/ThemeProvider/ThemeProvider'
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'