@xh/hoist 72.0.0-SNAPSHOT.1737150473558 → 72.0.0-SNAPSHOT.1737472364423
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.
- package/CHANGELOG.md +10 -1
- package/build/types/kit/onsen/index.d.ts +1 -1
- package/build/types/kit/swiper/index.d.ts +98 -0
- package/build/types/mobile/cmp/navigator/Navigator.d.ts +2 -6
- package/build/types/mobile/cmp/navigator/NavigatorModel.d.ts +18 -11
- package/build/types/mobile/cmp/navigator/impl/GestureRefresh.d.ts +8 -0
- package/build/types/mobile/cmp/navigator/impl/{swipe/SwiperModel.d.ts → GestureRefreshModel.d.ts} +1 -8
- package/build/types/mobile/cmp/navigator/impl/Utils.d.ts +9 -0
- package/cmp/ag-grid/AgGrid.scss +6 -0
- package/kit/onsen/index.ts +0 -1
- package/kit/onsen/theme.scss +0 -5
- package/kit/swiper/index.ts +14 -0
- package/kit/swiper/styles.scss +2 -0
- package/mobile/cmp/navigator/Navigator.scss +20 -0
- package/mobile/cmp/navigator/Navigator.ts +36 -19
- package/mobile/cmp/navigator/NavigatorModel.ts +156 -99
- package/mobile/cmp/navigator/impl/{swipe/Swiper.scss → GestureRefresh.scss} +6 -1
- package/mobile/cmp/navigator/impl/GestureRefresh.ts +55 -0
- package/mobile/cmp/navigator/impl/GestureRefreshModel.ts +86 -0
- package/mobile/cmp/navigator/impl/Page.scss +4 -2
- package/mobile/cmp/navigator/impl/Page.ts +10 -12
- package/mobile/cmp/navigator/impl/Utils.ts +107 -0
- package/package.json +2 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/build/types/mobile/cmp/navigator/impl/swipe/BackIndicator.d.ts +0 -6
- package/build/types/mobile/cmp/navigator/impl/swipe/RefreshIndicator.d.ts +0 -6
- package/build/types/mobile/cmp/navigator/impl/swipe/Swiper.d.ts +0 -8
- package/mobile/cmp/navigator/impl/swipe/BackIndicator.ts +0 -34
- package/mobile/cmp/navigator/impl/swipe/RefreshIndicator.ts +0 -36
- package/mobile/cmp/navigator/impl/swipe/Swiper.ts +0 -35
- package/mobile/cmp/navigator/impl/swipe/SwiperModel.ts +0 -153
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
* Copyright © 2025 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
7
|
import {HoistModel, RefreshMode, RenderMode, XH} from '@xh/hoist/core';
|
|
8
|
-
import '@xh/hoist/mobile/register';
|
|
9
8
|
import {action, bindable, makeObservable} from '@xh/hoist/mobx';
|
|
10
|
-
import {ensureNotEmpty, ensureUniqueBy, throwIf,
|
|
9
|
+
import {ensureNotEmpty, ensureUniqueBy, throwIf, mergeDeep} from '@xh/hoist/utils/js';
|
|
10
|
+
import {wait} from '@xh/hoist/promise';
|
|
11
11
|
import {find, isEqual, keys} from 'lodash';
|
|
12
|
-
import {
|
|
12
|
+
import {Swiper} from 'swiper/types';
|
|
13
|
+
import '@xh/hoist/mobile/register';
|
|
13
14
|
import {PageConfig, PageModel} from './PageModel';
|
|
15
|
+
import {findScrollableParent, isDraggableEl} from './impl/Utils';
|
|
14
16
|
|
|
15
17
|
export interface NavigatorConfig {
|
|
16
18
|
/** Configs for PageModels, representing all supported pages within this Navigator/App. */
|
|
@@ -22,12 +24,15 @@ export interface NavigatorConfig {
|
|
|
22
24
|
*/
|
|
23
25
|
track?: boolean;
|
|
24
26
|
|
|
25
|
-
/** True to enable 'swipe to go back' functionality. */
|
|
26
|
-
swipeToGoBack?: boolean;
|
|
27
|
-
|
|
28
27
|
/** True to enable 'pull down to refresh' functionality. */
|
|
29
28
|
pullDownToRefresh?: boolean;
|
|
30
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Time (in milliseconds) for the transition between pages on route change.
|
|
32
|
+
* Defaults to 500.
|
|
33
|
+
*/
|
|
34
|
+
transitionMs?: number;
|
|
35
|
+
|
|
31
36
|
/**
|
|
32
37
|
* Strategy for rendering pages. Can be set per-page via `PageModel.renderMode`.
|
|
33
38
|
* See enum for description of supported modes.
|
|
@@ -42,7 +47,7 @@ export interface NavigatorConfig {
|
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
/**
|
|
45
|
-
* Model for handling stack-based navigation between
|
|
50
|
+
* Model for handling stack-based navigation between pages.
|
|
46
51
|
* Provides support for routing based navigation.
|
|
47
52
|
*/
|
|
48
53
|
export class NavigatorModel extends HoistModel {
|
|
@@ -52,48 +57,54 @@ export class NavigatorModel extends HoistModel {
|
|
|
52
57
|
stack: PageModel[] = [];
|
|
53
58
|
|
|
54
59
|
pages: PageConfig[] = [];
|
|
55
|
-
|
|
56
60
|
track: boolean;
|
|
57
|
-
swipeToGoBack: boolean;
|
|
58
61
|
pullDownToRefresh: boolean;
|
|
62
|
+
transitionMs: number;
|
|
59
63
|
renderMode: RenderMode;
|
|
60
64
|
refreshMode: RefreshMode;
|
|
61
65
|
|
|
62
|
-
private
|
|
66
|
+
private _swiper: Swiper;
|
|
63
67
|
private _callback: () => void;
|
|
64
|
-
private
|
|
68
|
+
private _touchStartX: number;
|
|
65
69
|
|
|
66
70
|
get activePageId(): string {
|
|
67
71
|
return this.activePage?.id;
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
get activePage(): PageModel {
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
return this.stack[this.activePageIdx];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get activePageIdx(): number {
|
|
79
|
+
return this._swiper?.activeIndex ?? 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get allowSlideNext(): boolean {
|
|
83
|
+
return this.activePageIdx < this.stack.length - 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get allowSlidePrev(): boolean {
|
|
87
|
+
return this.activePageIdx > 0;
|
|
73
88
|
}
|
|
74
89
|
|
|
75
90
|
constructor({
|
|
76
91
|
pages,
|
|
77
92
|
track = false,
|
|
78
|
-
swipeToGoBack = true,
|
|
79
93
|
pullDownToRefresh = true,
|
|
94
|
+
transitionMs = 500,
|
|
80
95
|
renderMode = 'lazy',
|
|
81
96
|
refreshMode = 'onShowLazy'
|
|
82
97
|
}: NavigatorConfig) {
|
|
83
98
|
super();
|
|
84
99
|
makeObservable(this);
|
|
85
|
-
warnIf(
|
|
86
|
-
renderMode === 'always',
|
|
87
|
-
"RenderMode.ALWAYS is not supported in Navigator. Pages can't exist before being mounted."
|
|
88
|
-
);
|
|
89
100
|
|
|
90
101
|
ensureNotEmpty(pages, 'NavigatorModel needs at least one page.');
|
|
91
102
|
ensureUniqueBy(pages, 'id', 'Multiple NavigatorModel PageModels have the same id.');
|
|
92
103
|
|
|
93
104
|
this.pages = pages;
|
|
94
105
|
this.track = track;
|
|
95
|
-
this.swipeToGoBack = swipeToGoBack;
|
|
96
106
|
this.pullDownToRefresh = pullDownToRefresh;
|
|
107
|
+
this.transitionMs = transitionMs;
|
|
97
108
|
this.renderMode = renderMode;
|
|
98
109
|
this.refreshMode = refreshMode;
|
|
99
110
|
|
|
@@ -102,11 +113,6 @@ export class NavigatorModel extends HoistModel {
|
|
|
102
113
|
run: () => this.onRouteChange()
|
|
103
114
|
});
|
|
104
115
|
|
|
105
|
-
this.addReaction({
|
|
106
|
-
track: () => this.stack,
|
|
107
|
-
run: this.onStackChangeAsync
|
|
108
|
-
});
|
|
109
|
-
|
|
110
116
|
if (track) {
|
|
111
117
|
this.addReaction({
|
|
112
118
|
track: () => this.activePageId,
|
|
@@ -131,8 +137,89 @@ export class NavigatorModel extends HoistModel {
|
|
|
131
137
|
//--------------------
|
|
132
138
|
// Implementation
|
|
133
139
|
//--------------------
|
|
134
|
-
|
|
135
|
-
|
|
140
|
+
/** @internal */
|
|
141
|
+
setSwiper(swiper: Swiper) {
|
|
142
|
+
if (this._swiper) return;
|
|
143
|
+
this._swiper = swiper;
|
|
144
|
+
|
|
145
|
+
swiper.on('transitionEnd', () => this.onPageChange());
|
|
146
|
+
|
|
147
|
+
// Ensure Swiper's touch move is initially disabled, and capture
|
|
148
|
+
// the initial touch position. This is required to allow touch move
|
|
149
|
+
// to propagate to scrollable elements within the page.
|
|
150
|
+
swiper.on('touchStart', (s, event: PointerEvent) => {
|
|
151
|
+
swiper.allowTouchMove = false;
|
|
152
|
+
this._touchStartX = event.pageX;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Add our own "touchmove" handler to the swiper, allowing us to toggle
|
|
156
|
+
// the built-in touch detection based on the presence of scrollable elements.
|
|
157
|
+
swiper.el.addEventListener('touchmove', (event: TouchEvent) => {
|
|
158
|
+
const touch = event.touches[0],
|
|
159
|
+
distance = touch.clientX - this._touchStartX,
|
|
160
|
+
direction = distance > 0 ? 'right' : 'left';
|
|
161
|
+
|
|
162
|
+
const scrollableParent = findScrollableParent(event, 'horizontal');
|
|
163
|
+
if (scrollableParent) {
|
|
164
|
+
// If there is a scrollable parent we need to determine whether to allow
|
|
165
|
+
// the swiper or the scrollable parent to "win".
|
|
166
|
+
if (direction === 'left') {
|
|
167
|
+
// If we are scrolling "left" (i.e. "forward"), simply always prevent Swiper
|
|
168
|
+
// to allow internal scrolling. Our stack-based navigation does not allow
|
|
169
|
+
// forward navigation.
|
|
170
|
+
swiper.allowTouchMove = false;
|
|
171
|
+
} else {
|
|
172
|
+
// If we are scrolling "right" (i.e. "back"), we favor Swiper if the scrollable
|
|
173
|
+
// parent is at the leftmost start of its scroll, or if we are in the middle of
|
|
174
|
+
// a Swiper transition.
|
|
175
|
+
swiper.allowTouchMove =
|
|
176
|
+
swiper.progress < 1 || !isDraggableEl(scrollableParent, 'right');
|
|
177
|
+
|
|
178
|
+
// During the swiper transition, undo the scrollable parent's internal scroll
|
|
179
|
+
// to keep it static.
|
|
180
|
+
if (swiper.progress < 1) {
|
|
181
|
+
scrollableParent.scrollLeft -= distance;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
// If there is no scrollable parent, simply allow the swipe to proceed.
|
|
186
|
+
swiper.allowTouchMove = true;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Ensure Swiper's touch move is disabled after each touch completes.
|
|
191
|
+
swiper.on('touchEnd', () => {
|
|
192
|
+
swiper.allowTouchMove = false;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
this.onRouteChange(true);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** @internal */
|
|
199
|
+
@action
|
|
200
|
+
onPageChange = () => {
|
|
201
|
+
// 1) Clear any pages after the active page. These can be left over from a back swipe.
|
|
202
|
+
this.stack = this.stack.slice(0, this._swiper.activeIndex + 1);
|
|
203
|
+
|
|
204
|
+
// 2) Sync route to match the current page stack
|
|
205
|
+
const newRouteName = this.stack.map(it => it.id).join('.'),
|
|
206
|
+
newRouteParams = mergeDeep({}, ...this.stack.map(it => it.props));
|
|
207
|
+
|
|
208
|
+
XH.navigate(newRouteName, newRouteParams);
|
|
209
|
+
|
|
210
|
+
// 3) Update state according to the active page and trigger optional callback
|
|
211
|
+
this.disableAppRefreshButton = this.activePage?.disableAppRefreshButton;
|
|
212
|
+
this._callback?.();
|
|
213
|
+
this._callback = null;
|
|
214
|
+
|
|
215
|
+
// 4) Remove finished shadow components. These are created during transitions,
|
|
216
|
+
// and remain overlaid on the page, preventing touch events from reaching the page.
|
|
217
|
+
// Presumably a bug in the Swiper library.
|
|
218
|
+
document.querySelectorAll('.swiper-slide-shadow-creative').forEach(e => e.remove());
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
private onRouteChange(init: boolean = false) {
|
|
222
|
+
if (!this._swiper || !XH.routerState) return;
|
|
136
223
|
|
|
137
224
|
// Break the current route name into parts, and collect any params for each part.
|
|
138
225
|
// Use meta.params to determine which params are associated with each route part.
|
|
@@ -154,7 +241,6 @@ export class NavigatorModel extends HoistModel {
|
|
|
154
241
|
|
|
155
242
|
// Loop through the route parts, rebuilding the page stack to match.
|
|
156
243
|
const stack = [];
|
|
157
|
-
|
|
158
244
|
for (let i = 0; i < routeParts.length; i++) {
|
|
159
245
|
const part = routeParts[i],
|
|
160
246
|
pageModelCfg = find(this.pages, {id: part.id});
|
|
@@ -176,85 +262,56 @@ export class NavigatorModel extends HoistModel {
|
|
|
176
262
|
return;
|
|
177
263
|
}
|
|
178
264
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
265
|
+
// Re-use existing PageModels where possible
|
|
266
|
+
const existingPageModel = this.stack[i];
|
|
267
|
+
if (
|
|
268
|
+
existingPageModel?.id === part.id &&
|
|
269
|
+
isEqual(existingPageModel?.props, part.props)
|
|
270
|
+
) {
|
|
271
|
+
stack.push(existingPageModel);
|
|
272
|
+
} else {
|
|
273
|
+
stack.push(
|
|
274
|
+
new PageModel({
|
|
275
|
+
navigatorModel: this,
|
|
276
|
+
...mergeDeep({}, pageModelCfg, part)
|
|
277
|
+
})
|
|
278
|
+
);
|
|
279
|
+
}
|
|
185
280
|
}
|
|
186
281
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const {stack} = this,
|
|
194
|
-
keyStack = stack.map(it => it.key),
|
|
195
|
-
prevKeyStack = this._prevKeyStack || [],
|
|
196
|
-
backOnePage = isEqual(keyStack, prevKeyStack.slice(0, -1)),
|
|
197
|
-
forwardOnePage = isEqual(keyStack.slice(0, -1), prevKeyStack);
|
|
198
|
-
|
|
199
|
-
// Skip transition animation if the active page is going to be unmounted
|
|
200
|
-
let options;
|
|
201
|
-
if (this.activePage?.renderMode === 'unmountOnHide') {
|
|
202
|
-
options = {animation: 'none'};
|
|
282
|
+
// Immediately set the stack if this is the initial route change
|
|
283
|
+
if (init) {
|
|
284
|
+
this.stack = stack;
|
|
285
|
+
this._swiper.update();
|
|
286
|
+
this._swiper.activeIndex = this.stack.length - 1;
|
|
287
|
+
return;
|
|
203
288
|
}
|
|
204
289
|
|
|
205
|
-
|
|
290
|
+
// Compare new stack to current stack to determine how to navigate
|
|
291
|
+
const {transitionMs} = this,
|
|
292
|
+
newKeyStack = stack.map(it => it.key),
|
|
293
|
+
currKeyStack = this.stack.map(it => it.key),
|
|
294
|
+
backOnePage = isEqual(newKeyStack, currKeyStack.slice(0, -1)),
|
|
295
|
+
forwardOnePage = isEqual(newKeyStack.slice(0, -1), currKeyStack);
|
|
206
296
|
|
|
207
297
|
if (backOnePage) {
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
// If we have gone forward one page in the same stack, we can safely push() the new page
|
|
212
|
-
return this._navigator.pushPage(stack[stack.length - 1], options);
|
|
298
|
+
// Don't update the stack yet. Instead, wait until after the animation has
|
|
299
|
+
// completed in onPageChange().
|
|
300
|
+
this._swiper.slidePrev(transitionMs);
|
|
213
301
|
} else {
|
|
214
|
-
// Otherwise,
|
|
215
|
-
|
|
302
|
+
// Otherwise, update the stack immediately and navigate to the new page.
|
|
303
|
+
this.stack = stack;
|
|
304
|
+
this._swiper.update();
|
|
305
|
+
|
|
306
|
+
// Wait for the new stack to be rendered before sliding to the new page.
|
|
307
|
+
wait(1).then(() => {
|
|
308
|
+
if (forwardOnePage) {
|
|
309
|
+
this._swiper.slideNext(transitionMs);
|
|
310
|
+
} else {
|
|
311
|
+
// Jump instantly to the active page.
|
|
312
|
+
this._swiper.slideTo(stack.length - 1, 0);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
216
315
|
}
|
|
217
316
|
}
|
|
218
|
-
|
|
219
|
-
renderPage = (model, navigator) => {
|
|
220
|
-
const {init, key} = model;
|
|
221
|
-
|
|
222
|
-
// Note: We use the special 'init' object to obtain a reference to the
|
|
223
|
-
// navigator and to read the initial route.
|
|
224
|
-
if (init) {
|
|
225
|
-
if (!this._navigator) {
|
|
226
|
-
this._navigator = navigator;
|
|
227
|
-
this.onRouteChange(init);
|
|
228
|
-
}
|
|
229
|
-
return null;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// This is a workaround for an Onsen issue with resetPageStack(),
|
|
233
|
-
// which can result in transient duplicate pages in a stack. Having duplicate pages
|
|
234
|
-
// will cause React to throw with a duplicate key error. The error occurs
|
|
235
|
-
// when navigating from one page stack to another where the last page of
|
|
236
|
-
// the new stack is already present in the previous stack.
|
|
237
|
-
//
|
|
238
|
-
// For this workaround, we skip rendering the duplicate page (the one at the incorrect index).
|
|
239
|
-
//
|
|
240
|
-
// See https://github.com/OnsenUI/OnsenUI/issues/2682
|
|
241
|
-
const onsenNavPages = this._navigator.routes.filter(it => !it.init),
|
|
242
|
-
hasDupes = onsenNavPages.filter(it => it.key === key).length > 1;
|
|
243
|
-
|
|
244
|
-
if (hasDupes) {
|
|
245
|
-
const onsenIdx = onsenNavPages.indexOf(model),
|
|
246
|
-
ourIdx = this.stack.findIndex(it => it.key === key);
|
|
247
|
-
|
|
248
|
-
if (onsenIdx !== ourIdx) return null;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
return page({model, key});
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
@action
|
|
255
|
-
onPageChange = () => {
|
|
256
|
-
this.disableAppRefreshButton = this.activePage?.disableAppRefreshButton;
|
|
257
|
-
this._callback?.();
|
|
258
|
-
this._callback = null;
|
|
259
|
-
};
|
|
260
317
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file belongs to Hoist, an application development toolkit
|
|
3
|
+
* developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
|
|
4
|
+
*
|
|
5
|
+
* Copyright © 2025 Extremely Heavy Industries Inc.
|
|
6
|
+
*/
|
|
7
|
+
import {div, frame} from '@xh/hoist/cmp/layout';
|
|
8
|
+
import {creates, hoistCmp} from '@xh/hoist/core';
|
|
9
|
+
import {Icon} from '@xh/hoist/icon';
|
|
10
|
+
import {gestureDetector} from '@xh/hoist/kit/onsen';
|
|
11
|
+
import classNames from 'classnames';
|
|
12
|
+
import './GestureRefresh.scss';
|
|
13
|
+
import {GestureRefreshModel} from './GestureRefreshModel';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Wrap the Navigator with gesture that triggers a refresh by pulling down.
|
|
17
|
+
*
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
export const gestureRefresh = hoistCmp.factory({
|
|
21
|
+
model: creates(GestureRefreshModel),
|
|
22
|
+
render({model, children}) {
|
|
23
|
+
return frame(
|
|
24
|
+
refreshIndicator(),
|
|
25
|
+
gestureDetector({
|
|
26
|
+
className: 'xh-gesture-refresh',
|
|
27
|
+
onDragStart: model.onDragStart,
|
|
28
|
+
onDrag: model.onDrag,
|
|
29
|
+
onDragEnd: model.onDragEnd,
|
|
30
|
+
item: children
|
|
31
|
+
})
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const refreshIndicator = hoistCmp.factory<GestureRefreshModel>(({model}) => {
|
|
37
|
+
const {refreshStarted, refreshProgress, refreshCompleted} = model,
|
|
38
|
+
top = -40 + refreshProgress * 85,
|
|
39
|
+
degrees = Math.floor(refreshProgress * 360),
|
|
40
|
+
className = classNames(
|
|
41
|
+
'xh-gesture-refresh-indicator',
|
|
42
|
+
refreshCompleted ? 'xh-gesture-refresh-indicator--complete' : null,
|
|
43
|
+
refreshStarted ? 'xh-gesture-refresh-indicator--started' : null
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return div({
|
|
47
|
+
className,
|
|
48
|
+
style: {
|
|
49
|
+
top,
|
|
50
|
+
left: '50%',
|
|
51
|
+
transform: `translateX(-50%) rotate(${degrees}deg)`
|
|
52
|
+
},
|
|
53
|
+
item: Icon.refresh()
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file belongs to Hoist, an application development toolkit
|
|
3
|
+
* developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
|
|
4
|
+
*
|
|
5
|
+
* Copyright © 2025 Extremely Heavy Industries Inc.
|
|
6
|
+
*/
|
|
7
|
+
import {HoistModel, lookup, XH} from '@xh/hoist/core';
|
|
8
|
+
import {action, computed, makeObservable, observable} from '@xh/hoist/mobx';
|
|
9
|
+
import {consumeEvent} from '@xh/hoist/utils/js';
|
|
10
|
+
import {isFinite, clamp} from 'lodash';
|
|
11
|
+
import {NavigatorModel} from '../NavigatorModel';
|
|
12
|
+
import {hasDraggableParent} from './Utils';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export class GestureRefreshModel extends HoistModel {
|
|
18
|
+
@lookup(NavigatorModel) navigatorModel;
|
|
19
|
+
|
|
20
|
+
@observable refreshProgress = null;
|
|
21
|
+
|
|
22
|
+
@computed
|
|
23
|
+
get refreshStarted() {
|
|
24
|
+
return isFinite(this.refreshProgress);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@computed
|
|
28
|
+
get refreshCompleted() {
|
|
29
|
+
return this.refreshProgress === 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@action
|
|
33
|
+
refreshStart() {
|
|
34
|
+
this.refreshProgress = 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@action
|
|
38
|
+
refreshEnd() {
|
|
39
|
+
this.refreshProgress = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
constructor() {
|
|
43
|
+
super();
|
|
44
|
+
makeObservable(this);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@action
|
|
48
|
+
onDragStart = e => {
|
|
49
|
+
const {navigatorModel} = this,
|
|
50
|
+
{direction} = e.gesture;
|
|
51
|
+
|
|
52
|
+
this.refreshEnd();
|
|
53
|
+
if (
|
|
54
|
+
direction === 'down' &&
|
|
55
|
+
navigatorModel.pullDownToRefresh &&
|
|
56
|
+
!hasDraggableParent(e, 'down')
|
|
57
|
+
) {
|
|
58
|
+
this.refreshStart();
|
|
59
|
+
consumeEvent(e);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
@action
|
|
65
|
+
onDrag = e => {
|
|
66
|
+
const {direction, deltaY} = e.gesture;
|
|
67
|
+
if (this.refreshStarted) {
|
|
68
|
+
if (direction !== 'down') {
|
|
69
|
+
this.refreshEnd();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
this.refreshProgress = clamp(deltaY / 150, 0, 1);
|
|
73
|
+
consumeEvent(e);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
@action
|
|
79
|
+
onDragEnd = e => {
|
|
80
|
+
if (this.refreshStarted) {
|
|
81
|
+
if (this.refreshCompleted) XH.refreshAppAsync();
|
|
82
|
+
this.refreshEnd();
|
|
83
|
+
consumeEvent(e);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* Copyright © 2025 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
7
|
import {hoistCmp, refreshContextView, uses} from '@xh/hoist/core';
|
|
8
|
-
import {
|
|
8
|
+
import {div} from '@xh/hoist/cmp/layout';
|
|
9
|
+
import {throwIf} from '@xh/hoist/utils/js';
|
|
9
10
|
import {elementFromContent} from '@xh/hoist/utils/react';
|
|
10
|
-
import {useRef} from 'react';
|
|
11
11
|
import {PageModel} from '../PageModel';
|
|
12
12
|
import './Page.scss';
|
|
13
13
|
import {errorBoundary} from '@xh/hoist/cmp/error/ErrorBoundary';
|
|
@@ -26,22 +26,20 @@ export const page = hoistCmp.factory({
|
|
|
26
26
|
model: uses(PageModel, {publishMode: 'limited'}),
|
|
27
27
|
|
|
28
28
|
render({model}) {
|
|
29
|
-
const {content, props, isActive, renderMode, refreshContextModel} = model
|
|
30
|
-
|
|
29
|
+
const {content, props, isActive, renderMode, refreshContextModel} = model;
|
|
30
|
+
throwIf(
|
|
31
|
+
renderMode === 'always',
|
|
32
|
+
"RenderMode 'always' is not supported in Navigator. Pages can't exist before being mounted."
|
|
33
|
+
);
|
|
31
34
|
|
|
32
|
-
if (!
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
!isActive &&
|
|
36
|
-
(renderMode === 'unmountOnHide' || (renderMode === 'lazy' && !wasActivated.current))
|
|
37
|
-
) {
|
|
35
|
+
if (!isActive && renderMode === 'unmountOnHide') {
|
|
38
36
|
// Note: We must render an empty placeholder page to work with the Navigator.
|
|
39
|
-
return
|
|
37
|
+
return div({className: 'xh-page'});
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
return refreshContextView({
|
|
43
41
|
model: refreshContextModel,
|
|
44
|
-
item:
|
|
42
|
+
item: div({
|
|
45
43
|
className: 'xh-page',
|
|
46
44
|
item: errorBoundary(elementFromContent(content, props))
|
|
47
45
|
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file belongs to Hoist, an application development toolkit
|
|
3
|
+
* developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
|
|
4
|
+
*
|
|
5
|
+
* Copyright © 2025 Extremely Heavy Industries Inc.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
//---------------------------
|
|
13
|
+
// "Scrollable" in this context means styled to allow scrolling in the given axis, and it's
|
|
14
|
+
// internal size is larger than the container.
|
|
15
|
+
//---------------------------
|
|
16
|
+
export function hasScrollableParent(e: TouchEvent, axis: 'horizontal' | 'vertical'): boolean {
|
|
17
|
+
return !!findScrollableParent(e, axis);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function findScrollableParent(e: TouchEvent, axis: 'horizontal' | 'vertical'): HTMLElement {
|
|
21
|
+
for (let el = e.target as HTMLElement; el && el !== document.body; el = el.parentElement) {
|
|
22
|
+
if (isScrollableEl(el, axis)) {
|
|
23
|
+
return el;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isScrollableEl(el: HTMLElement, axis: 'horizontal' | 'vertical') {
|
|
30
|
+
// Don't conflict with grid header reordering or chart dragging.
|
|
31
|
+
if (el.classList.contains('xh-grid-header') || el.classList.contains('xh-chart')) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Ignore Onsen "swiper" elements created by tab container (even without swiping enabled)
|
|
36
|
+
if (el.classList.contains('ons-swiper') || el.classList.contains('ons-swiper-target')) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const {overflowX, overflowY} = window.getComputedStyle(el);
|
|
41
|
+
if (
|
|
42
|
+
axis === 'horizontal' &&
|
|
43
|
+
el.scrollWidth > el.offsetWidth &&
|
|
44
|
+
(overflowX === 'auto' || overflowX === 'scroll')
|
|
45
|
+
) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (
|
|
49
|
+
axis === 'vertical' &&
|
|
50
|
+
el.scrollHeight > el.offsetHeight &&
|
|
51
|
+
(overflowY === 'auto' || overflowY === 'scroll')
|
|
52
|
+
) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
//---------------------------
|
|
59
|
+
// "Draggable" in this context means both "Scrollable" and has room to scroll in the given direction,
|
|
60
|
+
// i.e. it would consume a drag gesture. Open to suggestions for a better name.
|
|
61
|
+
//---------------------------
|
|
62
|
+
export function hasDraggableParent(
|
|
63
|
+
e: TouchEvent,
|
|
64
|
+
direction: 'up' | 'right' | 'down' | 'left'
|
|
65
|
+
): boolean {
|
|
66
|
+
return !!findDraggableParent(e, direction);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function findDraggableParent(
|
|
70
|
+
e: TouchEvent,
|
|
71
|
+
direction: 'up' | 'right' | 'down' | 'left'
|
|
72
|
+
): HTMLElement {
|
|
73
|
+
// Loop through the touch targets to ensure it is safe to swipe
|
|
74
|
+
for (let el = e.target as HTMLElement; el && el !== document.body; el = el.parentElement) {
|
|
75
|
+
if (isDraggableEl(el, direction)) {
|
|
76
|
+
return el;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isDraggableEl(
|
|
83
|
+
el: HTMLElement,
|
|
84
|
+
direction: 'up' | 'right' | 'down' | 'left'
|
|
85
|
+
): boolean {
|
|
86
|
+
// Don't conflict with grid header reordering or chart dragging.
|
|
87
|
+
if (el.classList.contains('xh-grid-header') || el.classList.contains('xh-chart')) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const axis = direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical';
|
|
92
|
+
if (isScrollableEl(el, axis)) {
|
|
93
|
+
// Ensure any scrolling element in the target path takes priority over swipe navigation.
|
|
94
|
+
if (direction === 'left' && el.scrollLeft < el.scrollWidth - el.offsetWidth) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
if (direction === 'right' && el.scrollLeft > 0) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
if (direction === 'up' && el.scrollTop < el.scrollHeight - el.offsetHeight) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
if (direction === 'down' && el.scrollTop > 0) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xh/hoist",
|
|
3
|
-
"version": "72.0.0-SNAPSHOT.
|
|
3
|
+
"version": "72.0.0-SNAPSHOT.1737472364423",
|
|
4
4
|
"description": "Hoist add-on for building and deploying React Applications.",
|
|
5
5
|
"repository": "github:xh/hoist-react",
|
|
6
6
|
"homepage": "https://xh.io",
|
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
"semver": "~7.6.0",
|
|
85
85
|
"short-unique-id": "~5.2.0",
|
|
86
86
|
"store2": "~2.14.3",
|
|
87
|
+
"swiper": "^11.2.0",
|
|
87
88
|
"ua-parser-js": "~1.0.2"
|
|
88
89
|
},
|
|
89
90
|
"peerDependencies": {
|