react-native-nitro-modal 0.1.4 → 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 (61) hide show
  1. package/README.md +19 -7
  2. package/android/src/main/java/com/margelo/nitro/nitromodal/BottomSheetPresenter.kt +72 -21
  3. package/android/src/main/java/com/margelo/nitro/nitromodal/HybridNitroModal.kt +17 -3
  4. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalPresenter.kt +121 -12
  5. package/android/src/main/java/com/margelo/nitro/nitromodal/PopupPresenter.kt +92 -31
  6. package/lib/module/NitroModal.js +1 -2
  7. package/lib/module/NitroModal.js.map +1 -1
  8. package/lib/module/NitroModalView.js +4 -3
  9. package/lib/module/NitroModalView.js.map +1 -1
  10. package/lib/module/NitroModalView.native.js +3 -0
  11. package/lib/module/NitroModalView.native.js.map +1 -1
  12. package/lib/module/NitroModalView.web.js +129 -0
  13. package/lib/module/NitroModalView.web.js.map +1 -0
  14. package/lib/module/web/AnimatedValue.js +123 -0
  15. package/lib/module/web/AnimatedValue.js.map +1 -0
  16. package/lib/module/web/ModalConfig.js +26 -0
  17. package/lib/module/web/ModalConfig.js.map +1 -0
  18. package/lib/module/web/ModalController.js +279 -0
  19. package/lib/module/web/ModalController.js.map +1 -0
  20. package/lib/module/web/ModalPresenter.js +245 -0
  21. package/lib/module/web/ModalPresenter.js.map +1 -0
  22. package/lib/module/web/PopupPresenter.js +117 -0
  23. package/lib/module/web/PopupPresenter.js.map +1 -0
  24. package/lib/module/web/SheetPresenter.js +469 -0
  25. package/lib/module/web/SheetPresenter.js.map +1 -0
  26. package/lib/module/web/dom.js +125 -0
  27. package/lib/module/web/dom.js.map +1 -0
  28. package/lib/typescript/src/NitroModal.d.ts +2 -2
  29. package/lib/typescript/src/NitroModal.d.ts.map +1 -1
  30. package/lib/typescript/src/NitroModalView.d.ts +2 -1
  31. package/lib/typescript/src/NitroModalView.d.ts.map +1 -1
  32. package/lib/typescript/src/NitroModalView.native.d.ts +1 -0
  33. package/lib/typescript/src/NitroModalView.native.d.ts.map +1 -1
  34. package/lib/typescript/src/NitroModalView.web.d.ts +6 -0
  35. package/lib/typescript/src/NitroModalView.web.d.ts.map +1 -0
  36. package/lib/typescript/src/web/AnimatedValue.d.ts +48 -0
  37. package/lib/typescript/src/web/AnimatedValue.d.ts.map +1 -0
  38. package/lib/typescript/src/web/ModalConfig.d.ts +24 -0
  39. package/lib/typescript/src/web/ModalConfig.d.ts.map +1 -0
  40. package/lib/typescript/src/web/ModalController.d.ts +65 -0
  41. package/lib/typescript/src/web/ModalController.d.ts.map +1 -0
  42. package/lib/typescript/src/web/ModalPresenter.d.ts +98 -0
  43. package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -0
  44. package/lib/typescript/src/web/PopupPresenter.d.ts +20 -0
  45. package/lib/typescript/src/web/PopupPresenter.d.ts.map +1 -0
  46. package/lib/typescript/src/web/SheetPresenter.d.ts +65 -0
  47. package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -0
  48. package/lib/typescript/src/web/dom.d.ts +40 -0
  49. package/lib/typescript/src/web/dom.d.ts.map +1 -0
  50. package/package.json +3 -2
  51. package/src/NitroModal.tsx +3 -4
  52. package/src/NitroModalView.native.tsx +4 -0
  53. package/src/NitroModalView.tsx +9 -4
  54. package/src/NitroModalView.web.tsx +138 -0
  55. package/src/web/AnimatedValue.ts +189 -0
  56. package/src/web/ModalConfig.ts +57 -0
  57. package/src/web/ModalController.ts +305 -0
  58. package/src/web/ModalPresenter.ts +298 -0
  59. package/src/web/PopupPresenter.ts +129 -0
  60. package/src/web/SheetPresenter.ts +582 -0
  61. package/src/web/dom.ts +154 -0
