react-native-nitro-modal 0.2.0 → 0.3.1-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 (83) hide show
  1. package/README.md +52 -4
  2. package/android/src/main/java/com/margelo/nitro/nitromodal/BottomSheetPresenter.kt +399 -34
  3. package/android/src/main/java/com/margelo/nitro/nitromodal/HybridNitroModal.kt +67 -22
  4. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalConfig.kt +35 -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 +115 -20
  9. package/ios/ModalConfig.swift +48 -2
  10. package/ios/NitroModalHostView.swift +73 -8
  11. package/ios/SheetView.swift +647 -0
  12. package/ios/SheetViewController.swift +19 -450
  13. package/lib/module/NitroModal.js +54 -6
  14. package/lib/module/NitroModal.js.map +1 -1
  15. package/lib/module/NitroModalView.web.js +86 -26
  16. package/lib/module/NitroModalView.web.js.map +1 -1
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/module/web/AnimatedValue.js +3 -0
  19. package/lib/module/web/AnimatedValue.js.map +1 -1
  20. package/lib/module/web/ModalConfig.js +10 -4
  21. package/lib/module/web/ModalConfig.js.map +1 -1
  22. package/lib/module/web/ModalController.js +29 -2
  23. package/lib/module/web/ModalController.js.map +1 -1
  24. package/lib/module/web/ModalPresenter.js +47 -12
  25. package/lib/module/web/ModalPresenter.js.map +1 -1
  26. package/lib/module/web/SheetPresenter.js +118 -34
  27. package/lib/module/web/SheetPresenter.js.map +1 -1
  28. package/lib/module/web/dom.js +16 -0
  29. package/lib/module/web/dom.js.map +1 -1
  30. package/lib/typescript/src/NitroModal.d.ts +42 -2
  31. package/lib/typescript/src/NitroModal.d.ts.map +1 -1
  32. package/lib/typescript/src/NitroModal.nitro.d.ts +37 -2
  33. package/lib/typescript/src/NitroModal.nitro.d.ts.map +1 -1
  34. package/lib/typescript/src/NitroModalView.web.d.ts.map +1 -1
  35. package/lib/typescript/src/index.d.ts +2 -2
  36. package/lib/typescript/src/index.d.ts.map +1 -1
  37. package/lib/typescript/src/web/AnimatedValue.d.ts +1 -0
  38. package/lib/typescript/src/web/AnimatedValue.d.ts.map +1 -1
  39. package/lib/typescript/src/web/ModalConfig.d.ts +10 -0
  40. package/lib/typescript/src/web/ModalConfig.d.ts.map +1 -1
  41. package/lib/typescript/src/web/ModalController.d.ts +5 -0
  42. package/lib/typescript/src/web/ModalController.d.ts.map +1 -1
  43. package/lib/typescript/src/web/ModalPresenter.d.ts +13 -0
  44. package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -1
  45. package/lib/typescript/src/web/SheetPresenter.d.ts +13 -2
  46. package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -1
  47. package/lib/typescript/src/web/dom.d.ts +4 -0
  48. package/lib/typescript/src/web/dom.d.ts.map +1 -1
  49. package/nitrogen/generated/android/c++/JHybridNitroModalSpec.cpp +78 -6
  50. package/nitrogen/generated/android/c++/JHybridNitroModalSpec.hpp +14 -2
  51. package/nitrogen/generated/android/c++/JNamedSheetDetent.hpp +64 -0
  52. package/nitrogen/generated/android/c++/JSheetDetent.cpp +26 -0
  53. package/nitrogen/generated/android/c++/JSheetDetent.hpp +42 -37
  54. package/nitrogen/generated/android/c++/views/JHybridNitroModalStateUpdater.cpp +30 -0
  55. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/HybridNitroModalSpec.kt +52 -0
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/NamedSheetDetent.kt +25 -0
  57. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/SheetDetent.kt +47 -10
  58. package/nitrogen/generated/android/nitromodal+autolinking.cmake +1 -0
  59. package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Bridge.hpp +68 -8
  60. package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Umbrella.hpp +4 -3
  61. package/nitrogen/generated/ios/c++/HybridNitroModalSpecSwift.hpp +46 -5
  62. package/nitrogen/generated/ios/c++/views/HybridNitroModalComponent.mm +36 -0
  63. package/nitrogen/generated/ios/swift/HybridNitroModalSpec.swift +6 -0
  64. package/nitrogen/generated/ios/swift/HybridNitroModalSpec_cxx.swift +158 -5
  65. package/nitrogen/generated/ios/swift/NamedSheetDetent.swift +48 -0
  66. package/nitrogen/generated/ios/swift/SheetDetent.swift +15 -33
  67. package/nitrogen/generated/shared/c++/HybridNitroModalSpec.cpp +12 -0
  68. package/nitrogen/generated/shared/c++/HybridNitroModalSpec.hpp +18 -5
  69. package/nitrogen/generated/shared/c++/{SheetDetent.hpp → NamedSheetDetent.hpp} +17 -17
  70. package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.cpp +13 -1
  71. package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.hpp +21 -2
  72. package/nitrogen/generated/shared/json/NitroModalConfig.json +6 -0
  73. package/package.json +1 -1
  74. package/src/NitroModal.nitro.ts +38 -2
  75. package/src/NitroModal.tsx +93 -4
  76. package/src/NitroModalView.web.tsx +79 -12
  77. package/src/index.tsx +2 -0
  78. package/src/web/AnimatedValue.ts +4 -0
  79. package/src/web/ModalConfig.ts +19 -3
  80. package/src/web/ModalController.ts +35 -2
  81. package/src/web/ModalPresenter.ts +50 -11
  82. package/src/web/SheetPresenter.ts +169 -37
  83. package/src/web/dom.ts +20 -0
