react-native-nitro-modal 0.1.5 → 0.2.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 (57) hide show
  1. package/README.md +17 -7
  2. package/lib/module/NitroModal.js +1 -2
  3. package/lib/module/NitroModal.js.map +1 -1
  4. package/lib/module/NitroModalView.js +4 -3
  5. package/lib/module/NitroModalView.js.map +1 -1
  6. package/lib/module/NitroModalView.native.js +3 -0
  7. package/lib/module/NitroModalView.native.js.map +1 -1
  8. package/lib/module/NitroModalView.web.js +129 -0
  9. package/lib/module/NitroModalView.web.js.map +1 -0
  10. package/lib/module/web/AnimatedValue.js +123 -0
  11. package/lib/module/web/AnimatedValue.js.map +1 -0
  12. package/lib/module/web/ModalConfig.js +26 -0
  13. package/lib/module/web/ModalConfig.js.map +1 -0
  14. package/lib/module/web/ModalController.js +279 -0
  15. package/lib/module/web/ModalController.js.map +1 -0
  16. package/lib/module/web/ModalPresenter.js +245 -0
  17. package/lib/module/web/ModalPresenter.js.map +1 -0
  18. package/lib/module/web/PopupPresenter.js +117 -0
  19. package/lib/module/web/PopupPresenter.js.map +1 -0
  20. package/lib/module/web/SheetPresenter.js +469 -0
  21. package/lib/module/web/SheetPresenter.js.map +1 -0
  22. package/lib/module/web/dom.js +125 -0
  23. package/lib/module/web/dom.js.map +1 -0
  24. package/lib/typescript/src/NitroModal.d.ts +2 -2
  25. package/lib/typescript/src/NitroModal.d.ts.map +1 -1
  26. package/lib/typescript/src/NitroModalView.d.ts +2 -1
  27. package/lib/typescript/src/NitroModalView.d.ts.map +1 -1
  28. package/lib/typescript/src/NitroModalView.native.d.ts +1 -0
  29. package/lib/typescript/src/NitroModalView.native.d.ts.map +1 -1
  30. package/lib/typescript/src/NitroModalView.web.d.ts +6 -0
  31. package/lib/typescript/src/NitroModalView.web.d.ts.map +1 -0
  32. package/lib/typescript/src/web/AnimatedValue.d.ts +48 -0
  33. package/lib/typescript/src/web/AnimatedValue.d.ts.map +1 -0
  34. package/lib/typescript/src/web/ModalConfig.d.ts +24 -0
  35. package/lib/typescript/src/web/ModalConfig.d.ts.map +1 -0
  36. package/lib/typescript/src/web/ModalController.d.ts +65 -0
  37. package/lib/typescript/src/web/ModalController.d.ts.map +1 -0
  38. package/lib/typescript/src/web/ModalPresenter.d.ts +98 -0
  39. package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -0
  40. package/lib/typescript/src/web/PopupPresenter.d.ts +20 -0
  41. package/lib/typescript/src/web/PopupPresenter.d.ts.map +1 -0
  42. package/lib/typescript/src/web/SheetPresenter.d.ts +65 -0
  43. package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -0
  44. package/lib/typescript/src/web/dom.d.ts +40 -0
  45. package/lib/typescript/src/web/dom.d.ts.map +1 -0
  46. package/package.json +3 -2
  47. package/src/NitroModal.tsx +3 -4
  48. package/src/NitroModalView.native.tsx +4 -0
  49. package/src/NitroModalView.tsx +9 -4
  50. package/src/NitroModalView.web.tsx +138 -0
  51. package/src/web/AnimatedValue.ts +189 -0
  52. package/src/web/ModalConfig.ts +57 -0
  53. package/src/web/ModalController.ts +305 -0
  54. package/src/web/ModalPresenter.ts +298 -0
  55. package/src/web/PopupPresenter.ts +129 -0
  56. package/src/web/SheetPresenter.ts +582 -0
  57. package/src/web/dom.ts +154 -0
