react-native-nitro-modal 0.1.5 → 0.3.0-rc

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 (99) hide show
  1. package/README.md +65 -11
  2. package/android/src/main/java/com/margelo/nitro/nitromodal/BottomSheetPresenter.kt +335 -34
  3. package/android/src/main/java/com/margelo/nitro/nitromodal/HybridNitroModal.kt +63 -22
  4. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalConfig.kt +31 -1
  5. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalContentRoot.kt +48 -5
  6. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalPresenter.kt +63 -4
  7. package/android/src/main/java/com/margelo/nitro/nitromodal/NitroModalHostView.kt +68 -8
  8. package/ios/HybridNitroModal.swift +111 -20
  9. package/ios/ModalConfig.swift +44 -2
  10. package/ios/NitroModalHostView.swift +73 -8
  11. package/ios/SheetView.swift +575 -0
  12. package/ios/SheetViewController.swift +19 -450
  13. package/lib/module/NitroModal.js +39 -7
  14. package/lib/module/NitroModal.js.map +1 -1
  15. package/lib/module/NitroModalView.js +4 -3
  16. package/lib/module/NitroModalView.js.map +1 -1
  17. package/lib/module/NitroModalView.native.js +3 -0
  18. package/lib/module/NitroModalView.native.js.map +1 -1
  19. package/lib/module/NitroModalView.web.js +159 -0
  20. package/lib/module/NitroModalView.web.js.map +1 -0
  21. package/lib/module/index.js.map +1 -1
  22. package/lib/module/web/AnimatedValue.js +126 -0
  23. package/lib/module/web/AnimatedValue.js.map +1 -0
  24. package/lib/module/web/ModalConfig.js +30 -0
  25. package/lib/module/web/ModalConfig.js.map +1 -0
  26. package/lib/module/web/ModalController.js +306 -0
  27. package/lib/module/web/ModalController.js.map +1 -0
  28. package/lib/module/web/ModalPresenter.js +280 -0
  29. package/lib/module/web/ModalPresenter.js.map +1 -0
  30. package/lib/module/web/PopupPresenter.js +117 -0
  31. package/lib/module/web/PopupPresenter.js.map +1 -0
  32. package/lib/module/web/SheetPresenter.js +494 -0
  33. package/lib/module/web/SheetPresenter.js.map +1 -0
  34. package/lib/module/web/dom.js +138 -0
  35. package/lib/module/web/dom.js.map +1 -0
  36. package/lib/typescript/src/NitroModal.d.ts +35 -4
  37. package/lib/typescript/src/NitroModal.d.ts.map +1 -1
  38. package/lib/typescript/src/NitroModal.nitro.d.ts +29 -2
  39. package/lib/typescript/src/NitroModal.nitro.d.ts.map +1 -1
  40. package/lib/typescript/src/NitroModalView.d.ts +2 -1
  41. package/lib/typescript/src/NitroModalView.d.ts.map +1 -1
  42. package/lib/typescript/src/NitroModalView.native.d.ts +1 -0
  43. package/lib/typescript/src/NitroModalView.native.d.ts.map +1 -1
  44. package/lib/typescript/src/NitroModalView.web.d.ts +6 -0
  45. package/lib/typescript/src/NitroModalView.web.d.ts.map +1 -0
  46. package/lib/typescript/src/index.d.ts +2 -2
  47. package/lib/typescript/src/index.d.ts.map +1 -1
  48. package/lib/typescript/src/web/AnimatedValue.d.ts +49 -0
  49. package/lib/typescript/src/web/AnimatedValue.d.ts.map +1 -0
  50. package/lib/typescript/src/web/ModalConfig.d.ts +30 -0
  51. package/lib/typescript/src/web/ModalConfig.d.ts.map +1 -0
  52. package/lib/typescript/src/web/ModalController.d.ts +70 -0
  53. package/lib/typescript/src/web/ModalController.d.ts.map +1 -0
  54. package/lib/typescript/src/web/ModalPresenter.d.ts +107 -0
  55. package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -0
  56. package/lib/typescript/src/web/PopupPresenter.d.ts +20 -0
  57. package/lib/typescript/src/web/PopupPresenter.d.ts.map +1 -0
  58. package/lib/typescript/src/web/SheetPresenter.d.ts +65 -0
  59. package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -0
  60. package/lib/typescript/src/web/dom.d.ts +42 -0
  61. package/lib/typescript/src/web/dom.d.ts.map +1 -0
  62. package/nitrogen/generated/android/c++/JHybridNitroModalSpec.cpp +60 -6
  63. package/nitrogen/generated/android/c++/JHybridNitroModalSpec.hpp +10 -2
  64. package/nitrogen/generated/android/c++/JNamedSheetDetent.hpp +64 -0
  65. package/nitrogen/generated/android/c++/JSheetDetent.cpp +26 -0
  66. package/nitrogen/generated/android/c++/JSheetDetent.hpp +42 -37
  67. package/nitrogen/generated/android/c++/views/JHybridNitroModalStateUpdater.cpp +20 -0
  68. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/HybridNitroModalSpec.kt +40 -0
  69. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/NamedSheetDetent.kt +25 -0
  70. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/SheetDetent.kt +47 -10
  71. package/nitrogen/generated/android/nitromodal+autolinking.cmake +1 -0
  72. package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Bridge.hpp +53 -8
  73. package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Umbrella.hpp +4 -3
  74. package/nitrogen/generated/ios/c++/HybridNitroModalSpecSwift.hpp +32 -5
  75. package/nitrogen/generated/ios/c++/views/HybridNitroModalComponent.mm +24 -0
  76. package/nitrogen/generated/ios/swift/HybridNitroModalSpec.swift +4 -0
  77. package/nitrogen/generated/ios/swift/HybridNitroModalSpec_cxx.swift +110 -5
  78. package/nitrogen/generated/ios/swift/NamedSheetDetent.swift +48 -0
  79. package/nitrogen/generated/ios/swift/SheetDetent.swift +15 -33
  80. package/nitrogen/generated/shared/c++/HybridNitroModalSpec.cpp +8 -0
  81. package/nitrogen/generated/shared/c++/HybridNitroModalSpec.hpp +14 -5
  82. package/nitrogen/generated/shared/c++/{SheetDetent.hpp → NamedSheetDetent.hpp} +17 -17
  83. package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.cpp +9 -1
  84. package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.hpp +15 -2
  85. package/nitrogen/generated/shared/json/NitroModalConfig.json +4 -0
  86. package/package.json +3 -2
  87. package/src/NitroModal.nitro.ts +30 -2
  88. package/src/NitroModal.tsx +71 -8
  89. package/src/NitroModalView.native.tsx +4 -0
  90. package/src/NitroModalView.tsx +9 -4
  91. package/src/NitroModalView.web.tsx +180 -0
  92. package/src/index.tsx +2 -0
  93. package/src/web/AnimatedValue.ts +193 -0
  94. package/src/web/ModalConfig.ts +67 -0
  95. package/src/web/ModalController.ts +338 -0
  96. package/src/web/ModalPresenter.ts +333 -0
  97. package/src/web/PopupPresenter.ts +129 -0
  98. package/src/web/SheetPresenter.ts +647 -0
  99. package/src/web/dom.ts +171 -0
