@octane-xplat/pager 0.14.0 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octane-xplat/pager",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Paged swipe container for Octane xplat — web scroll-snap row + @nativescript-community/ui-pager (ViewPager2 / paging UICollectionView)",
5
5
  "keywords": [
6
6
  "android",
@@ -24,9 +24,10 @@
24
24
  "directory": "packages/pager"
25
25
  },
26
26
  "files": [
27
- "dist",
28
27
  "types",
29
- "src"
28
+ "src",
29
+ "!src/**/*.test.*",
30
+ "!src/**/*.spec.*"
30
31
  ],
31
32
  "type": "module",
32
33
  "exports": {
@@ -36,9 +37,9 @@
36
37
  "default": "./src/index.macos.ts"
37
38
  },
38
39
  "types": "./types/index.d.ts",
39
- "web": "./dist/web/index.js",
40
- "native": "./dist/native/index.js",
41
- "default": "./dist/native/index.js"
40
+ "web": "./src/index.web.ts",
41
+ "native": "./src/index.ts",
42
+ "default": "./src/index.ts"
42
43
  }
43
44
  },
44
45
  "publishConfig": {
@@ -48,17 +49,20 @@
48
49
  "@nativescript-community/ui-pager": "14.1.42"
49
50
  },
50
51
  "devDependencies": {
51
- "@nativescript-community/octane": "0.2.4",
52
+ "@nativescript-community/octane": "0.2.5",
52
53
  "@nativescript/core": "9.1.3",
54
+ "@octane-xplat/macos-renderer": "0.16.0",
55
+ "@octane-xplat/ui": "0.16.0",
53
56
  "@octanejs/vite-plugin": "0.1.61",
54
57
  "octane": "0.11.0",
55
- "tsrx-typegen": "0.2.2",
58
+ "tsrx-typegen": "0.3.1",
56
59
  "typescript": "5.9.3",
57
60
  "vite": "8.3.0"
58
61
  },
59
62
  "peerDependencies": {
60
63
  "@nativescript-community/octane": ">=0.2.1 <1",
61
64
  "@nativescript/core": ">=9.1.0 <10",
65
+ "@octane-xplat/ui": ">=0.6.0 <1",
62
66
  "octane": ">=0.6.3 <1"
63
67
  },
64
68
  "peerDependenciesMeta": {
@@ -70,7 +74,7 @@
70
74
  }
71
75
  },
