@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
@@ -1,6 +1,5 @@
1
1
  import {type ReactNode, useState, useCallback} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
- import {isNative} from '../../platform'
4
3
  import {styles} from './styles.css'
5
4
  import {visuallyHidden} from '../../styles/visually-hidden.css'
6
5
 
@@ -30,7 +29,7 @@ export function Checkbox({
30
29
  const [internalChecked, setInternalChecked] = useState(defaultChecked)
31
30
  const isChecked = isControlled ? controlledChecked : internalChecked
32
31
 
33
- // Web: the real <input type=checkbox> drives toggling, a11y and form submit.
32
+ // The real <input type=checkbox> drives toggling, a11y and form submit.
34
33
  const handleChange = useCallback(
35
34
  (e: React.ChangeEvent<HTMLInputElement>) => {
36
35
  if (!isControlled) {
@@ -41,54 +40,6 @@ export function Checkbox({
41
40
  [isControlled, onChange],
42
41
  )
43
42
 
44
- // Native: RSD only makes <button> pressable and has no <input type=checkbox>,
45
- // so the whole row is a button and we synthesize the change payload that
46
- // consumers read (e.target.checked).
47
- const handlePress = useCallback(() => {
48
- if (disabled) return
49
- const next = !isChecked
50
- if (!isControlled) {
51
- setInternalChecked(next)
52
- }
53
- onChange?.({
54
- target: {checked: next, name, value},
55
- } as unknown as React.ChangeEvent<HTMLInputElement>)
56
- }, [disabled, isChecked, isControlled, onChange, name, value])
57
-
58
- if (isNative) {
59
- return (
60
- <html.button
61
- type="button"
62
- onClick={handlePress}
63
- disabled={disabled}
64
- role="checkbox"
65
- aria-checked={isChecked}
66
- aria-label={!children ? ariaLabel : undefined}
67
- style={[
68
- styles.root,
69
- styles.nativeFlex,
70
- styles.nativeButton,
71
- disabled && styles.rootDisabled,
72
- ]}
73
- >
74
- <html.div
75
- style={[
76
- styles.box,
77
- styles.nativeFlex,
78
- isChecked ? styles.boxChecked : styles.boxUnchecked,
79
- ]}
80
- >
81
- {/* CSS-border checkmark (rotated right+bottom borders) — no SVG, no
82
- react-native-svg dep, so nothing is added to the web bundle. */}
83
- <html.div
84
- style={[styles.checkmark, isChecked ? styles.checkmarkVisible : styles.checkmarkHidden]}
85
- />
86
- </html.div>
87
- {children && <html.span style={styles.labelText}>{children}</html.span>}
88
- </html.button>
89
- )
90
- }
91
-
92
43
  return (
93
44
  <html.label style={[styles.root, disabled && styles.rootDisabled]}>
94
45
  <html.input
@@ -74,6 +74,9 @@ export function ConfirmDialog({
74
74
  value={typed}
75
75
  onChange={(e) => setTyped(e.target.value)}
76
76
  autoFocus
77
+ // The phrase must match exactly: no capitalising or correcting it.
78
+ autoCapitalize="none"
79
+ spellCheck={false}
77
80
  aria-label="confirmation phrase"
78
81
  />
79
82
  </Field.Root>
@@ -10,7 +10,8 @@ import {
10
10
  useRef,
11
11
  } from 'react'
12
12
  import {html} from 'react-strict-dom'
13
- import {DURATION_MS, type DurationToken} from '@duro-app/tokens/keys'
13
+ import type {DurationToken} from '@duro-app/tokens/keys'
14
+ import {useOverlayOpenState} from '../../shared/useOverlayOpenState'
14
15
  import {styles} from './styles.css'
15
16
 
16
17
  // --- Types ---
@@ -71,34 +72,19 @@ function Root({
71
72
  onOpenChange,
72
73
  closeAnimationDuration = 'base',
73
74
  }: RootProps) {
74
- const isControlled = controlledOpen !== undefined
75
- const [internalOpen, setInternalOpen] = useState(defaultOpen)
76
- const [present, setPresent] = useState(defaultOpen)
77
- const [closing, setClosing] = useState(false)
78
- const closingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
75
+ const {present, closing, requestOpen, requestClose} = useOverlayOpenState({
76
+ open: controlledOpen,
77
+ defaultOpen,
78
+ onOpenChange,
79
+ exitDuration: closeAnimationDuration,
80
+ closeNotice: 'at-once',
81
+ })
79
82
  const panelRef = useRef<HTMLDivElement>(null)
80
83
  const previousFocusRef = useRef<Element | null>(null)
81
84
 
82
85
  const titleId = useId()
83
86
  const [labelId, setLabelId] = useState<string | undefined>(undefined)
84
87
 
85
- const isOpen = isControlled ? controlledOpen : internalOpen
86
-
87
- // Sync presence with open state
88
- useEffect(() => {
89
- if (isOpen && !present) {
90
- setPresent(true)
91
- setClosing(false)
92
- } else if (!isOpen && present && !closing) {
93
- setClosing(true)
94
- closingTimerRef.current = setTimeout(() => {
95
- setClosing(false)
96
- setPresent(false)
97
- closingTimerRef.current = null
98
- }, DURATION_MS[closeAnimationDuration])
99
- }
100
- }, [isOpen, present, closing, closeAnimationDuration])
101
-
102
88
  // Focus management: move focus on open, restore on close
103
89
  useEffect(() => {
104
90
  if (present && !closing) {
@@ -131,28 +117,6 @@ function Root({
131
117
  return () => document.removeEventListener('keydown', onKeyDown)
132
118
  }, [present, closing]) // eslint-disable-line react-hooks/exhaustive-deps
133
119
 
134
- // Clean up timer on unmount
135
- useEffect(() => {
136
- return () => {
137
- if (closingTimerRef.current) clearTimeout(closingTimerRef.current)
138
- }
139
- }, [])
140
-
141
- const requestOpen = useCallback(() => {
142
- if (closingTimerRef.current) {
143
- clearTimeout(closingTimerRef.current)
144
- closingTimerRef.current = null
145
- setClosing(false)
146
- }
147
- if (!isControlled) setInternalOpen(true)
148
- onOpenChange?.(true)
149
- }, [isControlled, onOpenChange])
150
-
151
- const requestClose = useCallback(() => {
152
- if (!isControlled) setInternalOpen(false)
153
- onOpenChange?.(false)
154
- }, [isControlled, onOpenChange])
155
-
156
120
  const registerTitle = useCallback((id: string) => {
157
121
  setLabelId(id)
158
122
  }, [])
@@ -0,0 +1,296 @@
1
+ import {
2
+ Children,
3
+ cloneElement,
4
+ createContext,
5
+ isValidElement,
6
+ type ReactElement,
7
+ type ReactNode,
8
+ type Ref,
9
+ type RefObject,
10
+ useCallback,
11
+ useContext,
12
+ useEffect,
13
+ useId,
14
+ useRef,
15
+ useState,
16
+ } from 'react'
17
+ import {
18
+ KeyboardAvoidingView,
19
+ Platform,
20
+ ScrollView,
21
+ StyleSheet,
22
+ useWindowDimensions,
23
+ View,
24
+ } from 'react-native'
25
+ import {html} from 'react-strict-dom'
26
+ import type {DurationToken} from '@duro-app/tokens/keys'
27
+ import {mergeRefs} from '../../shared/mergeRefs'
28
+ import {ModalContext} from '../../shared/ModalContext'
29
+ import {OverlayModal} from '../../shared/portal/OverlayModal'
30
+ import {focusForAccessibility} from '../../shared/accessibilityFocus'
31
+ import {useOverlayOpenState} from '../../shared/useOverlayOpenState'
32
+ import {ControlContextBoundary} from '../Toolbar/ControlContextBoundary'
33
+ import {Icon} from '../Icon/Icon'
34
+ import {nativeStyles} from './native.css'
35
+ import {partStyles} from './parts.css'
36
+ import {
37
+ Description,
38
+ DialogContext,
39
+ Footer,
40
+ Header,
41
+ Title,
42
+ useDialog,
43
+ type DialogSize,
44
+ } from './parts'
45
+
46
+ export type {DialogSize}
47
+
48
+ const exitDurationMap = {
49
+ instant: nativeStyles.exitInstant,
50
+ minimal: nativeStyles.exitMinimal,
51
+ quick: nativeStyles.exitQuick,
52
+ fast: nativeStyles.exitFast,
53
+ brisk: nativeStyles.exitBrisk,
54
+ base: nativeStyles.exitBase,
55
+ slow: nativeStyles.exitSlow,
56
+ } as const satisfies Record<DurationToken, unknown>
57
+
58
+ /** Native-only wiring: where accessibility focus goes on open and returns on close. */
59
+ interface NativeDialogContextValue {
60
+ triggerRef: RefObject<unknown>
61
+ panelRef: RefObject<View | null>
62
+ closeOnEscape: boolean
63
+ }
64
+
65
+ const NativeDialogContext = createContext<NativeDialogContextValue | null>(null)
66
+
67
+ function useNativeDialog() {
68
+ const ctx = useContext(NativeDialogContext)
69
+ if (!ctx) throw new Error('Dialog compound components must be used within Dialog.Root')
70
+ return ctx
71
+ }
72
+
73
+ interface RootProps {
74
+ children: ReactNode
75
+ open?: boolean
76
+ defaultOpen?: boolean
77
+ onOpenChange?: (open: boolean) => void
78
+ /** Whether pressing the backdrop closes the dialog. Default: true */
79
+ dismissable?: boolean
80
+ /** Whether Android's back button closes the dialog (Escape on the web). Default: true */
81
+ closeOnEscape?: boolean
82
+ /** Web only: on native, screen-reader focus moves to the dialog itself. */
83
+ initialFocus?: RefObject<HTMLElement | null>
84
+ closeAnimationDuration?: DurationToken
85
+ }
86
+
87
+ /**
88
+ * Native Dialog (plan N0, Slice A): the same compound API, rendered in an
89
+ * OverlayModal. The backdrop press and Android's back button close it; the
90
+ * screen reader's focus moves into the panel on open and back to the trigger
91
+ * on close.
92
+ */
93
+ function Root({
94
+ children,
95
+ open: controlledOpen,
96
+ defaultOpen = false,
97
+ onOpenChange,
98
+ dismissable = true,
99
+ closeOnEscape = true,
100
+ closeAnimationDuration = 'fast',
101
+ }: RootProps) {
102
+ const {present, closing, requestOpen, requestClose} = useOverlayOpenState({
103
+ open: controlledOpen,
104
+ defaultOpen,
105
+ onOpenChange,
106
+ exitDuration: closeAnimationDuration,
107
+ closeNotice: 'after-exit',
108
+ })
109
+ const titleId = useId()
110
+ const descriptionId = useId()
111
+ const popupRef = useRef<HTMLDivElement>(null)
112
+ const triggerRef = useRef<unknown>(null)
113
+ const panelRef = useRef<View>(null)
114
+ const registerClose = useCallback(() => () => {}, [])
115
+
116
+ // Back to the trigger once the dialog is gone.
117
+ const wasPresent = useRef(present)
118
+ useEffect(() => {
119
+ if (wasPresent.current && !present) focusForAccessibility(triggerRef.current, 'Dialog')
120
+ wasPresent.current = present
121
+ }, [present])
122
+
123
+ return (
124
+ <DialogContext.Provider
125
+ value={{
126
+ open: present,
127
+ closing,
128
+ closeDuration: closeAnimationDuration,
129
+ dismissable,
130
+ requestOpen,
131
+ requestClose,
132
+ titleId,
133
+ descriptionId,
134
+ popupRef,
135
+ registerClose,
136
+ }}
137
+ >
138
+ <NativeDialogContext.Provider value={{triggerRef, panelRef, closeOnEscape}}>
139
+ {children}
140
+ </NativeDialogContext.Provider>
141
+ </DialogContext.Provider>
142
+ )
143
+ }
144
+
145
+ interface PressableChildProps {
146
+ onClick?: () => void
147
+ ref?: Ref<unknown>
148
+ }
149
+
150
+ /**
151
+ * Opens the dialog from its one child (a Button). Native presses do not
152
+ * bubble to a wrapper as web clicks do, so the child gets the handler, and
153
+ * its ref is where focus returns on close.
154
+ */
155
+ function Trigger({children}: {children: ReactNode}) {
156
+ const {requestOpen} = useDialog()
157
+ const {triggerRef} = useNativeDialog()
158
+ const child = Children.only(children)
159
+ if (!isValidElement<PressableChildProps>(child)) return null
160
+ const own = child.props.onClick
161
+ return cloneElement(child as ReactElement<PressableChildProps>, {
162
+ ref: mergeRefs(child.props.ref, triggerRef),
163
+ onClick: () => {
164
+ own?.()
165
+ requestOpen()
166
+ },
167
+ })
168
+ }
169
+
170
+ interface PortalProps {
171
+ children: ReactNode
172
+ size?: DialogSize
173
+ }
174
+
175
+ function Portal({children, size = 'md'}: PortalProps) {
176
+ const {open, closing, closeDuration, dismissable, requestClose, titleId} = useDialog()
177
+ const {panelRef, closeOnEscape} = useNativeDialog()
178
+ // First frame hidden, then shown: the enter transition has a start state.
179
+ const [entered, setEntered] = useState(false)
180
+ useEffect(() => {
181
+ if (!open) {
182
+ setEntered(false)
183
+ return
184
+ }
185
+ const frame = requestAnimationFrame(() => setEntered(true))
186
+ return () => cancelAnimationFrame(frame)
187
+ }, [open])
188
+
189
+ // 85% of the window, as the web caps the panel at 85vh.
190
+ const maxPanelHeight = useWindowDimensions().height * 0.85
191
+ const shown = entered && !closing
192
+ const visibility = shown ? nativeStyles.shown : nativeStyles.hidden
193
+ return (
194
+ <OverlayModal
195
+ visible={open}
196
+ onRequestClose={() => {
197
+ if (closeOnEscape) requestClose()
198
+ }}
199
+ onShow={() => focusForAccessibility(panelRef.current, 'Dialog')}
200
+ >
201
+ <html.div
202
+ aria-hidden
203
+ onClick={dismissable ? requestClose : undefined}
204
+ style={[
205
+ nativeStyles.fill,
206
+ nativeStyles.backdrop,
207
+ shown ? nativeStyles.shown : nativeStyles.hidden,
208
+ closing && exitDurationMap[closeDuration],
209
+ ]}
210
+ />
211
+ <KeyboardAvoidingView
212
+ behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
213
+ style={StyleSheet.absoluteFill}
214
+ pointerEvents="box-none"
215
+ >
216
+ <View style={localStyles.viewport} pointerEvents="box-none">
217
+ <html.div style={nativeStyles.inset}>
218
+ <View ref={panelRef} accessibilityViewIsModal style={localStyles.panelHost}>
219
+ <html.div
220
+ role="dialog"
221
+ aria-modal
222
+ aria-labelledby={titleId}
223
+ style={[
224
+ nativeStyles.popup,
225
+ nativeStyles.maxHeight(maxPanelHeight),
226
+ partStyles[size],
227
+ visibility,
228
+ closing && exitDurationMap[closeDuration],
229
+ ]}
230
+ >
231
+ <ModalContext.Provider value={true}>
232
+ <ControlContextBoundary>{children}</ControlContextBoundary>
233
+ </ModalContext.Provider>
234
+ </html.div>
235
+ </View>
236
+ </html.div>
237
+ </View>
238
+ </KeyboardAvoidingView>
239
+ </OverlayModal>
240
+ )
241
+ }
242
+
243
+ // Layout-only RN views: the centring viewport lets presses outside the panel
244
+ // through to the backdrop (`box-none`, which RSD styles cannot express), and
245
+ // the panel host spans the width so the panel's size cap applies.
246
+ const localStyles = StyleSheet.create({
247
+ viewport: {flex: 1, alignItems: 'center', justifyContent: 'center'},
248
+ panelHost: {width: '100%', alignItems: 'center'},
249
+ body: {flexGrow: 0, flexShrink: 1},
250
+ })
251
+
252
+ /** Native Body: a ScrollView, since a native div does not scroll its overflow. */
253
+ function Body({children}: {children: ReactNode}) {
254
+ return (
255
+ <ScrollView style={localStyles.body}>
256
+ <html.div style={nativeStyles.bodyContent}>{children}</html.div>
257
+ </ScrollView>
258
+ )
259
+ }
260
+
261
+ interface CloseProps {
262
+ children?: ReactNode
263
+ 'aria-label'?: string
264
+ }
265
+
266
+ function Close({children, 'aria-label': ariaLabel = 'Close'}: CloseProps) {
267
+ const {requestClose} = useDialog()
268
+ if (children) {
269
+ const child = Children.only(children)
270
+ if (!isValidElement<PressableChildProps>(child)) return null
271
+ const own = child.props.onClick
272
+ return cloneElement(child as ReactElement<PressableChildProps>, {
273
+ onClick: () => {
274
+ own?.()
275
+ requestClose()
276
+ },
277
+ })
278
+ }
279
+ return (
280
+ <html.button onClick={requestClose} aria-label={ariaLabel} style={partStyles.closeButton}>
281
+ <Icon name="x" size="sm" color="textMuted" />
282
+ </html.button>
283
+ )
284
+ }
285
+
286
+ export const Dialog = {
287
+ Root,
288
+ Trigger,
289
+ Portal,
290
+ Header,
291
+ Title,
292
+ Description,
293
+ Body,
294
+ Footer,
295
+ Close,
296
+ }
@@ -13,6 +13,7 @@ import {Select} from '../Select/Select'
13
13
  import {spacing} from '@duro-app/tokens/tokens/spacing.css'
14
14
  import {colors} from '@duro-app/tokens/tokens/colors.css'
15
15
  import {borders} from '@duro-app/tokens/tokens/borders.css'
16
+ import {DURATION_MS} from '@duro-app/tokens/keys'
16
17
 
17
18
  const meta: Meta = {
18
19
  title: 'Components/Dialog',
@@ -560,3 +561,73 @@ export const CloseOnEscapeOff: Story = {
560
561
  await expect(screen.getByRole('dialog', {name: 'Required step'})).toBeInTheDocument()
561
562
  },
562
563
  }
564
+
565
+ // --- Close semantics (useOverlayOpenState) ---
566
+
567
+ function DoubleCloseDemo() {
568
+ const [closes, setCloses] = useState(0)
569
+ return (
570
+ <Stack gap="md">
571
+ <Text>Closes reported: {closes}</Text>
572
+ <Dialog.Root
573
+ defaultOpen
574
+ onOpenChange={(next) => {
575
+ if (!next) setCloses((count) => count + 1)
576
+ }}
577
+ >
578
+ <Dialog.Portal size="sm">
579
+ <Dialog.Header>
580
+ <Dialog.Title>Close twice</Dialog.Title>
581
+ </Dialog.Header>
582
+ <Dialog.Footer>
583
+ <Dialog.Close>
584
+ <Button>Done</Button>
585
+ </Dialog.Close>
586
+ </Dialog.Footer>
587
+ </Dialog.Portal>
588
+ </Dialog.Root>
589
+ </Stack>
590
+ )
591
+ }
592
+
593
+ /** A second close while the exit animation plays reports the close once. */
594
+ export const DoubleCloseReportsOnce: Story = {
595
+ render: () => <DoubleCloseDemo />,
596
+ play: async ({canvasElement}) => {
597
+ const doc = within(canvasElement.ownerDocument.body)
598
+ const done = await doc.findByRole('button', {name: 'Done'})
599
+ await userEvent.click(done)
600
+ await userEvent.click(done)
601
+ await waitFor(() => expect(doc.queryByRole('dialog')).toBeNull())
602
+ // Past every exit timer a second close could have started.
603
+ await new Promise((resolve) => setTimeout(resolve, DURATION_MS.fast * 2))
604
+ await expect(within(canvasElement).getByText('Closes reported: 1')).toBeInTheDocument()
605
+ },
606
+ }
607
+
608
+ function ControlledCloseDemo() {
609
+ const [open, setOpen] = useState(true)
610
+ return (
611
+ <Dialog.Root open={open} onOpenChange={setOpen} closeAnimationDuration="slow">
612
+ <Dialog.Portal size="sm">
613
+ <Dialog.Header>
614
+ <Dialog.Title>Closed by its owner</Dialog.Title>
615
+ </Dialog.Header>
616
+ <Dialog.Footer>
617
+ <Button onClick={() => setOpen(false)}>Save</Button>
618
+ </Dialog.Footer>
619
+ </Dialog.Portal>
620
+ </Dialog.Root>
621
+ )
622
+ }
623
+
624
+ /** A controlled `open` that turns false plays the exit animation before unmounting. */
625
+ export const ControlledCloseAnimates: Story = {
626
+ render: () => <ControlledCloseDemo />,
627
+ play: async ({canvasElement}) => {
628
+ const doc = within(canvasElement.ownerDocument.body)
629
+ await userEvent.click(await doc.findByRole('button', {name: 'Save'}))
630
+ await expect(doc.getByRole('dialog')).toBeInTheDocument()
631
+ await waitFor(() => expect(doc.queryByRole('dialog')).toBeNull())
632
+ },
633
+ }