@@ -0,0 +1,338 @@
1
+ /// <reference lib="dom" />
2
+
3
+ import type {
4
+ DismissReason,
5
+ ModalContentArea,
6
+ NitroModalMethods,
7
+ NitroModalProps,
8
+ } from '../NitroModal.nitro';
9
+ import {
10
+ canUseDOM,
11
+ estimateViewport,
12
+ inlineInsets,
13
+ readSafeAreaInsets,
14
+ } from './dom';
15
+ import { isSameConfig, makeConfig, type ModalConfig } from './ModalConfig';
16
+ import type {
17
+ ModalElements,
18
+ ModalPresenter,
19
+ PresenterListener,
20
+ } from './ModalPresenter';
21
+ import { popupContentArea, PopupPresenter } from './PopupPresenter';
22
+ import { sheetContentArea, SheetPresenter } from './SheetPresenter';
23
+
24
+ type Phase = 'idle' | 'presenting' | 'presented' | 'dismissing';
25
+
26
+ /**
27
+ * The web counterpart of the native `HybridNitroModal`: receives the host
28
+ * view's props and drives one presenter per presentation.
29
+ *
30
+ * Presentation is a small state machine so that rapid `isOpen` toggles, user
31
+ * dismissals and unmounts always settle on a consistent state, and every
32
+ * accepted present request ends with exactly one `onDismiss`. Transitions are
33
+ * interrupted rather than queued: closing mid-enter turns the enter around,
34
+ * and opening mid-exit turns the exit around (that session carries on, so it
35
+ * gets no `onDismiss` and at most one `onPresent`).
36
+ */
37
+ export class ModalController implements PresenterListener {
38
+ readonly methods: Pick<
39
+ NitroModalMethods,
40
+ 'present' | 'dismiss' | 'snapToDetent'
41
+ > = {
42
+ present: () => this.requestPresent(),
43
+ dismiss: () => this.requestDismiss('programmatic'),
44
+ snapToDetent: (index: number) => {
45
+ if (Number.isFinite(index)) {
46
+ this.presenter?.snapToDetent(Math.trunc(index));
47
+ }
48
+ },
49
+ };
50
+
51
+ private props: NitroModalProps | null = null;
52
+ private config: ModalConfig | null = null;
53
+ private isOpen = false;
54
+ private phase: Phase = 'idle';
55
+ /** What React (or an imperative call) asked for. */
56
+ private wantsOpen = false;
57
+ /** A present request was accepted and its `onDismiss` is still owed. */
58
+ private sessionActive = false;
59
+ /** `onPresent` was sent for the current session. */
60
+ private presentSent = false;
61
+ private dismissReason: DismissReason = 'programmatic';
62
+ private reconcileScheduled = false;
63
+ private isAttached = false;
64
+ private presenter: ModalPresenter | null = null;
65
+ private elements: ModalElements | null = null;
66
+ /** Inline: the element the sheet fills. */
67
+ private host: HTMLElement | null = null;
68
+ private lastContentArea: ModalContentArea | null = null;
69
+
70
+ /** `setMounted` renders (or removes) the modal's elements. */
71
+ constructor(private readonly setMounted: (mounted: boolean) => void) {}
72
+
73
+ // Host view lifecycle
74
+
75
+ attach() {
76
+ this.isAttached = true;
77
+ window.addEventListener('resize', this.handleWindowResize);
78
+ }
79
+
80
+ /** The host view unmounted. Also runs for React's strict-mode remount, so `attach` must work again. */
81
+ detach() {
82
+ this.isAttached = false;
83
+ window.removeEventListener('resize', this.handleWindowResize);
84
+ this.presenter?.destroy();
85
+ this.presenter = null;
86
+ this.phase = 'idle';
87
+ this.wantsOpen = false;
88
+ this.sessionActive = false;
89
+ this.presentSent = false;
90
+ this.dismissReason = 'programmatic';
91
+ // Applied again from the props on the next `update`.
92
+ this.isOpen = false;
93
+ this.config = null;
94
+ this.lastContentArea = null;
95
+ this.setMounted(false);
96
+ }
97
+
98
+ /** The host view rendered with new props. */
99
+ update(props: NitroModalProps) {
100
+ this.props = props;
101
+ const next = makeConfig(props);
102
+ if (!this.config || !isSameConfig(next, this.config)) {
103
+ this.config = next;
104
+ if (this.presenter) {
105
+ this.presenter.update(next);
106
+ } else {
107
+ this.reportEstimatedContentArea();
108
+ }
109
+ }
110
+ if (props.isOpen !== this.isOpen) {
111
+ this.isOpen = props.isOpen;
112
+ if (props.isOpen) {
113
+ this.requestPresent();
114
+ } else {
115
+ this.requestDismiss('programmatic');
116
+ }
117
+ }
118
+ }
119
+
120
+ /** Inline: the element the sheet fills, rendered in place. */
121
+ hostDidChange(host: HTMLElement | null) {
122
+ if (host === this.host) return;
123
+ this.host = host;
124
+ if (!this.presenter) {
125
+ this.reportEstimatedContentArea();
126
+ }
127
+ }
128
+
129
+ elementsDidMount(elements: ModalElements) {
130
+ this.elements = elements;
131
+ if (this.presenter && !this.presenter.isShown) {
132
+ this.presenter.show(elements);
133
+ }
134
+ }
135
+
136
+ elementsWillUnmount() {
137
+ this.elements = null;
138
+ }
139
+
140
+ contentDidRender() {
141
+ this.presenter?.contentDidRender();
142
+ }
143
+
144
+ /** Escape key, routed by React Native Web's `Modal` to the topmost modal. */
145
+ handleBackPress = () => {
146
+ // Already on its way out: the press is spent.
147
+ if (!this.isVisible) return;
148
+ this.props?.onBackButtonPress?.();
149
+ if (!this.config?.dismissOnBackButton) return;
150
+ this.requestDismiss('backButton');
151
+ this.reconcile();
152
+ };
153
+
154
+ // State machine
155
+
156
+ private get isVisible(): boolean {
157
+ return this.phase === 'presenting' || this.phase === 'presented';
158
+ }
159
+
160
+ private requestPresent() {
161
+ if (!this.isAttached) return;
162
+ this.wantsOpen = true;
163
+ this.sessionActive = true;
164
+ this.scheduleReconcile();
165
+ }
166
+
167
+ private requestDismiss(reason: DismissReason) {
168
+ if (this.wantsOpen) {
169
+ this.dismissReason = reason;
170
+ }
171
+ this.wantsOpen = false;
172
+ this.scheduleReconcile();
173
+ }
174
+
175
+ /** Coalesces the requests made in one task, e.g. an open and a close in the same handler. */
176
+ private scheduleReconcile() {
177
+ if (this.reconcileScheduled) return;
178
+ this.reconcileScheduled = true;
179
+ queueMicrotask(() => {
180
+ this.reconcileScheduled = false;
181
+ if (this.isAttached) {
182
+ this.reconcile();
183
+ }
184
+ });
185
+ }
186
+
187
+ private reconcile() {
188
+ switch (this.phase) {
189
+ case 'presenting':
190
+ case 'presented':
191
+ if (!this.wantsOpen) {
192
+ this.startDismiss();
193
+ }
194
+ break;
195
+ case 'dismissing':
196
+ if (this.wantsOpen && this.presenter?.cancelDismiss()) {
197
+ this.phase = 'presenting';
198
+ this.dismissReason = 'programmatic';
199
+ }
200
+ break;
201
+ case 'idle':
202
+ if (this.wantsOpen) {
203
+ this.startPresent();
204
+ } else if (this.sessionActive) {
205
+ this.endSession(); // Cancelled before anything appeared.
206
+ }
207
+ break;
208
+ }
209
+ }
210
+
211
+ private startPresent() {
212
+ const { config } = this;
213
+ if (!canUseDOM || !config) return;
214
+ const presenter =
215
+ config.mode === 'popup'
216
+ ? new PopupPresenter(config, this)
217
+ : new SheetPresenter(config, this);
218
+ this.presenter = presenter;
219
+ this.phase = 'presenting';
220
+ this.setMounted(true);
221
+ // Still mounted from a presentation that just ended.
222
+ if (this.elements) {
223
+ presenter.show(this.elements);
224
+ }
225
+ }
226
+
227
+ private startDismiss() {
228
+ const { presenter } = this;
229
+ if (!presenter?.isShown) {
230
+ // Its elements never mounted.
231
+ presenter?.destroy();
232
+ this.finishDismiss();
233
+ return;
234
+ }
235
+ this.phase = 'dismissing';
236
+ presenter.dismiss();
237
+ }
238
+
239
+ private finishDismiss() {
240
+ this.phase = 'idle';
241
+ this.presenter = null;
242
+ this.setMounted(false);
243
+ this.endSession();
244
+ this.reconcile();
245
+ }
246
+
247
+ private endSession() {
248
+ if (!this.sessionActive) return;
249
+ const reason = this.dismissReason;
250
+ this.dismissReason = 'programmatic';
251
+ this.presentSent = false;
252
+ // A present requested while we were dismissing starts a new session.
253
+ this.sessionActive = this.wantsOpen;
254
+ if (this.isAttached) {
255
+ this.props?.onDismiss?.(reason);
256
+ }
257
+ }
258
+
259
+ // PresenterListener
260
+
261
+ onPresented() {
262
+ if (this.phase !== 'presenting') return;
263
+ this.phase = 'presented';
264
+ if (!this.presentSent) {
265
+ this.presentSent = true;
266
+ this.props?.onPresent?.();
267
+ }
268
+ this.reconcile();
269
+ }
270
+
271
+ onDismissed() {
272
+ this.finishDismiss();
273
+ }
274
+
275
+ onBackdropPress() {
276
+ this.props?.onBackdropPress?.();
277
+ if (!this.config?.dismissOnBackdropPress || !this.isVisible) return;
278
+ this.requestDismiss('backdrop');
279
+ this.reconcile();
280
+ }
281
+
282
+ onSwipeDismiss() {
283
+ if (!this.config?.dismissOnSwipe || !this.isVisible) return;
284
+ this.requestDismiss('swipe');
285
+ this.reconcile();
286
+ }
287
+
288
+ onDetentChange(index: number) {
289
+ this.props?.onDetentChange?.(index);
290
+ }
291
+
292
+ onPullToRefresh() {
293
+ this.props?.onPullToRefresh?.();
294
+ }
295
+
296
+ onContentAreaChange(width: number, height: number) {
297
+ const area = { width: Math.floor(width), height: Math.floor(height) };
298
+ const last = this.lastContentArea;
299
+ if (area.width <= 0 || area.height <= 0) return;
300
+ if (last?.width === area.width && last.height === area.height) return;
301
+ this.lastContentArea = area;
302
+ this.props?.onContentAreaChange?.(area);
303
+ }
304
+
305
+ // Helpers
306
+
307
+ /** Lets React size the content before the first presentation. */
308
+ private reportEstimatedContentArea() {
309
+ const { config } = this;
310
+ if (!canUseDOM || !this.isAttached || !config) return;
311
+ if (config.isInline) {
312
+ const { host } = this;
313
+ if (!host || host.clientWidth === 0 || host.clientHeight === 0) return;
314
+ const viewport = { width: host.clientWidth, height: host.clientHeight };
315
+ const area = sheetContentArea(
316
+ config.detents,
317
+ viewport,
318
+ inlineInsets(host),
319
+ true
320
+ );
321
+ this.onContentAreaChange(area.width, area.height);
322
+ return;
323
+ }
324
+ const viewport = estimateViewport();
325
+ const insets = readSafeAreaInsets();
326
+ const area =
327
+ config.mode === 'popup'
328
+ ? popupContentArea(viewport, insets)
329
+ : sheetContentArea(config.detents, viewport, insets, false);
330
+ this.onContentAreaChange(area.width, area.height);
331
+ }
332
+
333
+ private handleWindowResize = () => {
334
+ if (!this.presenter) {
335
+ this.reportEstimatedContentArea();
336
+ }
337
+ };
338
+ }
@@ -0,0 +1,333 @@
1
+ /// <reference lib="dom" />
2
+
3
+ import {
4
+ canScrollWithin,
5
+ clipOverflow,
6
+ inlineInsets,
7
+ readKeyboardHeight,
8
+ readSafeAreaInsets,
9
+ toCSSColor,
10
+ ZERO_INSETS,
11
+ type Insets,
12
+ type Size,
13
+ } from './dom';
14
+ import type { ModalConfig } from './ModalConfig';
15
+
16
+ /** DOM rendered by the web host view for one presentation. */
17
+ export interface ModalElements {
18
+ /** Full-window layer holding the backdrop and the surface. */
19
+ overlay: HTMLElement;
20
+ backdrop: HTMLElement;
21
+ /** The sheet or the popup card. */
22
+ surface: HTMLElement;
23
+ /** Hosts the React content. */
24
+ content: HTMLElement;
25
+ grabber: HTMLElement;
26
+ }
27
+
28
+ export interface PresenterListener {
29
+ /** An enter transition settled: the first one, or one that reversed an exit. */
30
+ onPresented(): void;
31
+ /** The exit transition finished. Called at most once per presenter. */
32
+ onDismissed(): void;
33
+ onBackdropPress(): void;
34
+ /** The user swiped the sheet down far enough to dismiss it. */
35
+ onSwipeDismiss(): void;
36
+ onDetentChange(index: number): void;
37
+ onContentAreaChange(width: number, height: number): void;
38
+ /** The user pulled the sheet past its lowest detent and let go. */
39
+ onPullToRefresh(): void;
40
+ }
41
+
42
+ /**
43
+ * Owns one presentation from show to dismiss. Subclasses lay out the surface
44
+ * and drive the enter/exit animations; this class tracks the viewport, the
45
+ * on-screen keyboard and the content size, and handles the backdrop and
46
+ * teardown.
47
+ *
48
+ * Transitions are interruptible: dismissing mid-enter turns the enter around,
49
+ * and `cancelDismiss` turns an exit back around. Once an exit starts the modal
50
+ * stops taking input.
51
+ *
52
+ * Inline, the elements fill the host view in place of a full-window layer:
53
+ * only the surface takes input, and the keyboard is left to the page.
54
+ */
55
+ export abstract class ModalPresenter {
56
+ protected elements: ModalElements | null = null;
57
+ protected viewport: Size = { width: 0, height: 0 };
58
+ protected insets: Insets = ZERO_INSETS;
59
+ /** Part of the viewport covered by the on-screen keyboard, from the bottom. */
60
+ protected keyboardHeight = 0;
61
+ /** Measured size of the React content. */
62
+ protected contentSize: Size = { width: 0, height: 0 };
63
+ protected entering = false;
64
+ protected isDismissing = false;
65
+ private finished = false;
66
+ private observedContent: HTMLElement | null = null;
67
+ private resizeObserver: ResizeObserver | null = null;
68
+ /** Inline: follows the host's size, which can change without a window resize. */
69
+ private hostObserver: ResizeObserver | null = null;
70
+
71
+ constructor(
72
+ protected config: ModalConfig,
73
+ protected readonly listener: PresenterListener
74
+ ) {}
75
+
76
+ get isShown(): boolean {
77
+ return this.elements != null;
78
+ }
79
+
80
+ /** Takes over the rendered elements and starts the enter transition. */
81
+ show(elements: ModalElements) {
82
+ if (this.elements || this.finished) return;
83
+ this.elements = elements;
84
+ // The elements can outlive a previous presentation (an immediate re-present).
85
+ this.setInteractive(true);
86
+ elements.surface.style.overflow = clipOverflow();
87
+ elements.surface.style.opacity = '';
88
+ elements.surface.style.transform = '';
89
+ elements.backdrop.style.display = this.config.isInline ? 'none' : '';
90
+
91
+ window.addEventListener('resize', this.handleResize);
92
+ if (this.config.isInline) {
93
+ if (typeof ResizeObserver !== 'undefined') {
94
+ this.hostObserver = new ResizeObserver(this.handleResize);
95
+ this.hostObserver.observe(elements.overlay);
96
+ }
97
+ } else {
98
+ window.visualViewport?.addEventListener('resize', this.handleKeyboard);
99
+ window.visualViewport?.addEventListener('scroll', this.handleKeyboard);
100
+ elements.backdrop.addEventListener('click', this.handleBackdropClick);
101
+ elements.overlay.addEventListener('wheel', this.handleWheel, {
102
+ passive: false,
103
+ });
104
+ }
105
+ if (typeof ResizeObserver !== 'undefined') {
106
+ this.resizeObserver = new ResizeObserver(this.handleContentResize);
107
+ }
108
+
109
+ this.measureViewport();
110
+ this.observeContent();
111
+ this.applyBackdrop();
112
+ this.didShow();
113
+ this.entering = true;
114
+ this.animateIn();
115
+ }
116
+
117
+ update(config: ModalConfig) {
118
+ const previous = this.config;
119
+ this.config = config;
120
+ if (!this.elements) return;
121
+ this.applyBackdrop();
122
+ this.configDidChange(previous);
123
+ }
124
+
125
+ /** The React content re-rendered; its root element may have changed. */
126
+ contentDidRender() {
127
+ if (this.elements && this.observeContent()) {
128
+ this.contentSizeDidChange();
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Animated dismissal, also from mid-enter. `onDismissed` follows when it
134
+ * completes, unless `cancelDismiss` turns it around first.
135
+ */
136
+ dismiss() {
137
+ const elements = this.elements;
138
+ if (!elements || this.isDismissing || this.finished) return;
139
+ this.isDismissing = true;
140
+ this.entering = false;
141
+ this.setInteractive(false);
142
+ // Also hides the on-screen keyboard.
143
+ const focused = document.activeElement;
144
+ if (focused instanceof HTMLElement && elements.overlay.contains(focused)) {
145
+ focused.blur();
146
+ }
147
+ this.animateOut(() => this.finish());
148
+ }
149
+
150
+ /**
151
+ * Reverses a running exit from wherever it is. `onPresented` follows once
152
+ * it settles. Returns false when there is no exit to reverse.
153
+ */
154
+ cancelDismiss(): boolean {
155
+ if (!this.elements || !this.isDismissing || this.finished) return false;
156
+ this.isDismissing = false;
157
+ this.setInteractive(true);
158
+ this.entering = true;
159
+ this.animateIn();
160
+ return true;
161
+ }
162
+
163
+ /** Tears down without animating or notifying the listener. */
164
+ destroy() {
165
+ if (this.finished) return;
166
+ this.finished = true;
167
+ this.teardown();
168
+ }
169
+
170
+ snapToDetent(_index: number) {}
171
+
172
+ /** The elements are in place; lay out the hidden state. */
173
+ protected abstract didShow(): void;
174
+
175
+ /** Animates from the current state to presented, then calls `notifyPresented`. */
176
+ protected abstract animateIn(): void;
177
+
178
+ /** Animates from the current state (possibly mid-enter) to gone, then calls `onEnd`. */
179
+ protected abstract animateOut(onEnd: () => void): void;
180
+
181
+ protected abstract configDidChange(previous: ModalConfig): void;
182
+
183
+ /** The viewport size, safe area or keyboard height changed. */
184
+ protected abstract viewportDidChange(): void;
185
+
186
+ protected abstract contentSizeDidChange(): void;
187
+
188
+ protected abstract stopAnimations(): void;
189
+
190
+ /** For subclasses: the running enter animation settled. */
191
+ protected notifyPresented() {
192
+ if (!this.entering || this.isDismissing || this.finished) return;
193
+ this.entering = false;
194
+ this.listener.onPresented();
195
+ }
196
+
197
+ protected reportContentArea(width: number, height: number) {
198
+ if (width > 0 && height > 0) {
199
+ this.listener.onContentAreaChange(width, height);
200
+ }
201
+ }
202
+
203
+ protected teardown() {
204
+ this.stopAnimations();
205
+ window.removeEventListener('resize', this.handleResize);
206
+ window.visualViewport?.removeEventListener('resize', this.handleKeyboard);
207
+ window.visualViewport?.removeEventListener('scroll', this.handleKeyboard);
208
+ this.elements?.backdrop.removeEventListener(
209
+ 'click',
210
+ this.handleBackdropClick
211
+ );
212
+ this.elements?.overlay.removeEventListener('wheel', this.handleWheel);
213
+ this.resizeObserver?.disconnect();
214
+ this.resizeObserver = null;
215
+ this.hostObserver?.disconnect();
216
+ this.hostObserver = null;
217
+ this.observedContent = null;
218
+ }
219
+
220
+ private finish() {
221
+ if (this.finished) return;
222
+ this.finished = true;
223
+ this.teardown();
224
+ this.listener.onDismissed();
225
+ }
226
+
227
+ private applyBackdrop() {
228
+ const { backdrop, surface } = this.elements!;
229
+ const { config } = this;
230
+ backdrop.style.backgroundColor = toCSSColor(
231
+ config.backdropColor,
232
+ config.backdropOpacity
233
+ );
234
+ const blur =
235
+ config.backdropBlurRadius > 0
236
+ ? `blur(${config.backdropBlurRadius}px)`
237
+ : '';
238
+ backdrop.style.setProperty('backdrop-filter', blur);
239
+ backdrop.style.setProperty('-webkit-backdrop-filter', blur);
240
+ // `Canvas` follows the page's `color-scheme`, like a system background.
241
+ surface.style.backgroundColor =
242
+ config.contentBackgroundColor != null
243
+ ? toCSSColor(config.contentBackgroundColor)
244
+ : 'Canvas';
245
+ }
246
+
247
+ /** Inline, only the surface takes input; the rest reaches the page. */
248
+ private setInteractive(interactive: boolean) {
249
+ const { overlay, surface } = this.elements!;
250
+ if (this.config.isInline) {
251
+ overlay.style.pointerEvents = 'none';
252
+ surface.style.pointerEvents = interactive ? 'auto' : 'none';
253
+ } else {
254
+ overlay.style.pointerEvents = interactive ? '' : 'none';
255
+ }
256
+ }
257
+
258
+ private measureViewport() {
259
+ const { overlay } = this.elements!;
260
+ this.viewport = {
261
+ width: overlay.clientWidth,
262
+ height: overlay.clientHeight,
263
+ };
264
+ if (this.config.isInline) {
265
+ this.insets = inlineInsets(overlay);
266
+ this.keyboardHeight = 0;
267
+ } else {
268
+ this.insets = readSafeAreaInsets();
269
+ this.keyboardHeight = readKeyboardHeight(this.viewport.height);
270
+ }
271
+ }
272
+
273
+ /** Re-targets the observer at the content's root element. Returns whether its size changed. */
274
+ private observeContent(): boolean {
275
+ const root = this.elements?.content.firstElementChild;
276
+ const element = root instanceof HTMLElement ? root : null;
277
+ if (element !== this.observedContent) {
278
+ if (this.observedContent) {
279
+ this.resizeObserver?.unobserve(this.observedContent);
280
+ }
281
+ this.observedContent = element;
282
+ if (element) {
283
+ this.resizeObserver?.observe(element);
284
+ }
285
+ }
286
+ return this.measureContent();
287
+ }
288
+
289
+ private measureContent(): boolean {
290
+ // Layout sizes, unaffected by the transition's transforms.
291
+ const width = this.observedContent?.offsetWidth ?? 0;
292
+ const height = this.observedContent?.offsetHeight ?? 0;
293
+ if (width === this.contentSize.width && height === this.contentSize.height)
294
+ return false;
295
+ this.contentSize = { width, height };
296
+ return true;
297
+ }
298
+
299
+ private handleContentResize = () => {
300
+ if (this.measureContent()) {
301
+ this.contentSizeDidChange();
302
+ }
303
+ };
304
+
305
+ private handleResize = () => {
306
+ if (!this.elements) return;
307
+ this.measureViewport();
308
+ this.viewportDidChange();
309
+ };
310
+
311
+ private handleKeyboard = () => {
312
+ if (this.config.isInline) return;
313
+ const height = readKeyboardHeight(this.viewport.height);
314
+ if (height === this.keyboardHeight) return;
315
+ this.keyboardHeight = height;
316
+ this.viewportDidChange();
317
+ };
318
+
319
+ private handleBackdropClick = () => {
320
+ if (!this.isDismissing && !this.finished) {
321
+ this.listener.onBackdropPress();
322
+ }
323
+ };
324
+
325
+ /** Keeps the page behind the modal from scrolling; scrollable content still scrolls. */
326
+ private handleWheel = (event: WheelEvent) => {
327
+ if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY))
328
+ return;
329
+ if (!canScrollWithin(event.target, this.elements!.overlay, event.deltaY)) {
330
+ event.preventDefault();
331
+ }
332
+ };
333
+ }