72
76
  "scripts": {
73
- "build": "vite build && vite build --mode native && pnpm typegen && node ../../scripts/check-native-dist.mjs",
77
+ "test:native": "vitest run --config vitest.native.config.mts",
74
78
  "typegen": "tsc -p tsconfig.types.json",
75
79
  "test:packed": "node tests/packed-consumer.mjs",
76
80
  "pack:check": "tsrx-typegen --pack-check"
@@ -0,0 +1,19 @@
1
+ import type { ParityCheck } from '@octane-xplat/ui/parity/check-helpers'
2
+ import { dims } from '@octane-xplat/ui/parity/check-helpers'
3
+
4
+ export const CHECKS: ParityCheck[] = [
5
+
6
+ {
7
+ fixture: 'pager-two-pages',
8
+ targets: ['web', 'ios', 'android'],
9
+ elements: { pager: 'parity-pager-root', page: 'parity-pager-page' },
10
+ equal: [
11
+ 'pager.box.w',
12
+ 'pager.box.h',
13
+ 'pager.style.backgroundColor',
14
+ 'page.box.w',
15
+ 'page.box.h',
16
+ ],
17
+ check: (m) => [dims(m('pager'), 220, 96), dims(m('page'), 220, 96)],
18
+ }
19
+ ]
@@ -0,0 +1,19 @@
1
+ // Measured parity fixtures — assertions live in Pager.parity.check.ts.
2
+ import { Pager } from './Pager'
3
+ import { View } from '@octane-xplat/ui'
4
+ import type { ParityFixture } from '@octane-xplat/ui/parity'
5
+
6
+ function PagerFixture() @{
7
+ <Pager
8
+ className="parity-pager-root"
9
+ style={{ width: 220, height: 96, backgroundColor: '#f5f5f5' }}
10
+ items={[{ key: 'first' }, { key: 'second' }]}
11
+ renderItem={() => (
12
+ <View className="parity-pager-page" style={{ width: 220, height: 96 }} />
13
+ )}
14
+ />
15
+ }
16
+
17
+ export const PARITY: ParityFixture[] = [
18
+ { name: 'pager-two-pages', component: 'Pager', renderer: PagerFixture, props: {} },
19
+ ]
package/src/Pager.tsrx CHANGED
@@ -1,24 +1,27 @@
1
1
  /** @jsxImportSource @nativescript-community/octane */
2
- import { memo, useCallback, useEffect, useRef } from 'octane';
2
+ import { memo, useCallback, useEffect, useRef, useState } from 'octane'
3
+ import { registerElement } from '@nativescript-community/octane'
4
+
5
+ import type { Attributes } from '@nativescript-community/octane/intrinsics'
6
+ import {
7
+ createPortal as createUniversalPortal,
8
+ type UniversalRenderable,
9
+ } from 'octane/universal/native'
10
+
11
+ import { Pager as NSPager } from '@nativescript-community/ui-pager'
12
+ import { GridLayout } from '@nativescript/core'
13
+ import type { PagerProps } from './props'
3
14
  import {
4
- NATIVESCRIPT_RENDERER_ID,
5
- createNativeScriptRoot,
6
- defineUniversalComponent,
7
- registerElement,
8
- } from '@nativescript-community/octane';
9
-
10
- import type { Attributes } from '@nativescript-community/octane/intrinsics';
11
- import type { UniversalRoot } from '@nativescript-community/octane';
12
- import { Pager as NSPager } from '@nativescript-community/ui-pager';
13
- import { GridLayout } from '@nativescript/core';
14
- import type { PagerProps } from './props';
15
- import { cx } from './cx.mobile';
16
- import { applyEscapeProps, nativeAccessibilityRole, nativeAccessibilityState } from './escape-props.mobile';
17
- import { layoutChildProps } from './layout-child';
15
+ applyEscapeProps,
16
+ nativeAccessibilityRole,
17
+ nativeAccessibilityState,
18
+ } from './escape-props.mobile'
19
+
20
+ import { layoutChildProps } from './layout-child'
18
21
 
19
22
  declare module '@nativescript-community/octane/intrinsics' {
20
23
  interface NativeScriptElements {
21
- pager: Attributes<typeof NSPager>;
24
+ pager: Attributes<typeof NSPager>
22
25
  }
23
26
  }
24
27
 
