@duro-app/ui 5.10.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.
Files changed (179) hide show
  1. package/dist/{Table-gRiLI243.js → Table-ColfpgKV.js} +8 -4
  2. package/dist/Table-ColfpgKV.js.map +1 -0
  3. package/dist/compiler-runtime-BuugS4D0.js.map +1 -1
  4. package/dist/components/Alert/Alert.d.ts.map +1 -1
  5. package/dist/components/Button/Button.d.ts +2 -1
  6. package/dist/components/Button/Button.d.ts.map +1 -1
  7. package/dist/components/Button/styles.css.d.ts +1 -0
  8. package/dist/components/Button/styles.css.d.ts.map +1 -1
  9. package/dist/components/Callout/Callout.d.ts.map +1 -1
  10. package/dist/components/Callout/styles.css.d.ts +3 -0
  11. package/dist/components/Callout/styles.css.d.ts.map +1 -1
  12. package/dist/components/Checkbox/Checkbox.d.ts.map +1 -1
  13. package/dist/components/ConfirmDialog/ConfirmDialog.d.ts.map +1 -1
  14. package/dist/components/DetailPanel/DetailPanel.d.ts.map +1 -1
  15. package/dist/components/Dialog/Dialog.d.ts +2 -17
  16. package/dist/components/Dialog/Dialog.d.ts.map +1 -1
  17. package/dist/components/Dialog/Dialog.stories.d.ts +4 -0
  18. package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -1
  19. package/dist/components/Dialog/native.css.d.ts +82 -0
  20. package/dist/components/Dialog/native.css.d.ts.map +1 -0
  21. package/dist/components/Dialog/parts.css.d.ts +82 -0
  22. package/dist/components/Dialog/parts.css.d.ts.map +1 -0
  23. package/dist/components/Dialog/parts.d.ts +35 -0
  24. package/dist/components/Dialog/parts.d.ts.map +1 -0
  25. package/dist/components/Dialog/styles.css.d.ts +1 -74
  26. package/dist/components/Dialog/styles.css.d.ts.map +1 -1
  27. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  28. package/dist/components/Drawer/styles.css.d.ts +2 -0
  29. package/dist/components/Drawer/styles.css.d.ts.map +1 -1
  30. package/dist/components/FileTrigger/FileTrigger.d.ts +23 -0
  31. package/dist/components/FileTrigger/FileTrigger.d.ts.map +1 -0
  32. package/dist/components/FileTrigger/FileTrigger.meta.d.ts +3 -0
  33. package/dist/components/FileTrigger/FileTrigger.meta.d.ts.map +1 -0
  34. package/dist/components/FileTrigger/FileTrigger.stories.d.ts +9 -0
  35. package/dist/components/FileTrigger/FileTrigger.stories.d.ts.map +1 -0
  36. package/dist/components/FileTrigger/index.d.ts +3 -0
  37. package/dist/components/FileTrigger/index.d.ts.map +1 -0
  38. package/dist/components/FileTrigger/pick.d.ts +14 -0
  39. package/dist/components/FileTrigger/pick.d.ts.map +1 -0
  40. package/dist/components/Grid/styles.css.d.ts +1 -0
  41. package/dist/components/Grid/styles.css.d.ts.map +1 -1
  42. package/dist/components/Icon/Icon.d.ts +3 -9
  43. package/dist/components/Icon/Icon.d.ts.map +1 -1
  44. package/dist/components/Icon/Icon.stories.d.ts.map +1 -1
  45. package/dist/components/Icon/geometry.d.ts +60 -0
  46. package/dist/components/Icon/geometry.d.ts.map +1 -0
  47. package/dist/components/Icon/types.d.ts +15 -0
  48. package/dist/components/Icon/types.d.ts.map +1 -0
  49. package/dist/components/Input/Input.d.ts +5 -1
  50. package/dist/components/Input/Input.d.ts.map +1 -1
  51. package/dist/components/Input/styles.css.d.ts +1 -0
  52. package/dist/components/Input/styles.css.d.ts.map +1 -1
  53. package/dist/components/LinkButton/styles.css.d.ts +1 -0
  54. package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
  55. package/dist/components/Meter/styles.css.d.ts +1 -0
  56. package/dist/components/Meter/styles.css.d.ts.map +1 -1
  57. package/dist/components/PageShell/styles.css.d.ts +2 -0
  58. package/dist/components/PageShell/styles.css.d.ts.map +1 -1
  59. package/dist/components/RadioGroup/RadioGroup.d.ts +1 -17
  60. package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -1
  61. package/dist/components/RadioGroup/RadioGroup.stories.d.ts +3 -0
  62. package/dist/components/RadioGroup/RadioGroup.stories.d.ts.map +1 -1
  63. package/dist/components/RadioGroup/group.d.ts +31 -0
  64. package/dist/components/RadioGroup/group.d.ts.map +1 -0
  65. package/dist/components/RadioGroup/styles.css.d.ts +10 -0
  66. package/dist/components/RadioGroup/styles.css.d.ts.map +1 -1
  67. package/dist/components/ScrollArea/styles.css.d.ts +1 -0
  68. package/dist/components/ScrollArea/styles.css.d.ts.map +1 -1
  69. package/dist/components/Spinner/Spinner.d.ts +2 -6
  70. package/dist/components/Spinner/Spinner.d.ts.map +1 -1
  71. package/dist/components/Spinner/Spinner.types.d.ts +6 -0
  72. package/dist/components/Spinner/Spinner.types.d.ts.map +1 -0
  73. package/dist/components/Spinner/ring.css.d.ts +39 -0
  74. package/dist/components/Spinner/ring.css.d.ts.map +1 -0
  75. package/dist/components/Spinner/styles.css.d.ts +1 -28
  76. package/dist/components/Spinner/styles.css.d.ts.map +1 -1
  77. package/dist/components/StatusIcon/StatusIcon.d.ts.map +1 -1
  78. package/dist/components/StatusIcon/styles.css.d.ts +3 -0
  79. package/dist/components/StatusIcon/styles.css.d.ts.map +1 -1
  80. package/dist/components/Switch/Switch.d.ts +2 -18
  81. package/dist/components/Switch/Switch.d.ts.map +1 -1
  82. package/dist/components/Switch/Switch.types.d.ts +18 -0
  83. package/dist/components/Switch/Switch.types.d.ts.map +1 -0
  84. package/dist/components/TableOfContents/styles.css.d.ts +1 -0
  85. package/dist/components/TableOfContents/styles.css.d.ts.map +1 -1
  86. package/dist/components/Textarea/styles.css.d.ts +1 -0
  87. package/dist/components/Textarea/styles.css.d.ts.map +1 -1
  88. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  89. package/dist/components/Toggle/styles.css.d.ts +11 -2
  90. package/dist/components/Toggle/styles.css.d.ts.map +1 -1
  91. package/dist/components/VirtualTable/styles.css.d.ts +6 -0
  92. package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
  93. package/dist/index.css +1 -1
  94. package/dist/index.d.ts +1 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +4589 -3788
  97. package/dist/index.js.map +1 -1
  98. package/dist/shared/portal/hostRegistry.d.ts +33 -0
  99. package/dist/shared/portal/hostRegistry.d.ts.map +1 -0
  100. package/dist/shared/useOverlayOpenState.d.ts +40 -0
  101. package/dist/shared/useOverlayOpenState.d.ts.map +1 -0
  102. package/dist/table.js +141 -135
  103. package/dist/table.js.map +1 -1
  104. package/package.json +34 -4
  105. package/src/components/Alert/Alert.tsx +14 -2
  106. package/src/components/Button/Button.tsx +3 -2
  107. package/src/components/Button/styles.css.ts +2 -0
  108. package/src/components/Callout/Callout.tsx +17 -3
  109. package/src/components/Callout/styles.css.ts +5 -0
  110. package/src/components/Checkbox/Checkbox.native.tsx +67 -0
  111. package/src/components/Checkbox/Checkbox.tsx +1 -50
  112. package/src/components/ConfirmDialog/ConfirmDialog.tsx +3 -0
  113. package/src/components/DetailPanel/DetailPanel.tsx +9 -45
  114. package/src/components/Dialog/Dialog.native.tsx +296 -0
  115. package/src/components/Dialog/Dialog.stories.tsx +71 -0
  116. package/src/components/Dialog/Dialog.tsx +41 -130
  117. package/src/components/Dialog/native.css.ts +78 -0
  118. package/src/components/Dialog/parts.css.ts +101 -0
  119. package/src/components/Dialog/parts.tsx +61 -0
  120. package/src/components/Dialog/styles.css.ts +2 -89
  121. package/src/components/Drawer/Drawer.tsx +20 -49
  122. package/src/components/Drawer/styles.css.ts +4 -0
  123. package/src/components/FileTrigger/FileTrigger.meta.ts +24 -0
  124. package/src/components/FileTrigger/FileTrigger.stories.tsx +36 -0
  125. package/src/components/FileTrigger/FileTrigger.tsx +80 -0
  126. package/src/components/FileTrigger/index.ts +2 -0
  127. package/src/components/FileTrigger/pick.ts +21 -0
  128. package/src/components/Grid/styles.css.ts +2 -0
  129. package/src/components/Icon/Icon.native.tsx +54 -0
  130. package/src/components/Icon/Icon.stories.tsx +1 -0
  131. package/src/components/Icon/Icon.tsx +19 -400
  132. package/src/components/Icon/geometry.ts +456 -0
  133. package/src/components/Icon/types.ts +74 -0
  134. package/src/components/Input/Input.tsx +8 -0
  135. package/src/components/Input/styles.css.ts +2 -0
  136. package/src/components/LinkButton/styles.css.ts +2 -0
  137. package/src/components/Meter/styles.css.ts +2 -0
  138. package/src/components/PageShell/styles.css.ts +4 -0
  139. package/src/components/RadioGroup/RadioGroup.meta.ts +1 -1
  140. package/src/components/RadioGroup/RadioGroup.native.tsx +48 -0
  141. package/src/components/RadioGroup/RadioGroup.stories.tsx +64 -1
  142. package/src/components/RadioGroup/RadioGroup.tsx +8 -83
  143. package/src/components/RadioGroup/group.tsx +85 -0
  144. package/src/components/RadioGroup/styles.css.ts +13 -0
  145. package/src/components/ScrollArea/styles.css.ts +2 -0
  146. package/src/components/Spinner/Spinner.native.tsx +37 -0
  147. package/src/components/Spinner/Spinner.tsx +6 -15
  148. package/src/components/Spinner/Spinner.types.ts +6 -0
  149. package/src/components/Spinner/ring.css.ts +45 -0
  150. package/src/components/Spinner/styles.css.ts +2 -32
  151. package/src/components/StatusIcon/StatusIcon.tsx +13 -2
  152. package/src/components/StatusIcon/styles.css.ts +5 -0
  153. package/src/components/Switch/Switch.native.tsx +45 -0
  154. package/src/components/Switch/Switch.tsx +22 -44
  155. package/src/components/Switch/Switch.types.ts +18 -0
  156. package/src/components/TableOfContents/styles.css.ts +2 -0
  157. package/src/components/Textarea/styles.css.ts +2 -0
  158. package/src/components/ThemeProvider/ThemeProvider.native.tsx +70 -10
  159. package/src/components/Toggle/Toggle.tsx +2 -0
  160. package/src/components/Toggle/styles.css.ts +14 -2
  161. package/src/components/VirtualTable/styles.css.ts +12 -0
  162. package/src/index.native.ts +28 -1
  163. package/src/index.ts +5 -0
  164. package/src/native-guard/globals.d.ts +10 -0
  165. package/src/native-guard/react-dom.d.ts +3 -0
  166. package/src/shared/accessibilityFocus.native.ts +23 -0
  167. package/src/shared/portal/OverlayModal.native.tsx +51 -0
  168. package/src/shared/portal/PortalHost.native.tsx +102 -0
  169. package/src/shared/portal/hostRegistry.ts +76 -0
  170. package/src/shared/portal/themeScope.native.ts +18 -0
  171. package/src/shared/safeArea.native.ts +22 -0
  172. package/src/shared/useOverlayOpenState.ts +128 -0
  173. package/dist/Table-gRiLI243.js.map +0 -1
  174. package/dist/components/ThemeProvider/ThemeProvider.native.d.ts +0 -10
  175. package/dist/components/ThemeProvider/ThemeProvider.native.d.ts.map +0 -1
  176. package/dist/index.native.d.ts +0 -33
  177. package/dist/index.native.d.ts.map +0 -1
  178. package/dist/platform.native.d.ts +0 -2
  179. 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 StyleX styles to a plain flex-column container and