@@ -0,0 +1,138 @@
1
+ /// <reference lib="dom" />
2
+
3
+ import {
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ type CSSProperties,
8
+ type ReactNode,
9
+ } from 'react';
10
+ import { Modal } from 'react-native';
11
+ import type { ReactNativeView } from 'react-native-nitro-modules';
12
+ import type { NitroModalMethods, NitroModalProps } from './NitroModal.nitro';
13
+ import { ModalController } from './web/ModalController';
14
+
15
+ interface WebNitroModalViewProps extends NitroModalProps {
16
+ hybridRef?: (instance: NitroModalMethods) => void;
17
+ children?: ReactNode;
18
+ }
19
+
20
+ /** The web view takes plain functions; only native host components need them wrapped. */
21
+ export function callback<T>(func: T): T {
22
+ return func;
23
+ }
24
+
25
+ /**
26
+ * Web implementation of the hybrid view. The modal renders through React
27
+ * Native Web's `Modal` (a portal with focus trapping, Escape handling and
28
+ * stacking with other modals) and is laid out and animated by the presenters
29
+ * in `./web`. In place, it renders nothing.
30
+ */
31
+ function WebNitroModalView(props: WebNitroModalViewProps) {
32
+ const { hybridRef, children } = props;
33
+ const [mounted, setMounted] = useState(false);
34
+ const [controller] = useState(() => new ModalController(setMounted));
35
+ const overlay = useRef<HTMLDivElement>(null);
36
+ const backdrop = useRef<HTMLDivElement>(null);
37
+ const surface = useRef<HTMLDivElement>(null);
38
+ const content = useRef<HTMLDivElement>(null);
39
+ const grabber = useRef<HTMLDivElement>(null);
40
+
41
+ useLayoutEffect(() => {
42
+ controller.attach();
43
+ return () => controller.detach();
44
+ }, [controller]);
45
+
46
+ useLayoutEffect(() => {
47
+ hybridRef?.(controller.methods as NitroModalMethods);
48
+ }, [controller, hybridRef]);
49
+
50
+ useLayoutEffect(() => {
51
+ controller.update(props);
52
+ });
53
+
54
+ useLayoutEffect(() => {
55
+ if (!mounted) return undefined;
56
+ controller.elementsDidMount({
57
+ overlay: overlay.current!,
58
+ backdrop: backdrop.current!,
59
+ surface: surface.current!,
60
+ content: content.current!,
61
+ grabber: grabber.current!,
62
+ });
63
+ return () => controller.elementsWillUnmount();
64
+ }, [controller, mounted]);
65
+
66
+ useLayoutEffect(() => {
67
+ if (mounted) {
68
+ controller.contentDidRender();
69
+ }
70
+ });
71
+
72
+ if (!mounted) return null;
73
+
74
+ // Styles set here never change; the presenters write sizes, positions,
75
+ // colors and animated values straight to the elements.
76
+ return (
77
+ <Modal
78
+ visible
79
+ transparent
80
+ animationType="none"
81
+ onRequestClose={controller.handleBackPress}
82
+ >
83
+ {/* Focusable so the modal's focus trap lands here first rather than on the first input. */}
84
+ <div ref={overlay} tabIndex={-1} style={styles.overlay}>
85
+ <div ref={backdrop} aria-hidden style={styles.backdrop} />
86
+ <div ref={surface} style={styles.surface}>
87
+ <div ref={content} style={styles.content}>
88
+ {children}
89
+ </div>
90
+ <div ref={grabber} aria-hidden style={styles.grabber} />
91
+ </div>
92
+ </div>
93
+ </Modal>
94
+ );
95
+ }
96
+
97
+ const styles = {
98
+ overlay: {
99
+ position: 'fixed',
100
+ inset: 0,
101
+ outline: 'none',
102
+ },
103
+ backdrop: {
104
+ position: 'absolute',
105
+ inset: 0,
106
+ touchAction: 'none',
107
+ },
108
+ surface: {
109
+ position: 'absolute',
110
+ left: 0,
111
+ top: 0,
112
+ boxSizing: 'border-box',
113
+ willChange: 'transform',
114
+ },
115
+ // Pinned at the origin like the native content view; the React content is
116
+ // positioned and sized inside it by `NitroModal`.
117
+ content: {
118
+ position: 'absolute',
119
+ left: 0,
120
+ top: 0,
121
+ },
122
+ grabber: {
123
+ position: 'absolute',
124
+ top: 5,
125
+ left: '50%',
126
+ width: 36,
127
+ height: 5,
128
+ marginLeft: -18,
129
+ borderRadius: 2.5,
130
+ backgroundColor: 'rgba(127, 127, 127, 0.4)',
131
+ pointerEvents: 'none',
132
+ },
133
+ } satisfies Record<string, CSSProperties>;
134
+
135
+ export const NitroModalView = WebNitroModalView as unknown as ReactNativeView<
136
+ NitroModalProps,
137
+ NitroModalMethods
138
+ >;
@@ -0,0 +1,189 @@
1
+ /// <reference lib="dom" />
2
+
3
+ /** How an `AnimatedValue` moves to its target. */
4
+ export type Motion =
5
+ | {
6
+ type: 'spring';
7
+ /** Period of the undamped spring, in seconds. Lower is snappier. */
8
+ response: number;
9
+ /** 1 settles without overshoot; lower values bounce. */
10
+ dampingRatio: number;
11
+ }
12
+ | {
13
+ type: 'timing';
14
+ /** In milliseconds. */
15
+ duration: number;
16
+ easing: (progress: number) => number;
17
+ };
18
+
19
+ /** Jumps to the target at once. The completion still arrives on the next frame. */
20
+ export const INSTANT: Motion = {
21
+ type: 'timing',
22
+ duration: 0,
23
+ easing: (t) => t,
24
+ };
25
+
26
+ export interface AnimateOptions {
27
+ /** Starting velocity in units per second. Defaults to the current velocity. */
28
+ velocity?: number;
29
+ /** Finish as soon as a spring reaches the target instead of when it settles. */
30
+ stopAtTarget?: boolean;
31
+ /** Runs once the target is reached, unless another animation or `set` takes over first. */
32
+ onEnd?: () => void;
33
+ }
34
+
35
+ interface Run {
36
+ target: number;
37
+ motion: Motion;
38
+ options: AnimateOptions;
39
+ from: number;
40
+ startTime: number;
41
+ /** Side of the target the value started on, for `stopAtTarget`. */
42
+ side: number;
43
+ }
44
+
45
+ /** Longest frame integrated at once, so a dropped frame can't throw the spring off. */
46
+ const MAX_FRAME_SECONDS = 0.064;
47
+ const SPRING_STEP_SECONDS = 0.004;
48
+
49
+ /**
50
+ * A number animated on `requestAnimationFrame` that reports every frame
51
+ * through `onChange`. A new animation starts from the current value and
52
+ * velocity, so transitions can be turned around mid-flight.
53
+ */
54
+ export class AnimatedValue {
55
+ private current: number;
56
+ private speed = 0;
57
+ private run: Run | null = null;
58
+ private frame: number | null = null;
59
+ private lastTime = 0;
60
+
61
+ /** `precision`: distance from the target at which a spring counts as settled. */
62
+ constructor(
63
+ value: number,
64
+ private readonly precision: number,
65
+ private readonly onChange: (value: number) => void
66
+ ) {
67
+ this.current = value;
68
+ }
69
+
70
+ get value(): number {
71
+ return this.current;
72
+ }
73
+
74
+ /** Jumps to `value`, cancelling any animation. */
75
+ set(value: number) {
76
+ this.stop();
77
+ this.current = value;
78
+ this.speed = 0;
79
+ this.onChange(value);
80
+ }
81
+
82
+ stop() {
83
+ this.run = null;
84
+ if (this.frame != null) {
85
+ cancelAnimationFrame(this.frame);
86
+ this.frame = null;
87
+ }
88
+ }
89
+
90
+ animateTo(target: number, motion: Motion, options: AnimateOptions = {}) {
91
+ if (options.velocity !== undefined) {
92
+ this.speed = options.velocity;
93
+ }
94
+ const now = performance.now();
95
+ this.run = {
96
+ target,
97
+ motion,
98
+ options,
99
+ from: this.current,
100
+ startTime: now,
101
+ side: Math.sign(this.current - target),
102
+ };
103
+ if (motion.type === 'timing' && motion.duration <= 0) {
104
+ this.current = target;
105
+ this.speed = 0;
106
+ this.onChange(target);
107
+ }
108
+ if (this.frame == null) {
109
+ this.lastTime = now;
110
+ this.frame = requestAnimationFrame(this.step);
111
+ }
112
+ }
113
+
114
+ private step = (time: number) => {
115
+ this.frame = null;
116
+ const run = this.run;
117
+ if (!run) return;
118
+ const dt = Math.min(
119
+ Math.max((time - this.lastTime) / 1000, 0),
120
+ MAX_FRAME_SECONDS
121
+ );
122
+ this.lastTime = time;
123
+
124
+ const done =
125
+ run.motion.type === 'timing'
126
+ ? this.stepTiming(run, run.motion, time, dt)
127
+ : this.stepSpring(run, run.motion, dt);
128
+ if (done) {
129
+ this.current = run.target;
130
+ this.speed = 0;
131
+ this.run = null;
132
+ }
133
+ this.onChange(this.current);
134
+ if (done) {
135
+ run.options.onEnd?.();
136
+ } else {
137
+ this.frame = requestAnimationFrame(this.step);
138
+ }
139
+ };
140
+
141
+ private stepTiming(
142
+ run: Run,
143
+ motion: Extract<Motion, { type: 'timing' }>,
144
+ time: number,
145
+ dt: number
146
+ ): boolean {
147
+ const progress =
148
+ motion.duration > 0
149
+ ? Math.min(Math.max((time - run.startTime) / motion.duration, 0), 1)
150
+ : 1;
151
+ const next = run.from + (run.target - run.from) * motion.easing(progress);
152
+ if (dt > 0) {
153
+ this.speed = (next - this.current) / dt;
154
+ }
155
+ this.current = next;
156
+ return progress >= 1;
157
+ }
158
+
159
+ private stepSpring(
160
+ run: Run,
161
+ motion: Extract<Motion, { type: 'spring' }>,
162
+ dt: number
163
+ ): boolean {
164
+ const omega = (2 * Math.PI) / motion.response;
165
+ const stiffness = omega * omega;
166
+ const damping = 2 * motion.dampingRatio * omega;
167
+ let offset = this.current - run.target;
168
+ let velocity = this.speed;
169
+ const steps = Math.ceil(dt / SPRING_STEP_SECONDS);
170
+ for (let i = 0; i < steps; i++) {
171
+ const h = dt / steps;
172
+ velocity += (-stiffness * offset - damping * velocity) * h;
173
+ offset += velocity * h;
174
+ }
175
+ this.current = run.target + offset;
176
+ this.speed = velocity;
177
+
178
+ if (
179
+ run.options.stopAtTarget &&
180
+ (run.side === 0 || Math.sign(offset) !== run.side)
181
+ ) {
182
+ return true;
183
+ }
184
+ return (
185
+ Math.abs(offset) < this.precision &&
186
+ Math.abs(velocity) < this.precision * 20
187
+ );
188
+ }
189
+ }
@@ -0,0 +1,57 @@
1
+ import type {
2
+ KeyboardBehavior,
3
+ ModalMode,
4
+ NitroModalProps,
5
+ PopupAnimation,
6
+ SheetDetent,
7
+ } from '../NitroModal.nitro';
8
+
9
+ /** Snapshot of the props that shape a presentation. */
10
+ export interface ModalConfig {
11
+ mode: ModalMode;
12
+ detents: SheetDetent[];
13
+ initialDetentIndex: number;
14
+ /** 0xAARRGGBB, from `processColor`. */
15
+ backdropColor: number;
16
+ backdropOpacity: number;
17
+ backdropBlurRadius: number;
18
+ dismissOnBackdropPress: boolean;
19
+ dismissOnSwipe: boolean;
20
+ dismissOnBackButton: boolean;
21
+ grabberVisible: boolean;
22
+ /** `null` uses the default. */
23
+ cornerRadius: number | null;
24
+ /** 0xAARRGGBB; `null` uses the system background. */
25
+ contentBackgroundColor: number | null;
26
+ keyboardBehavior: KeyboardBehavior;
27
+ popupAnimation: PopupAnimation;
28
+ }
29
+
30
+ export function makeConfig(props: NitroModalProps): ModalConfig {
31
+ return {
32
+ mode: props.mode,
33
+ detents: props.detents,
34
+ initialDetentIndex: Number.isFinite(props.initialDetentIndex)
35
+ ? Math.trunc(props.initialDetentIndex)
36
+ : 0,
37
+ backdropColor: props.backdropColor,
38
+ backdropOpacity: props.backdropOpacity,
39
+ backdropBlurRadius: props.backdropBlurRadius,
40
+ dismissOnBackdropPress: props.dismissOnBackdropPress,
41
+ dismissOnSwipe: props.dismissOnSwipe,
42
+ dismissOnBackButton: props.dismissOnBackButton,
43
+ grabberVisible: props.grabberVisible,
44
+ cornerRadius: props.cornerRadius >= 0 ? props.cornerRadius : null,
45
+ contentBackgroundColor: props.contentBackgroundColor ?? null,
46
+ keyboardBehavior: props.keyboardBehavior,
47
+ popupAnimation: props.popupAnimation,
48
+ };
49
+ }
50
+
51
+ export function isSameConfig(a: ModalConfig, b: ModalConfig): boolean {
52
+ return (Object.keys(a) as (keyof ModalConfig)[]).every((key) =>
53
+ key === 'detents'
54
+ ? a.detents.join() === b.detents.join()
55
+ : a[key] === b[key]
56
+ );
57
+ }
@@ -0,0 +1,305 @@
1
+ /// <reference lib="dom" />
2
+
3
+ import type {
4
+ DismissReason,
5
+ ModalContentArea,
6
+ NitroModalMethods,
7
+ NitroModalProps,
8
+ } from '../NitroModal.nitro';
9
+ import { canUseDOM, estimateViewport, readSafeAreaInsets } from './dom';
10
+ import { isSameConfig, makeConfig, type ModalConfig } from './ModalConfig';
11
+ import type {
12
+ ModalElements,
13
+ ModalPresenter,
14
+ PresenterListener,
15
+ } from './ModalPresenter';
16
+ import { popupContentArea, PopupPresenter } from './PopupPresenter';
17
+ import { sheetContentArea, SheetPresenter } from './SheetPresenter';
18
+
19
+ type Phase = 'idle' | 'presenting' | 'presented' | 'dismissing';
20
+
21
+ /**
22
+ * The web counterpart of the native `HybridNitroModal`: receives the host
23
+ * view's props and drives one presenter per presentation.
24
+ *
25
+ * Presentation is a small state machine so that rapid `isOpen` toggles, user
26
+ * dismissals and unmounts always settle on a consistent state, and every
27
+ * accepted present request ends with exactly one `onDismiss`. Transitions are
28
+ * interrupted rather than queued: closing mid-enter turns the enter around,
29
+ * and opening mid-exit turns the exit around (that session carries on, so it
30
+ * gets no `onDismiss` and at most one `onPresent`).
31
+ */
32
+ export class ModalController implements PresenterListener {
33
+ readonly methods: Pick<
34
+ NitroModalMethods,
35
+ 'present' | 'dismiss' | 'snapToDetent'
36
+ > = {
37
+ present: () => this.requestPresent(),
38
+ dismiss: () => this.requestDismiss('programmatic'),
39
+ snapToDetent: (index: number) => {
40
+ if (Number.isFinite(index)) {
41
+ this.presenter?.snapToDetent(Math.trunc(index));
42
+ }
43
+ },
44
+ };
45
+
46
+ private props: NitroModalProps | null = null;
47
+ private config: ModalConfig | null = null;
48
+ private isOpen = false;
49
+ private phase: Phase = 'idle';
50
+ /** What React (or an imperative call) asked for. */
51
+ private wantsOpen = false;
52
+ /** A present request was accepted and its `onDismiss` is still owed. */
53
+ private sessionActive = false;
54
+ /** `onPresent` was sent for the current session. */
55
+ private presentSent = false;
56
+ private dismissReason: DismissReason = 'programmatic';
57
+ private reconcileScheduled = false;
58
+ private isAttached = false;
59
+ private presenter: ModalPresenter | null = null;
60
+ private elements: ModalElements | null = null;
61
+ private lastContentArea: ModalContentArea | null = null;
62
+
63
+ /** `setMounted` renders (or removes) the modal's elements. */
64
+ constructor(private readonly setMounted: (mounted: boolean) => void) {}
65
+
66
+ // Host view lifecycle
67
+
68
+ attach() {
69
+ this.isAttached = true;
70
+ window.addEventListener('resize', this.handleWindowResize);
71
+ }
72
+
73
+ /** The host view unmounted. Also runs for React's strict-mode remount, so `attach` must work again. */
74
+ detach() {
75
+ this.isAttached = false;
76
+ window.removeEventListener('resize', this.handleWindowResize);
77
+ this.presenter?.destroy();
78
+ this.presenter = null;
79
+ this.phase = 'idle';
80
+ this.wantsOpen = false;
81
+ this.sessionActive = false;
82
+ this.presentSent = false;
83
+ this.dismissReason = 'programmatic';
84
+ // Applied again from the props on the next `update`.
85
+ this.isOpen = false;
86
+ this.config = null;
87
+ this.lastContentArea = null;
88
+ this.setMounted(false);
89
+ }
90
+
91
+ /** The host view rendered with new props. */
92
+ update(props: NitroModalProps) {
93
+ this.props = props;
94
+ const next = makeConfig(props);
95
+ if (!this.config || !isSameConfig(next, this.config)) {
96
+ this.config = next;
97
+ if (this.presenter) {
98
+ this.presenter.update(next);
99
+ } else {
100
+ this.reportEstimatedContentArea();
101
+ }
102
+ }
103
+ if (props.isOpen !== this.isOpen) {
104
+ this.isOpen = props.isOpen;
105
+ if (props.isOpen) {
106
+ this.requestPresent();
107
+ } else {
108
+ this.requestDismiss('programmatic');
109
+ }
110
+ }
111
+ }
112
+
113
+ elementsDidMount(elements: ModalElements) {
114
+ this.elements = elements;
115
+ if (this.presenter && !this.presenter.isShown) {
116
+ this.presenter.show(elements);
117
+ }
118
+ }
119
+
120
+ elementsWillUnmount() {
121
+ this.elements = null;
122
+ }
123
+
124
+ contentDidRender() {
125
+ this.presenter?.contentDidRender();
126
+ }
127
+
128
+ /** Escape key, routed by React Native Web's `Modal` to the topmost modal. */
129
+ handleBackPress = () => {
130
+ // Already on its way out: the press is spent.
131
+ if (!this.isVisible) return;
132
+ this.props?.onBackButtonPress?.();
133
+ if (!this.config?.dismissOnBackButton) return;
134
+ this.requestDismiss('backButton');
135
+ this.reconcile();
136
+ };
137
+
138
+ // State machine
139
+
140
+ private get isVisible(): boolean {
141
+ return this.phase === 'presenting' || this.phase === 'presented';
142
+ }
143
+
144
+ private requestPresent() {
145
+ if (!this.isAttached) return;
146
+ this.wantsOpen = true;
147
+ this.sessionActive = true;
148
+ this.scheduleReconcile();
149
+ }
150
+
151
+ private requestDismiss(reason: DismissReason) {
152
+ if (this.wantsOpen) {
153
+ this.dismissReason = reason;
154
+ }
155
+ this.wantsOpen = false;
156
+ this.scheduleReconcile();
157
+ }
158
+
159
+ /** Coalesces the requests made in one task, e.g. an open and a close in the same handler. */
160
+ private scheduleReconcile() {
161
+ if (this.reconcileScheduled) return;
162
+ this.reconcileScheduled = true;
163
+ queueMicrotask(() => {
164
+ this.reconcileScheduled = false;
165
+ if (this.isAttached) {
166
+ this.reconcile();
167
+ }
168
+ });
169
+ }
170
+
171
+ private reconcile() {
172
+ switch (this.phase) {
173
+ case 'presenting':
174
+ case 'presented':
175
+ if (!this.wantsOpen) {
176
+ this.startDismiss();
177
+ }
178
+ break;
179
+ case 'dismissing':
180
+ if (this.wantsOpen && this.presenter?.cancelDismiss()) {
181
+ this.phase = 'presenting';
182
+ this.dismissReason = 'programmatic';
183
+ }
184
+ break;
185
+ case 'idle':
186
+ if (this.wantsOpen) {
187
+ this.startPresent();
188
+ } else if (this.sessionActive) {
189
+ this.endSession(); // Cancelled before anything appeared.
190
+ }
191
+ break;
192
+ }
193
+ }
194
+
195
+ private startPresent() {
196
+ const { config } = this;
197
+ if (!canUseDOM || !config) return;
198
+ const presenter =
199
+ config.mode === 'popup'
200
+ ? new PopupPresenter(config, this)
201
+ : new SheetPresenter(config, this);
202
+ this.presenter = presenter;
203
+ this.phase = 'presenting';
204
+ this.setMounted(true);
205
+ // Still mounted from a presentation that just ended.
206
+ if (this.elements) {
207
+ presenter.show(this.elements);
208
+ }
209
+ }
210
+
211
+ private startDismiss() {
212
+ const { presenter } = this;
213
+ if (!presenter?.isShown) {
214
+ // Its elements never mounted.
215
+ presenter?.destroy();
216
+ this.finishDismiss();
217
+ return;
218
+ }
219
+ this.phase = 'dismissing';
220
+ presenter.dismiss();
221
+ }
222
+
223
+ private finishDismiss() {
224
+ this.phase = 'idle';
225
+ this.presenter = null;
226
+ this.setMounted(false);
227
+ this.endSession();
228
+ this.reconcile();
229
+ }
230
+
231
+ private endSession() {
232
+ if (!this.sessionActive) return;
233
+ const reason = this.dismissReason;
234
+ this.dismissReason = 'programmatic';
235
+ this.presentSent = false;
236
+ // A present requested while we were dismissing starts a new session.
237
+ this.sessionActive = this.wantsOpen;
238
+ if (this.isAttached) {
239
+ this.props?.onDismiss?.(reason);
240
+ }
241
+ }
242
+
243
+ // PresenterListener
244
+
245
+ onPresented() {
246
+ if (this.phase !== 'presenting') return;
247
+ this.phase = 'presented';
248
+ if (!this.presentSent) {
249
+ this.presentSent = true;
250
+ this.props?.onPresent?.();
251
+ }
252
+ this.reconcile();
253
+ }
254
+
255
+ onDismissed() {
256
+ this.finishDismiss();
257
+ }
258
+
259
+ onBackdropPress() {
260
+ this.props?.onBackdropPress?.();
261
+ if (!this.config?.dismissOnBackdropPress || !this.isVisible) return;
262
+ this.requestDismiss('backdrop');
263
+ this.reconcile();
264
+ }
265
+
266
+ onSwipeDismiss() {
267
+ if (!this.config?.dismissOnSwipe || !this.isVisible) return;
268
+ this.requestDismiss('swipe');
269
+ this.reconcile();
270
+ }
271
+
272
+ onDetentChange(index: number) {
273
+ this.props?.onDetentChange?.(index);
274
+ }
275
+
276
+ onContentAreaChange(width: number, height: number) {
277
+ const area = { width: Math.floor(width), height: Math.floor(height) };
278
+ const last = this.lastContentArea;
279
+ if (area.width <= 0 || area.height <= 0) return;
280
+ if (last?.width === area.width && last.height === area.height) return;
281
+ this.lastContentArea = area;
282
+ this.props?.onContentAreaChange?.(area);
283
+ }
284
+
285
+ // Helpers
286
+
287
+ /** Lets React size the content before the first presentation. */
288
+ private reportEstimatedContentArea() {
289
+ const { config } = this;
290
+ if (!canUseDOM || !this.isAttached || !config) return;
291
+ const viewport = estimateViewport();
292
+ const insets = readSafeAreaInsets();
293
+ const area =
294
+ config.mode === 'popup'
295
+ ? popupContentArea(viewport, insets)
296
+ : sheetContentArea(config.detents, viewport, insets);
297
+ this.onContentAreaChange(area.width, area.height);
298
+ }
299
+
300
+ private handleWindowResize = () => {
301
+ if (!this.presenter) {
302
+ this.reportEstimatedContentArea();
303
+ }
304
+ };
305
+ }