@duro-app/ui 0.17.0 → 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.0",
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.0"
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
+ }
@@ -50,7 +50,16 @@ function ControlledRoot({
50
50
  },
51
51
  errorMessage: fieldState.error?.message,
52
52
  }),
53
- [id, invalid, field.value, field.onChange, field.onBlur, field.ref, field.name, fieldState.error],
53
+ [
54
+ id,
55
+ invalid,
56
+ field.value,
57
+ field.onChange,
58
+ field.onBlur,
59
+ field.ref,
60
+ field.name,
61
+ fieldState.error,
62
+ ],
54
63
  )
55
64
 
56
65
  return (
@@ -93,11 +93,7 @@ export const ValidationOnBlur: Story = {
93
93
  name: 'Validation — on blur',
94
94
  render: () => (
95
95
  <html.div style={wrapStyles.wrap}>
96
- <Form
97
- schema={LoginSchema}
98
- defaultValues={{username: '', password: ''}}
99
- onSubmit={fn()}
100
- >
96
+ <Form schema={LoginSchema} defaultValues={{username: '', password: ''}} onSubmit={fn()}>
101
97
  <Fieldset.Root gap="md">
102
98
  <Field.Root name="username">
103
99
  <Field.Label>Username</Field.Label>
@@ -131,10 +127,13 @@ export const ValidationOnBlur: Story = {
131
127
  await expect(alert).toHaveTextContent('Username must be at least 3 characters')
132
128
  })
133
129
 
134
- await step('Fix username by typing more — error clears (reValidateMode: onChange)', async () => {
135
- await userEvent.type(usernameInput, 'c') // now "abc"
136
- expect(canvas.queryByText('Username must be at least 3 characters')).not.toBeInTheDocument()
137
- })
130
+ await step(
131
+ 'Fix username by typing more — error clears (reValidateMode: onChange)',
132
+ async () => {
133
+ await userEvent.type(usernameInput, 'c') // now "abc"
134
+ expect(canvas.queryByText('Username must be at least 3 characters')).not.toBeInTheDocument()
135
+ },
136
+ )
138
137
 
139
138
  await step('Blur empty password — error appears', async () => {
140
139
  await userEvent.click(passwordInput)
@@ -150,11 +149,7 @@ export const WithTextarea: Story = {
150
149
  name: 'With Textarea',
151
150
  render: () => (
152
151
  <html.div style={wrapStyles.wrap}>
153
- <Form
154
- schema={FeedbackSchema}
155
- defaultValues={{email: '', message: ''}}
156
- onSubmit={fn()}
157
- >
152
+ <Form schema={FeedbackSchema} defaultValues={{email: '', message: ''}} onSubmit={fn()}>
158
153
  {({formState}) => (
159
154
  <Fieldset.Root gap="md">
160
155
  <Field.Root name="email">
@@ -193,11 +188,7 @@ export const SuccessfulSubmit: Story = {
193
188
  const onSubmit = fn()
194
189
  return (
195
190
  <html.div style={wrapStyles.wrap}>
196
- <Form
197
- schema={LoginSchema}
198
- defaultValues={{username: '', password: ''}}
199
- onSubmit={onSubmit}
200
- >
191
+ <Form schema={LoginSchema} defaultValues={{username: '', password: ''}} onSubmit={onSubmit}>
201
192
  <Fieldset.Root gap="md">
202
193
  <Field.Root name="username">
203
194
  <Field.Label>Username</Field.Label>
@@ -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'