5
- // drops the portal mount (native overlays don't portal to a fixed div).
6
- import {createContext, type ReactNode, useContext} from 'react'
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
- const themeMap: Partial<Record<ThemeName, readonly [typeof lightTheme, typeof lightShadows]>> = {
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; flex:1 lets a root provider fill.
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. (The components that consume it aren't in the native barrel.)
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 overrides = themeMap[theme]
47
- const themeStyles = [overrides?.[0], overrides?.[1], styles.root] as DivStyle
48
- return <html.div style={themeStyles}>{children}</html.div>
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
- padding: `${spacing.sm} ${spacing.md}`,
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
- padding: `${spacing.xs} ${spacing.sm}`,
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',
@@ -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`, audited 2026-06-02). The rest get RN variants in a later pass.
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'
package/src/index.ts CHANGED
@@ -20,6 +20,11 @@ export {Icon, type IconName} from './components/Icon'
20
20
  export {Badge, type BadgeVariant, type BadgeSize} from './components/Badge/Badge'
21
21
  export {Breadcrumb} from './components/Breadcrumb/Breadcrumb'
22
22
  export {Button, type ButtonVariant, type ButtonSize} from './components/Button/Button'
23
+ export {
24
+ FileTrigger,
25
+ type FileTriggerProps,
26
+ type FileTriggerHandle,
27
+ } from './components/FileTrigger/FileTrigger'
23
28
  export type {ControlSize} from './shared/types'
24
29
  export {ButtonGroup, type ButtonGroupProps} from './components/ButtonGroup/ButtonGroup'
25
30
  export {Callout, type CalloutVariant} from './components/Callout/Callout'
@@ -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,3 @@
1
+ // Empty on purpose: tsconfig.native.json maps "react-dom" here, so a react-dom
2
+ // import on the native graph has no exports to resolve and fails to typecheck.
3
+ export {}
@@ -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
+ }