@xh/hoist 72.0.0-SNAPSHOT.1737063455869 → 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.
Files changed (32) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/build/types/kit/onsen/index.d.ts +1 -1
  3. package/build/types/kit/swiper/index.d.ts +98 -0
  4. package/build/types/mobile/cmp/navigator/Navigator.d.ts +2 -6
  5. package/build/types/mobile/cmp/navigator/NavigatorModel.d.ts +18 -11
  6. package/build/types/mobile/cmp/navigator/impl/GestureRefresh.d.ts +8 -0
  7. package/build/types/mobile/cmp/navigator/impl/{swipe/SwiperModel.d.ts → GestureRefreshModel.d.ts} +1 -8
  8. package/build/types/mobile/cmp/navigator/impl/Utils.d.ts +9 -0
  9. package/cmp/ag-grid/AgGrid.scss +6 -0
  10. package/cmp/grid/GridModel.ts +3 -2
  11. package/kit/onsen/index.ts +0 -1
  12. package/kit/onsen/theme.scss +0 -5
  13. package/kit/swiper/index.ts +14 -0
  14. package/kit/swiper/styles.scss +2 -0
  15. package/mobile/cmp/navigator/Navigator.scss +20 -0
  16. package/mobile/cmp/navigator/Navigator.ts +36 -19
  17. package/mobile/cmp/navigator/NavigatorModel.ts +156 -99
  18. package/mobile/cmp/navigator/impl/{swipe/Swiper.scss → GestureRefresh.scss} +6 -1
  19. package/mobile/cmp/navigator/impl/GestureRefresh.ts +55 -0
  20. package/mobile/cmp/navigator/impl/GestureRefreshModel.ts +86 -0
  21. package/mobile/cmp/navigator/impl/Page.scss +4 -2
  22. package/mobile/cmp/navigator/impl/Page.ts +10 -12
  23. package/mobile/cmp/navigator/impl/Utils.ts +107 -0
  24. package/package.json +2 -1
  25. package/tsconfig.tsbuildinfo +1 -1
  26. package/build/types/mobile/cmp/navigator/impl/swipe/BackIndicator.d.ts +0 -6
  27. package/build/types/mobile/cmp/navigator/impl/swipe/RefreshIndicator.d.ts +0 -6
  28. package/build/types/mobile/cmp/navigator/impl/swipe/Swiper.d.ts +0 -8
  29. package/mobile/cmp/navigator/impl/swipe/BackIndicator.ts +0 -34
  30. package/mobile/cmp/navigator/impl/swipe/RefreshIndicator.ts +0 -36
  31. package/mobile/cmp/navigator/impl/swipe/Swiper.ts +0 -35
  32. 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, warnIf, mergeDeep} from '@xh/hoist/utils/js';
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 {page} from './impl/Page';
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 Onsen pages.
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 _navigator = null;
66
+ private _swiper: Swiper;
63
67
  private _callback: () => void;
64
- private _prevKeyStack: string[];
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
- const {stack} = this;
72
- return stack[stack.length - 1];
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
- private onRouteChange(init = null) {
135
- if (!this._navigator || !XH.routerState) return;
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
- const page = new PageModel({
180
- navigatorModel: this,
181
- ...mergeDeep({}, pageModelCfg, part)
182
- });
183
-
184
- stack.push(page);
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
- this.stack = stack;
188
- }
189
-
190
- private async onStackChangeAsync() {
191
- // Sync Onsen Navigator's pages with our stack
192
- if (!this._navigator) return;
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
- this._prevKeyStack = keyStack;
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
- // If we have gone back one page in the same stack, we can safely pop() the page
209
- return this._navigator.popPage(options);
210
- } else if (forwardOnePage) {
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, we should reset the page stack
215
- return this._navigator.resetPageStack(stack, {animation: 'none'});
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
  }
@@ -7,7 +7,12 @@
7
7
  $size: 36px;
8
8
  $half-size: 18px;
9
9
 
10
- .xh-swiper-indicator {
10
+ .xh-gesture-refresh {
11
+ flex: 1;
12
+ width: 100%;
13
+ }
14
+
15
+ .xh-gesture-refresh-indicator {
11
16
  margin: -$half-size 0 0;
12
17
  position: absolute;
13
18
  display: flex;
@@ -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
+ }
@@ -1,7 +1,9 @@
1
- .xh-page .page__content {
1
+ .xh-page {
2
2
  display: flex;
3
3
  align-items: stretch;
4
4
  flex-direction: column;
5
- transform: translateX(0);
5
+ height: 100%;
6
+ width: 100%;
7
+ background: var(--xh-bg);
6
8
  font-family: var(--xh-font-family);
7
9
  }
@@ -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 {page as onsenPage} from '@xh/hoist/kit/onsen';
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
- wasActivated = useRef(false);
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 (!wasActivated.current && isActive) wasActivated.current = true;
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 onsenPage({className: 'xh-page'});
37
+ return div({className: 'xh-page'});
40
38
  }
41
39
 
42
40
  return refreshContextView({
43
41
  model: refreshContextModel,
44
- item: onsenPage({
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.1737063455869",
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": {