@@ -3,6 +3,7 @@
3
3
  import {
4
4
  canScrollWithin,
5
5
  clipOverflow,
6
+ inlineInsets,
6
7
  readKeyboardHeight,
7
8
  readSafeAreaInsets,
8
9
  toCSSColor,
@@ -22,6 +23,10 @@ export interface ModalElements {
22
23
  /** Hosts the React content. */
23
24
  content: HTMLElement;
24
25
  grabber: HTMLElement;
26
+ /** Behind the sheet, uncovered as it's pulled down to refresh. */
27
+ refreshIndicator: HTMLElement;
28
+ /** The spinning ring inside `refreshIndicator`. */
29
+ refreshSpinner: HTMLElement;
25
30
  }
26
31
 
27
32
  export interface PresenterListener {
@@ -34,6 +39,8 @@ export interface PresenterListener {
34
39
  onSwipeDismiss(): void;
35
40
  onDetentChange(index: number): void;
36
41
  onContentAreaChange(width: number, height: number): void;
42
+ /** The user pulled the sheet past its lowest detent and let go. */
43
+ onPullToRefresh(): void;
37
44
  }
38
45
 
39
46
  /**
@@ -45,6 +52,9 @@ export interface PresenterListener {
45
52
  * Transitions are interruptible: dismissing mid-enter turns the enter around,
46
53
  * and `cancelDismiss` turns an exit back around. Once an exit starts the modal
47
54
  * stops taking input.
55
+ *
56
+ * Inline, the elements fill the host view in place of a full-window layer:
57
+ * only the surface takes input, and the keyboard is left to the page.
48
58
  */
49
59
  export abstract class ModalPresenter {
50
60
  protected elements: ModalElements | null = null;
@@ -59,6 +69,8 @@ export abstract class ModalPresenter {
59
69
  private finished = false;
60
70
  private observedContent: HTMLElement | null = null;
61
71
  private resizeObserver: ResizeObserver | null = null;
72
+ /** Inline: follows the host's size, which can change without a window resize. */
73
+ private hostObserver: ResizeObserver | null = null;
62
74
 
63
75
  constructor(
64
76
  protected config: ModalConfig,
@@ -74,18 +86,26 @@ export abstract class ModalPresenter {
74
86
  if (this.elements || this.finished) return;
75
87
  this.elements = elements;
76
88
  // The elements can outlive a previous presentation (an immediate re-present).
77
- elements.overlay.style.pointerEvents = '';
89
+ this.setInteractive(true);
78
90
  elements.surface.style.overflow = clipOverflow();
79
91
  elements.surface.style.opacity = '';
80
92
  elements.surface.style.transform = '';
93
+ elements.backdrop.style.display = this.config.isInline ? 'none' : '';
81
94
 
82
95
  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
- });
96
+ if (this.config.isInline) {
97
+ if (typeof ResizeObserver !== 'undefined') {
98
+ this.hostObserver = new ResizeObserver(this.handleResize);
99
+ this.hostObserver.observe(elements.overlay);
100
+ }
101
+ } else {
102
+ window.visualViewport?.addEventListener('resize', this.handleKeyboard);
103
+ window.visualViewport?.addEventListener('scroll', this.handleKeyboard);
104
+ elements.backdrop.addEventListener('click', this.handleBackdropClick);
105
+ elements.overlay.addEventListener('wheel', this.handleWheel, {
106
+ passive: false,
107
+ });
108
+ }
89
109
  if (typeof ResizeObserver !== 'undefined') {
90
110
  this.resizeObserver = new ResizeObserver(this.handleContentResize);
91
111
  }
@@ -122,7 +142,7 @@ export abstract class ModalPresenter {
122
142
  if (!elements || this.isDismissing || this.finished) return;
123
143
  this.isDismissing = true;
124
144
  this.entering = false;
125
- elements.overlay.style.pointerEvents = 'none';
145
+ this.setInteractive(false);
126
146
  // Also hides the on-screen keyboard.
127
147
  const focused = document.activeElement;
128
148
  if (focused instanceof HTMLElement && elements.overlay.contains(focused)) {
@@ -138,7 +158,7 @@ export abstract class ModalPresenter {
138
158
  cancelDismiss(): boolean {
139
159
  if (!this.elements || !this.isDismissing || this.finished) return false;
140
160
  this.isDismissing = false;
141
- this.elements.overlay.style.pointerEvents = '';
161
+ this.setInteractive(true);
142
162
  this.entering = true;
143
163
  this.animateIn();
144
164
  return true;
@@ -196,6 +216,8 @@ export abstract class ModalPresenter {
196
216
  this.elements?.overlay.removeEventListener('wheel', this.handleWheel);
197
217
  this.resizeObserver?.disconnect();
198
218
  this.resizeObserver = null;
219
+ this.hostObserver?.disconnect();
220
+ this.hostObserver = null;
199
221
  this.observedContent = null;
200
222
  }
201
223
 
@@ -226,14 +248,30 @@ export abstract class ModalPresenter {
226
248
  : 'Canvas';
227
249
  }
228
250
 
251
+ /** Inline, only the surface takes input; the rest reaches the page. */
252
+ private setInteractive(interactive: boolean) {
253
+ const { overlay, surface } = this.elements!;
254
+ if (this.config.isInline) {
255
+ overlay.style.pointerEvents = 'none';
256
+ surface.style.pointerEvents = interactive ? 'auto' : 'none';
257
+ } else {
258
+ overlay.style.pointerEvents = interactive ? '' : 'none';
259
+ }
260
+ }
261
+
229
262
  private measureViewport() {
230
263
  const { overlay } = this.elements!;
231
264
  this.viewport = {
232
265
  width: overlay.clientWidth,
233
266
  height: overlay.clientHeight,
234
267
  };
235
- this.insets = readSafeAreaInsets();
236
- this.keyboardHeight = readKeyboardHeight(this.viewport.height);
268
+ if (this.config.isInline) {
269
+ this.insets = inlineInsets(overlay);
270
+ this.keyboardHeight = 0;
271
+ } else {
272
+ this.insets = readSafeAreaInsets();
273
+ this.keyboardHeight = readKeyboardHeight(this.viewport.height);
274
+ }
237
275
  }
238
276
 
239
277
  /** Re-targets the observer at the content's root element. Returns whether its size changed. */
@@ -275,6 +313,7 @@ export abstract class ModalPresenter {
275
313
  };
276
314
 
277
315
  private handleKeyboard = () => {
316
+ if (this.config.isInline) return;
278
317
  const height = readKeyboardHeight(this.viewport.height);
279
318
  if (height === this.keyboardHeight) return;
280
319
  this.keyboardHeight = height;
@@ -6,15 +6,22 @@ import {
6
6
  findVerticalScroller,
7
7
  isTextInput,
8
8
  prefersReducedMotion,
9
+ REFRESH_INDICATOR_SIZE,
9
10
  suppressNextClick,
11
+ toCSSColor,
10
12
  type Insets,
11
13
  type Size,
12
14
  } from './dom';
13
15
  import type { ModalConfig } from './ModalConfig';
14
16
  import { ModalPresenter, type PresenterListener } from './ModalPresenter';
15
17
 
16
- /** Gap kept above the sheet at its tallest. */
18
+ /** Gap kept above a modal sheet at its tallest. */
17
19
  const TOP_GAP = 10;
20
+ /**
21
+ * How far below its lowest detent the sheet must be pulled to refresh, and
22
+ * where it rests while refreshing.
23
+ */
24
+ const REFRESH_THRESHOLD = 56;
18
25
  /**
19
26
  * Extra sheet height below the viewport so it stays attached to the bottom
20
27
  * edge while rubber-banding upward.
@@ -47,39 +54,77 @@ interface Drag {
47
54
  rawTop: number;
48
55
  /** Whether the last movement moved the sheet rather than the scroller. */
49
56
  sheetDrove: boolean;
57
+ /**
58
+ * The scroller moved during this gesture. It then keeps the downward part
59
+ * too: scrolled content goes back to its top, and only a new gesture moves
60
+ * the sheet.
61
+ */
62
+ contentScrolled: boolean;
63
+ /** Started with the sheet resting on its lowest detent, so pulling it further down refreshes. */
64
+ canRefresh: boolean;
65
+ /** Pulled far enough that letting go refreshes. */
66
+ refreshArmed: boolean;
50
67
  samples: { time: number; y: number }[];
51
68
  }
52
69
 
53
- function sheetWidth(available: number): number {
54
- return Math.min(available, MAX_WIDTH);
70
+ function sheetWidth(available: number, isInline: boolean): number {
71
+ return isInline ? available : Math.min(available, MAX_WIDTH);
55
72
  }
56
73
 
57
74
  /** Tallest height the content may occupy (excludes the bottom safe area). */
58
- function maximumDetentValue(viewportHeight: number, insets: Insets): number {
59
- return Math.max(0, viewportHeight - insets.top - insets.bottom - TOP_GAP);
75
+ function maximumDetentValue(
76
+ viewportHeight: number,
77
+ insets: Insets,
78
+ isInline: boolean
79
+ ): number {
80
+ const gap = isInline ? 0 : TOP_GAP;
81
+ return Math.max(0, viewportHeight - insets.top - insets.bottom - gap);
82
+ }
83
+
84
+ /** Content height at `detent`; `fitContent` uses the measured `fitting` height. */
85
+ function detentHeight(
86
+ detent: SheetDetent,
87
+ maximum: number,
88
+ fitting: number
89
+ ): number {
90
+ switch (detent) {
91
+ case 'small':
92
+ return maximum * 0.25;
93
+ case 'medium':
94
+ return maximum * 0.5;
95
+ case 'large':
96
+ return maximum;
97
+ case 'fitContent':
98
+ return Math.min(Math.max(fitting, 1), maximum);
99
+ default:
100
+ return Number.isFinite(detent)
101
+ ? Math.min(Math.max(detent, 1), maximum)
102
+ : maximum;
103
+ }
60
104
  }
61
105
 
62
106
  /** Height the content may occupy at the largest of `detents`. */
63
107
  function contentHeight(detents: SheetDetent[], maximum: number): number {
64
- if (
65
- detents.length === 0 ||
66
- detents.includes('large') ||
67
- detents.includes('fitContent')
68
- ) {
69
- return maximum;
70
- }
71
- return detents.includes('medium') ? maximum * 0.5 : maximum * 0.25;
108
+ if (detents.length === 0) return maximum;
109
+ // `fitContent` content can grow up to the maximum.
110
+ return Math.max(
111
+ ...detents.map((detent) => detentHeight(detent, maximum, maximum))
112
+ );
72
113
  }
73
114
 
74
115
  /** Best guess of the content area before the sheet is on screen. */
75
116
  export function sheetContentArea(
76
117
  detents: SheetDetent[],
77
118
  viewport: Size,
78
- insets: Insets
119
+ insets: Insets,
120
+ isInline: boolean
79
121
  ): Size {
80
122
  return {
81
- width: sheetWidth(viewport.width),
82
- height: contentHeight(detents, maximumDetentValue(viewport.height, insets)),
123
+ width: sheetWidth(viewport.width, isInline),
124
+ height: contentHeight(
125
+ detents,
126
+ maximumDetentValue(viewport.height, insets, isInline)
127
+ ),
83
128
  };
84
129
  }
85
130
 
@@ -107,6 +152,9 @@ export class SheetPresenter extends ModalPresenter {
107
152
  private drag: Drag | null = null;
108
153
  /** Vertical velocity of the swipe that requested the dismissal. */
109
154
  private releaseVelocity = 0;
155
+ /** The sheet rests below its lowest detent, the indicator spinning above it. */
156
+ private refreshing = false;
157
+ private refreshSpin: Animation | null = null;
110
158
  /** The sheet's top edge in the viewport. */
111
159
  private readonly top = new AnimatedValue(0, 0.5, (value) =>
112
160
  this.render(value)
@@ -118,6 +166,7 @@ export class SheetPresenter extends ModalPresenter {
118
166
  Math.max(config.initialDetentIndex, 0),
119
167
  this.detents.length - 1
120
168
  );
169
+ this.refreshing = config.refreshing ?? false;
121
170
  }
122
171
 
123
172
  snapToDetent(index: number) {
@@ -159,7 +208,10 @@ export class SheetPresenter extends ModalPresenter {
159
208
  });
160
209
  }
161
210
 
162
- protected configDidChange() {
211
+ protected configDidChange(previous: ModalConfig) {
212
+ if (previous.refreshing !== this.config.refreshing) {
213
+ this.refreshing = this.config.refreshing ?? false;
214
+ }
163
215
  this.selectedIndex = Math.min(this.selectedIndex, this.detents.length - 1);
164
216
  this.layout();
165
217
  this.animateToRest();
@@ -183,6 +235,8 @@ export class SheetPresenter extends ModalPresenter {
183
235
  protected teardown() {
184
236
  super.teardown();
185
237
  this.cancelDrag();
238
+ this.refreshSpin?.cancel();
239
+ this.refreshSpin = null;
186
240
  const surface = this.elements?.surface;
187
241
  surface?.removeEventListener('touchstart', this.handleTouchStart);
188
242
  surface?.removeEventListener('touchmove', this.handleTouchMove);
@@ -200,7 +254,11 @@ export class SheetPresenter extends ModalPresenter {
200
254
  }
201
255
 
202
256
  private get maximumDetentValue(): number {
203
- return maximumDetentValue(this.viewport.height, this.insets);
257
+ return maximumDetentValue(
258
+ this.viewport.height,
259
+ this.insets,
260
+ this.config.isInline
261
+ );
204
262
  }
205
263
 
206
264
  private get keyboardLift(): number {
@@ -210,17 +268,11 @@ export class SheetPresenter extends ModalPresenter {
210
268
  }
211
269
 
212
270
  private height(detent: SheetDetent): number {
213
- const maximum = this.maximumDetentValue;
214
- switch (detent) {
215
- case 'small':
216
- return maximum * 0.25;
217
- case 'medium':
218
- return maximum * 0.5;
219
- case 'large':
220
- return maximum;
221
- case 'fitContent':
222
- return Math.min(Math.max(this.contentSize.height, 1), maximum);
223
- }
271
+ return detentHeight(
272
+ detent,
273
+ this.maximumDetentValue,
274
+ this.contentSize.height
275
+ );
224
276
  }
225
277
 
226
278
  /** Sheet top when resting on `detents[index]`. The bottom safe area is added below the content. */
@@ -233,16 +285,31 @@ export class SheetPresenter extends ModalPresenter {
233
285
  this.insets.bottom -
234
286
  this.keyboardLift -
235
287
  this.height(detent);
236
- return Math.max(top, this.insets.top + TOP_GAP);
288
+ return Math.max(
289
+ top,
290
+ this.insets.top + (this.config.isInline ? 0 : TOP_GAP)
291
+ );
237
292
  }
238
293
 
239
294
  private get detentTops(): number[] {
240
295
  return this.detents.map((_, index) => this.restingTop(index));
241
296
  }
242
297
 
298
+ /** Where the sheet settles on `detents[index]`: held below the lowest detent while refreshing. */
299
+ private settledTop(index: number): number {
300
+ const top = this.restingTop(index);
301
+ if (!this.refreshing || Math.abs(top - Math.max(...this.detentTops)) >= 1)
302
+ return top;
303
+ return top + REFRESH_THRESHOLD;
304
+ }
305
+
306
+ private get settledTops(): number[] {
307
+ return this.detents.map((_, index) => this.settledTop(index));
308
+ }
309
+
243
310
  private layout() {
244
311
  const { surface, content, grabber } = this.elements!;
245
- const width = sheetWidth(this.viewport.width);
312
+ const width = sheetWidth(this.viewport.width, this.config.isInline);
246
313
  const radius = `${this.config.cornerRadius ?? DEFAULT_CORNER_RADIUS}px`;
247
314
  Object.assign(surface.style, {
248
315
  left: `${(this.viewport.width - width) / 2}px`,
@@ -257,6 +324,10 @@ export class SheetPresenter extends ModalPresenter {
257
324
  content.style.width = `${width}px`;
258
325
  content.style.height = `${this.maximumDetentValue}px`;
259
326
  grabber.style.display = this.config.grabberVisible ? '' : 'none';
327
+ this.elements!.refreshSpinner.style.color =
328
+ this.config.refreshIndicatorColor != null
329
+ ? toCSSColor(this.config.refreshIndicatorColor)
330
+ : 'GrayText';
260
331
 
261
332
  let height = contentHeight(this.config.detents, this.maximumDetentValue);
262
333
  if (this.config.keyboardBehavior === 'resize') {
@@ -270,6 +341,38 @@ export class SheetPresenter extends ModalPresenter {
270
341
  if (!this.elements) return;
271
342
  this.elements.surface.style.transform = `translate3d(0, ${top}px, 0)`;
272
343
  this.elements.backdrop.style.opacity = String(this.backdropAlpha(top));
344
+ this.renderRefreshIndicator(top);
345
+ }
346
+
347
+ /**
348
+ * Centered in the gap a pull uncovers below the lowest detent, fading in
349
+ * as the pull nears the threshold.
350
+ */
351
+ private renderRefreshIndicator(top: number) {
352
+ const { refreshIndicator, refreshSpinner } = this.elements!;
353
+ const visible =
354
+ this.config.refreshing != null &&
355
+ !this.entering &&
356
+ !this.isDismissing &&
357
+ !this.isOffscreen;
358
+ const lowest = Math.max(...this.detentTops);
359
+ const progress = visible
360
+ ? Math.min(Math.max((top - lowest) / REFRESH_THRESHOLD, 0), 1)
361
+ : 0;
362
+ const x = (this.viewport.width - REFRESH_INDICATOR_SIZE) / 2;
363
+ const y = top - (REFRESH_THRESHOLD + REFRESH_INDICATOR_SIZE) / 2;
364
+ refreshIndicator.style.transform = `translate3d(${x}px, ${y}px, 0)`;
365
+ refreshIndicator.style.opacity = String(progress);
366
+ if (progress > 0 && !this.refreshSpin) {
367
+ this.refreshSpin =
368
+ refreshSpinner.animate?.(
369
+ [{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }],
370
+ { duration: 800, iterations: Infinity }
371
+ ) ?? null;
372
+ } else if (progress === 0 && this.refreshSpin) {
373
+ this.refreshSpin.cancel();
374
+ this.refreshSpin = null;
375
+ }
273
376
  }
274
377
 
275
378
  /** Fully dimmed at every detent; fades out below the lowest one. */
@@ -286,7 +389,7 @@ export class SheetPresenter extends ModalPresenter {
286
389
 
287
390
  private settle(motion: Motion, velocity?: number) {
288
391
  this.top.animateTo(
289
- this.restingTop(this.selectedIndex),
392
+ this.settledTop(this.selectedIndex),
290
393
  this.motion(motion),
291
394
  {
292
395
  velocity,
@@ -324,6 +427,9 @@ export class SheetPresenter extends ModalPresenter {
324
427
  active: false,
325
428
  rawTop: 0,
326
429
  sheetDrove: false,
430
+ contentScrolled: false,
431
+ canRefresh: false,
432
+ refreshArmed: false,
327
433
  samples: [],
328
434
  };
329
435
  }
@@ -351,8 +457,15 @@ export class SheetPresenter extends ModalPresenter {
351
457
 
352
458
  private activate(drag: Drag) {
353
459
  drag.active = true;
460
+ const isSettled = !this.top.isAnimating;
354
461
  this.top.stop();
355
462
  drag.rawTop = this.top.value;
463
+ drag.canRefresh =
464
+ this.config.pullToRefreshEnabled &&
465
+ !this.config.dismissOnSwipe &&
466
+ !this.refreshing &&
467
+ isSettled &&
468
+ Math.abs(drag.rawTop - Math.max(...this.detentTops)) < 1;
356
469
  const { surface } = this.elements!;
357
470
  // A native scroll on an ancestor makes React Native Web cancel the press
358
471
  // under the pointer, so dragging from a button doesn't leave it pressed.
@@ -379,21 +492,33 @@ export class SheetPresenter extends ModalPresenter {
379
492
  const { scroller } = drag;
380
493
  if (scroller) {
381
494
  // The browser's own scrolling is prevented for this gesture, so the
382
- // content is scrolled here whenever the sheet can't move.
495
+ // content is scrolled here whenever the sheet can't move. Up: expand
496
+ // the sheet before scrolling. Down: scroll back to the top before
497
+ // collapsing the sheet, in a separate gesture.
383
498
  const minTop = Math.min(...this.detentTops);
384
499
  const drive =
385
- delta < 0 ? drag.rawTop > minTop + 0.5 : scroller.scrollTop <= 0;
500
+ delta < 0
501
+ ? drag.rawTop > minTop + 0.5
502
+ : !drag.contentScrolled && scroller.scrollTop <= 0;
386
503
  if (drive) {
387
504
  drag.rawTop = Math.max(drag.rawTop + delta, minTop);
388
505
  } else {
389
506
  scroller.scrollTop -= delta;
507
+ if (delta !== 0) {
508
+ drag.contentScrolled = true;
509
+ }
390
510
  }
391
511
  drag.sheetDrove = drive;
392
512
  } else {
393
513
  drag.rawTop += delta;
394
514
  drag.sheetDrove = true;
395
515
  }
396
- this.top.set(this.constrained(drag.rawTop));
516
+ const top = this.constrained(drag.rawTop);
517
+ if (drag.canRefresh) {
518
+ drag.refreshArmed =
519
+ top - Math.max(...this.detentTops) >= REFRESH_THRESHOLD;
520
+ }
521
+ this.top.set(top);
397
522
  }
398
523
 
399
524
  private release(drag: Drag) {
@@ -406,7 +531,11 @@ export class SheetPresenter extends ModalPresenter {
406
531
 
407
532
  const velocity = drag.sheetDrove ? this.velocity(drag) : 0;
408
533
  const top = this.top.value;
409
- const tops = this.detentTops;
534
+ if (drag.refreshArmed && this.config.refreshing != null) {
535
+ // Settles on the refreshing position; React confirms or ends it.
536
+ this.refreshing = true;
537
+ }
538
+ const tops = this.settledTops;
410
539
  const lowest = Math.max(...tops);
411
540
  // Where a fling of this velocity would come to rest.
412
541
  const projected = top + velocity * 0.2;
@@ -434,6 +563,9 @@ export class SheetPresenter extends ModalPresenter {
434
563
  this.listener.onDetentChange(index);
435
564
  }
436
565
  this.settle(SETTLE, velocity);
566
+ if (drag.refreshArmed) {
567
+ this.listener.onPullToRefresh();
568
+ }
437
569
  }
438
570
 
439
571
  /** Abandons the gesture without settling (the exit or the content takes over). */
@@ -464,7 +596,7 @@ export class SheetPresenter extends ModalPresenter {
464
596
 
465
597
  /** Rubber-bands above the tallest detent, and below the lowest one when swiping can't dismiss. */
466
598
  private constrained(top: number): number {
467
- const tops = this.detentTops;
599
+ const tops = this.settledTops;
468
600
  const minTop = Math.min(...tops);
469
601
  const maxTop = Math.max(...tops);
470
602
  const dimension = Math.max(this.viewport.height, 1);
package/src/web/dom.ts CHANGED
@@ -17,6 +17,9 @@ export const ZERO_INSETS: Insets = { top: 0, right: 0, bottom: 0, left: 0 };
17
17
  export const canUseDOM =
18
18
  typeof window !== 'undefined' && typeof document !== 'undefined';
19
19
 
20
+ /** Width and height of the pull-to-refresh indicator. */
21
+ export const REFRESH_INDICATOR_SIZE = 24;
22
+
20
23
  /** Converts a color from `processColor` (0xAARRGGBB) to CSS, scaling its alpha by `opacity`. */
21
24
  export function toCSSColor(argb: number, opacity = 1): string {
22
25
  // As an unsigned 32-bit value, whatever the sign it was passed with.
@@ -53,6 +56,23 @@ export function readSafeAreaInsets(): Insets {
53
56
  };
54
57
  }
55
58
 
59
+ /** The part of the safe area insets that `element` overlaps. */
60
+ export function inlineInsets(element: Element): Insets {
61
+ const safeArea = readSafeAreaInsets();
62
+ const rect = element.getBoundingClientRect();
63
+ const clamp = (value: number, max: number) =>
64
+ Math.min(Math.max(value, 0), max);
65
+ return {
66
+ top: clamp(safeArea.top - rect.top, rect.height),
67
+ right: clamp(rect.right - (window.innerWidth - safeArea.right), rect.width),
68
+ bottom: clamp(
69
+ rect.bottom - (window.innerHeight - safeArea.bottom),
70
+ rect.height
71
+ ),
72
+ left: clamp(safeArea.left - rect.left, rect.width),
73
+ };
74
+ }
75
+
56
76
  /**
57
77
  * Height of the on-screen keyboard over a layout viewport `layoutHeight` tall.
58
78
  * Mobile browsers keep the layout viewport and shrink the visual one.