@octane-xplat/pager 0.0.0-nightly.g14544fe

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Alec Larson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # `@octane-xplat/pager`
2
+
3
+ A paged horizontal swipe container for Octane Xplat apps — onboarding flows,
4
+ media galleries. Each page fills the host; the user swipes between pages.
5
+
6
+ ```sh
7
+ pnpm add @octane-xplat/pager
8
+ ```
9
+
10
+ ```tsx
11
+ import { useState } from 'octane'
12
+ import { Text } from '@octane-xplat/ui'
13
+ import { Pager } from '@octane-xplat/pager'
14
+
15
+ export function Onboarding() {
16
+ const [page, setPage] = useState(0)
17
+ return (
18
+ <Pager
19
+ items={['Pack', 'Travel']}
20
+ page={page}
21
+ onPageChange={setPage}
22
+ renderItem={(title) => <Text>{title}</Text>}
23
+ renderEmpty={() => <Text>No pages</Text>}
24
+ />
25
+ )
26
+ }
27
+ ```
28
+
29
+ It follows the platform-list contract — `items` + `renderItem`, no children;
30
+ each page is a full-host-size cell. `onPageChange` fires on settled user
31
+ swipes; programmatic `page` writes don't echo back. `renderEmpty` covers the
32
+ empty list.
33
+
34
+ There is no built-in page indicator. Compose one from `HStack` + `Text`
35
+ driven by the `page` state:
36
+
37
+ ```tsx
38
+ import { HStack, Text } from '@octane-xplat/ui'
39
+
40
+ ;<HStack>
41
+ {titles.map((title, index) => (
42
+ <Text>{index === page ? '●' : '○'}</Text>
43
+ ))}
44
+ </HStack>
45
+ ```
46
+
47
+ ## Platform support
48
+
49
+ - **iOS/Android** — `@nativescript-community/ui-pager` (ViewPager2 on Android,
50
+ a paging `UICollectionView` on iOS)
51
+ - **Web** — a scroll-snap scroller
52
+ - **macOS** — an entry exists
53
+
54
+ Per-target limits are recorded in
55
+ [known limits](../../docs/verify/known-limits.md). Exercised by
56
+ [`PagerDemo`](../demos/src/PagerDemo.tsrx).
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "@octane-xplat/pager",
3
+ "version": "0.0.0-nightly.g14544fe",
4
+ "description": "Paged swipe container for Octane xplat — web scroll-snap row + @nativescript-community/ui-pager (ViewPager2 / paging UICollectionView)",
5
+ "keywords": [
6
+ "android",
7
+ "carousel",
8
+ "cross-platform",
9
+ "ios",
10
+ "nativescript",
11
+ "octane",
12
+ "pager",
13
+ "paging",
14
+ "swipe",
15
+ "viewpager",
16
+ "web",
17
+ "xplat"
18
+ ],
19
+ "homepage": "https://github.com/octane-xplat/octane-xplat/tree/main/packages/pager",
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/octane-xplat/octane-xplat",
24
+ "directory": "packages/pager"
25
+ },
26
+ "files": [
27
+ "types",
28
+ "src",
29
+ "!src/**/*.test.*",
30
+ "!src/**/*.spec.*"
31
+ ],
32
+ "type": "module",
33
+ "exports": {
34
+ ".": {
35
+ "macos": {
36
+ "types": "./types/index.macos.d.ts",
37
+ "default": "./src/index.macos.ts"
38
+ },
39
+ "types": "./types/index.d.ts",
40
+ "web": "./src/index.web.ts",
41
+ "native": "./src/index.ts",
42
+ "default": "./src/index.ts"
43
+ }
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "dependencies": {
49
+ "@nativescript-community/ui-pager": "14.1.42"
50
+ },
51
+ "devDependencies": {
52
+ "@nativescript-community/octane": "0.2.5",
53
+ "@nativescript/core": "9.1.3",
54
+ "@octane-xplat/macos-renderer": "0.0.0-nightly.g14544fe",
55
+ "@octane-xplat/ui": "0.0.0-nightly.g14544fe",
56
+ "@octanejs/vite-plugin": "0.1.61",
57
+ "octane": "0.11.0",
58
+ "tsrx-typegen": "0.3.0",
59
+ "typescript": "5.9.3",
60
+ "vite": "8.3.0"
61
+ },
62
+ "peerDependencies": {
63
+ "@nativescript-community/octane": ">=0.2.1 <1",
64
+ "@nativescript/core": ">=9.1.0 <10",
65
+ "@octane-xplat/ui": ">=0.6.0 <1",
66
+ "octane": ">=0.6.3 <1"
67
+ },
68
+ "peerDependenciesMeta": {
69
+ "@nativescript-community/octane": {
70
+ "optional": true
71
+ },
72
+ "@nativescript/core": {
73
+ "optional": true
74
+ }
75
+ },
76
+ "scripts": {
77
+ "test:native": "vitest run --config vitest.native.config.mts",
78
+ "typegen": "tsc -p tsconfig.types.json",
79
+ "test:packed": "node tests/packed-consumer.mjs",
80
+ "pack:check": "tsrx-typegen --pack-check"
81
+ }
82
+ }
@@ -0,0 +1,7 @@
1
+ /** @jsxImportSource @octane-xplat/macos-renderer */
2
+ import type { PagerProps } from './props'
3
+
4
+ /** Explicit placeholder until the AppKit host has a paging surface. */
5
+ export function Pager(_props: PagerProps) @{
6
+ <label className="vx-unsupported">Pager is unsupported by the AppKit host</label>
7
+ }
@@ -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 ADDED
@@ -0,0 +1,283 @@
1
+ /** @jsxImportSource @nativescript-community/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'
14
+ import {
15
+ applyEscapeProps,
16
+ nativeAccessibilityRole,
17
+ nativeAccessibilityState,
18
+ } from './escape-props.mobile'
19
+
20
+ import { layoutChildProps } from './layout-child'
21
+
22
+ declare module '@nativescript-community/octane/intrinsics' {
23
+ interface NativeScriptElements {
24
+ pager: Attributes<typeof NSPager>
25
+ }
26
+ }
27
+
28
+ // @nativescript-community/ui-pager — ViewPager2 on Android, a paging
29
+ // UICollectionView on iOS. It is a required peer: this leaf sits in the
30
+ // shared native barrel, so every native app must declare the plugin for
31
+ // the /ns/m dev bridge to resolve it under the app root.
32
+ registerElement('pager', NSPager)
33
+
34
+ function createPortal(children: unknown, target: unknown) {
35
+ return createUniversalPortal(children as UniversalRenderable, target)
36
+ }
37
+
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
+ }
47
+
48
+ const cellsByHost = new WeakMap<object, PagerCell>()
49
+ const cellsByPager = new WeakMap<object, Set<PagerCell>>()
50
+
51
+ function cellFor(host: any): PagerCell {
52
+ let cell = cellsByHost.get(host)
53
+ if (!cell) {
54
+ cell = { host, pager: null, renderItem: null, item: undefined, index: -1 }
55
+ cellsByHost.set(host, cell)
56
+ }
57
+
58
+ return cell
59
+ }
60
+
61
+ /** `createView` runs inside the plugin's adapter — no pager instance in
62
+ * scope, so the cell is only linked to its pager at `itemLoading` (where
63
+ * `args.object` is the owner). */
64
+ function createPageHost() {
65
+ const host = new GridLayout()
66
+ cellFor(host)
67
+ return host
68
+ }
69
+
70
+ const PAGER_TEMPLATES = [{ key: 'xplat-page', createView: createPageHost }]
71
+ // The built-in 'default' template stays first in the plugin's internal
72
+ // list, so the selector must name our key — a 'default' entry would match
73
+ // the built-in (an XML string parse, which yields nothing here).
74
+ const selectPageTemplate = () => 'xplat-page'
75
+
76
+ function releasePagerCells(pager: any) {
77
+ const cells = cellsByPager.get(pager)
78
+ if (!cells) {
79
+ return
80
+ }
81
+
82
+ for (const cell of cells) {
83
+ cellsByHost.delete(cell.host)
84
+ }
85
+
86
+ cellsByPager.delete(pager)
87
+ }
88
+
89
+ function PagerInner(props: PagerProps) @{
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
94
+ // 'selectedIndexChange' for script writes exactly as for swipes, and
95
+ // the driver's auto-mute only covers JSX prop writes, not ours.
96
+ const current = useRef(props.page ?? props.defaultPage ?? 0)
97
+ const pageSeeded = useRef(false)
98
+
99
+ // Stable ref identity — an inline closure re-fires ref(null)/ref(v) per
100
+ // render, which would bounce host.current around the effects below.
101
+ const bindHost = useCallback((v: any) => {
102
+ host.current = v
103
+ }, [])
104
+
105
+ const bindCell = (args: any) => {
106
+ const cell = cellFor(args.view)
107
+ cell.pager = args.object
108
+ let owned = cellsByPager.get(cell.pager)
109
+ if (!owned) {
110
+ owned = new Set()
111
+ cellsByPager.set(cell.pager, owned)
112
+ }
113
+
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
122
+ }
123
+
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
+ }
132
+
133
+ const disposeCell = (args: any) => {
134
+ const cell = args.view && cellsByHost.get(args.view)
135
+ if (cell) {
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))
142
+ }
143
+ }
144
+
145
+ // One-time wiring (mount): the template set + selector are imperative —
146
+ // as JSX props they would be re-applied on every render. The cleanup
147
+ // captures the pager so cell roots release after the ref detaches.
148
+ useEffect(() => {
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
+ }, [])
158
+
159
+ // Items and the index are imperative writes, not JSX props: `items` must
160
+ // land before `selectedIndex` (the plugin coerces the index against the
161
+ // page count — a write ahead of items clamps to -1), and a re-assert
162
+ // after an items change repairs the plugin's own re-coercion.
163
+ useEffect(() => {
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)
175
+ if (target !== undefined && pager.selectedIndex !== target) {
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
179
+ }
180
+
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
+ </>
215
+ }
216
+
217
+ // The pager subtree is memoized on `items` and configuration: the universal
218
+ // driver re-applies props on every render (no value diffing) and a stray
219
+ // `items` write would refresh the whole adapter. Constraint matches
220
+ // UITableView: `renderItem` should be identity-stable — a fresh closure per
221
+ // render is deliberately not part of the compare. (`PagerInner` must precede
222
+ // this — compiled components are consts, not hoisted functions.)
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
+ )
253
+
254
+ /** Pager — paged horizontal swiping via @nativescript-community/ui-pager
255
+ * (ViewPager2 / paging UICollectionView), chrome-reset: one full-size cell
256
+ * per item, no indicator. Pages recycle through `itemTemplates` +
257
+ * `itemLoading`; active cell hosts are portals from the Pager's declaring
258
+ * tree, so provider context follows each cell. */
259
+ export function Pager(props: PagerProps) @{
260
+ @if (props.items.length > 0) {
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}
269
+ id={props.id}
270
+ className={props.className}
271
+ items={props.items}
272
+ renderItem={props.renderItem}
273
+ page={props.page}
274
+ defaultPage={props.defaultPage}
275
+ onPageChange={props.onPageChange}
276
+ ios={props.ios}
277
+ android={props.android}
278
+ {...layoutChildProps(props)}
279
+ />
280
+ } @else {
281
+ <>{props.renderEmpty?.()}</>
282
+ }
283
+ }
@@ -0,0 +1,107 @@
1
+ import { useEffect, useRef } from 'octane';
2
+ import type { PagerProps } from './props';
3
+ import { applyWebProps } from './platformProps.web';
4
+ import { layoutChildProps } from './layout-child.web';
5
+
6
+ /** Paged horizontal container — web leaf: a mandatory scroll-snap row, one
7
+ * page per item at 100% host width. `onPageChange` fires on the settled
8
+ * snap page (a scroll-idle debounce reads scrollLeft after the snap lands),
9
+ * matching the native widget's selected-index event. Programmatic `page`
10
+ * writes are tracked in `current` so their own scroll never echoes back
11
+ * through `onPageChange` — same contract as the muted property echo on
12
+ * native. No indicator — compose dots from Row + View outside. */
13
+ export function Pager(props: PagerProps) @{
14
+ const host = useRef<HTMLElement | null>(null);
15
+ const latest = useRef(props);
16
+ // The page the leaf believes it is on: seeded by `page`/`defaultPage`,
17
+ // updated by controlled writes and settled swipes. A scroll that lands
18
+ // on `current` is programmatic — the settle never reports it.
19
+ const current = useRef(props.page ?? props.defaultPage ?? 0);
20
+ const hasItems = props.items.length > 0;
21
+ const settle = useRef<ReturnType<typeof setTimeout> | null>(null);
22
+
23
+ useEffect(() => {
24
+ latest.current = props;
25
+ });
26
+
27
+ useEffect(() => {
28
+ const el = host.current;
29
+ if (!el) return;
30
+ const onScroll = () => {
31
+ if (settle.current !== null) clearTimeout(settle.current);
32
+ settle.current = setTimeout(() => {
33
+ settle.current = null;
34
+ const el2 = host.current;
35
+ if (!el2 || el2.clientWidth === 0) return;
36
+ const max = Math.max(latest.current.items.length - 1, 0);
37
+ const next = Math.max(0, Math.min(max, Math.round(el2.scrollLeft / el2.clientWidth)));
38
+ if (next !== current.current) {
39
+ current.current = next;
40
+ latest.current.onPageChange?.(next);
41
+ }
42
+ }, 90);
43
+ };
44
+
45
+ el.addEventListener('scroll', onScroll, { passive: true });
46
+ return () => {
47
+ el.removeEventListener('scroll', onScroll);
48
+ if (settle.current !== null) clearTimeout(settle.current);
49
+ settle.current = null;
50
+ };
51
+ }, [hasItems]);
52
+
53
+ // Each new host: jump (no animation) to the starting page — an initial position
54
+ // is not a scroll. Runs after the scroll listener attaches so the write
55
+ // still flows through the tracked-index path.
56
+ useEffect(() => {
57
+ const el = host.current;
58
+ if (!el) return;
59
+ current.current = props.page ?? current.current;
60
+ el.scrollLeft = current.current * el.clientWidth;
61
+ }, [hasItems]);
62
+
63
+ // Controlled writes scroll smoothly. Skipped when the page already
64
+ // matches `current` — a user swipe that the parent echoes back unchanged
65
+ // must not scroll again.
66
+ useEffect(() => {
67
+ const el = host.current;
68
+ if (!el || props.page === undefined || props.page === current.current) return;
69
+ current.current = props.page;
70
+ el.scrollTo({ left: props.page * el.clientWidth, behavior: 'smooth' });
71
+ }, [props.page]);
72
+
73
+ @if (props.items.length > 0) {
74
+ <div
75
+ ref={(el: any) => {
76
+ host.current = el;
77
+ applyWebProps(el, props.web);
78
+ }}
79
+ id={props.id}
80
+ className={['vx-pager', props.className]}
81
+ {...layoutChildProps(props)}
82
+ role={props.accessibilityRole ?? 'group'}
83
+ aria-roledescription="carousel"
84
+ aria-label={props.accessibilityLabel}
85
+ aria-hidden={props.accessible === false ? true : undefined}
86
+ aria-description={props.accessibilityHint}
87
+ aria-valuetext={props.accessibilityValue}
88
+ aria-disabled={props.accessibilityState?.disabled}
89
+ aria-selected={props.accessibilityState?.selected}
90
+ aria-checked={props.accessibilityState?.checked}
91
+ aria-live={props.accessibilityLiveRegion === 'none' ? 'off' : props.accessibilityLiveRegion}
92
+ >
93
+ @for (const item of props.items; index i; key i) {
94
+ <div
95
+ className="vx-pager-page"
96
+ role="group"
97
+ aria-roledescription="slide"
98
+ aria-label={`${i + 1} of ${props.items.length}`}
99
+ >
100
+ {props.renderItem(item, i)}
101
+ </div>
102
+ }
103
+ </div>
104
+ } @else {
105
+ <>{props.renderEmpty?.()}</>
106
+ }
107
+ }
@@ -0,0 +1,65 @@
1
+ import { isAndroid, isIOS } from '@nativescript/core'
2
+ import type { Role } from './props'
3
+
4
+ /** Apply only the active device's escape bag after the primitive's own props. */
5
+ export function applyEscapeProps(view: any, props: { ios?: any; android?: any }): void {
6
+ if (!view) {
7
+ return
8
+ }
9
+
10
+ const bag = isIOS ? props.ios : isAndroid ? props.android : undefined
11
+ if (bag) {
12
+ Object.assign(view, bag)
13
+ }
14
+ }
15
+
16
+ const NATIVE_ACCESSIBILITY_ROLES: Record<Role, string> = {
17
+ button: 'button',
18
+ link: 'link',
19
+ search: 'search',
20
+ image: 'image',
21
+ heading: 'header',
22
+ adjustable: 'adjustable',
23
+ summary: 'summary',
24
+ text: 'text',
25
+ none: 'none',
26
+ progressbar: 'progressBar',
27
+ checkbox: 'checkbox',
28
+ switch: 'switch',
29
+ radio: 'radioButton',
30
+ spinbutton: 'spinButton',
31
+ // NativeScript has no tab role; a tab is still an actionable button and
32
+ // its selected state is carried separately by accessibilityState.
33
+ tab: 'button',
34
+ }
35
+
36
+ /** Translate the shared/ARIA spelling to NativeScript's narrower role enum. */
37
+ export function nativeAccessibilityRole(role?: Role): string | undefined {
38
+ return role ? NATIVE_ACCESSIBILITY_ROLES[role] : undefined
39
+ }
40
+
41
+ /** NativeScript's AccessibilityState is a single enum value, unlike the
42
+ * web's independent ARIA booleans. Priority follows the strongest state. */
43
+ export function nativeAccessibilityState(state?: {
44
+ disabled?: boolean
45
+ selected?: boolean
46
+ checked?: boolean
47
+ }): string | undefined {
48
+ if (state?.disabled) {
49
+ return 'disabled'
50
+ }
51
+
52
+ if (state?.selected) {
53
+ return 'selected'
54
+ }
55
+
56
+ if (state?.checked === true) {
57
+ return 'checked'
58
+ }
59
+
60
+ if (state?.checked === false) {
61
+ return 'unchecked'
62
+ }
63
+
64
+ return undefined
65
+ }
@@ -0,0 +1,2 @@
1
+ export { Pager } from './Pager.macos.tsrx'
2
+ export type { PagerProps } from './props'
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { Pager } from './Pager.tsrx'
2
+ export type { PagerProps } from './props'
@@ -0,0 +1,2 @@
1
+ export { Pager } from './Pager.web.tsrx'
2
+ export type { PagerProps } from './props'
@@ -0,0 +1,95 @@
1
+ import type { LayoutChildProps, FlexContainerProps } from './props'
2
+ import { mergeStyle } from './style-merge'
3
+
4
+ // start/end shorthand → flex-start/flex-end.
5
+ const FLEX_JUSTIFY: Record<string, string> = { start: 'flex-start', end: 'flex-end' }
6
+ const FLEX_ALIGN: Record<string, string> = { start: 'flex-start', end: 'flex-end' }
7
+
8
+ /** Convert parent-layout metadata + the flex-container contract to the CSS
9
+ * style bag on the host masonview — mirrors layout-child.web.ts so the same
10
+ * component props compile to the same CSS on both targets. `dock` has no
11
+ * CSS equivalent on web (ignored there); on native it becomes absolute
12
+ * insets. Native adds `position: absolute` for edge-placed children since
13
+ * there is no `.vx-absolute > *` stylesheet rule on the mason engine. */
14
+ export function layoutChildProps(
15
+ props: LayoutChildProps & Partial<FlexContainerProps> & { style?: any },
16
+ baseStyle = props.style,
17
+ ) {
18
+ const style: Record<string, any> = { ...(typeof baseStyle === 'object' ? baseStyle : {}) }
19
+ if (props.row !== undefined) {
20
+ style.gridRow = `${props.row + 1} / span ${props.rowSpan ?? 1}`
21
+ } else if (props.rowSpan !== undefined) {
22
+ style.gridRow = `span ${props.rowSpan}`
23
+ }
24
+
25
+ if (props.col !== undefined) {
26
+ style.gridColumn = `${props.col + 1} / span ${props.colSpan ?? 1}`
27
+ } else if (props.colSpan !== undefined) {
28
+ style.gridColumn = `span ${props.colSpan}`
29
+ }
30
+
31
+ if (props.dock !== undefined) {
32
+ style.position = style.position ?? 'absolute'
33
+ if (props.dock === 'left' || props.dock === 'top' || props.dock === 'bottom') {
34
+ style.left = style.left ?? 0
35
+ }
36
+ if (props.dock === 'right' || props.dock === 'top' || props.dock === 'bottom') {
37
+ style.right = style.right ?? 0
38
+ }
39
+ if (props.dock === 'top' || props.dock === 'left' || props.dock === 'right') {
40
+ style.top = style.top ?? 0
41
+ }
42
+ if (props.dock === 'bottom' || props.dock === 'left' || props.dock === 'right') {
43
+ style.bottom = style.bottom ?? 0
44
+ }
45
+ }
46
+
47
+ if (props.left !== undefined) {
48
+ style.left = props.left
49
+ }
50
+ if (props.top !== undefined) {
51
+ style.top = props.top
52
+ }
53
+ if ((props.left !== undefined || props.top !== undefined) && props.dock === undefined) {
54
+ style.position = style.position ?? 'absolute'
55
+ }
56
+
57
+ if (props.flexGrow !== undefined) {
58
+ style.flexGrow = props.flexGrow
59
+ }
60
+ if (props.flexShrink !== undefined) {
61
+ style.flexShrink = props.flexShrink
62
+ }
63
+ if (props.alignSelf !== undefined) {
64
+ style.alignSelf = props.alignSelf
65
+ }
66
+ if (props.order !== undefined) {
67
+ style.order = props.order
68
+ }
69
+
70
+ // Flex-container props (View/Row/Pressable hosts) — RN vocabulary on the
71
+ // element style.
72
+ if (props.justifyContent !== undefined) {
73
+ style.justifyContent = FLEX_JUSTIFY[props.justifyContent] ?? props.justifyContent
74
+ }
75
+ if (props.alignItems !== undefined) {
76
+ style.alignItems = FLEX_ALIGN[props.alignItems] ?? props.alignItems
77
+ }
78
+ if (props.flexWrap !== undefined) {
79
+ style.flexWrap =
80
+ props.flexWrap === true ? 'wrap' : props.flexWrap === false ? 'nowrap' : props.flexWrap
81
+ }
82
+ if (props.gap !== undefined) {
83
+ style.gap = props.gap
84
+ }
85
+ if (props.rowGap !== undefined) {
86
+ style.rowGap = props.rowGap
87
+ }
88
+ if (props.columnGap !== undefined) {
89
+ style.columnGap = props.columnGap
90
+ }
91
+
92
+ // A string base is inline CSS — serialize the emitted keys under it so the
93
+ // authored declarations still win.
94
+ return { style: typeof baseStyle === 'string' ? mergeStyle(style, baseStyle) : style }
95
+ }
@@ -0,0 +1,108 @@
1
+ import type { LayoutChildProps, FlexContainerProps } from './props'
2
+
3
+ /** The shared contract is "numbers are dips" (RN semantics), but octane's DOM
4
+ * renderer keeps React's unitless list — lineHeight is unitless there, so
5
+ * `style={{lineHeight: 40}}` would render a 40× multiplier instead of 40px.
6
+ * lineHeight is the one unitless-in-React prop that is a length in the
7
+ * shared vocabulary; force the px suffix. */
8
+ export function normStyle(style: any) {
9
+ if (style && typeof style.lineHeight === 'number') {
10
+ return { ...style, lineHeight: style.lineHeight + 'px' }
11
+ }
12
+
13
+ return style
14
+ }
15
+
16
+ // start/end shorthand → flex-start/flex-end (valid in both engines).
17
+ const FLEX_JUSTIFY: Record<string, string> = {
18
+ start: 'flex-start',
19
+ end: 'flex-end',
20
+ }
21
+
22
+ const FLEX_ALIGN: Record<string, string> = {
23
+ start: 'flex-start',
24
+ end: 'flex-end',
25
+ }
26
+
27
+ /** Convert parent-layout metadata to CSS on the web leaf. Native's `dock`
28
+ * attribute has no CSS equivalent and is ignored on web. Grid indices are
29
+ * zero-based in the shared API, matching NativeScript. */
30
+ export function layoutChildProps(
31
+ props: LayoutChildProps & Partial<FlexContainerProps> & { style?: any },
32
+ baseStyle = props.style,
33
+ ) {
34
+ const style = { ...normStyle(baseStyle) }
35
+ if (props.row !== undefined) {
36
+ style.gridRow = `${props.row + 1} / span ${props.rowSpan ?? 1}`
37
+ } else if (props.rowSpan !== undefined) {
38
+ style.gridRow = `span ${props.rowSpan}`
39
+ }
40
+
41
+ if (props.col !== undefined) {
42
+ style.gridColumn = `${props.col + 1} / span ${props.colSpan ?? 1}`
43
+ } else if (props.colSpan !== undefined) {
44
+ style.gridColumn = `span ${props.colSpan}`
45
+ }
46
+
47
+ if (props.left !== undefined) {
48
+ style.left = props.left
49
+ }
50
+
51
+ if (props.top !== undefined) {
52
+ style.top = props.top
53
+ }
54
+
55
+ if (props.flexGrow !== undefined) {
56
+ style.flexGrow = props.flexGrow
57
+ }
58
+
59
+ if (props.flexShrink !== undefined) {
60
+ style.flexShrink = props.flexShrink
61
+ }
62
+
63
+ if (props.alignSelf !== undefined) {
64
+ style.alignSelf = props.alignSelf
65
+ }
66
+
67
+ if (props.order !== undefined) {
68
+ style.order = props.order
69
+ }
70
+
71
+ // Flex-container props (View/Row/Pressable hosts) — RN vocabulary on the
72
+ // element style. Numbers become px via octane's style normalization.
73
+ if (props.justifyContent !== undefined) {
74
+ style.justifyContent = FLEX_JUSTIFY[props.justifyContent] ?? props.justifyContent
75
+ }
76
+
77
+ if (props.alignItems !== undefined) {
78
+ style.alignItems = FLEX_ALIGN[props.alignItems] ?? props.alignItems
79
+ }
80
+
81
+ if (props.flexWrap !== undefined) {
82
+ style.flexWrap =
83
+ props.flexWrap === true ? 'wrap' : props.flexWrap === false ? 'nowrap' : props.flexWrap
84
+ }
85
+
86
+ if (props.gap !== undefined) {
87
+ style.gap = props.gap
88
+ }
89
+
90
+ if (props.rowGap !== undefined) {
91
+ style.rowGap = props.rowGap
92
+ }
93
+
94
+ if (props.columnGap !== undefined) {
95
+ style.columnGap = props.columnGap
96
+ }
97
+
98
+ // The shared contract is "numbers are dips" (RN semantics), but octane's
99
+ // DOM renderer keeps React's unitless list — lineHeight is unitless there,
100
+ // so `style={{lineHeight: 40}}` would render a 40× multiplier instead of
101
+ // 40px. lineHeight is the one unitless-in-React prop that is a length in
102
+ // the shared vocabulary; force the px suffix.
103
+ if (typeof style.lineHeight === 'number') {
104
+ style.lineHeight = style.lineHeight + 'px'
105
+ }
106
+
107
+ return { style }
108
+ }
@@ -0,0 +1,14 @@
1
+ /** Apply a web escape-hatch bag after the primitive's standard props. */
2
+ export function applyWebProps(el: any, props: Record<string, any> | undefined) {
3
+ if (!el || !props) {
4
+ return
5
+ }
6
+
7
+ for (const [name, value] of Object.entries(props)) {
8
+ if (name.includes('-')) {
9
+ el.setAttribute(name, String(value))
10
+ } else {
11
+ el[name] = value
12
+ }
13
+ }
14
+ }
package/src/props.ts ADDED
@@ -0,0 +1,84 @@
1
+ export interface LayoutChildProps {
2
+ row?: number
3
+ col?: number
4
+ rowSpan?: number
5
+ colSpan?: number
6
+ dock?: 'left' | 'top' | 'right' | 'bottom'
7
+ left?: number
8
+ top?: number
9
+ flexGrow?: number
10
+ flexShrink?: number
11
+ alignSelf?: string
12
+ order?: number
13
+ }
14
+
15
+ /** Flex-container props shared by View/Row/Pressable — RN vocabulary, applied
16
+ * to the host flexboxlayout natively and the element's style on web.
17
+ * `gap` is a dip number (px on web); NS supports it on FlexboxLayout only
18
+ * (GridLayout has no gap). */
19
+ export interface FlexContainerProps {
20
+ justifyContent?: 'start' | 'center' | 'end' | 'space-between' | 'space-around' | 'space-evenly'
21
+ alignItems?: 'start' | 'center' | 'end' | 'stretch' | 'baseline'
22
+ flexWrap?: boolean | 'wrap' | 'nowrap' | 'wrap-reverse'
23
+ gap?: number | string
24
+ rowGap?: number | string
25
+ columnGap?: number | string
26
+ }
27
+
28
+ export type Role =
29
+ | 'button'
30
+ | 'link'
31
+ | 'search'
32
+ | 'image'
33
+ | 'heading'
34
+ | 'adjustable'
35
+ | 'summary'
36
+ | 'text'
37
+ | 'none'
38
+ | 'progressbar'
39
+ | 'checkbox'
40
+ | 'switch'
41
+ | 'radio'
42
+ | 'spinbutton'
43
+ | 'tab'
44
+
45
+ export interface AccessibilityProps {
46
+ accessible?: boolean
47
+ accessibilityLabel?: string
48
+ accessibilityRole?: Role
49
+ accessibilityHint?: string
50
+ accessibilityValue?: string
51
+ accessibilityState?: {
52
+ disabled?: boolean
53
+ selected?: boolean
54
+ checked?: boolean
55
+ }
56
+ accessibilityLiveRegion?: 'none' | 'polite' | 'assertive'
57
+ }
58
+
59
+ /** Paged horizontal swipe container — onboarding flows, media galleries.
60
+ * Chrome-reset OS paging on native (ViewPager2 / UICollectionView paging via
61
+ * @nativescript-community/ui-pager), a scroll-snap scroller on web. Follows
62
+ * the platform-list contract: `items` + `renderItem`, no children — each
63
+ * page is a full-host-size cell. No indicator is built in; compose dots
64
+ * from `Row` + `View` driven by `page`/`onPageChange`. */
65
+ export interface PagerProps extends LayoutChildProps, AccessibilityProps {
66
+ className?: any
67
+ style?: any
68
+ id?: string
69
+ items: any[]
70
+ renderItem: (item: any, index: number) => any
71
+ renderEmpty?: () => any
72
+ /** Controlled page index — pass with `onPageChange` to own the page.
73
+ * Writes scroll/settle to the page; user swipes call `onPageChange`. */
74
+ page?: number
75
+ /** Starting page for uncontrolled use (default 0). */
76
+ defaultPage?: number
77
+ /** Fires when the settled page changes — a completed swipe on both
78
+ * targets. Programmatic `page` writes do not echo back through it. */
79
+ onPageChange?: (index: number) => void
80
+ /** Platform escape hatches, applied after the shared props. */
81
+ ios?: Record<string, any>
82
+ android?: Record<string, any>
83
+ web?: Record<string, any>
84
+ }
@@ -0,0 +1,59 @@
1
+ const kebab = (key: string) => key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())
2
+
3
+ /** Anything accepted as a style value, including nested arrays. */
4
+ export type StyleInput =
5
+ | string
6
+ | Record<string, any>
7
+ | null
8
+ | undefined
9
+ | readonly StyleInput[]
10
+
11
+ /** Merge component-emitted style defaults under the authored `style` prop.
12
+ * Authored keys win; array styles fold in order; a string style is appended
13
+ * after the serialized defaults so its declarations override them too. */
14
+ export function mergeStyle(
15
+ base: Record<string, any>,
16
+ style?: Record<string, any> | readonly (Record<string, any> | null | undefined)[] | null,
17
+ ): Record<string, any>
18
+ export function mergeStyle(
19
+ base: Record<string, any>,
20
+ style?: StyleInput,
21
+ ): string | Record<string, any>
22
+ export function mergeStyle(
23
+ base: Record<string, any>,
24
+ style?: StyleInput,
25
+ ): string | Record<string, any> {
26
+ if (style == null) {
27
+ return base
28
+ }
29
+
30
+ let obj: Record<string, any> = { ...base }
31
+ const strings: string[] = []
32
+ const add = (s: StyleInput) => {
33
+ if (s == null) {
34
+ return
35
+ }
36
+ if (typeof s === 'string') {
37
+ strings.push(s)
38
+ } else if (Array.isArray(s)) {
39
+ for (const part of s) {
40
+ add(part)
41
+ }
42
+ } else {
43
+ obj = { ...obj, ...s }
44
+ }
45
+ }
46
+ add(style)
47
+
48
+ if (strings.length === 0) {
49
+ return obj
50
+ }
51
+
52
+ const prefix = Object.entries(obj)
53
+ .filter(([, v]) => v != null)
54
+ .map(([k, v]) => `${kebab(k)}:${v}`)
55
+ .join(';')
56
+ return [prefix, ...strings].filter(Boolean).join(';')
57
+ }
58
+
59
+ export type StyleProp = string | Record<string, any> | readonly StyleProp[] | null | undefined
@@ -0,0 +1,5 @@
1
+ import type { UniversalComponent } from 'octane/universal'
2
+ import type { PagerProps } from './props.js'
3
+
4
+ export declare const Pager: UniversalComponent<PagerProps>
5
+ export type { PagerProps } from './props.js'
@@ -0,0 +1,4 @@
1
+ import type { PagerProps } from './props.js'
2
+
3
+ export type { PagerProps } from './props.js'
4
+ export declare function Pager(props: PagerProps): unknown
@@ -0,0 +1,65 @@
1
+ export interface LayoutChildProps {
2
+ row?: number;
3
+ col?: number;
4
+ rowSpan?: number;
5
+ colSpan?: number;
6
+ dock?: 'left' | 'top' | 'right' | 'bottom';
7
+ left?: number;
8
+ top?: number;
9
+ flexGrow?: number;
10
+ flexShrink?: number;
11
+ alignSelf?: string;
12
+ order?: number;
13
+ }
14
+ /** Flex-container props shared by View/Row/Pressable — RN vocabulary, applied
15
+ * to the host flexboxlayout natively and the element's style on web.
16
+ * `gap` is a dip number (px on web); NS supports it on FlexboxLayout only
17
+ * (GridLayout has no gap). */
18
+ export interface FlexContainerProps {
19
+ justifyContent?: 'start' | 'center' | 'end' | 'space-between' | 'space-around' | 'space-evenly';
20
+ alignItems?: 'start' | 'center' | 'end' | 'stretch' | 'baseline';
21
+ flexWrap?: boolean | 'wrap' | 'nowrap' | 'wrap-reverse';
22
+ gap?: number | string;
23
+ rowGap?: number | string;
24
+ columnGap?: number | string;
25
+ }
26
+ export type Role = 'button' | 'link' | 'search' | 'image' | 'heading' | 'adjustable' | 'summary' | 'text' | 'none' | 'progressbar' | 'checkbox' | 'switch' | 'radio' | 'spinbutton' | 'tab';
27
+ export interface AccessibilityProps {
28
+ accessible?: boolean;
29
+ accessibilityLabel?: string;
30
+ accessibilityRole?: Role;
31
+ accessibilityHint?: string;
32
+ accessibilityValue?: string;
33
+ accessibilityState?: {
34
+ disabled?: boolean;
35
+ selected?: boolean;
36
+ checked?: boolean;
37
+ };
38
+ accessibilityLiveRegion?: 'none' | 'polite' | 'assertive';
39
+ }
40
+ /** Paged horizontal swipe container — onboarding flows, media galleries.
41
+ * Chrome-reset OS paging on native (ViewPager2 / UICollectionView paging via
42
+ * @nativescript-community/ui-pager), a scroll-snap scroller on web. Follows
43
+ * the platform-list contract: `items` + `renderItem`, no children — each
44
+ * page is a full-host-size cell. No indicator is built in; compose dots
45
+ * from `Row` + `View` driven by `page`/`onPageChange`. */
46
+ export interface PagerProps extends LayoutChildProps, AccessibilityProps {
47
+ className?: any;
48
+ style?: any;
49
+ id?: string;
50
+ items: any[];
51
+ renderItem: (item: any, index: number) => any;
52
+ renderEmpty?: () => any;
53
+ /** Controlled page index — pass with `onPageChange` to own the page.
54
+ * Writes scroll/settle to the page; user swipes call `onPageChange`. */
55
+ page?: number;
56
+ /** Starting page for uncontrolled use (default 0). */
57
+ defaultPage?: number;
58
+ /** Fires when the settled page changes — a completed swipe on both
59
+ * targets. Programmatic `page` writes do not echo back through it. */
60
+ onPageChange?: (index: number) => void;
61
+ /** Platform escape hatches, applied after the shared props. */
62
+ ios?: Record<string, any>;
63
+ android?: Record<string, any>;
64
+ web?: Record<string, any>;
65
+ }