@object-ui/mobile 17.6.0 → 18.0.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/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, useGesture } from '@object-ui/mobile';
28
+ import { MobileProvider, useBreakpoint } from '@object-ui/mobile';
29
29
 
30
- function App() {
31
- return (
32
- <MobileProvider>
33
- <ResponsiveApp />
34
- </MobileProvider>
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, isTablet, isDesktop } = useBreakpoint();
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
- <MobileProvider>
59
- <App />
60
- </MobileProvider>
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
- const { isMobile, isTablet, isDesktop, current } = useBreakpoint();
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
- const columns = useResponsive({ mobile: 1, tablet: 2, desktop: 4 });
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
- Hooks for gesture detection on touch devices:
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
- const gestureRef = useGesture({
85
- onSwipeLeft: () => navigateNext(),
86
- onSwipeRight: () => navigateBack(),
87
- onPinch: (scale) => handleZoom(scale),
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
- return <div ref={gestureRef}>Swipeable content</div>;
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
- const { isRefreshing } = usePullToRefresh({
99
- onRefresh: async () => await fetchData(),
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
- const { targetProps } = useTouchTarget({ minSize: 44 });
109
- return <button {...targetProps}>Tap me</button>;
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
- <ResponsiveContainer mobile={<MobileView />} desktop={<DesktopView />} />
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({ name: 'My App', themeColor: '#000' });
126
- registerServiceWorker({ cacheStrategy: 'network-first' });
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, MobileResponsiveConfig, PWAConfig, PWAIcon, FetchCacheStrategy, PWAOfflineConfig, OfflineRoute, GestureType, GestureConfig, GestureContext, MobileComponentConfig, SpecGestureConfig, SwipeGestureConfig, PinchGestureConfig, LongPressGestureConfig, TouchInteraction, TouchTargetConfig, } from '@object-ui/types';
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
@@ -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;AACnD,OAAO,EAAE,mBAAmB,EAAE,KAAK,oBAAoB,EAAE,KAAK,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AACxH,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,EACf,sBAAsB,EACtB,SAAS,EACT,OAAO,EACP,kBAAkB,EAClB,gBAAgB,EAChB,YAAY,EACZ,WAAW,EACX,aAAa,EACb,cAAc,EACd,qBAAqB,EACrB,iBAAiB,EACjB,kBAAkB,EAClB,kBAAkB,EAClB,sBAAsB,EACtB,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,kBAAkB,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
- export { useResponsiveConfig } from './useResponsiveConfig.js';
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;;;;EA+D9B"}
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"}
@@ -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
- const el = ref.current;
55
- if (!el || !enabled)
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
- el.addEventListener('touchstart', handleTouchStart, { passive: true });
58
- el.addEventListener('touchmove', handleTouchMove, { passive: true });
59
- el.addEventListener('touchend', handleTouchEnd, { passive: true });
60
- return () => {
61
- el.removeEventListener('touchstart', handleTouchStart);
62
- el.removeEventListener('touchmove', handleTouchMove);
63
- el.removeEventListener('touchend', handleTouchEnd);
64
- };
65
- }, [handleTouchStart, handleTouchMove, handleTouchEnd, enabled]);
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
+ }
@@ -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
- /** Fallback for gestures without a dedicated callback */
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` resolves per configured
39
- * direction, so it maps through {@link SWIPE_DIRECTION_MAP} instead.
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, not this package's own
51
- * `GestureConfig` — onto the existing useGesture hook.
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,yDAAyD;IACzD,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;AASD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAQ7D,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,EAChE,OAAO,EAAE,qBAAqB,uCAoE/B"}
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"}
@@ -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` resolves per configured
22
- * direction, so it maps through {@link SWIPE_DIRECTION_MAP} instead.
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', // per-direction; resolved via SWIPE_DIRECTION_MAP
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, not this package's own
42
- * `GestureConfig` — onto the existing useGesture hook.
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
- const dir = Array.isArray(config.swipe?.direction)
76
- ? config.swipe?.direction[0]
77
- : config.swipe?.direction;
78
- gestureType = (dir ? SWIPE_DIRECTION_MAP[dir] : undefined) ?? 'swipe-left';
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) => (onSwipe ? onSwipe(ctx.direction ?? dir ?? 'left') : fallback({ type: 'swipe', ...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', ...ctx }));
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, ...ctx }));
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', ...ctx }));
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.6.0",
3
+ "version": "18.0.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,10 +30,10 @@
30
30
  "react": "^18.0.0 || ^19.0.0"
31
31
  },
32
32
  "dependencies": {
33
- "@object-ui/types": "17.6.0"
33
+ "@object-ui/types": "18.0.0"
34
34
  },
35
35
  "devDependencies": {
36
- "@objectstack/spec": "^17.0.0",
36
+ "@objectstack/spec": "^18.0.0",
37
37
  "@types/react": "19.2.18",
38
38
  "react": "19.2.8",
39
39
  "typescript": "^6.0.3",
@@ -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
  }