@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/dist/components/ActionBar/ActionBar.d.ts +1 -1
- package/dist/components/ActionBar/ActionBar.d.ts.map +1 -1
- package/dist/components/ActionBar/ActionBarProvider.d.ts +12 -0
- package/dist/components/ActionBar/ActionBarProvider.d.ts.map +1 -0
- package/dist/components/Field/Field.d.ts.map +1 -1
- package/dist/components/Form/Form.stories.d.ts.map +1 -1
- package/dist/components/ThemeProvider/ThemeProvider.d.ts +0 -2
- package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1383 -1314
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/ActionBar/ActionBar.tsx +28 -12
- package/src/components/ActionBar/ActionBarProvider.tsx +100 -0
- package/src/components/Field/Field.tsx +10 -1
- package/src/components/Form/Form.stories.tsx +10 -19
- package/src/components/ThemeProvider/ThemeProvider.tsx +2 -19
- package/src/index.ts +2 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "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.
|
|
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
|
|
2
|
-
import {createPortal} from 'react-dom'
|
|
1
|
+
import {useEffect, useCallback, type ReactNode} from 'react'
|
|
3
2
|
import {html} from 'react-strict-dom'
|
|
4
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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(
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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'
|