@@ -0,0 +1,298 @@
1
+ /// <reference lib="dom" />
2
+
3
+ import {
4
+ canScrollWithin,
5
+ clipOverflow,
6
+ readKeyboardHeight,
7
+ readSafeAreaInsets,
8
+ toCSSColor,
9
+ ZERO_INSETS,
10
+ type Insets,
11
+ type Size,
12
+ } from './dom';
13
+ import type { ModalConfig } from './ModalConfig';
14
+
15
+ /** DOM rendered by the web host view for one presentation. */
16
+ export interface ModalElements {
17
+ /** Full-window layer holding the backdrop and the surface. */
18
+ overlay: HTMLElement;
19
+ backdrop: HTMLElement;
20
+ /** The sheet or the popup card. */
21
+ surface: HTMLElement;
22
+ /** Hosts the React content. */
23
+ content: HTMLElement;
24
+ grabber: HTMLElement;
25
+ }
26
+
27
+ export interface PresenterListener {
28
+ /** An enter transition settled: the first one, or one that reversed an exit. */
29
+ onPresented(): void;
30
+ /** The exit transition finished. Called at most once per presenter. */
31
+ onDismissed(): void;
32
+ onBackdropPress(): void;
33
+ /** The user swiped the sheet down far enough to dismiss it. */
34
+ onSwipeDismiss(): void;
35
+ onDetentChange(index: number): void;
36
+ onContentAreaChange(width: number, height: number): void;
37
+ }
38
+
39
+ /**
40
+ * Owns one presentation from show to dismiss. Subclasses lay out the surface
41
+ * and drive the enter/exit animations; this class tracks the viewport, the
42
+ * on-screen keyboard and the content size, and handles the backdrop and
43
+ * teardown.
44
+ *
45
+ * Transitions are interruptible: dismissing mid-enter turns the enter around,
46
+ * and `cancelDismiss` turns an exit back around. Once an exit starts the modal
47
+ * stops taking input.
48
+ */
49
+ export abstract class ModalPresenter {
50
+ protected elements: ModalElements | null = null;
51
+ protected viewport: Size = { width: 0, height: 0 };
52
+ protected insets: Insets = ZERO_INSETS;
53
+ /** Part of the viewport covered by the on-screen keyboard, from the bottom. */
54
+ protected keyboardHeight = 0;
55
+ /** Measured size of the React content. */
56
+ protected contentSize: Size = { width: 0, height: 0 };
57
+ protected entering = false;
58
+ protected isDismissing = false;
59
+ private finished = false;
60
+ private observedContent: HTMLElement | null = null;
61
+ private resizeObserver: ResizeObserver | null = null;
62
+
63
+ constructor(
64
+ protected config: ModalConfig,
65
+ protected readonly listener: PresenterListener
66
+ ) {}
67
+
68
+ get isShown(): boolean {
69
+ return this.elements != null;
70
+ }
71
+
72
+ /** Takes over the rendered elements and starts the enter transition. */
73
+ show(elements: ModalElements) {
74
+ if (this.elements || this.finished) return;
75
+ this.elements = elements;
76
+ // The elements can outlive a previous presentation (an immediate re-present).
77
+ elements.overlay.style.pointerEvents = '';
78
+ elements.surface.style.overflow = clipOverflow();
79
+ elements.surface.style.opacity = '';
80
+ elements.surface.style.transform = '';
81
+
82
+ window.addEventListener('resize', this.handleResize);
83
+ window.visualViewport?.addEventListener('resize', this.handleKeyboard);
84
+ window.visualViewport?.addEventListener('scroll', this.handleKeyboard);
85
+ elements.backdrop.addEventListener('click', this.handleBackdropClick);
86
+ elements.overlay.addEventListener('wheel', this.handleWheel, {
87
+ passive: false,
88
+ });
89
+ if (typeof ResizeObserver !== 'undefined') {
90
+ this.resizeObserver = new ResizeObserver(this.handleContentResize);
91
+ }
92
+
93
+ this.measureViewport();
94
+ this.observeContent();
95
+ this.applyBackdrop();
96
+ this.didShow();
97
+ this.entering = true;
98
+ this.animateIn();
99
+ }
100
+
101
+ update(config: ModalConfig) {
102
+ const previous = this.config;
103
+ this.config = config;
104
+ if (!this.elements) return;
105
+ this.applyBackdrop();
106
+ this.configDidChange(previous);
107
+ }
108
+
109
+ /** The React content re-rendered; its root element may have changed. */
110
+ contentDidRender() {
111
+ if (this.elements && this.observeContent()) {
112
+ this.contentSizeDidChange();
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Animated dismissal, also from mid-enter. `onDismissed` follows when it
118
+ * completes, unless `cancelDismiss` turns it around first.
119
+ */
120
+ dismiss() {
121
+ const elements = this.elements;
122
+ if (!elements || this.isDismissing || this.finished) return;
123
+ this.isDismissing = true;
124
+ this.entering = false;
125
+ elements.overlay.style.pointerEvents = 'none';
126
+ // Also hides the on-screen keyboard.
127
+ const focused = document.activeElement;
128
+ if (focused instanceof HTMLElement && elements.overlay.contains(focused)) {
129
+ focused.blur();
130
+ }
131
+ this.animateOut(() => this.finish());
132
+ }
133
+
134
+ /**
135
+ * Reverses a running exit from wherever it is. `onPresented` follows once
136
+ * it settles. Returns false when there is no exit to reverse.
137
+ */
138
+ cancelDismiss(): boolean {
139
+ if (!this.elements || !this.isDismissing || this.finished) return false;
140
+ this.isDismissing = false;
141
+ this.elements.overlay.style.pointerEvents = '';
142
+ this.entering = true;
143
+ this.animateIn();
144
+ return true;
145
+ }
146
+
147
+ /** Tears down without animating or notifying the listener. */
148
+ destroy() {
149
+ if (this.finished) return;
150
+ this.finished = true;
151
+ this.teardown();
152
+ }
153
+
154
+ snapToDetent(_index: number) {}
155
+
156
+ /** The elements are in place; lay out the hidden state. */
157
+ protected abstract didShow(): void;
158
+
159
+ /** Animates from the current state to presented, then calls `notifyPresented`. */
160
+ protected abstract animateIn(): void;
161
+
162
+ /** Animates from the current state (possibly mid-enter) to gone, then calls `onEnd`. */
163
+ protected abstract animateOut(onEnd: () => void): void;
164
+
165
+ protected abstract configDidChange(previous: ModalConfig): void;
166
+
167
+ /** The viewport size, safe area or keyboard height changed. */
168
+ protected abstract viewportDidChange(): void;
169
+
170
+ protected abstract contentSizeDidChange(): void;
171
+
172
+ protected abstract stopAnimations(): void;
173
+
174
+ /** For subclasses: the running enter animation settled. */
175
+ protected notifyPresented() {
176
+ if (!this.entering || this.isDismissing || this.finished) return;
177
+ this.entering = false;
178
+ this.listener.onPresented();
179
+ }
180
+
181
+ protected reportContentArea(width: number, height: number) {
182
+ if (width > 0 && height > 0) {
183
+ this.listener.onContentAreaChange(width, height);
184
+ }
185
+ }
186
+
187
+ protected teardown() {
188
+ this.stopAnimations();
189
+ window.removeEventListener('resize', this.handleResize);
190
+ window.visualViewport?.removeEventListener('resize', this.handleKeyboard);
191
+ window.visualViewport?.removeEventListener('scroll', this.handleKeyboard);
192
+ this.elements?.backdrop.removeEventListener(
193
+ 'click',
194
+ this.handleBackdropClick
195
+ );
196
+ this.elements?.overlay.removeEventListener('wheel', this.handleWheel);
197
+ this.resizeObserver?.disconnect();
198
+ this.resizeObserver = null;
199
+ this.observedContent = null;
200
+ }
201
+
202
+ private finish() {
203
+ if (this.finished) return;
204
+ this.finished = true;
205
+ this.teardown();
206
+ this.listener.onDismissed();
207
+ }
208
+
209
+ private applyBackdrop() {
210
+ const { backdrop, surface } = this.elements!;
211
+ const { config } = this;
212
+ backdrop.style.backgroundColor = toCSSColor(
213
+ config.backdropColor,
214
+ config.backdropOpacity
215
+ );
216
+ const blur =
217
+ config.backdropBlurRadius > 0
218
+ ? `blur(${config.backdropBlurRadius}px)`
219
+ : '';
220
+ backdrop.style.setProperty('backdrop-filter', blur);
221
+ backdrop.style.setProperty('-webkit-backdrop-filter', blur);
222
+ // `Canvas` follows the page's `color-scheme`, like a system background.
223
+ surface.style.backgroundColor =
224
+ config.contentBackgroundColor != null
225
+ ? toCSSColor(config.contentBackgroundColor)
226
+ : 'Canvas';
227
+ }
228
+
229
+ private measureViewport() {
230
+ const { overlay } = this.elements!;
231
+ this.viewport = {
232
+ width: overlay.clientWidth,
233
+ height: overlay.clientHeight,
234
+ };
235
+ this.insets = readSafeAreaInsets();
236
+ this.keyboardHeight = readKeyboardHeight(this.viewport.height);
237
+ }
238
+
239
+ /** Re-targets the observer at the content's root element. Returns whether its size changed. */
240
+ private observeContent(): boolean {
241
+ const root = this.elements?.content.firstElementChild;
242
+ const element = root instanceof HTMLElement ? root : null;
243
+ if (element !== this.observedContent) {
244
+ if (this.observedContent) {
245
+ this.resizeObserver?.unobserve(this.observedContent);
246
+ }
247
+ this.observedContent = element;
248
+ if (element) {
249
+ this.resizeObserver?.observe(element);
250
+ }
251
+ }
252
+ return this.measureContent();
253
+ }
254
+
255
+ private measureContent(): boolean {
256
+ // Layout sizes, unaffected by the transition's transforms.
257
+ const width = this.observedContent?.offsetWidth ?? 0;
258
+ const height = this.observedContent?.offsetHeight ?? 0;
259
+ if (width === this.contentSize.width && height === this.contentSize.height)
260
+ return false;
261
+ this.contentSize = { width, height };
262
+ return true;
263
+ }
264
+
265
+ private handleContentResize = () => {
266
+ if (this.measureContent()) {
267
+ this.contentSizeDidChange();
268
+ }
269
+ };
270
+
271
+ private handleResize = () => {
272
+ if (!this.elements) return;
273
+ this.measureViewport();
274
+ this.viewportDidChange();
275
+ };
276
+
277
+ private handleKeyboard = () => {
278
+ const height = readKeyboardHeight(this.viewport.height);
279
+ if (height === this.keyboardHeight) return;
280
+ this.keyboardHeight = height;
281
+ this.viewportDidChange();
282
+ };
283
+
284
+ private handleBackdropClick = () => {
285
+ if (!this.isDismissing && !this.finished) {
286
+ this.listener.onBackdropPress();
287
+ }
288
+ };
289
+
290
+ /** Keeps the page behind the modal from scrolling; scrollable content still scrolls. */
291
+ private handleWheel = (event: WheelEvent) => {
292
+ if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY))
293
+ return;
294
+ if (!canScrollWithin(event.target, this.elements!.overlay, event.deltaY)) {
295
+ event.preventDefault();
296
+ }
297
+ };
298
+ }
@@ -0,0 +1,129 @@
1
+ /// <reference lib="dom" />
2
+
3
+ import { AnimatedValue, INSTANT, type Motion } from './AnimatedValue';
4
+ import { prefersReducedMotion, type Insets, type Size } from './dom';
5
+ import { ModalPresenter } from './ModalPresenter';
6
+
7
+ const MARGIN = 16;
8
+ const DEFAULT_CORNER_RADIUS = 16;
9
+ const ENTER_SCALE = 0.9;
10
+
11
+ const ENTER: Motion = { type: 'spring', response: 0.3, dampingRatio: 0.82 };
12
+ const EXIT: Motion = { type: 'timing', duration: 200, easing: (t) => t * t };
13
+
14
+ interface Rect extends Size {
15
+ x: number;
16
+ y: number;
17
+ }
18
+
19
+ /** Area a card may occupy, clear of the safe area and the part of the keyboard above it. */
20
+ function availableRect(
21
+ viewport: Size,
22
+ insets: Insets,
23
+ keyboardHeight: number
24
+ ): Rect {
25
+ const covered = Math.max(0, keyboardHeight - insets.bottom);
26
+ return {
27
+ x: insets.left + MARGIN,
28
+ y: insets.top + MARGIN,
29
+ width: Math.max(
30
+ 0,
31
+ viewport.width - insets.left - insets.right - 2 * MARGIN
32
+ ),
33
+ height: Math.max(
34
+ 0,
35
+ viewport.height - insets.top - insets.bottom - 2 * MARGIN - covered
36
+ ),
37
+ };
38
+ }
39
+
40
+ /** Best guess of the content area before the popup is on screen. */
41
+ export function popupContentArea(viewport: Size, insets: Insets): Size {
42
+ const { width, height } = availableRect(viewport, insets, 0);
43
+ return { width, height };
44
+ }
45
+
46
+ /** `popup` mode: a centered card over the backdrop with a fade/scale transition. */
47
+ export class PopupPresenter extends ModalPresenter {
48
+ /** 0 hidden, 1 presented. */
49
+ private readonly progress = new AnimatedValue(0, 0.001, (value) =>
50
+ this.render(value)
51
+ );
52
+
53
+ protected didShow() {
54
+ this.layout();
55
+ this.progress.set(0);
56
+ }
57
+
58
+ protected animateIn() {
59
+ this.progress.animateTo(1, this.isAnimated ? ENTER : INSTANT, {
60
+ onEnd: () => this.notifyPresented(),
61
+ });
62
+ }
63
+
64
+ protected animateOut(onEnd: () => void) {
65
+ this.progress.animateTo(0, this.isAnimated ? EXIT : INSTANT, { onEnd });
66
+ }
67
+
68
+ protected configDidChange() {
69
+ this.layout();
70
+ this.render(this.progress.value);
71
+ }
72
+
73
+ protected viewportDidChange() {
74
+ this.layout();
75
+ }
76
+
77
+ protected contentSizeDidChange() {
78
+ this.layout();
79
+ }
80
+
81
+ protected stopAnimations() {
82
+ this.progress.stop();
83
+ }
84
+
85
+ private get isAnimated(): boolean {
86
+ return this.config.popupAnimation !== 'none' && !prefersReducedMotion();
87
+ }
88
+
89
+ private layout() {
90
+ const { surface, content, grabber } = this.elements!;
91
+ const behavior = this.config.keyboardBehavior;
92
+ const available = availableRect(
93
+ this.viewport,
94
+ this.insets,
95
+ behavior === 'none' ? 0 : this.keyboardHeight
96
+ );
97
+ const width = Math.min(this.contentSize.width, available.width);
98
+ const height = Math.min(this.contentSize.height, available.height);
99
+ Object.assign(surface.style, {
100
+ left: `${available.x + (available.width - width) / 2}px`,
101
+ top: `${available.y + (available.height - height) / 2}px`,
102
+ width: `${width}px`,
103
+ height: `${height}px`,
104
+ borderRadius: `${this.config.cornerRadius ?? DEFAULT_CORNER_RADIUS}px`,
105
+ });
106
+ grabber.style.display = 'none';
107
+
108
+ // The content host also bounds the content's own (shrink-to-fit) width.
109
+ const area =
110
+ behavior === 'resize'
111
+ ? available
112
+ : availableRect(this.viewport, this.insets, 0);
113
+ content.style.width = `${area.width}px`;
114
+ content.style.height = `${area.height}px`;
115
+ this.reportContentArea(area.width, area.height);
116
+ }
117
+
118
+ private render(progress: number) {
119
+ if (!this.elements) return;
120
+ const { surface, backdrop } = this.elements;
121
+ const visible = Math.min(Math.max(progress, 0), 1);
122
+ const animation = this.config.popupAnimation;
123
+ backdrop.style.opacity = String(visible);
124
+ surface.style.opacity = animation === 'none' ? '1' : String(visible);
125
+ const scale =
126
+ animation === 'scale' ? ENTER_SCALE + (1 - ENTER_SCALE) * progress : 1;
127
+ surface.style.transform = `scale(${scale})`;
128
+ }
129
+ }