@duro-app/ui 0.17.1 → 0.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -52,7 +52,7 @@
52
52
  }
53
53
  },
54
54
  "dependencies": {
55
- "@duro-app/tokens": "^0.17.1"
55
+ "@duro-app/tokens": "^0.18.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@babel/preset-typescript": "^7.28.0",
@@ -1,7 +1,6 @@
1
- import type {ReactNode} from 'react'
2
- import {createPortal} from 'react-dom'
1
+ import {useEffect, useCallback, type ReactNode} from 'react'
3
2
  import {html} from 'react-strict-dom'
4
- import {useOverlayContainer} from '../ThemeProvider/ThemeProvider'
3
+ import {useActionBarStack} from './ActionBarProvider'
5
4
  import {styles} from './styles.css'
6
5
 
7
6
  export interface ActionBarProps {
@@ -19,7 +18,7 @@ export interface ActionBarProps {
19
18
  children: ReactNode
20
19
  }
21
20
 
22
- function ActionBarInner({
21
+ function ActionBarContent({
23
22
  selectedItemCount,
24
23
  selectedLabel,
25
24
  isEmphasized = false,
@@ -27,14 +26,10 @@ function ActionBarInner({
27
26
  dismissible = true,
28
27
  children,
29
28
  }: ActionBarProps) {
30
- const container = useOverlayContainer()
31
-
32
- if (selectedItemCount === 0) return null
33
-
34
29
  const countStr = selectedItemCount === 'all' ? 'All' : `${selectedItemCount}`
35
30
  const label = selectedLabel ? selectedLabel(countStr) : `${countStr} selected`
36
31
 
37
- const bar = (
32
+ return (
38
33
  <html.div
39
34
  role="toolbar"
40
35
  aria-label={label}
@@ -64,12 +59,33 @@ function ActionBarInner({
64
59
  )}
65
60
  </html.div>
66
61
  )
62
+ }
63
+
64
+ function ActionBarInner(props: ActionBarProps) {
65
+ const {selectedItemCount} = props
66
+ const stack = useActionBarStack()
67
+ const isActive = selectedItemCount !== 0
68
+
69
+ const renderBar = useCallback(() => <ActionBarContent {...props} />, [props])
70
+
71
+ useEffect(() => {
72
+ if (!stack.managed) return
73
+ if (isActive) {
74
+ stack.register(renderBar)
75
+ } else {
76
+ stack.unregister()
77
+ }
78
+ return () => stack.unregister()
79
+ }, [stack, isActive, renderBar])
67
80
 
68
- if (container) {
69
- return createPortal(bar, container)
81
+ // If no provider, fall back to standalone rendering
82
+ if (!stack.managed) {
83
+ if (!isActive) return null
84
+ return <ActionBarContent {...props} />
70
85
  }
71
86
 
72
- return bar
87
+ // Managed by provider — rendering happens there
88
+ return null
73
89
  }
74
90
 
75
91
  export const ActionBar = ActionBarInner
@@ -0,0 +1,100 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useId,
6
+ useMemo,
7
+ useRef,
8
+ useSyncExternalStore,
9
+ type ReactNode,
10
+ } from 'react'
11
+
12
+ interface ActionBarEntry {
13
+ id: string
14
+ render: () => ReactNode
15
+ }
16
+
17
+ interface ActionBarStore {
18
+ subscribe: (cb: () => void) => () => void
19
+ getSnapshot: () => readonly ActionBarEntry[]
20
+ push: (id: string, render: () => ReactNode) => void
21
+ update: (id: string, render: () => ReactNode) => void
22
+ remove: (id: string) => void
23
+ }
24
+
25
+ function createActionBarStore(): ActionBarStore {
26
+ let entries: ActionBarEntry[] = []
27
+ const listeners = new Set<() => void>()
28
+
29
+ const notify = () => listeners.forEach((l) => l())
30
+
31
+ return {
32
+ subscribe: (cb) => {
33
+ listeners.add(cb)
34
+ return () => listeners.delete(cb)
35
+ },
36
+ getSnapshot: () => entries,
37
+ push: (id, render) => {
38
+ entries = [...entries, {id, render}]
39
+ notify()
40
+ },
41
+ update: (id, render) => {
42
+ entries = entries.map((e) => (e.id === id ? {id, render} : e))
43
+ notify()
44
+ },
45
+ remove: (id) => {
46
+ entries = entries.filter((e) => e.id !== id)
47
+ notify()
48
+ },
49
+ }
50
+ }
51
+
52
+ const ActionBarContext = createContext<ActionBarStore | null>(null)
53
+
54
+ export function ActionBarProvider({children}: {children: ReactNode}) {
55
+ const storeRef = useRef<ActionBarStore>(null)
56
+ if (storeRef.current === null) {
57
+ storeRef.current = createActionBarStore()
58
+ }
59
+ const store = storeRef.current
60
+
61
+ const entries = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
62
+ const topEntry = entries.length > 0 ? entries[entries.length - 1] : null
63
+
64
+ return (
65
+ <ActionBarContext.Provider value={store}>
66
+ {children}
67
+ {topEntry?.render()}
68
+ </ActionBarContext.Provider>
69
+ )
70
+ }
71
+
72
+ export function useActionBarStack() {
73
+ const store = useContext(ActionBarContext)
74
+ const id = useId()
75
+
76
+ const register = useCallback(
77
+ (render: () => ReactNode) => {
78
+ if (!store) return
79
+ const entries = store.getSnapshot()
80
+ if (entries.some((e) => e.id === id)) {
81
+ store.update(id, render)
82
+ } else {
83
+ store.push(id, render)
84
+ }
85
+ },
86
+ [store, id],
87
+ )
88
+
89
+ const unregister = useCallback(() => {
90
+ store?.remove(id)
91
+ }, [store, id])
92
+
93
+ const isTop = useMemo(() => {
94
+ if (!store) return false
95
+ const entries = store.getSnapshot()
96
+ return entries.length > 0 && entries[entries.length - 1].id === id
97
+ }, [store, id])
98
+
99
+ return {id, register, unregister, managed: store !== null, isTop}
100
+ }
@@ -1,4 +1,4 @@
1
- import {type ReactNode, createContext, useContext, useRef} from 'react'
1
+ import {type ReactNode} 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'
@@ -27,26 +27,9 @@ const styles = css.create({
27
27
  // concrete VarGroup lacks the generic index signature. This is a known typing gap.
28
28
  type DivStyle = Parameters<typeof html.div>[0]['style']
29
29
 
30
- /** Context providing a portal target inside the themed tree. */
31
- const OverlayContainerContext = createContext<React.RefObject<HTMLDivElement | null> | null>(null)
32
-
33
- /** Returns the overlay container element for portals (inside the ThemeProvider). */
34
- export function useOverlayContainer(): HTMLElement {
35
- const ref = useContext(OverlayContainerContext)
36
- return ref?.current ?? (typeof document !== 'undefined' ? document.body : null!)
37
- }
38
-
39
30
  export function ThemeProvider({theme = 'dark', children}: ThemeProviderProps) {
40
- const overlayRef = useRef<HTMLDivElement>(null)
41
31
  const overrides = themeMap[theme]
42
32
  const themeStyles = [overrides?.[0], overrides?.[1], styles.root] as DivStyle
43
33
 
44
- return (
45
- <html.div style={themeStyles}>
46
- <OverlayContainerContext.Provider value={overlayRef}>
47
- {children}
48
- <html.div ref={overlayRef} />
49
- </OverlayContainerContext.Provider>
50
- </html.div>
51
- )
34
+ return <html.div style={themeStyles}>{children}</html.div>
52
35
  }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import './strict.css'
3
3
 
4
4
  // Components
5
5
  export {ActionBar, type ActionBarProps} from './components/ActionBar/ActionBar'
6
+ export {ActionBarProvider} from './components/ActionBar/ActionBarProvider'
6
7
  export {Alert, type AlertVariant} from './components/Alert/Alert'
7
8
  export {Icon, type IconName} from './components/Icon'
8
9
  export {Badge, type BadgeVariant, type BadgeSize} from './components/Badge/Badge'
@@ -35,11 +36,7 @@ export {
35
36
  export {Table, type TableVariant, type TableSize} from './components/Table/Table'
36
37
  export {Tabs} from './components/Tabs/Tabs'
37
38
  export {Textarea, type TextareaVariant} from './components/Textarea/Textarea'
38
- export {
39
- ThemeProvider,
40
- useOverlayContainer,
41
- type ThemeName,
42
- } from './components/ThemeProvider/ThemeProvider'
39
+ export {ThemeProvider, type ThemeName} from './components/ThemeProvider/ThemeProvider'
43
40
  export {Text, type TextVariant, type TextColor} from './components/Text/Text'
44
41
  export {Toggle, type ToggleSize} from './components/Toggle/Toggle'
45
42
  export {ToggleGroup} from './components/ToggleGroup/ToggleGroup'