@@ -26,166 +29,189 @@ declare module '@nativescript-community/octane/intrinsics' {
26
29
  // UICollectionView on iOS. It is a required peer: this leaf sits in the
27
30
  // shared native barrel, so every native app must declare the plugin for
28
31
  // the /ns/m dev bridge to resolve it under the app root.
29
- registerElement('pager', NSPager);
32
+ registerElement('pager', NSPager)
30
33
 
31
- /** One recycled page: the host view the plugin's adapter asks for plus the
32
- * Octane root mounted into it. Mirrors the driver's ListView cell seam —
33
- * ui-pager fires the same `itemLoading`/`itemDisposing` pair. */
34
- interface PagerCell {
35
- host: any;
36
- root: UniversalRoot;
37
- pager: any;
38
- renderItem: any;
39
- item: any;
40
- index: number;
34
+ function createPortal(children: unknown, target: unknown) {
35
+ return createUniversalPortal(children as UniversalRenderable, target)
41
36
  }
42
37
 
43
- const cellsByHost = new WeakMap<object, PagerCell>();
44
- const cellsByPager = new WeakMap<object, Set<PagerCell>>();
38
+ /** One recycled page: the adapter-owned host plus the current render data.
39
+ * Its content is a portal from Pager's declaring tree. */
40
+ interface PagerCell {
41
+ host: any
42
+ pager: any
43
+ renderItem: any
44
+ item: any
45
+ index: number
46
+ }
45
47
 
46
- /** Every page root renders this one component, so rebinds are diffs of the
47
- * cell's tree rather than remounts. The renderItem output wraps in a
48
- * gridlayout — page content must stay single-root. */
49
- const PagerCell = defineUniversalComponent(NATIVESCRIPT_RENDERER_ID, (props: any) =>
50
- props.renderItem(props.item, props.index),
51
- );
48
+ const cellsByHost = new WeakMap<object, PagerCell>()
49
+ const cellsByPager = new WeakMap<object, Set<PagerCell>>()
52
50
 
53
51
  function cellFor(host: any): PagerCell {
54
- let cell = cellsByHost.get(host);
52
+ let cell = cellsByHost.get(host)
55
53
  if (!cell) {
56
- cell = { host, root: createNativeScriptRoot(host), pager: null, renderItem: null, item: undefined, index: -1 };
57
- cellsByHost.set(host, cell);
54
+ cell = { host, pager: null, renderItem: null, item: undefined, index: -1 }
55
+ cellsByHost.set(host, cell)
58
56
  }
59
57
 
60
- return cell;
58
+ return cell
61
59
  }
62
60
 
63
61
  /** `createView` runs inside the plugin's adapter — no pager instance in
64
62
  * scope, so the cell is only linked to its pager at `itemLoading` (where
65
63
  * `args.object` is the owner). */
66
64
  function createPageHost() {
67
- const host = new GridLayout();
68
- cellFor(host);
69
- return host;
65
+ const host = new GridLayout()
66
+ cellFor(host)
67
+ return host
70
68
  }
71
69
 
72
- const PAGER_TEMPLATES = [{ key: 'xplat-page', createView: createPageHost }];
70
+ const PAGER_TEMPLATES = [{ key: 'xplat-page', createView: createPageHost }]
73
71
  // The built-in 'default' template stays first in the plugin's internal
74
72
  // list, so the selector must name our key — a 'default' entry would match
75
73
  // the built-in (an XML string parse, which yields nothing here).
76
- const selectPageTemplate = () => 'xplat-page';
74
+ const selectPageTemplate = () => 'xplat-page'
77
75
 
78
76
  function releasePagerCells(pager: any) {
79
- const cells = cellsByPager.get(pager);
80
- if (!cells) return;
77
+ const cells = cellsByPager.get(pager)
78
+ if (!cells) {
79
+ return
80
+ }
81
+
81
82
  for (const cell of cells) {
82
- cellsByHost.delete(cell.host);
83
- cell.root.unmount();
83
+ cellsByHost.delete(cell.host)
84
84
  }
85
85
 
86
- cellsByPager.delete(pager);
86
+ cellsByPager.delete(pager)
87
87
  }
88
88
 
89
89
  function PagerInner(props: PagerProps) @{
90
- const host = useRef<any>(null);
91
- // selectedIndex writes the leaf makes itself — the plugin raises
90
+ const host = useRef<any>(null)
91
+ const [portalCells, setPortalCells] = useState<PagerCell[]>([])
92
+ // Track selectedIndex writes across synchronous and delayed echoes.
93
+ // The plugin raises
92
94
  // 'selectedIndexChange' for script writes exactly as for swipes, and
93
95
  // the driver's auto-mute only covers JSX prop writes, not ours.
94
- const suppressEcho = useRef(false);
95
- const pageSeeded = useRef(false);
96
+ const current = useRef(props.page ?? props.defaultPage ?? 0)
97
+ const pageSeeded = useRef(false)
96
98
 
97
99
  // Stable ref identity — an inline closure re-fires ref(null)/ref(v) per
98
100
  // render, which would bounce host.current around the effects below.
99
101
  const bindHost = useCallback((v: any) => {
100
- host.current = v;
101
- }, []);
102
+ host.current = v
103
+ }, [])
102
104
 
103
105
  const bindCell = (args: any) => {
104
- const cell = cellFor(args.view);
105
- cell.pager = args.object;
106
- let owned = cellsByPager.get(cell.pager);
106
+ const cell = cellFor(args.view)
107
+ cell.pager = args.object
108
+ let owned = cellsByPager.get(cell.pager)
107
109
  if (!owned) {
108
- owned = new Set();
109
- cellsByPager.set(cell.pager, owned);
110
+ owned = new Set()
111
+ cellsByPager.set(cell.pager, owned)
110
112
  }
111
113
 
112
- owned.add(cell);
113
- const item = args.bindingContext ?? props.items?.[args.index];
114
- if (cell.renderItem === props.renderItem && cell.index === args.index && Object.is(cell.item, item)) {
115
- return;
114
+ owned.add(cell)
115
+ const item = args.bindingContext ?? props.items?.[args.index]
116
+ if (
117
+ cell.renderItem === props.renderItem &&
118
+ cell.index === args.index &&
119
+ Object.is(cell.item, item)
120
+ ) {
121
+ return
116
122
  }
117
123
 
118
- cell.renderItem = props.renderItem;
119
- cell.item = item;
120
- cell.index = args.index;
121
- cell.root.render(PagerCell, {
122
- renderItem: (it: any, i: number) => <gridlayout>{props.renderItem(it, i)}</gridlayout>,
123
- item,
124
- index: args.index,
125
- });
126
- };
124
+ cell.renderItem = props.renderItem
125
+ cell.item = item
126
+ cell.index = args.index
127
+ setPortalCells((current) => {
128
+ const existing = current.some((entry) => entry === cell)
129
+ return existing ? [...current] : [...current, cell]
130
+ })
131
+ }
127
132
 
128
133
  const disposeCell = (args: any) => {
129
- const cell = args.view && cellsByHost.get(args.view);
134
+ const cell = args.view && cellsByHost.get(args.view)
130
135
  if (cell) {
131
- cellsByHost.delete(args.view);
132
- if (cell.pager) cellsByPager.get(cell.pager)?.delete(cell);
133
- cell.root.unmount();
136
+ cellsByHost.delete(args.view)
137
+ if (cell.pager) {
138
+ cellsByPager.get(cell.pager)?.delete(cell)
139
+ }
140
+
141
+ setPortalCells((current) => current.filter((entry) => entry !== cell))
134
142
  }
135
- };
143
+ }
136
144
 
