@object-ui/mobile 17.6.0 → 17.7.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.
- package/CHANGELOG.md +824 -0
- package/README.md +183 -34
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/usePullToRefresh.d.ts.map +1 -1
- package/dist/usePullToRefresh.js +96 -11
- package/dist/useSpecGesture.d.ts +12 -6
- package/dist/useSpecGesture.d.ts.map +1 -1
- package/dist/useSpecGesture.js +36 -20
- package/package.json +5 -5
- package/dist/useResponsiveConfig.d.ts +0 -71
- package/dist/useResponsiveConfig.d.ts.map +0 -1
- package/dist/useResponsiveConfig.js +0 -41
package/README.md
CHANGED
|
@@ -25,18 +25,22 @@ npm install @object-ui/mobile
|
|
|
25
25
|
## Quick Start
|
|
26
26
|
|
|
27
27
|
```tsx
|
|
28
|
-
import { MobileProvider, useBreakpoint
|
|
28
|
+
import { MobileProvider, useBreakpoint } from '@object-ui/mobile';
|
|
29
29
|
|
|
30
|
-
function
|
|
31
|
-
return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
function MobileNav() {
|
|
31
|
+
return <nav>Mobile navigation</nav>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function DesktopSidebar() {
|
|
35
|
+
return <aside>Desktop sidebar</aside>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function MainContent() {
|
|
39
|
+
return <main>Main content</main>;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
function ResponsiveApp() {
|
|
39
|
-
const { isMobile,
|
|
43
|
+
const { isMobile, isDesktop } = useBreakpoint();
|
|
40
44
|
|
|
41
45
|
return (
|
|
42
46
|
<div>
|
|
@@ -46,84 +50,229 @@ function ResponsiveApp() {
|
|
|
46
50
|
</div>
|
|
47
51
|
);
|
|
48
52
|
}
|
|
53
|
+
|
|
54
|
+
export function App() {
|
|
55
|
+
return (
|
|
56
|
+
<MobileProvider>
|
|
57
|
+
<ResponsiveApp />
|
|
58
|
+
</MobileProvider>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
49
61
|
```
|
|
50
62
|
|
|
51
63
|
## API
|
|
52
64
|
|
|
53
65
|
### MobileProvider
|
|
54
66
|
|
|
55
|
-
Wraps your application with mobile context:
|
|
67
|
+
Wraps your application with mobile context. Both props are optional: `pwa` takes a
|
|
68
|
+
`PWAConfig`, `offline` takes a `PWAOfflineConfig`.
|
|
56
69
|
|
|
57
70
|
```tsx
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
71
|
+
import { MobileProvider } from '@object-ui/mobile';
|
|
72
|
+
|
|
73
|
+
function App() {
|
|
74
|
+
return <p>Your application</p>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function Root() {
|
|
78
|
+
return (
|
|
79
|
+
<MobileProvider>
|
|
80
|
+
<App />
|
|
81
|
+
</MobileProvider>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
61
84
|
```
|
|
62
85
|
|
|
63
86
|
### useBreakpoint
|
|
64
87
|
|
|
65
|
-
Hook for detecting the current breakpoint
|
|
88
|
+
Hook for detecting the current breakpoint. The current breakpoint name is
|
|
89
|
+
`breakpoint` — one of `xs`, `sm`, `md`, `lg`, `xl`, `2xl`:
|
|
66
90
|
|
|
67
91
|
```tsx
|
|
68
|
-
|
|
92
|
+
import { useBreakpoint } from '@object-ui/mobile';
|
|
93
|
+
|
|
94
|
+
export function BreakpointBadge() {
|
|
95
|
+
const { isMobile, isTablet, isDesktop, breakpoint, width } = useBreakpoint();
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<span>
|
|
99
|
+
{breakpoint} at {width}px — mobile {String(isMobile)}, tablet {String(isTablet)}, desktop{' '}
|
|
100
|
+
{String(isDesktop)}
|
|
101
|
+
</span>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
69
104
|
```
|
|
70
105
|
|
|
106
|
+
`isAbove(bp)` and `isBelow(bp)` are also returned, for comparisons against a named
|
|
107
|
+
breakpoint.
|
|
108
|
+
|
|
71
109
|
### useResponsive
|
|
72
110
|
|
|
73
|
-
Hook for responsive values based on screen size
|
|
111
|
+
Hook for responsive values based on screen size. The keys are breakpoint names; a
|
|
112
|
+
breakpoint with no entry falls back to the next smaller one that has one:
|
|
74
113
|
|
|
75
114
|
```tsx
|
|
76
|
-
|
|
115
|
+
import { useResponsive } from '@object-ui/mobile';
|
|
116
|
+
|
|
117
|
+
export function ResponsiveGrid() {
|
|
118
|
+
const columns = useResponsive({ xs: 1, md: 2, lg: 4 });
|
|
119
|
+
|
|
120
|
+
return <div data-columns={columns}>{columns} column(s)</div>;
|
|
121
|
+
}
|
|
77
122
|
```
|
|
78
123
|
|
|
79
124
|
### useGesture / useSpecGesture
|
|
80
125
|
|
|
81
|
-
|
|
126
|
+
`useGesture` detects one gesture from Object UI's direction-fused vocabulary
|
|
127
|
+
(`tap`, `double-tap`, `long-press`, `swipe-left`, `swipe-right`, `swipe-up`,
|
|
128
|
+
`swipe-down`, `pinch`, `rotate`, `pan`) per call, and returns a ref to attach:
|
|
82
129
|
|
|
83
130
|
```tsx
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
131
|
+
import { useGesture } from '@object-ui/mobile';
|
|
132
|
+
|
|
133
|
+
function navigateNext() {}
|
|
134
|
+
function navigateBack() {}
|
|
135
|
+
|
|
136
|
+
export function SwipeArea() {
|
|
137
|
+
const nextRef = useGesture<HTMLDivElement>({
|
|
138
|
+
type: 'swipe-left',
|
|
139
|
+
onGesture: () => navigateNext(),
|
|
140
|
+
});
|
|
141
|
+
const backRef = useGesture<HTMLDivElement>({
|
|
142
|
+
type: 'swipe-right',
|
|
143
|
+
onGesture: () => navigateBack(),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div>
|
|
148
|
+
<div ref={nextRef}>Swipe left for the next record</div>
|
|
149
|
+
<div ref={backRef}>Swipe right to go back</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`useSpecGesture` takes the declarative `SpecGestureConfig` tuning shape instead, and
|
|
156
|
+
dispatches to per-gesture callbacks:
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
import { useSpecGesture } from '@object-ui/mobile';
|
|
160
|
+
|
|
161
|
+
function handleZoom(scale: number) {
|
|
162
|
+
return scale;
|
|
163
|
+
}
|
|
89
164
|
|
|
90
|
-
|
|
165
|
+
export function PinchArea() {
|
|
166
|
+
const ref = useSpecGesture<HTMLDivElement>({
|
|
167
|
+
config: { type: 'pinch', enabled: true, pinch: { minScale: 0.5, maxScale: 3 } },
|
|
168
|
+
onPinch: (scale) => handleZoom(scale),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return <div ref={ref}>Pinchable content</div>;
|
|
172
|
+
}
|
|
91
173
|
```
|
|
92
174
|
|
|
93
175
|
### usePullToRefresh
|
|
94
176
|
|
|
95
|
-
Hook for pull-to-refresh behavior
|
|
177
|
+
Hook for pull-to-refresh behavior. Attach the returned `ref` to the scrollable
|
|
178
|
+
container:
|
|
96
179
|
|
|
97
180
|
```tsx
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
181
|
+
import { usePullToRefresh } from '@object-ui/mobile';
|
|
182
|
+
|
|
183
|
+
async function fetchData(): Promise<void> {}
|
|
184
|
+
|
|
185
|
+
export function Feed() {
|
|
186
|
+
const { ref, isRefreshing, pullDistance } = usePullToRefresh<HTMLDivElement>({
|
|
187
|
+
onRefresh: async () => await fetchData(),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div ref={ref} data-pull-distance={pullDistance}>
|
|
192
|
+
{isRefreshing ? 'Refreshing…' : 'Pull to refresh'}
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
101
196
|
```
|
|
102
197
|
|
|
198
|
+
The element does not have to exist on the first render. A component that shows
|
|
199
|
+
a loading screen first and attaches `ref` once its data arrives works, and so
|
|
200
|
+
does one that later swaps in a new element. The hook binds its touch listeners
|
|
201
|
+
to whatever element `ref` points at after each render.
|
|
202
|
+
|
|
203
|
+
Pull hosts can nest, for example a list view that renders a grid view inside
|
|
204
|
+
it. One pull belongs to the outermost armed host: an inner host lets the
|
|
205
|
+
gesture go when an armed pull host sits above it in the DOM, so a single pull
|
|
206
|
+
draws one indicator and runs one `onRefresh`.
|
|
207
|
+
|
|
103
208
|
### useTouchTarget
|
|
104
209
|
|
|
105
|
-
Hook for ensuring minimum touch target sizes
|
|
210
|
+
Hook for ensuring minimum touch target sizes. It returns the `style` and `className`
|
|
211
|
+
to spread onto the element; the defaults follow WCAG 2.5.5 (44×44 CSS pixels):
|
|
106
212
|
|
|
107
213
|
```tsx
|
|
108
|
-
|
|
109
|
-
|
|
214
|
+
import { useTouchTarget } from '@object-ui/mobile';
|
|
215
|
+
|
|
216
|
+
export function TapButton() {
|
|
217
|
+
const { style, className } = useTouchTarget({
|
|
218
|
+
config: { minWidth: 44, minHeight: 44 },
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<button style={style} className={className}>
|
|
223
|
+
Tap me
|
|
224
|
+
</button>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
110
227
|
```
|
|
111
228
|
|
|
112
229
|
### ResponsiveContainer
|
|
113
230
|
|
|
114
|
-
Renders children based on breakpoint
|
|
231
|
+
Renders children based on breakpoint. Pick the range with `minBreakpoint` /
|
|
232
|
+
`maxBreakpoint`, or name the breakpoints outright with `showOn` / `hideOn`:
|
|
115
233
|
|
|
116
234
|
```tsx
|
|
117
|
-
|
|
235
|
+
import { ResponsiveContainer } from '@object-ui/mobile';
|
|
236
|
+
|
|
237
|
+
function MobileView() {
|
|
238
|
+
return <p>Compact layout</p>;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function DesktopView() {
|
|
242
|
+
return <p>Full layout</p>;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function BreakpointSwitch() {
|
|
246
|
+
return (
|
|
247
|
+
<div>
|
|
248
|
+
<ResponsiveContainer maxBreakpoint="md">
|
|
249
|
+
<MobileView />
|
|
250
|
+
</ResponsiveContainer>
|
|
251
|
+
<ResponsiveContainer minBreakpoint="lg">
|
|
252
|
+
<DesktopView />
|
|
253
|
+
</ResponsiveContainer>
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
118
257
|
```
|
|
119
258
|
|
|
120
259
|
### PWA Utilities
|
|
121
260
|
|
|
261
|
+
`PWAConfig` requires `enabled`, `name` and `shortName`. `registerServiceWorker` takes
|
|
262
|
+
the script `url` and `scope` plus lifecycle callbacks — the caching strategies live in
|
|
263
|
+
the generated worker (`getServiceWorkerSource`), not in this call:
|
|
264
|
+
|
|
122
265
|
```tsx
|
|
123
266
|
import { generatePWAManifest, registerServiceWorker } from '@object-ui/mobile';
|
|
124
267
|
|
|
125
|
-
const manifest = generatePWAManifest({
|
|
126
|
-
|
|
268
|
+
export const manifest = generatePWAManifest({
|
|
269
|
+
enabled: true,
|
|
270
|
+
name: 'My App',
|
|
271
|
+
shortName: 'My App',
|
|
272
|
+
themeColor: '#000',
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
void registerServiceWorker({ url: '/service-worker.js' });
|
|
127
276
|
```
|
|
128
277
|
|
|
129
278
|
## Links
|
package/dist/index.d.ts
CHANGED
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
*/
|
|
20
20
|
export { useBreakpoint, type BreakpointState } from './useBreakpoint.js';
|
|
21
21
|
export { useResponsive } from './useResponsive.js';
|
|
22
|
-
export { useResponsiveConfig, type SpecResponsiveConfig, type ResolvedResponsiveState } from './useResponsiveConfig.js';
|
|
23
22
|
export { useGesture, type UseGestureOptions } from './useGesture.js';
|
|
24
23
|
export { useSpecGesture, type UseSpecGestureOptions } from './useSpecGesture.js';
|
|
25
24
|
export { useTouchTarget, type UseTouchTargetOptions, type TouchTargetResult } from './useTouchTarget.js';
|
|
@@ -33,5 +32,5 @@ export { createOfflineDataSource, type OfflineDataSource, type OfflineDataSource
|
|
|
33
32
|
export { useOfflineSync, type OfflineSyncState } from './useOfflineSync.js';
|
|
34
33
|
export { getServiceWorkerSource, requestBackgroundSync, type ServiceWorkerSourceOptions, } from './serviceWorkerSource.js';
|
|
35
34
|
export { BREAKPOINTS, resolveResponsiveValue } from './breakpoints.js';
|
|
36
|
-
export type { BreakpointName, ResponsiveValue,
|
|
35
|
+
export type { BreakpointName, ResponsiveValue, PWAConfig, PWAIcon, FetchCacheStrategy, PWAOfflineConfig, OfflineRoute, GestureType, GestureContext, SpecGestureConfig, SwipeGestureConfig, PinchGestureConfig, LongPressGestureConfig, TouchInteraction, TouchTargetConfig, } from '@object-ui/types';
|
|
37
36
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,aAAa,EAAE,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACzE,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,aAAa,EAAE,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACzE,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAQnD,OAAO,EAAE,UAAU,EAAE,KAAK,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACrE,OAAO,EAAE,cAAc,EAAE,KAAK,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AACjF,OAAO,EAAE,cAAc,EAAE,KAAK,qBAAqB,EAAE,KAAK,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACzG,OAAO,EAAE,gBAAgB,EAAE,KAAK,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AACpF,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC/E,OAAO,EAAE,mBAAmB,EAAE,KAAK,wBAAwB,EAAE,MAAM,0BAA0B,CAAC;AAC9F,OAAO,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAC/C,OAAO,EAAE,qBAAqB,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACrF,OAAO,EACL,kBAAkB,EAClB,qBAAqB,EACrB,kBAAkB,EAClB,YAAY,EACZ,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,GACzB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,uBAAuB,EACvB,KAAK,iBAAiB,EACtB,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,GACzB,MAAM,8BAA8B,CAAC;AACtC,OAAO,EAAE,cAAc,EAAE,KAAK,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC5E,OAAO,EACL,sBAAsB,EACtB,qBAAqB,EACrB,KAAK,0BAA0B,GAChC,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,WAAW,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAGvE,YAAY,EACV,cAAc,EACd,eAAe,EAMf,SAAS,EACT,OAAO,EACP,kBAAkB,EAClB,gBAAgB,EAChB,YAAY,EACZ,WAAW,EACX,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,kBAAkB,EAClB,sBAAsB,EACtB,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,kBAAkB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -19,7 +19,13 @@
|
|
|
19
19
|
*/
|
|
20
20
|
export { useBreakpoint } from './useBreakpoint.js';
|
|
21
21
|
export { useResponsive } from './useResponsive.js';
|
|
22
|
-
|
|
22
|
+
// `useResponsiveConfig` REMOVED (objectui#7580, maintainer ruling 2026-09-04).
|
|
23
|
+
// It read `@objectstack/spec`'s `ResponsiveConfigSchema`, which objectstack#11027
|
|
24
|
+
// retired; it was measured at zero callers on objectui#4773. Its exported
|
|
25
|
+
// `SpecResponsiveConfig` / `ResolvedResponsiveState` went with it — both named
|
|
26
|
+
// the retired schema's shape and neither had a reader. No behaviour is retired:
|
|
27
|
+
// the live per-breakpoint readers are `useBreakpoint` and
|
|
28
|
+
// `ResponsiveContainer` below, which are untouched.
|
|
23
29
|
export { useGesture } from './useGesture.js';
|
|
24
30
|
export { useSpecGesture } from './useSpecGesture.js';
|
|
25
31
|
export { useTouchTarget } from './useTouchTarget.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"usePullToRefresh.d.ts","sourceRoot":"","sources":["../src/usePullToRefresh.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,MAAM,WAAW,oBAAoB;IACnC,iDAAiD;IACjD,SAAS,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/B,wDAAwD;IACxD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yCAAyC;IACzC,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,EAClE,OAAO,EAAE,oBAAoB;;;;
|
|
1
|
+
{"version":3,"file":"usePullToRefresh.d.ts","sourceRoot":"","sources":["../src/usePullToRefresh.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,MAAM,WAAW,oBAAoB;IACnC,iDAAiD;IACjD,SAAS,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/B,wDAAwD;IACxD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yCAAyC;IACzC,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,EAClE,OAAO,EAAE,oBAAoB;;;;EAoG9B"}
|
package/dist/usePullToRefresh.js
CHANGED
|
@@ -50,18 +50,103 @@ export function usePullToRefresh(options) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
}, [enabled, isRefreshing, pullDistance, threshold, onRefresh]);
|
|
53
|
+
/**
|
|
54
|
+
* The listeners follow the ELEMENT, not the mount (objectui#10105).
|
|
55
|
+
*
|
|
56
|
+
* `ref` is an object ref, so nothing tells this hook when a consumer
|
|
57
|
+
* attaches it. The binding used to live in an effect keyed on the handlers
|
|
58
|
+
* and `enabled`, which read `ref.current` once and then waited for one of
|
|
59
|
+
* those to change. A consumer whose first commit is a loading screen has
|
|
60
|
+
* `ref.current === null` at that moment. When the real host appears later,
|
|
61
|
+
* none of those keys has changed, so the effect never ran again and the
|
|
62
|
+
* gesture was dead. The consumers that do this, and the ones that do not,
|
|
63
|
+
* were measured on objectui#10105; the list is not repeated here because it
|
|
64
|
+
* would go stale. The same happened when a refresh put the loading screen
|
|
65
|
+
* back and the host came back as a NEW element: the listeners stayed on the
|
|
66
|
+
* old, detached one.
|
|
67
|
+
*
|
|
68
|
+
* So the binding effect below has no dependency list and runs after every
|
|
69
|
+
* commit. It re-binds only when the element it SHOULD be bound to
|
|
70
|
+
* (`ref.current`, or `null` while disabled) differs from the one it IS bound
|
|
71
|
+
* to. That is a DOM-identity comparison, never a comparison of memoised
|
|
72
|
+
* identities (AGENTS.md §5 #10). The native listeners stay the same for the
|
|
73
|
+
* life of a binding and call the latest handlers through `handlersRef`, so a
|
|
74
|
+
* state change such as each `pullDistance` step during a gesture no longer
|
|
75
|
+
* re-binds anything.
|
|
76
|
+
*/
|
|
77
|
+
const handlersRef = useRef({ handleTouchStart, handleTouchMove, handleTouchEnd });
|
|
53
78
|
useEffect(() => {
|
|
54
|
-
|
|
55
|
-
|
|
79
|
+
handlersRef.current = { handleTouchStart, handleTouchMove, handleTouchEnd };
|
|
80
|
+
});
|
|
81
|
+
const bindingRef = useRef(null);
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const target = enabled ? ref.current : null;
|
|
84
|
+
const bound = bindingRef.current;
|
|
85
|
+
if ((bound ? bound.el : null) === target)
|
|
56
86
|
return;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
87
|
+
if (bound)
|
|
88
|
+
unbindPullListeners(bound);
|
|
89
|
+
bindingRef.current = target ? bindPullListeners(target, handlersRef) : null;
|
|
90
|
+
});
|
|
91
|
+
// Unmount. The per-commit effect above returns no cleanup on purpose, since
|
|
92
|
+
// a cleanup there would re-bind on every commit. This is where the last
|
|
93
|
+
// binding is released. It releases the exact listeners that were added,
|
|
94
|
+
// which are recorded on the binding, so nothing depends on an identity
|
|
95
|
+
// staying stable.
|
|
96
|
+
useEffect(() => () => {
|
|
97
|
+
const bound = bindingRef.current;
|
|
98
|
+
if (bound)
|
|
99
|
+
unbindPullListeners(bound);
|
|
100
|
+
bindingRef.current = null;
|
|
101
|
+
}, []);
|
|
66
102
|
return { ref, isRefreshing, pullDistance };
|
|
67
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* One gesture, one owner: the OUTERMOST armed pull host (objectui#10105).
|
|
106
|
+
*
|
|
107
|
+
* Pull hosts nest. `ListView` has one, and so does the view it renders inside
|
|
108
|
+
* it. A touch on the inner host bubbles to the outer one, so without a rule
|
|
109
|
+
* both hooks drew an indicator and both ran their refresh for the same pull.
|
|
110
|
+
* The outer one is the owner: its single refetch reloads the rows the inner
|
|
111
|
+
* view was handed, and an inner view fed its rows by a parent has no refresh of
|
|
112
|
+
* its own to run.
|
|
113
|
+
*
|
|
114
|
+
* While bound, the hook marks its element with this attribute. It is a DOM
|
|
115
|
+
* attribute and not module state so that two copies of this package in one
|
|
116
|
+
* page still see each other. At `touchstart` an inner host looks for a marked
|
|
117
|
+
* ANCESTOR, and if one is armed it lets the gesture go: it never records a
|
|
118
|
+
* start point, so its move and end handlers do nothing for this gesture. The
|
|
119
|
+
* native listeners and the DOM ancestry are the same relation (the outer
|
|
120
|
+
* listener receives this event because it is an ancestor), which is why the
|
|
121
|
+
* check reads the DOM and not the React tree.
|
|
122
|
+
*/
|
|
123
|
+
const PULL_HOST_ATTRIBUTE = 'data-pull-to-refresh-host';
|
|
124
|
+
function armedPullHostAbove(el) {
|
|
125
|
+
const parent = el.parentElement;
|
|
126
|
+
return parent !== null && parent.closest(`[${PULL_HOST_ATTRIBUTE}]`) !== null;
|
|
127
|
+
}
|
|
128
|
+
function bindPullListeners(el, handlers) {
|
|
129
|
+
const binding = {
|
|
130
|
+
el,
|
|
131
|
+
start: (e) => {
|
|
132
|
+
if (armedPullHostAbove(el))
|
|
133
|
+
return;
|
|
134
|
+
handlers.current.handleTouchStart(e);
|
|
135
|
+
},
|
|
136
|
+
move: (e) => handlers.current.handleTouchMove(e),
|
|
137
|
+
end: () => {
|
|
138
|
+
void handlers.current.handleTouchEnd();
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
el.setAttribute(PULL_HOST_ATTRIBUTE, '');
|
|
142
|
+
el.addEventListener('touchstart', binding.start, { passive: true });
|
|
143
|
+
el.addEventListener('touchmove', binding.move, { passive: true });
|
|
144
|
+
el.addEventListener('touchend', binding.end, { passive: true });
|
|
145
|
+
return binding;
|
|
146
|
+
}
|
|
147
|
+
function unbindPullListeners(binding) {
|
|
148
|
+
binding.el.removeAttribute(PULL_HOST_ATTRIBUTE);
|
|
149
|
+
binding.el.removeEventListener('touchstart', binding.start);
|
|
150
|
+
binding.el.removeEventListener('touchmove', binding.move);
|
|
151
|
+
binding.el.removeEventListener('touchend', binding.end);
|
|
152
|
+
}
|
package/dist/useSpecGesture.d.ts
CHANGED
|
@@ -21,7 +21,10 @@ export interface UseSpecGestureOptions {
|
|
|
21
21
|
onPan?: (direction: string) => void;
|
|
22
22
|
/** Callback when a rotate gesture is detected (degrees, CW positive) */
|
|
23
23
|
onRotate?: (rotation: number) => void;
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Fallback for gestures without a dedicated callback. Its `type` is the
|
|
26
|
+
* DECLARED spec gesture, never the recognizer's own name for the same move.
|
|
27
|
+
*/
|
|
25
28
|
onGesture?: (context: {
|
|
26
29
|
type: string;
|
|
27
30
|
direction?: string;
|
|
@@ -35,8 +38,10 @@ export interface UseSpecGestureOptions {
|
|
|
35
38
|
* {@link GestureType} `useGesture` implements. Note the two sides are
|
|
36
39
|
* different vocabularies, which is why this map exists at all: the retired
|
|
37
40
|
* spec's `drag` and `pan` are one recognizer
|
|
38
|
-
* (any-direction move past the threshold); `swipe`
|
|
39
|
-
*
|
|
41
|
+
* (any-direction move past the threshold); `swipe` declares a SET of
|
|
42
|
+
* directions, so it recognizes on that same any-direction move and fires only
|
|
43
|
+
* when the DETECTED direction is a member of the declared set — no single
|
|
44
|
+
* recognizer name carries that, which is why its entry below is a placeholder.
|
|
40
45
|
* Exported for the spec-parity test.
|
|
41
46
|
*
|
|
42
47
|
* Before #2942 the hook never read `config.type` at all — it branched on
|
|
@@ -47,13 +52,14 @@ export interface UseSpecGestureOptions {
|
|
|
47
52
|
export declare const SPEC_GESTURE_TYPE_MAP: Record<string, GestureType>;
|
|
48
53
|
/**
|
|
49
54
|
* Spec-aware gesture hook that maps a {@link SpecGestureConfig} — the retired
|
|
50
|
-
* `@objectstack/spec` `ui/touch` shape,
|
|
51
|
-
* `
|
|
55
|
+
* `@objectstack/spec` `ui/touch` tuning shape, a different contract from the
|
|
56
|
+
* direction-fused {@link GestureType} vocabulary `useGesture` speaks — onto
|
|
57
|
+
* the existing useGesture hook.
|
|
52
58
|
*
|
|
53
59
|
* @example
|
|
54
60
|
* ```tsx
|
|
55
61
|
* const ref = useSpecGesture({
|
|
56
|
-
* config: { type: 'swipe', enabled: true, swipe: { direction: 'left', threshold: 80 } },
|
|
62
|
+
* config: { type: 'swipe', enabled: true, swipe: { direction: ['left'], threshold: 80 } },
|
|
57
63
|
* onSwipe: (dir) => console.log('Swiped', dir),
|
|
58
64
|
* });
|
|
59
65
|
* return <div ref={ref}>Swipe me</div>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useSpecGesture.d.ts","sourceRoot":"","sources":["../src/useSpecGesture.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAEvE,MAAM,WAAW,qBAAqB;IACpC,iCAAiC;IACjC,MAAM,EAAE,iBAAiB,CAAC;IAC1B,gDAAgD;IAChD,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,gDAAgD;IAChD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB,wEAAwE;IACxE,KAAK,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC
|
|
1
|
+
{"version":3,"file":"useSpecGesture.d.ts","sourceRoot":"","sources":["../src/useSpecGesture.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAEvE,MAAM,WAAW,qBAAqB;IACpC,iCAAiC;IACjC,MAAM,EAAE,iBAAiB,CAAC;IAC1B,gDAAgD;IAChD,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,gDAAgD;IAChD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB,wEAAwE;IACxE,KAAK,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC;;;OAGG;IACH,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACxG;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,qBAAqB,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAQ7D,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,EAChE,OAAO,EAAE,qBAAqB,uCAoF/B"}
|
package/dist/useSpecGesture.js
CHANGED
|
@@ -6,20 +6,16 @@
|
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
8
|
import { useGesture } from './useGesture.js';
|
|
9
|
-
const SWIPE_DIRECTION_MAP = {
|
|
10
|
-
left: 'swipe-left',
|
|
11
|
-
right: 'swipe-right',
|
|
12
|
-
up: 'swipe-up',
|
|
13
|
-
down: 'swipe-down',
|
|
14
|
-
};
|
|
15
9
|
/**
|
|
16
10
|
* `SPEC_GESTURE_TYPES` (the retired `ui/touch` vocabulary, owned by
|
|
17
11
|
* `@object-ui/types` since objectstack#4988) → the recognizer
|
|
18
12
|
* {@link GestureType} `useGesture` implements. Note the two sides are
|
|
19
13
|
* different vocabularies, which is why this map exists at all: the retired
|
|
20
14
|
* spec's `drag` and `pan` are one recognizer
|
|
21
|
-
* (any-direction move past the threshold); `swipe`
|
|
22
|
-
*
|
|
15
|
+
* (any-direction move past the threshold); `swipe` declares a SET of
|
|
16
|
+
* directions, so it recognizes on that same any-direction move and fires only
|
|
17
|
+
* when the DETECTED direction is a member of the declared set — no single
|
|
18
|
+
* recognizer name carries that, which is why its entry below is a placeholder.
|
|
23
19
|
* Exported for the spec-parity test.
|
|
24
20
|
*
|
|
25
21
|
* Before #2942 the hook never read `config.type` at all — it branched on
|
|
@@ -28,7 +24,7 @@ const SWIPE_DIRECTION_MAP = {
|
|
|
28
24
|
* fell through to the `'tap'` initializer and fired on a tap.
|
|
29
25
|
*/
|
|
30
26
|
export const SPEC_GESTURE_TYPE_MAP = {
|
|
31
|
-
swipe: 'swipe-left', //
|
|
27
|
+
swipe: 'swipe-left', // placeholder; the hook recognizes any direction and filters by the declared set
|
|
32
28
|
pinch: 'pinch',
|
|
33
29
|
long_press: 'long-press',
|
|
34
30
|
double_tap: 'double-tap',
|
|
@@ -38,13 +34,14 @@ export const SPEC_GESTURE_TYPE_MAP = {
|
|
|
38
34
|
};
|
|
39
35
|
/**
|
|
40
36
|
* Spec-aware gesture hook that maps a {@link SpecGestureConfig} — the retired
|
|
41
|
-
* `@objectstack/spec` `ui/touch` shape,
|
|
42
|
-
* `
|
|
37
|
+
* `@objectstack/spec` `ui/touch` tuning shape, a different contract from the
|
|
38
|
+
* direction-fused {@link GestureType} vocabulary `useGesture` speaks — onto
|
|
39
|
+
* the existing useGesture hook.
|
|
43
40
|
*
|
|
44
41
|
* @example
|
|
45
42
|
* ```tsx
|
|
46
43
|
* const ref = useSpecGesture({
|
|
47
|
-
* config: { type: 'swipe', enabled: true, swipe: { direction: 'left', threshold: 80 } },
|
|
44
|
+
* config: { type: 'swipe', enabled: true, swipe: { direction: ['left'], threshold: 80 } },
|
|
48
45
|
* onSwipe: (dir) => console.log('Swiped', dir),
|
|
49
46
|
* });
|
|
50
47
|
* return <div ref={ref}>Swipe me</div>;
|
|
@@ -69,15 +66,34 @@ export function useSpecGesture(options) {
|
|
|
69
66
|
let threshold;
|
|
70
67
|
let longPressDuration;
|
|
71
68
|
let onGesture = () => { };
|
|
69
|
+
// `type` goes LAST in every payload below. The recognizer's own `type`
|
|
70
|
+
// travels inside `ctx` at runtime (`GestureContext.type`), so a `type`
|
|
71
|
+
// written BEFORE the spread is overwritten by it and the callback reports the
|
|
72
|
+
// recognizer instead of the declared spec gesture (objectui#9691). The two
|
|
73
|
+
// are different vocabularies — see `SPEC_GESTURE_TYPE_MAP` — so they agree
|
|
74
|
+
// only by coincidence, arm by arm.
|
|
72
75
|
const fallback = (ctx) => onAny?.(ctx);
|
|
73
76
|
switch (declared) {
|
|
74
77
|
case 'swipe': {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
// `SwipeGestureConfig.direction` is declared as a SET
|
|
79
|
+
// (`SpecSwipeDirection[]`), so recognition is the any-direction move past
|
|
80
|
+
// the threshold and the swipe fires only when the DETECTED direction is a
|
|
81
|
+
// MEMBER of that set. `direction[0]` would honour one element of a declared
|
|
82
|
+
// many; a scalar `direction` is rejected by the declared type and nothing
|
|
83
|
+
// re-admits it here (AGENTS.md #0.1). An empty or absent set declares no
|
|
84
|
+
// direction, so it fires for none.
|
|
85
|
+
const declaredDirections = config.swipe?.direction ?? [];
|
|
86
|
+
gestureType = 'pan';
|
|
79
87
|
threshold = config.swipe?.threshold;
|
|
80
|
-
onGesture = (ctx) =>
|
|
88
|
+
onGesture = (ctx) => {
|
|
89
|
+
const detected = ctx.direction;
|
|
90
|
+
if (detected === undefined || !declaredDirections.includes(detected))
|
|
91
|
+
return;
|
|
92
|
+
if (onSwipe)
|
|
93
|
+
onSwipe(detected);
|
|
94
|
+
else
|
|
95
|
+
fallback({ ...ctx, type: 'swipe' });
|
|
96
|
+
};
|
|
81
97
|
break;
|
|
82
98
|
}
|
|
83
99
|
case 'long_press':
|
|
@@ -87,7 +103,7 @@ export function useSpecGesture(options) {
|
|
|
87
103
|
break;
|
|
88
104
|
case 'pinch':
|
|
89
105
|
gestureType = 'pinch';
|
|
90
|
-
onGesture = (ctx) => (onPinch ? onPinch(ctx.scale ?? 1) : fallback({ type: 'pinch'
|
|
106
|
+
onGesture = (ctx) => (onPinch ? onPinch(ctx.scale ?? 1) : fallback({ ...ctx, type: 'pinch' }));
|
|
91
107
|
break;
|
|
92
108
|
case 'double_tap':
|
|
93
109
|
gestureType = 'double-tap';
|
|
@@ -96,11 +112,11 @@ export function useSpecGesture(options) {
|
|
|
96
112
|
case 'pan':
|
|
97
113
|
case 'drag':
|
|
98
114
|
gestureType = 'pan';
|
|
99
|
-
onGesture = (ctx) => (onPan ? onPan(ctx.direction ?? 'left') : fallback({ type: declared
|
|
115
|
+
onGesture = (ctx) => (onPan ? onPan(ctx.direction ?? 'left') : fallback({ ...ctx, type: declared }));
|
|
100
116
|
break;
|
|
101
117
|
case 'rotate':
|
|
102
118
|
gestureType = 'rotate';
|
|
103
|
-
onGesture = (ctx) => (onRotate ? onRotate(ctx.rotation ?? 0) : fallback({ type: 'rotate'
|
|
119
|
+
onGesture = (ctx) => (onRotate ? onRotate(ctx.rotation ?? 0) : fallback({ ...ctx, type: 'rotate' }));
|
|
104
120
|
break;
|
|
105
121
|
default:
|
|
106
122
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/mobile",
|
|
3
|
-
"version": "17.
|
|
3
|
+
"version": "17.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Mobile optimization for Object UI with responsive components, PWA support, and touch gesture handling.",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"react": "^18.0.0 || ^19.0.0"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@object-ui/types": "17.
|
|
33
|
+
"@object-ui/types": "17.7.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@objectstack/spec": "^17.0.0",
|
|
@@ -61,9 +61,9 @@
|
|
|
61
61
|
"access": "public"
|
|
62
62
|
},
|
|
63
63
|
"scripts": {
|
|
64
|
-
"build": "tsc",
|
|
65
|
-
"clean": "rm -rf dist",
|
|
66
|
-
"test": "vitest run",
|
|
64
|
+
"build": "tsc && node ../../scripts/check-dist-completeness.mjs",
|
|
65
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
|
66
|
+
"test": "vitest run --root ../.. packages/mobile/",
|
|
67
67
|
"type-check": "tsc --noEmit && tsc -p tsconfig.test.json",
|
|
68
68
|
"lint": "eslint ."
|
|
69
69
|
}
|