@duro-app/ui 5.11.0 → 5.12.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/Table-ColfpgKV.js.map +1 -1
- package/dist/compiler-runtime-BuugS4D0.js.map +1 -1
- package/dist/components/Alert/Alert.d.ts.map +1 -1
- package/dist/components/Button/Button.d.ts +2 -1
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/Button/styles.css.d.ts +1 -0
- package/dist/components/Button/styles.css.d.ts.map +1 -1
- package/dist/components/Callout/Callout.d.ts.map +1 -1
- package/dist/components/Callout/styles.css.d.ts +3 -0
- package/dist/components/Callout/styles.css.d.ts.map +1 -1
- package/dist/components/Checkbox/Checkbox.d.ts.map +1 -1
- package/dist/components/ConfirmDialog/ConfirmDialog.d.ts.map +1 -1
- package/dist/components/DetailPanel/DetailPanel.d.ts.map +1 -1
- package/dist/components/Dialog/Dialog.d.ts +2 -17
- package/dist/components/Dialog/Dialog.d.ts.map +1 -1
- package/dist/components/Dialog/Dialog.stories.d.ts +4 -0
- package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -1
- package/dist/components/Dialog/native.css.d.ts +82 -0
- package/dist/components/Dialog/native.css.d.ts.map +1 -0
- package/dist/components/Dialog/parts.css.d.ts +82 -0
- package/dist/components/Dialog/parts.css.d.ts.map +1 -0
- package/dist/components/Dialog/parts.d.ts +35 -0
- package/dist/components/Dialog/parts.d.ts.map +1 -0
- package/dist/components/Dialog/styles.css.d.ts +1 -74
- package/dist/components/Dialog/styles.css.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.d.ts.map +1 -1
- package/dist/components/Drawer/styles.css.d.ts +2 -0
- package/dist/components/Drawer/styles.css.d.ts.map +1 -1
- package/dist/components/Grid/styles.css.d.ts +1 -0
- package/dist/components/Grid/styles.css.d.ts.map +1 -1
- package/dist/components/Icon/Icon.d.ts +3 -9
- package/dist/components/Icon/Icon.d.ts.map +1 -1
- package/dist/components/Icon/geometry.d.ts +60 -0
- package/dist/components/Icon/geometry.d.ts.map +1 -0
- package/dist/components/Icon/types.d.ts +15 -0
- package/dist/components/Icon/types.d.ts.map +1 -0
- package/dist/components/Input/Input.d.ts +5 -1
- package/dist/components/Input/Input.d.ts.map +1 -1
- package/dist/components/Input/styles.css.d.ts +1 -0
- package/dist/components/Input/styles.css.d.ts.map +1 -1
- package/dist/components/LinkButton/styles.css.d.ts +1 -0
- package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
- package/dist/components/Meter/styles.css.d.ts +1 -0
- package/dist/components/Meter/styles.css.d.ts.map +1 -1
- package/dist/components/PageShell/styles.css.d.ts +2 -0
- package/dist/components/PageShell/styles.css.d.ts.map +1 -1
- package/dist/components/RadioGroup/RadioGroup.d.ts +1 -17
- package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -1
- package/dist/components/RadioGroup/RadioGroup.stories.d.ts +3 -0
- package/dist/components/RadioGroup/RadioGroup.stories.d.ts.map +1 -1
- package/dist/components/RadioGroup/group.d.ts +31 -0
- package/dist/components/RadioGroup/group.d.ts.map +1 -0
- package/dist/components/RadioGroup/styles.css.d.ts +10 -0
- package/dist/components/RadioGroup/styles.css.d.ts.map +1 -1
- package/dist/components/ScrollArea/styles.css.d.ts +1 -0
- package/dist/components/ScrollArea/styles.css.d.ts.map +1 -1
- package/dist/components/Spinner/Spinner.d.ts +2 -6
- package/dist/components/Spinner/Spinner.d.ts.map +1 -1
- package/dist/components/Spinner/Spinner.types.d.ts +6 -0
- package/dist/components/Spinner/Spinner.types.d.ts.map +1 -0
- package/dist/components/Spinner/ring.css.d.ts +39 -0
- package/dist/components/Spinner/ring.css.d.ts.map +1 -0
- package/dist/components/Spinner/styles.css.d.ts +1 -28
- package/dist/components/Spinner/styles.css.d.ts.map +1 -1
- package/dist/components/StatusIcon/StatusIcon.d.ts.map +1 -1
- package/dist/components/StatusIcon/styles.css.d.ts +3 -0
- package/dist/components/StatusIcon/styles.css.d.ts.map +1 -1
- package/dist/components/Switch/Switch.d.ts +2 -18
- package/dist/components/Switch/Switch.d.ts.map +1 -1
- package/dist/components/Switch/Switch.types.d.ts +18 -0
- package/dist/components/Switch/Switch.types.d.ts.map +1 -0
- package/dist/components/TableOfContents/styles.css.d.ts +1 -0
- package/dist/components/TableOfContents/styles.css.d.ts.map +1 -1
- package/dist/components/Textarea/styles.css.d.ts +1 -0
- package/dist/components/Textarea/styles.css.d.ts.map +1 -1
- package/dist/components/Toggle/Toggle.d.ts.map +1 -1
- package/dist/components/Toggle/styles.css.d.ts +11 -2
- package/dist/components/Toggle/styles.css.d.ts.map +1 -1
- package/dist/components/VirtualTable/styles.css.d.ts +6 -0
- package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +4299 -3566
- package/dist/index.js.map +1 -1
- package/dist/shared/portal/hostRegistry.d.ts +33 -0
- package/dist/shared/portal/hostRegistry.d.ts.map +1 -0
- package/dist/shared/useOverlayOpenState.d.ts +40 -0
- package/dist/shared/useOverlayOpenState.d.ts.map +1 -0
- package/dist/table.js +141 -135
- package/dist/table.js.map +1 -1
- package/package.json +34 -4
- package/src/components/Alert/Alert.tsx +14 -2
- package/src/components/Button/Button.tsx +3 -2
- package/src/components/Button/styles.css.ts +2 -0
- package/src/components/Callout/Callout.tsx +17 -3
- package/src/components/Callout/styles.css.ts +5 -0
- package/src/components/Checkbox/Checkbox.native.tsx +67 -0
- package/src/components/Checkbox/Checkbox.tsx +1 -50
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +3 -0
- package/src/components/DetailPanel/DetailPanel.tsx +9 -45
- package/src/components/Dialog/Dialog.native.tsx +296 -0
- package/src/components/Dialog/Dialog.stories.tsx +71 -0
- package/src/components/Dialog/Dialog.tsx +41 -130
- package/src/components/Dialog/native.css.ts +78 -0
- package/src/components/Dialog/parts.css.ts +101 -0
- package/src/components/Dialog/parts.tsx +61 -0
- package/src/components/Dialog/styles.css.ts +2 -89
- package/src/components/Drawer/Drawer.tsx +20 -49
- package/src/components/Drawer/styles.css.ts +4 -0
- package/src/components/Grid/styles.css.ts +2 -0
- package/src/components/Icon/Icon.native.tsx +54 -0
- package/src/components/Icon/Icon.tsx +19 -430
- package/src/components/Icon/geometry.ts +456 -0
- package/src/components/Icon/types.ts +74 -0
- package/src/components/Input/Input.tsx +8 -0
- package/src/components/Input/styles.css.ts +2 -0
- package/src/components/LinkButton/styles.css.ts +2 -0
- package/src/components/Meter/styles.css.ts +2 -0
- package/src/components/PageShell/styles.css.ts +4 -0
- package/src/components/RadioGroup/RadioGroup.meta.ts +1 -1
- package/src/components/RadioGroup/RadioGroup.native.tsx +48 -0
- package/src/components/RadioGroup/RadioGroup.stories.tsx +64 -1
- package/src/components/RadioGroup/RadioGroup.tsx +8 -83
- package/src/components/RadioGroup/group.tsx +85 -0
- package/src/components/RadioGroup/styles.css.ts +13 -0
- package/src/components/ScrollArea/styles.css.ts +2 -0
- package/src/components/Spinner/Spinner.native.tsx +37 -0
- package/src/components/Spinner/Spinner.tsx +6 -15
- package/src/components/Spinner/Spinner.types.ts +6 -0
- package/src/components/Spinner/ring.css.ts +45 -0
- package/src/components/Spinner/styles.css.ts +2 -32
- package/src/components/StatusIcon/StatusIcon.tsx +13 -2
- package/src/components/StatusIcon/styles.css.ts +5 -0
- package/src/components/Switch/Switch.native.tsx +45 -0
- package/src/components/Switch/Switch.tsx +22 -44
- package/src/components/Switch/Switch.types.ts +18 -0
- package/src/components/TableOfContents/styles.css.ts +2 -0
- package/src/components/Textarea/styles.css.ts +2 -0
- package/src/components/ThemeProvider/ThemeProvider.native.tsx +70 -10
- package/src/components/Toggle/Toggle.tsx +2 -0
- package/src/components/Toggle/styles.css.ts +14 -2
- package/src/components/VirtualTable/styles.css.ts +12 -0
- package/src/index.native.ts +28 -1
- package/src/native-guard/globals.d.ts +10 -0
- package/src/native-guard/react-dom.d.ts +3 -0
- package/src/shared/accessibilityFocus.native.ts +23 -0
- package/src/shared/portal/OverlayModal.native.tsx +51 -0
- package/src/shared/portal/PortalHost.native.tsx +102 -0
- package/src/shared/portal/hostRegistry.ts +76 -0
- package/src/shared/portal/themeScope.native.ts +18 -0
- package/src/shared/safeArea.native.ts +22 -0
- package/src/shared/useOverlayOpenState.ts +128 -0
- package/dist/components/ThemeProvider/ThemeProvider.native.d.ts +0 -10
- package/dist/components/ThemeProvider/ThemeProvider.native.d.ts.map +0 -1
- package/dist/index.native.d.ts +0 -33
- package/dist/index.native.d.ts.map +0 -1
- package/dist/platform.native.d.ts +0 -2
- package/dist/platform.native.d.ts.map +0 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type {ReactNode} from 'react'
|
|
2
|
+
|
|
3
|
+
export interface SwitchProps {
|
|
4
|
+
/** Controlled checked state. */
|
|
5
|
+
checked?: boolean
|
|
6
|
+
/** Initial checked state (uncontrolled). */
|
|
7
|
+
defaultChecked?: boolean
|
|
8
|
+
/** Callback fired when the checked state changes. */
|
|
9
|
+
onCheckedChange?: (checked: boolean) => void
|
|
10
|
+
/** Prevents user interaction. */
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
/** Form field name for the hidden input. */
|
|
13
|
+
name?: string
|
|
14
|
+
/** Value submitted when checked. */
|
|
15
|
+
value?: string
|
|
16
|
+
/** Label content rendered beside the switch. */
|
|
17
|
+
children?: ReactNode
|
|
18
|
+
}
|
|
@@ -94,6 +94,8 @@ export const styles = css.create({
|
|
|
94
94
|
justifyContent: 'space-between',
|
|
95
95
|
gap: spacing.sm,
|
|
96
96
|
width: '100%',
|
|
97
|
+
// Native has no border-box reset (the web's global-reset.css); declare it.
|
|
98
|
+
boxSizing: 'border-box',
|
|
97
99
|
minHeight: sizes.touchTarget,
|
|
98
100
|
paddingTop: spacing.sm,
|
|
99
101
|
paddingBottom: spacing.sm,
|
|
@@ -8,6 +8,8 @@ import {borders} from '@duro-app/tokens/tokens/borders.css'
|
|
|
8
8
|
export const styles = css.create({
|
|
9
9
|
base: {
|
|
10
10
|
width: '100%',
|
|
11
|
+
// Native has no border-box reset (the web's global-reset.css); declare it.
|
|
12
|
+
boxSizing: 'border-box',
|
|
11
13
|
paddingTop: spacing.sm,
|
|
12
14
|
paddingBottom: spacing.sm,
|
|
13
15
|
paddingLeft: spacing.md,
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
// Native (React Native / Expo) ThemeProvider — Metro resolves this over
|
|
2
2
|
// ThemeProvider.tsx via the platform extension. The web version uses
|
|
3
3
|
// `display:contents` + a fixed portal-mount, neither of which exists on RN, so
|
|
4
|
-
// this applies the theme
|
|
5
|
-
//
|
|
6
|
-
|
|
4
|
+
// this applies the theme to a plain flex-column container, and passive
|
|
5
|
+
// overlays (toasts) draw through the host registry instead (plan N0,
|
|
6
|
+
// Decision 2).
|
|
7
|
+
import {createContext, type ReactNode, useContext, useMemo, useState} from 'react'
|
|
7
8
|
import {css, html} from 'react-strict-dom'
|
|
9
|
+
import type {ColorToken} from '@duro-app/tokens/keys'
|
|
10
|
+
import {darkColors, highContrastColors, lightColors, type RawColors} from '@duro-app/tokens/raw'
|
|
8
11
|
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
9
12
|
import {lightTheme, lightShadows} from '@duro-app/tokens/themes/light.css'
|
|
10
13
|
import {highContrastTheme, highContrastShadows} from '@duro-app/tokens/themes/high-contrast.css'
|
|
14
|
+
import {createHostRegistry, type HostRegistry} from '../../shared/portal/hostRegistry'
|
|
15
|
+
import {HostRegistryContext, PortalHost} from '../../shared/portal/PortalHost'
|
|
16
|
+
import {
|
|
17
|
+
ResolvedColorsContext,
|
|
18
|
+
ThemeScopeContext,
|
|
19
|
+
type ThemeScope,
|
|
20
|
+
} from '../../shared/portal/themeScope'
|
|
11
21
|
|
|
12
22
|
export type ThemeName = 'dark' | 'light' | 'high-contrast'
|
|
13
23
|
|
|
@@ -16,34 +26,84 @@ interface ThemeProviderProps {
|
|
|
16
26
|
children: ReactNode
|
|
17
27
|
}
|
|
18
28
|
|
|
19
|
-
|
|
29
|
+
// On the web, `dark` is the base vars and needs no theme. On native,
|
|
30
|
+
// createTheme is a runtime call, so dark gets an explicit one: a dark
|
|
31
|
+
// provider nested in a light one then draws dark, and its resolved colours
|
|
32
|
+
// (below) agree with what RSD draws.
|
|
33
|
+
const darkTheme = css.createTheme(colors, darkColors)
|
|
34
|
+
|
|
35
|
+
const themeMap: Record<ThemeName, readonly unknown[]> = {
|
|
36
|
+
dark: [darkTheme],
|
|
20
37
|
light: [lightTheme, lightShadows],
|
|
21
38
|
'high-contrast': [highContrastTheme, highContrastShadows],
|
|
22
39
|
}
|
|
23
40
|
|
|
41
|
+
const palettes: Record<ThemeName, RawColors> = {
|
|
42
|
+
dark: darkColors,
|
|
43
|
+
light: lightColors,
|
|
44
|
+
'high-contrast': highContrastColors,
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
const styles = css.create({
|
|
25
48
|
// No `display:contents` on RN. RSD uses web flex semantics on native, so
|
|
26
|
-
// declare flex + column explicitly
|
|
49
|
+
// declare flex + column explicitly.
|
|
27
50
|
root: {
|
|
28
51
|
display: 'flex',
|
|
29
52
|
flexDirection: 'column',
|
|
30
|
-
flex: 1,
|
|
31
53
|
color: colors.text,
|
|
32
54
|
},
|
|
55
|
+
// The app canvas: the web paints it on body from vars.css; native has no
|
|
56
|
+
// body, so the outermost provider paints it and fills its host (height,
|
|
57
|
+
// not flex: the host is an RN view, which RSD does not know is flex).
|
|
58
|
+
canvas: {
|
|
59
|
+
backgroundColor: colors.bg,
|
|
60
|
+
height: '100%',
|
|
61
|
+
},
|
|
33
62
|
})
|
|
34
63
|
|
|
35
64
|
type DivStyle = Parameters<typeof html.div>[0]['style']
|
|
36
65
|
|
|
37
66
|
// Parity with the web export — there's no portal mount on native, so this is
|
|
38
|
-
// always null.
|
|
67
|
+
// always null.
|
|
39
68
|
const PortalMountContext = createContext<HTMLElement | null>(null)
|
|
40
69
|
|
|
41
70
|
export function usePortalMount(): HTMLElement | null {
|
|
42
71
|
return useContext(PortalMountContext)
|
|
43
72
|
}
|
|
44
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The nearest ThemeProvider's colours as real colour strings, for native hosts
|
|
76
|
+
* that cannot read a `var(--…)` reference: an Icon renderer, an animated
|
|
77
|
+
* backdrop, a Pressable. RSD styles keep using the `colors` tokens.
|
|
78
|
+
*/
|
|
79
|
+
export function useResolvedColors(): Record<ColorToken, string> {
|
|
80
|
+
return useContext(ResolvedColorsContext)
|
|
81
|
+
}
|
|
82
|
+
|
|
45
83
|
export function ThemeProvider({theme = 'dark', children}: ThemeProviderProps) {
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
84
|
+
const outerRegistry = useContext(HostRegistryContext)
|
|
85
|
+
const outerScope = useContext(ThemeScopeContext)
|
|
86
|
+
// The outermost provider owns the registry and draws the root host.
|
|
87
|
+
const [ownRegistry] = useState<HostRegistry | null>(() =>
|
|
88
|
+
outerRegistry ? null : createHostRegistry(),
|
|
89
|
+
)
|
|
90
|
+
const registry = outerRegistry ?? ownRegistry
|
|
91
|
+
const themeStyles = themeMap[theme]
|
|
92
|
+
const scope = useMemo<ThemeScope>(
|
|
93
|
+
() => ({styles: [...outerScope.styles, ...themeStyles]}),
|
|
94
|
+
[outerScope, themeStyles],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<HostRegistryContext.Provider value={registry}>
|
|
99
|
+
<ThemeScopeContext.Provider value={scope}>
|
|
100
|
+
<ResolvedColorsContext.Provider value={palettes[theme]}>
|
|
101
|
+
<html.div style={[...themeStyles, styles.root, ownRegistry && styles.canvas] as DivStyle}>
|
|
102
|
+
{children}
|
|
103
|
+
{ownRegistry ? <PortalHost placement="base" /> : null}
|
|
104
|
+
</html.div>
|
|
105
|
+
</ResolvedColorsContext.Provider>
|
|
106
|
+
</ThemeScopeContext.Provider>
|
|
107
|
+
</HostRegistryContext.Provider>
|
|
108
|
+
)
|
|
49
109
|
}
|
|
@@ -5,6 +5,7 @@ import {useToggleGroup} from '../ToggleGroup/ToggleGroupContext'
|
|
|
5
5
|
import {useGroupedControl} from '../ButtonGroup/useGroupedControl'
|
|
6
6
|
import {mergeRefs} from '../../shared/mergeRefs'
|
|
7
7
|
import type {ControlSize} from '../../shared/types'
|
|
8
|
+
import {isNative} from '../../platform'
|
|
8
9
|
import {styles} from './styles.css'
|
|
9
10
|
|
|
10
11
|
export type ToggleSize = ControlSize
|
|
@@ -103,6 +104,7 @@ export function Toggle({
|
|
|
103
104
|
data-pressed={pressed ? '' : undefined}
|
|
104
105
|
style={[
|
|
105
106
|
styles.base,
|
|
107
|
+
isNative && styles.nativeFlex,
|
|
106
108
|
sizeMap[size],
|
|
107
109
|
pressed ? styles.pressed : styles.unpressed,
|
|
108
110
|
wrap && wrappedSizeMap[size],
|
|
@@ -27,13 +27,20 @@ export const styles = css.create({
|
|
|
27
27
|
minHeight: {default: null, '@media (pointer: coarse)': sizes.touchTarget},
|
|
28
28
|
},
|
|
29
29
|
sizeDefault: {
|
|
30
|
-
|
|
30
|
+
// Longhands: RSD-native rejects a multi-value shorthand.
|
|
31
|
+
paddingTop: spacing.sm,
|
|
32
|
+
paddingBottom: spacing.sm,
|
|
33
|
+
paddingLeft: spacing.md,
|
|
34
|
+
paddingRight: spacing.md,
|
|
31
35
|
fontSize: typography.fontSizeSm,
|
|
32
36
|
borderRadius: radii.sm,
|
|
33
37
|
gap: spacing.sm,
|
|
34
38
|
},
|
|
35
39
|
sizeSmall: {
|
|
36
|
-
|
|
40
|
+
paddingTop: spacing.xs,
|
|
41
|
+
paddingBottom: spacing.xs,
|
|
42
|
+
paddingLeft: spacing.sm,
|
|
43
|
+
paddingRight: spacing.sm,
|
|
37
44
|
fontSize: typography.fontSizeXs,
|
|
38
45
|
borderRadius: radii.sm,
|
|
39
46
|
gap: spacing.xs,
|
|
@@ -87,4 +94,9 @@ export const styles = css.create({
|
|
|
87
94
|
opacity: 0.5,
|
|
88
95
|
cursor: 'not-allowed',
|
|
89
96
|
},
|
|
97
|
+
// RSD-native only honors display:'flex' (not 'inline-flex'). Layered on
|
|
98
|
+
// native only.
|
|
99
|
+
nativeFlex: {
|
|
100
|
+
display: 'flex',
|
|
101
|
+
},
|
|
90
102
|
})
|
|
@@ -9,10 +9,14 @@ export const styles = css.create({
|
|
|
9
9
|
wrap: {
|
|
10
10
|
position: 'relative',
|
|
11
11
|
width: '100%',
|
|
12
|
+
// Native has no border-box reset (the web's global-reset.css); declare it.
|
|
13
|
+
boxSizing: 'border-box',
|
|
12
14
|
},
|
|
13
15
|
scroll: {
|
|
14
16
|
position: 'relative',
|
|
15
17
|
width: '100%',
|
|
18
|
+
// Native has no border-box reset (the web's global-reset.css); declare it.
|
|
19
|
+
boxSizing: 'border-box',
|
|
16
20
|
borderWidth: borders.hairline,
|
|
17
21
|
borderStyle: 'solid',
|
|
18
22
|
borderColor: colors.border,
|
|
@@ -66,6 +70,8 @@ export const styles = css.create({
|
|
|
66
70
|
bodyTotal: (height: number) => ({
|
|
67
71
|
position: 'relative',
|
|
68
72
|
width: '100%',
|
|
73
|
+
// Native has no border-box reset (the web's global-reset.css); declare it.
|
|
74
|
+
boxSizing: 'border-box',
|
|
69
75
|
height,
|
|
70
76
|
}),
|
|
71
77
|
row: {
|
|
@@ -73,6 +79,8 @@ export const styles = css.create({
|
|
|
73
79
|
flexDirection: 'row',
|
|
74
80
|
alignItems: 'center',
|
|
75
81
|
width: '100%',
|
|
82
|
+
// Native has no border-box reset (the web's global-reset.css); declare it.
|
|
83
|
+
boxSizing: 'border-box',
|
|
76
84
|
borderBottomWidth: borders.hairline,
|
|
77
85
|
borderBottomStyle: 'solid',
|
|
78
86
|
borderBottomColor: colors.border,
|
|
@@ -113,6 +121,8 @@ export const styles = css.create({
|
|
|
113
121
|
stackScroll: {
|
|
114
122
|
position: 'relative',
|
|
115
123
|
width: '100%',
|
|
124
|
+
// Native has no border-box reset (the web's global-reset.css); declare it.
|
|
125
|
+
boxSizing: 'border-box',
|
|
116
126
|
backgroundColor: 'transparent',
|
|
117
127
|
},
|
|
118
128
|
stackBody: {
|
|
@@ -125,6 +135,8 @@ export const styles = css.create({
|
|
|
125
135
|
flexDirection: 'column',
|
|
126
136
|
gap: spacing.xs,
|
|
127
137
|
width: '100%',
|
|
138
|
+
// Native has no border-box reset (the web's global-reset.css); declare it.
|
|
139
|
+
boxSizing: 'border-box',
|
|
128
140
|
padding: spacing.sm,
|
|
129
141
|
borderWidth: borders.hairline,
|
|
130
142
|
borderStyle: 'solid',
|
package/src/index.native.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* TagGroup, ThemeProvider's portal) that use web-only DOM APIs — and Combobox
|
|
8
8
|
* imports `react-dom`, so pulling the full barrel crashes on Metro. This barrel
|
|
9
9
|
* re-exports ONLY the components whose source is RN-safe (pure react-strict-dom
|
|
10
|
-
* `html` + `css
|
|
10
|
+
* `html` + `css`) or that have a `.native` variant; `pnpm typecheck:native` and
|
|
11
|
+
* `scripts/check-native-graph.mjs` hold the line (plan N0).
|
|
11
12
|
*
|
|
12
13
|
* Tokens resolve fine on native already: @duro-app/tokens's subpath exports
|
|
13
14
|
* (`./tokens/*.css`, `./themes/*.css`) point straight at their `.css.ts`
|
|
@@ -29,7 +30,30 @@ export {Textarea, type TextareaVariant} from './components/Textarea/Textarea'
|
|
|
29
30
|
export {Field} from './components/Field/Field'
|
|
30
31
|
export {InputGroup} from './components/InputGroup/InputGroup'
|
|
31
32
|
|
|
33
|
+
// Status and feedback (5.12: Icon draws with react-native-svg on native)
|
|
34
|
+
export {Alert, type AlertVariant} from './components/Alert/Alert'
|
|
35
|
+
export {Callout, type CalloutVariant} from './components/Callout/Callout'
|
|
36
|
+
export {EmptyState} from './components/EmptyState/EmptyState'
|
|
37
|
+
export {Icon, type IconName} from './components/Icon'
|
|
38
|
+
export {Meter, type MeterProps, type MeterTone} from './components/Meter/Meter'
|
|
39
|
+
export {Spinner, type SpinnerSize} from './components/Spinner/Spinner'
|
|
40
|
+
export {
|
|
41
|
+
StatusIcon,
|
|
42
|
+
type StatusIconName,
|
|
43
|
+
type StatusIconVariant,
|
|
44
|
+
} from './components/StatusIcon/StatusIcon'
|
|
45
|
+
|
|
46
|
+
// Choice and collection
|
|
47
|
+
export {List, type ListSelectionMode} from './components/List/List'
|
|
48
|
+
export {RadioGroup} from './components/RadioGroup/RadioGroup'
|
|
49
|
+
export {Toggle, type ToggleSize} from './components/Toggle/Toggle'
|
|
50
|
+
|
|
51
|
+
// Overlays (5.12: rendered in an OverlayModal on native)
|
|
52
|
+
export {ConfirmDialog} from './components/ConfirmDialog/ConfirmDialog'
|
|
53
|
+
export {Dialog, type DialogSize} from './components/Dialog/Dialog'
|
|
54
|
+
|
|
32
55
|
// Layout primitives
|
|
56
|
+
export {PageShell, type PageShellMaxWidth, type PageShellPadding} from './components/PageShell'
|
|
33
57
|
export {Stack, type SpacingKey} from './components/Stack/Stack'
|
|
34
58
|
export {Inline} from './components/Inline/Inline'
|
|
35
59
|
export {Cluster} from './components/Cluster/Cluster'
|
|
@@ -41,5 +65,8 @@ export type {GridTrack} from './shared/length'
|
|
|
41
65
|
export {
|
|
42
66
|
ThemeProvider,
|
|
43
67
|
usePortalMount,
|
|
68
|
+
useResolvedColors,
|
|
44
69
|
type ThemeName,
|
|
45
70
|
} from './components/ThemeProvider/ThemeProvider'
|
|
71
|
+
// Where toasts draw on native: mount one inside a natively presented route.
|
|
72
|
+
export {PortalHost} from './shared/portal/PortalHost'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The native graph has no DOM lib. A few spots on it still name DOM members:
|
|
2
|
+
// ConfirmDialog reads `event.target.value` from an Input change (RSD's native
|
|
3
|
+
// change event carries it too), and Grid reads `process.env.NODE_ENV`
|
|
4
|
+
// (inlined by Metro). Declare exactly those members rather than pulling in
|
|
5
|
+
// lib.dom.
|
|
6
|
+
interface HTMLInputElement {
|
|
7
|
+
value: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
declare const process: {env: {NODE_ENV?: string}}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {AccessibilityInfo} from 'react-native'
|
|
2
|
+
|
|
3
|
+
const reported = new Set<string>()
|
|
4
|
+
|
|
5
|
+
type FocusTarget = Parameters<typeof AccessibilityInfo.sendAccessibilityEvent>[0]
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Moves the screen reader's focus to `target` (a host view, or a
|
|
9
|
+
* react-strict-dom element ref). Native overlays call it on open and, for the
|
|
10
|
+
* trigger, on close: the screen-reader contract that stands in for the web's
|
|
11
|
+
* DOM focus (plan N0, Decision 6). A target the platform refuses is logged
|
|
12
|
+
* once per caller, in release builds too, rather than dropped.
|
|
13
|
+
*/
|
|
14
|
+
export function focusForAccessibility(target: unknown, from: string): void {
|
|
15
|
+
if (target == null) return
|
|
16
|
+
try {
|
|
17
|
+
AccessibilityInfo.sendAccessibilityEvent(target as FocusTarget, 'focus')
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (reported.has(from)) return
|
|
20
|
+
reported.add(from)
|
|
21
|
+
console.warn(`[duro] ${from}: could not move accessibility focus: ${String(error)}`)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type {ReactNode} from 'react'
|
|
2
|
+
import {Modal, StyleSheet} from 'react-native'
|
|
3
|
+
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
|
4
|
+
import {PortalHost} from './PortalHost'
|
|
5
|
+
|
|
6
|
+
interface OverlayModalProps {
|
|
7
|
+
visible: boolean
|
|
8
|
+
/** Android back button: the dismissal Escape is on the web. */
|
|
9
|
+
onRequestClose: () => void
|
|
10
|
+
/** The Modal has been presented: move accessibility focus in. */
|
|
11
|
+
onShow?: () => void
|
|
12
|
+
/** iOS: the Modal has finished dismissing (presenting another one is safe). */
|
|
13
|
+
onDismiss?: () => void
|
|
14
|
+
children: ReactNode
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The window every interactive native overlay renders in (plan N0,
|
|
19
|
+
* Decision 2). RN `Modal` is a React portal, so the app's contexts reach the
|
|
20
|
+
* content, and it presents above natively presented routes. Duro draws its own
|
|
21
|
+
* backdrop and motion, so the Modal's own animation is off.
|
|
22
|
+
*
|
|
23
|
+
* Content gets its own GestureHandlerRootView: on Android a Modal is a
|
|
24
|
+
* separate window that the app's root view does not cover. A PortalHost on
|
|
25
|
+
* top lets toasts raised while it is open draw above it.
|
|
26
|
+
*/
|
|
27
|
+
export function OverlayModal({
|
|
28
|
+
visible,
|
|
29
|
+
onRequestClose,
|
|
30
|
+
onShow,
|
|
31
|
+
onDismiss,
|
|
32
|
+
children,
|
|
33
|
+
}: OverlayModalProps) {
|
|
34
|
+
return (
|
|
35
|
+
<Modal
|
|
36
|
+
visible={visible}
|
|
37
|
+
transparent
|
|
38
|
+
statusBarTranslucent
|
|
39
|
+
navigationBarTranslucent
|
|
40
|
+
animationType="none"
|
|
41
|
+
onRequestClose={onRequestClose}
|
|
42
|
+
onShow={onShow}
|
|
43
|
+
onDismiss={onDismiss}
|
|
44
|
+
>
|
|
45
|
+
<GestureHandlerRootView style={StyleSheet.absoluteFill}>
|
|
46
|
+
{children}
|
|
47
|
+
<PortalHost />
|
|
48
|
+
</GestureHandlerRootView>
|
|
49
|
+
</Modal>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useSyncExternalStore,
|
|
9
|
+
} from 'react'
|
|
10
|
+
import {StyleSheet, View} from 'react-native'
|
|
11
|
+
import {css, html} from 'react-strict-dom'
|
|
12
|
+
import type {HostRegistry} from './hostRegistry'
|
|
13
|
+
import {ResolvedColorsContext, ThemeScopeContext} from './themeScope'
|
|
14
|
+
|
|
15
|
+
/** The registry the outermost ThemeProvider owns; null outside every provider. */
|
|
16
|
+
export const HostRegistryContext = createContext<HostRegistry | null>(null)
|
|
17
|
+
|
|
18
|
+
function useHostRegistry(component: string): HostRegistry {
|
|
19
|
+
const registry = useContext(HostRegistryContext)
|
|
20
|
+
if (!registry) throw new Error(`${component} must be rendered inside a Duro ThemeProvider`)
|
|
21
|
+
return registry
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const styles = css.create({
|
|
25
|
+
// An entry covers its host without taking touches the entry does not draw on.
|
|
26
|
+
entry: {
|
|
27
|
+
position: 'absolute',
|
|
28
|
+
top: 0,
|
|
29
|
+
left: 0,
|
|
30
|
+
right: 0,
|
|
31
|
+
bottom: 0,
|
|
32
|
+
pointerEvents: 'box-none',
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
type DivStyle = Parameters<typeof html.div>[0]['style']
|
|
37
|
+
|
|
38
|
+
interface PortalHostProps {
|
|
39
|
+
/**
|
|
40
|
+
* `base`: the app root, always below every other host (the ThemeProvider
|
|
41
|
+
* draws it). `presented` (the default): a natively presented route, which
|
|
42
|
+
* a consumer mounts in that route's layout so toasts draw above it.
|
|
43
|
+
*/
|
|
44
|
+
placement?: 'base' | 'presented'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where passive overlays (toasts) draw on native. The ThemeProvider draws the
|
|
49
|
+
* root host and each open Duro Modal draws its own; mount one inside a
|
|
50
|
+
* natively presented route (`presentation: 'modal'`), or toasts raised there
|
|
51
|
+
* draw behind it.
|
|
52
|
+
*/
|
|
53
|
+
export function PortalHost({placement = 'presented'}: PortalHostProps) {
|
|
54
|
+
const registry = useHostRegistry('PortalHost')
|
|
55
|
+
const id = useId()
|
|
56
|
+
useLayoutEffect(() => registry.addHost(id, placement), [registry, id, placement])
|
|
57
|
+
const {topHost, entries} = useSyncExternalStore(registry.subscribe, registry.getSnapshot)
|
|
58
|
+
if (topHost !== id) return null
|
|
59
|
+
return (
|
|
60
|
+
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
|
|
61
|
+
{entries.map((entry) => (
|
|
62
|
+
<View key={entry.key} style={StyleSheet.absoluteFill} pointerEvents="box-none">
|
|
63
|
+
{entry.node}
|
|
64
|
+
</View>
|
|
65
|
+
))}
|
|
66
|
+
</View>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface PortalProps {
|
|
71
|
+
/** Numeric layer from `LAYERS` in `@duro-app/tokens/keys`. */
|
|
72
|
+
layer: number
|
|
73
|
+
children: ReactNode
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Draws its children in the top host, above the app and every open Duro
|
|
78
|
+
* Modal. The children keep the source's theme and resolved colours; no other
|
|
79
|
+
* context crosses, so pass data, not components that read the app's contexts.
|
|
80
|
+
*/
|
|
81
|
+
export function Portal({layer, children}: PortalProps) {
|
|
82
|
+
const registry = useHostRegistry('Portal')
|
|
83
|
+
const key = useId()
|
|
84
|
+
const scope = useContext(ThemeScopeContext)
|
|
85
|
+
const resolvedColors = useContext(ResolvedColorsContext)
|
|
86
|
+
|
|
87
|
+
useLayoutEffect(() => {
|
|
88
|
+
registry.setEntry({
|
|
89
|
+
key,
|
|
90
|
+
layer,
|
|
91
|
+
node: (
|
|
92
|
+
<ThemeScopeContext.Provider value={scope}>
|
|
93
|
+
<ResolvedColorsContext.Provider value={resolvedColors}>
|
|
94
|
+
<html.div style={[...scope.styles, styles.entry] as DivStyle}>{children}</html.div>
|
|
95
|
+
</ResolvedColorsContext.Provider>
|
|
96
|
+
</ThemeScopeContext.Provider>
|
|
97
|
+
),
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
useEffect(() => () => registry.removeEntry(key), [registry, key])
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type {ReactNode} from 'react'
|
|
2
|
+
|
|
3
|
+
/** One passive surface (a toast region) waiting for a host to draw it. */
|
|
4
|
+
export interface PortalEntry {
|
|
5
|
+
readonly key: string
|
|
6
|
+
/** Numeric layer from `LAYERS` in `@duro-app/tokens/keys`; higher draws on top. */
|
|
7
|
+
readonly layer: number
|
|
8
|
+
readonly node: ReactNode
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface HostRegistrySnapshot {
|
|
12
|
+
/** The host that draws every entry: the last one presented, else the root. */
|
|
13
|
+
readonly topHost: string | undefined
|
|
14
|
+
/** Entries ordered by layer, lowest first (equal layers keep insertion order). */
|
|
15
|
+
readonly entries: readonly PortalEntry[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The hosts that passive surfaces draw into, on native: the app root, each
|
|
20
|
+
* open Duro Modal and each `PortalHost` a consumer mounts in a natively
|
|
21
|
+
* presented route. Entries render in the top host only, so a toast raised
|
|
22
|
+
* under an open Dialog draws above it.
|
|
23
|
+
*
|
|
24
|
+
* A plain store (subscribe / getSnapshot) for `useSyncExternalStore`; it holds
|
|
25
|
+
* no React state and imports nothing from React Native.
|
|
26
|
+
*/
|
|
27
|
+
export interface HostRegistry {
|
|
28
|
+
/** Adds a host. A `base` host (the app root) always sits below the others. */
|
|
29
|
+
addHost(id: string, placement: 'base' | 'presented'): () => void
|
|
30
|
+
setEntry(entry: PortalEntry): void
|
|
31
|
+
removeEntry(key: string): void
|
|
32
|
+
getSnapshot(): HostRegistrySnapshot
|
|
33
|
+
subscribe(listener: () => void): () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createHostRegistry(): HostRegistry {
|
|
37
|
+
let hosts: readonly string[] = []
|
|
38
|
+
let entries: readonly PortalEntry[] = []
|
|
39
|
+
let snapshot: HostRegistrySnapshot = {topHost: undefined, entries}
|
|
40
|
+
const listeners = new Set<() => void>()
|
|
41
|
+
|
|
42
|
+
const publish = () => {
|
|
43
|
+
snapshot = {topHost: hosts[hosts.length - 1], entries}
|
|
44
|
+
for (const listener of listeners) listener()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
addHost(id, placement) {
|
|
49
|
+
hosts = placement === 'base' ? [id, ...hosts] : [...hosts, id]
|
|
50
|
+
publish()
|
|
51
|
+
return () => {
|
|
52
|
+
hosts = hosts.filter((host) => host !== id)
|
|
53
|
+
publish()
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
setEntry(entry) {
|
|
57
|
+
const others = entries.filter((existing) => existing.key !== entry.key)
|
|
58
|
+
const at = others.findIndex((existing) => existing.layer > entry.layer)
|
|
59
|
+
entries =
|
|
60
|
+
at === -1 ? [...others, entry] : [...others.slice(0, at), entry, ...others.slice(at)]
|
|
61
|
+
publish()
|
|
62
|
+
},
|
|
63
|
+
removeEntry(key) {
|
|
64
|
+
if (!entries.some((entry) => entry.key === key)) return
|
|
65
|
+
entries = entries.filter((entry) => entry.key !== key)
|
|
66
|
+
publish()
|
|
67
|
+
},
|
|
68
|
+
getSnapshot: () => snapshot,
|
|
69
|
+
subscribe(listener) {
|
|
70
|
+
listeners.add(listener)
|
|
71
|
+
return () => {
|
|
72
|
+
listeners.delete(listener)
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {createContext} from 'react'
|
|
2
|
+
import type {ColorToken} from '@duro-app/tokens/keys'
|
|
3
|
+
import {darkColors} from '@duro-app/tokens/raw'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The theme styles in force where a component renders: every enclosing
|
|
7
|
+
* ThemeProvider's createTheme objects, outermost first. A native Portal
|
|
8
|
+
* re-applies them around its entry, because the host it draws into sits
|
|
9
|
+
* outside the source's ThemeProviders.
|
|
10
|
+
*/
|
|
11
|
+
export interface ThemeScope {
|
|
12
|
+
readonly styles: readonly unknown[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const ThemeScopeContext = createContext<ThemeScope>({styles: []})
|
|
16
|
+
|
|
17
|
+
/** The nearest ThemeProvider's palette as colour strings (native). */
|
|
18
|
+
export const ResolvedColorsContext = createContext<Record<ColorToken, string>>(darkColors)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {useContext} from 'react'
|
|
2
|
+
import {SafeAreaInsetsContext, type EdgeInsets} from 'react-native-safe-area-context'
|
|
3
|
+
import {devWarnOnce} from './devWarnOnce'
|
|
4
|
+
|
|
5
|
+
const NO_INSETS: EdgeInsets = {top: 0, right: 0, bottom: 0, left: 0}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The window's safe-area insets from the app's SafeAreaProvider (expo-router
|
|
9
|
+
* mounts one). Modal content is a React portal, so overlays inside an
|
|
10
|
+
* OverlayModal read the same insets. Without a provider, no insets, and a
|
|
11
|
+
* development warning names the missing provider.
|
|
12
|
+
*/
|
|
13
|
+
export function useSafeAreaInsets(): EdgeInsets {
|
|
14
|
+
const insets = useContext(SafeAreaInsetsContext)
|
|
15
|
+
if (insets) return insets
|
|
16
|
+
devWarnOnce(
|
|
17
|
+
'safeArea',
|
|
18
|
+
'no-provider',
|
|
19
|
+
'No SafeAreaProvider above this Duro overlay: it draws without safe-area insets. Wrap the app in react-native-safe-area-context’s SafeAreaProvider.',
|
|
20
|
+
)
|
|
21
|
+
return NO_INSETS
|
|
22
|
+
}
|