137
145
  // One-time wiring (mount): the template set + selector are imperative —
138
146
  // as JSX props they would be re-applied on every render. The cleanup
139
147
  // captures the pager so cell roots release after the ref detaches.
140
148
  useEffect(() => {
141
- const pager = host.current;
142
- if (!pager) return;
143
- pager.itemTemplates = PAGER_TEMPLATES;
144
- pager.itemTemplateSelector = selectPageTemplate;
145
- return () => releasePagerCells(pager);
146
- }, []);
149
+ const pager = host.current
150
+ if (!pager) {
151
+ return
152
+ }
153
+
154
+ pager.itemTemplates = PAGER_TEMPLATES
155
+ pager.itemTemplateSelector = selectPageTemplate
156
+ return () => releasePagerCells(pager)
157
+ }, [])
147
158
 
148
159
  // Items and the index are imperative writes, not JSX props: `items` must
149
160
  // land before `selectedIndex` (the plugin coerces the index against the
150
161
  // page count — a write ahead of items clamps to -1), and a re-assert
151
162
  // after an items change repairs the plugin's own re-coercion.
152
163
  useEffect(() => {
153
- const pager = host.current;
154
- if (!pager) return;
155
- applyEscapeProps(pager, props);
156
- if (pager.items !== props.items) pager.items = props.items;
157
- const target = props.page ?? (pageSeeded.current ? undefined : props.defaultPage);
164
+ const pager = host.current
165
+ if (!pager) {
166
+ return
167
+ }
168
+
169
+ applyEscapeProps(pager, props)
170
+ if (pager.items !== props.items) {
171
+ pager.items = props.items
172
+ }
173
+
174
+ const target = props.page ?? (pageSeeded.current ? undefined : props.defaultPage)
158
175
  if (target !== undefined && pager.selectedIndex !== target) {
159
- suppressEcho.current = true;
160
- try {
161
- pager.selectedIndex = target;
162
- } finally {
163
- suppressEcho.current = false;
164
- }
176
+ // Track the coerced target before writing: Android may echo later.
177
+ current.current = Math.max(0, Math.min(props.items.length - 1, target))
178
+ pager.selectedIndex = target
165
179
  }
166
180
 
167
- pageSeeded.current = true;
168
- });
169
-
170
- <pager
171
- ref={bindHost}
172
- id={props.id}
173
- {...layoutChildProps(props)}
174
- className={cx('vx-pager', props.className)}
175
- style={props.style}
176
- onItemLoading={bindCell}
177
- onItemDisposing={disposeCell}
178
- onSelectedIndexChange={(args: any) => {
179
- if (!suppressEcho.current) props.onPageChange?.(args.value);
180
- }}
181
- accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
182
- accessibilityLabel={props.accessibilityLabel}
183
- accessibilityHint={props.accessibilityHint}
184
- accessibilityValue={props.accessibilityValue}
185
- accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
186
- accessible={props.accessible}
187
- accessibilityLiveRegion={props.accessibilityLiveRegion as any}
188
- />
181
+ pageSeeded.current = true
182
+ })
183
+
184
+ <>
185
+ <pager
186
+ ref={bindHost}
187
+ id={props.id}
188
+ {...layoutChildProps(props)}
189
+ className={['vx-pager', props.className]}
190
+ onItemLoading={bindCell}
191
+ onItemDisposing={disposeCell}
192
+ onSelectedIndexChange={(args: any) => {
193
+ if (args.value !== current.current) {
194
+ current.current = args.value
195
+ props.onPageChange?.(args.value)
196
+ }
197
+ }}
198
+ accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
199
+ accessibilityLabel={props.accessibilityLabel}
200
+ accessibilityHint={props.accessibilityHint}
201
+ accessibilityValue={props.accessibilityValue}
202
+ accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
203
+ accessible={props.accessible}
204
+ accessibilityLiveRegion={props.accessibilityLiveRegion as any}
205
+ />
206
+ {portalCells.map((cell) =>
207
+ createPortal(
208
+ <masonview style={{ display: 'grid' }}>
209
+ {cell.renderItem?.(cell.item, cell.index)}
210
+ </masonview>,
211
+ cell.host,
212
+ ),
213
+ )}
214
+ </>
189
215
  }
190
216
 
191
217
  // The pager subtree is memoized on `items` and configuration: the universal
@@ -194,28 +220,54 @@ function PagerInner(props: PagerProps) @{
194
220
  // UITableView: `renderItem` should be identity-stable — a fresh closure per
195
221
  // render is deliberately not part of the compare. (`PagerInner` must precede
196
222
  // this — compiled components are consts, not hoisted functions.)
197
- const MemoPager = memo(PagerInner, (a, b) =>
198
- a.items === b.items &&
199
- a.className === b.className &&
200
- a.style === b.style &&
201
- a.id === b.id &&
202
- a.page === b.page &&
203
- a.onPageChange === b.onPageChange &&
204
- a.ios === b.ios &&
205
- a.android === b.android
206
- );
223
+ const MemoPager = memo(
224
+ PagerInner,
225
+ (a, b) =>
226
+ a.items === b.items &&
227
+ a.className === b.className &&
228
+ a.style === b.style &&
229
+ a.id === b.id &&
230
+ a.page === b.page &&
231
+ a.onPageChange === b.onPageChange &&
232
+ a.ios === b.ios &&
233
+ a.android === b.android &&
234
+ a.row === b.row &&
235
+ a.col === b.col &&
236
+ a.rowSpan === b.rowSpan &&
237
+ a.colSpan === b.colSpan &&
238
+ a.dock === b.dock &&
239
+ a.left === b.left &&
240
+ a.top === b.top &&
241
+ a.flexGrow === b.flexGrow &&
242
+ a.flexShrink === b.flexShrink &&
243
+ a.alignSelf === b.alignSelf &&
244
+ a.order === b.order &&
245
+ a.accessible === b.accessible &&
246
+ a.accessibilityRole === b.accessibilityRole &&
247
+ a.accessibilityLabel === b.accessibilityLabel &&
248
+ a.accessibilityHint === b.accessibilityHint &&
249
+ a.accessibilityValue === b.accessibilityValue &&
250
+ a.accessibilityState === b.accessibilityState &&
251
+ a.accessibilityLiveRegion === b.accessibilityLiveRegion,
252
+ )
207
253
 
208
254
  /** Pager — paged horizontal swiping via @nativescript-community/ui-pager
209
255
  * (ViewPager2 / paging UICollectionView), chrome-reset: one full-size cell
210
256
  * per item, no indicator. Pages recycle through `itemTemplates` +
211
- * `itemLoading`, each slot hosting its own Octane root — the same cell
212
- * machinery the driver builds for `listview`, hand-wired here. */
257
+ * `itemLoading`; active cell hosts are portals from the Pager's declaring
258
+ * tree, so provider context follows each cell. */
213
259
  export function Pager(props: PagerProps) @{
214
260
  @if (props.items.length > 0) {
215
261
  <MemoPager
262
+ accessible={props.accessible}
263
+ accessibilityRole={props.accessibilityRole}
264
+ accessibilityLabel={props.accessibilityLabel}
265
+ accessibilityHint={props.accessibilityHint}
266
+ accessibilityValue={props.accessibilityValue}
267
+ accessibilityState={props.accessibilityState}
268
+ accessibilityLiveRegion={props.accessibilityLiveRegion}
216
269
  id={props.id}
217
270
  className={props.className}
218
- style={props.style}
219
271
  items={props.items}
220
272
  renderItem={props.renderItem}
221
273
  page={props.page}
@@ -17,6 +17,7 @@ export function Pager(props: PagerProps) @{
17
17
  // updated by controlled writes and settled swipes. A scroll that lands
18
18
  // on `current` is programmatic — the settle never reports it.
19
19
  const current = useRef(props.page ?? props.defaultPage ?? 0);
20
+ const hasItems = props.items.length > 0;
20
21
  const settle = useRef<ReturnType<typeof setTimeout> | null>(null);
21
22
 
22
23
  useEffect(() => {
@@ -45,17 +46,19 @@ export function Pager(props: PagerProps) @{
45
46
  return () => {
46
47
  el.removeEventListener('scroll', onScroll);
47
48
  if (settle.current !== null) clearTimeout(settle.current);
49
+ settle.current = null;
48
50
  };
49
- }, []);
51
+ }, [hasItems]);
50
52
 
51
- // Mount: jump (no animation) to the starting page — an initial position
53
+ // Each new host: jump (no animation) to the starting page — an initial position
52
54
  // is not a scroll. Runs after the scroll listener attaches so the write
53
55
  // still flows through the tracked-index path.
54
56
  useEffect(() => {
55
57
  const el = host.current;
56
- if (!el || current.current <= 0) return;
58
+ if (!el) return;
59
+ current.current = props.page ?? current.current;
57
60
  el.scrollLeft = current.current * el.clientWidth;
58
- }, []);
61
+ }, [hasItems]);
59
62
 
60
63
  // Controlled writes scroll smoothly. Skipped when the page already
61
64
  // matches `current` — a user swipe that the parent echoes back unchanged