lecodes-cli 0.7.1 → 0.8.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 +3 -3
- package/dist/index.js +2941 -745
- package/package.json +4 -4
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/canvas/Canvas.ts +16 -0
- package/runtime/sdk/gl/Material.ts +4 -0
- package/runtime/sdk/inject.ts +8 -5
- package/runtime/sdk/plugins/service.ts +47 -0
- package/runtime/sdk/runtime/app.ts +21 -1
- package/runtime/sdk/runtime/appEvents.ts +25 -0
- package/runtime/sdk/runtime/service.ts +3 -2
- package/runtime/sdk/ui/NativeView.ts +2 -1
- package/runtime/sdk/ui/UI.ts +3 -1
- package/runtime/sdk/ui/UIButton.ts +10 -5
- package/runtime/sdk/ui/UIContainer.ts +16 -9
- package/runtime/sdk/ui/UIImage.ts +5 -2
- package/runtime/sdk/ui/UIInput.ts +75 -8
- package/runtime/sdk/ui/UIModal.ts +149 -0
- package/runtime/sdk/ui/UINode.ts +126 -55
- package/runtime/sdk/ui/UIPager.ts +297 -0
- package/runtime/sdk/ui/UIPopover.ts +106 -0
- package/runtime/sdk/ui/UIScreen.ts +11 -10
- package/runtime/sdk/ui/UIScrollable.ts +2 -1
- package/runtime/sdk/ui/UISpacer.ts +1 -1
- package/runtime/sdk/ui/UIText.ts +2 -1
- package/runtime/sdk/ui/UIVideo.ts +2 -1
- package/runtime/sdk/ui/UIVirtualizedList.ts +2 -1
- package/runtime/sdk/ui/UIWidget.ts +19 -22
- package/runtime/sdk/ui/theme.ts +66 -0
- package/runtime/sdk-types.json +1 -1
- package/runtime/sdk/compile/aspectMacro.ts +0 -42
- package/runtime/sdk/compile/assetMacro.ts +0 -45
- package/runtime/sdk/compile/bundler.ts +0 -205
- package/runtime/sdk/compile/compileProject.ts +0 -108
- package/runtime/sdk/compile/header.ts +0 -46
- package/runtime/sdk/compile/index.ts +0 -18
- package/runtime/sdk/compile/libraryImports.ts +0 -47
- package/runtime/sdk/compile/sceneEditor.ts +0 -78
- package/runtime/sdk/compile/sourcemap.ts +0 -25
- package/runtime/sdk/kit/UITabs.ts +0 -105
- package/runtime/sdk/ui/UIScreenHost.ts +0 -213
package/runtime/sdk/ui/UINode.ts
CHANGED
|
@@ -73,62 +73,63 @@ export type ScrollStyle = {
|
|
|
73
73
|
scrollY?: "auto" | "all" | "none"
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
type UIValue = number |
|
|
77
|
-
`${number}px` |
|
|
78
|
-
`${number}vw` |
|
|
79
|
-
`${number}vh` |
|
|
80
|
-
`${number}vmin` |
|
|
81
|
-
`${number}vmax` |
|
|
82
|
-
`${number}em` |
|
|
76
|
+
type UIValue = number |
|
|
77
|
+
`${number}px` |
|
|
78
|
+
`${number}vw` |
|
|
79
|
+
`${number}vh` |
|
|
80
|
+
`${number}vmin` |
|
|
81
|
+
`${number}vmax` |
|
|
82
|
+
`${number}em` |
|
|
83
83
|
`calc(${string})` |
|
|
84
84
|
`min(${string})` |
|
|
85
85
|
`max(${string})` |
|
|
86
86
|
`${string}%` |
|
|
87
|
-
`clamp(${string})`
|
|
87
|
+
`clamp(${string})` |
|
|
88
|
+
`var(--${string})`
|
|
88
89
|
|
|
89
90
|
export type PaddingStyle = {
|
|
90
|
-
paddingLeft?: UIValue | "safe-left" | "
|
|
91
|
-
paddingTop?: UIValue | "safe-top" | "
|
|
92
|
-
paddingRight?: UIValue | "safe-right" | "
|
|
93
|
-
paddingBottom?: UIValue | "safe-bottom" | "
|
|
94
|
-
padding?: UIValue | "safe-all" | "
|
|
95
|
-
paddingHorizontal?: UIValue,
|
|
96
|
-
paddingVertical?: UIValue,
|
|
97
|
-
|
|
98
|
-
pl?: UIValue | "safe-left" | "
|
|
99
|
-
pt?: UIValue | "safe-top" | "
|
|
100
|
-
pr?: UIValue | "safe-right" | "
|
|
101
|
-
pb?: UIValue | "safe-bottom" | "
|
|
102
|
-
p?: UIValue | "safe-all" | "
|
|
103
|
-
px?: UIValue,
|
|
104
|
-
py?: UIValue,
|
|
91
|
+
paddingLeft?: UIValue | "safe-left" | "comfort-left",
|
|
92
|
+
paddingTop?: UIValue | "safe-top" | "comfort-top",
|
|
93
|
+
paddingRight?: UIValue | "safe-right" | "comfort-right",
|
|
94
|
+
paddingBottom?: UIValue | "safe-bottom" | "comfort-bottom",
|
|
95
|
+
padding?: UIValue | "safe-all" | "comfort-all",
|
|
96
|
+
paddingHorizontal?: UIValue | "comfort-x",
|
|
97
|
+
paddingVertical?: UIValue | "comfort-y",
|
|
98
|
+
|
|
99
|
+
pl?: UIValue | "safe-left" | "comfort-left",
|
|
100
|
+
pt?: UIValue | "safe-top" | "comfort-top",
|
|
101
|
+
pr?: UIValue | "safe-right" | "comfort-right",
|
|
102
|
+
pb?: UIValue | "safe-bottom" | "comfort-bottom",
|
|
103
|
+
p?: UIValue | "safe-all" | "comfort-all",
|
|
104
|
+
px?: UIValue | "comfort-x",
|
|
105
|
+
py?: UIValue | "comfort-y",
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
|
|
108
109
|
|
|
109
110
|
export type MarginStyle = {
|
|
110
|
-
marginLeft?: UIValue | "auto" | "safe-left" | "
|
|
111
|
-
marginTop?: UIValue | "auto" | "safe-top" | "
|
|
112
|
-
marginRight?: UIValue | "auto" | "safe-right" | "
|
|
113
|
-
marginBottom?: UIValue | "auto" | "safe-bottom" | "
|
|
114
|
-
margin?: UIValue | "auto" | "safe-all" | "
|
|
115
|
-
marginHorizontal?: UIValue | "auto",
|
|
116
|
-
marginVertical?: UIValue | "auto",
|
|
117
|
-
|
|
118
|
-
ml?: UIValue | "auto" | "safe-left" | "
|
|
119
|
-
mt?: UIValue | "auto" | "safe-top" | "
|
|
120
|
-
mr?: UIValue | "auto" | "safe-right" | "
|
|
121
|
-
mb?: UIValue | "auto" | "safe-bottom" | "
|
|
122
|
-
m?: UIValue | "auto" | "safe-all" | "
|
|
123
|
-
mx?: UIValue | "auto",
|
|
124
|
-
my?: UIValue | "auto",
|
|
111
|
+
marginLeft?: UIValue | "auto" | "safe-left" | "comfort-left",
|
|
112
|
+
marginTop?: UIValue | "auto" | "safe-top" | "comfort-top",
|
|
113
|
+
marginRight?: UIValue | "auto" | "safe-right" | "comfort-right",
|
|
114
|
+
marginBottom?: UIValue | "auto" | "safe-bottom" | "comfort-bottom",
|
|
115
|
+
margin?: UIValue | "auto" | "safe-all" | "comfort-all",
|
|
116
|
+
marginHorizontal?: UIValue | "auto" | "comfort-x",
|
|
117
|
+
marginVertical?: UIValue | "auto" | "comfort-y",
|
|
118
|
+
|
|
119
|
+
ml?: UIValue | "auto" | "safe-left" | "comfort-left",
|
|
120
|
+
mt?: UIValue | "auto" | "safe-top" | "comfort-top",
|
|
121
|
+
mr?: UIValue | "auto" | "safe-right" | "comfort-right",
|
|
122
|
+
mb?: UIValue | "auto" | "safe-bottom" | "comfort-bottom",
|
|
123
|
+
m?: UIValue | "auto" | "safe-all" | "comfort-all",
|
|
124
|
+
mx?: UIValue | "auto" | "comfort-x",
|
|
125
|
+
my?: UIValue | "auto" | "comfort-y",
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
export type PositionStyle = {
|
|
128
|
-
top?: UIValue | `${number}%` | 'safe-top' | '
|
|
129
|
-
left?: UIValue | `${number}%` | 'safe-left' | '
|
|
130
|
-
bottom?: UIValue | `${number}%` | 'safe-bottom' | '
|
|
131
|
-
right?: UIValue | `${number}%` | 'safe-right' | '
|
|
129
|
+
top?: UIValue | `${number}%` | 'safe-top' | 'comfort-top',
|
|
130
|
+
left?: UIValue | `${number}%` | 'safe-left' | 'comfort-left',
|
|
131
|
+
bottom?: UIValue | `${number}%` | 'safe-bottom' | 'comfort-bottom',
|
|
132
|
+
right?: UIValue | `${number}%` | 'safe-right' | 'comfort-right',
|
|
132
133
|
width?: UIValue | `${number}%` | "auto",
|
|
133
134
|
height?: UIValue | `${number}%` | "auto",
|
|
134
135
|
maxWidth?: UIValue | `${number}%` | "auto",
|
|
@@ -195,6 +196,7 @@ function styleFunction(this: any, newStyle: any) {
|
|
|
195
196
|
this.name = newStyle.name
|
|
196
197
|
delete newStyle.name
|
|
197
198
|
}
|
|
199
|
+
if (newStyle) this._mapStyle?.(newStyle)
|
|
198
200
|
const fns = extractBindings(this, newStyle)
|
|
199
201
|
Object.assign(this._style, newStyle)
|
|
200
202
|
if (this._id !== 0) {
|
|
@@ -220,10 +222,16 @@ const handler: ProxyHandler<any> = {
|
|
|
220
222
|
createBinding(el, key, () => { el.style[key] = fn() })
|
|
221
223
|
return true
|
|
222
224
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
+
const el = target._el
|
|
226
|
+
if (el._mapStyle !== undefined) {
|
|
227
|
+
const mapped = { [key]: value }
|
|
228
|
+
el._mapStyle(mapped)
|
|
229
|
+
value = mapped[key]
|
|
225
230
|
}
|
|
226
|
-
|
|
231
|
+
if (el._id !== 0) {
|
|
232
|
+
_creatorUI.updateStyle(el._id, key, value)
|
|
233
|
+
}
|
|
234
|
+
el._style[key] = value
|
|
227
235
|
return true
|
|
228
236
|
},
|
|
229
237
|
getOwnPropertyDescriptor(target, p) {
|
|
@@ -245,15 +253,20 @@ export type Reactive<T> = { [K in keyof T]: NonNullable<T[K]> extends object ? T
|
|
|
245
253
|
|
|
246
254
|
// User-defined style classes: any `$`-prefixed key in .style() declares a state block (like
|
|
247
255
|
// onPressed, but with any name), toggled from code via setClass/toggleClass. `duration`/`delay`
|
|
248
|
-
// make the swap transition.
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
256
|
+
// make the swap transition. Class state INHERITS down the tree (CSS-`.dark`-on-body style): a
|
|
257
|
+
// class set on a node also activates same-name `$` blocks on all descendants within the same
|
|
258
|
+
// root — see docs/style-class-cascade-plan.md. `$pressed`/`$focused` are reserved: hosts toggle
|
|
259
|
+
// them on press/focus (they beat other classes, lose to onPressed/onFocused), so children can
|
|
260
|
+
// react to an ancestor's press. Instantiate with the node's own style `T` (see StyleFn below) — a
|
|
261
|
+
// class block accepts everything the node's `.style()` does (e.g. `color` on text, layout on any
|
|
262
|
+
// node), exactly like `onLandscape`/`onPortrait`. Narrowing it (it once was `DrawableStyle &
|
|
263
|
+
// BaseStyle`) wrongly rejected valid props such as `$active: { color }`. Guarded by
|
|
264
|
+
// tests/ui-types.test.ts.
|
|
252
265
|
export type ClassStyles<T> = { [key: `$${string}`]: T & { duration?: number, delay?: number } }
|
|
253
266
|
|
|
254
267
|
// NB: do NOT annotate `this: R` here. `this` is a parameter position, so it makes R contravariant,
|
|
255
268
|
// which makes every node type INVARIANT in its own type params (and in `this`). That is what made
|
|
256
|
-
// `UIScreen<false>` unassignable to `UIScreen<boolean>`
|
|
269
|
+
// `UIScreen<false>` unassignable to `UIScreen<boolean>` (a navigator page), and would block any
|
|
257
270
|
// UINode subtype from being assignable to its base. Runtime `this` is already fixed by the
|
|
258
271
|
// `styleFunction.bind(this)` in the `style` getter — the annotation bought nothing and cost variance.
|
|
259
272
|
// R stays only in the (covariant) return, so `.style()` still returns the concrete node type for
|
|
@@ -271,13 +284,42 @@ export type OrientationStyles<T> = {
|
|
|
271
284
|
export type StyleFn <R, T extends object> = ((style: Reactive<T> & OrientationStyles<T> & ClassStyles<T>) => R)
|
|
272
285
|
export type Style <R, T extends object> = StyleFn<R,T> & T & OrientationStyles<T> & ClassStyles<T>
|
|
273
286
|
|
|
274
|
-
|
|
287
|
+
// The options bag of animateTo/animateFrom: the target style plus flat meta keys. Meta keys are
|
|
288
|
+
// stripped before the commit into _style and skipped by the hosts' style staging.
|
|
289
|
+
export type AnimateStyle<R, T extends object> = ((style: T & {
|
|
290
|
+
/** Tween length in **milliseconds** (default 225). */
|
|
291
|
+
duration?: number,
|
|
292
|
+
/** Wait before starting, in **milliseconds**. A looping animation delays once, not per cycle. */
|
|
293
|
+
delay?: number,
|
|
294
|
+
/** Escape hatch: target an internal style layer (the onPressed/orientation mechanism). Not part of the supported surface. */
|
|
295
|
+
layer?: number,
|
|
296
|
+
/** `false` = animate without writing the targets into the element's style (e.g. a fade-out right before `.hide()`). */
|
|
297
|
+
commit?: boolean,
|
|
298
|
+
/** Repeat the animation: `true` = forever, a number = that many cycles. A looping animation is an
|
|
299
|
+
* effect, not a state change — it never commits, and the element ends back at its base style.
|
|
300
|
+
* The loop stops when a later `animateTo`/`animateFrom` runs on the element or it leaves the screen. */
|
|
301
|
+
loop?: boolean | number,
|
|
302
|
+
/** `"ping-pong"` (default) animates there and back each cycle — no jump. `"restart"` snaps back and
|
|
303
|
+
* replays forward — for full-turn spinners (`transform: "rotate(360deg)"`) and shimmers. Only meaningful with `loop`. */
|
|
304
|
+
loopMode?: "restart" | "ping-pong",
|
|
305
|
+
}) => R)
|
|
306
|
+
|
|
307
|
+
/** Meta keys of the animateTo/animateFrom options bag — everything that is not a style target. */
|
|
308
|
+
const ANIMATE_META = new Set(["duration", "delay", "layer", "commit", "loop", "loopMode"])
|
|
275
309
|
|
|
276
310
|
export type AppearStyle <R, T> = (arg: { from: T, duration?: number, delay?: number }) => R
|
|
277
311
|
export type DisappearStyle <R, T> = (arg: { to: T, duration?: number, delay?: number }) => R
|
|
278
312
|
|
|
279
313
|
export type OnLayoutCallback = (layout: { left: number, top: number, width: number, height: number }) => void
|
|
280
314
|
|
|
315
|
+
// Web-DOMRect shape on purpose (getBoundingClientRect priors must hold): all eight fields, with
|
|
316
|
+
// right/bottom/x/y derived SDK-side so hosts only report [left, top, width, height].
|
|
317
|
+
export type BoundingClientRect = {
|
|
318
|
+
x: number, y: number,
|
|
319
|
+
left: number, top: number, right: number, bottom: number,
|
|
320
|
+
width: number, height: number,
|
|
321
|
+
}
|
|
322
|
+
|
|
281
323
|
export class Element<T extends string> {
|
|
282
324
|
readonly type: T
|
|
283
325
|
/** Author-given semantic name (LeCodes `name`) — see BaseStyle.name. Extracted from the options
|
|
@@ -294,12 +336,21 @@ export class Element<T extends string> {
|
|
|
294
336
|
* is the effect's only strong owner, so discarding a subtree lets its bindings collect with it. */
|
|
295
337
|
_fx?: Map<string, any>
|
|
296
338
|
|
|
339
|
+
/** @internal Per-subclass style normalization (SDK-name → wire-name, e.g. UIInput's
|
|
340
|
+
* `type: "phone"` → `"tel"`), mutating the style in place before it is stored or sent. Runs on
|
|
341
|
+
* every entry point: the first-arg style, `.style()`, and single-key `el.style.k =` writes. */
|
|
342
|
+
_mapStyle?(style: any): void
|
|
343
|
+
|
|
344
|
+
// Per-type default styles are inlined at each component's construction site (spread UNDER the
|
|
345
|
+
// user's style — e.g. UIRow's `flexDirection: "row"`), so every node reaches the host with
|
|
346
|
+
// explicit values and hosts keep no per-type style opinions of their own.
|
|
297
347
|
constructor(type: T, style: any) {
|
|
298
348
|
this.type = type
|
|
299
349
|
if (style && typeof style.name === "string" && style.name.length > 0) {
|
|
300
350
|
this.name = style.name
|
|
301
351
|
delete style.name
|
|
302
352
|
}
|
|
353
|
+
if (style) this._mapStyle?.(style)
|
|
303
354
|
this._style = style
|
|
304
355
|
if (style) {
|
|
305
356
|
const fns = extractBindings(this, style)
|
|
@@ -326,8 +377,12 @@ export class Element<T extends string> {
|
|
|
326
377
|
|
|
327
378
|
animateTo(style: any): this{
|
|
328
379
|
_creatorUI.animateTo(this._id, style)
|
|
329
|
-
|
|
330
|
-
|
|
380
|
+
// A loop is an effect, not a state change — looping animations never commit. Meta keys
|
|
381
|
+
// (duration/delay/…) stay out of the stored style.
|
|
382
|
+
if (style.commit !== false && !style.loop) {
|
|
383
|
+
for (const key in style) {
|
|
384
|
+
if (!ANIMATE_META.has(key)) this._style[key] = style[key]
|
|
385
|
+
}
|
|
331
386
|
}
|
|
332
387
|
return this
|
|
333
388
|
}
|
|
@@ -341,7 +396,10 @@ export class Element<T extends string> {
|
|
|
341
396
|
* re-assign _id on every open). */
|
|
342
397
|
protected _classes?: Set<string>
|
|
343
398
|
|
|
344
|
-
/** Activate/deactivate a `$`-class declared in .style() (accepts the name with or without `$`).
|
|
399
|
+
/** Activate/deactivate a `$`-class declared in .style() (accepts the name with or without `$`).
|
|
400
|
+
* The class cascades: it is also active on every descendant (their same-name `$` blocks light
|
|
401
|
+
* up), until the subtree's root — hosted screens/widgets don't inherit. `hasClass`/`toggleClass`
|
|
402
|
+
* reflect only THIS node's own classes; there is no opt-out below an active ancestor. */
|
|
345
403
|
setClass(name: string, enabled: boolean): this {
|
|
346
404
|
if (name[0] === "$") name = name.slice(1)
|
|
347
405
|
if (enabled) {
|
|
@@ -372,7 +430,7 @@ export class Element<T extends string> {
|
|
|
372
430
|
}
|
|
373
431
|
|
|
374
432
|
protected ll?: OnLayoutCallback[]
|
|
375
|
-
onLayout(onLayout: OnLayoutCallback): this {
|
|
433
|
+
onLayout(onLayout: OnLayoutCallback): this {
|
|
376
434
|
if (this.ll) {
|
|
377
435
|
this.ll.push(onLayout)
|
|
378
436
|
} else {
|
|
@@ -380,6 +438,19 @@ export class Element<T extends string> {
|
|
|
380
438
|
}
|
|
381
439
|
return this
|
|
382
440
|
}
|
|
441
|
+
|
|
442
|
+
/** Absolute rect in device space — the space `UIWidget` positions in and touch events report
|
|
443
|
+
* `clientX`/`clientY` in — including scroll offsets, read live at call time (unlike `onLayout`,
|
|
444
|
+
* whose coordinates are parent-relative and go stale when an ancestor scrolls). `null` before
|
|
445
|
+
* the element is mounted and on hosts without the bridge read. Anchor popovers position-once
|
|
446
|
+
* at open; don't poll per frame. */
|
|
447
|
+
getBoundingClientRect(): BoundingClientRect | null {
|
|
448
|
+
if (this._id === 0) return null
|
|
449
|
+
const rect = _creatorUI.getBoundingClientRect?.(this._id)
|
|
450
|
+
if (!rect) return null
|
|
451
|
+
const [left, top, width, height] = rect
|
|
452
|
+
return { x: left, y: top, left, top, right: left + width, bottom: top + height, width, height }
|
|
453
|
+
}
|
|
383
454
|
}
|
|
384
455
|
|
|
385
456
|
// ---- reactive children --------------------------------------------------------------------------
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { ContainerElement, type AnimateStyle, type BaseStyle, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type BoundingClientRect, type Style } from "./UINode"
|
|
2
|
+
import type { UIScreen } from "./UIScreen"
|
|
3
|
+
|
|
4
|
+
// UIPager — the system's ONE screen-navigation element (docs/pager-component-plan.md): sibling
|
|
5
|
+
// tabs that swipe natively side to side, with a native navigation STACK per tab. `push` opens a
|
|
6
|
+
// screen on top of the currently open tab (within the pager); `pop` / the interactive edge
|
|
7
|
+
// back-swipe unwinds it; `replace` swaps the top. A pager with one tab is a plain stack.
|
|
8
|
+
//
|
|
9
|
+
// Reachable from any scope as a global: `UIPager.push(screen)` pushes onto the pager that owns
|
|
10
|
+
// the current screen (UIPager.current) — no reference threading into child screens. Router stays
|
|
11
|
+
// distinct: `Router.push` opens ON TOP of the pager (bars and all), the pager navigates within.
|
|
12
|
+
//
|
|
13
|
+
// Wire node type "pager": children are separate layout roots (full UIScreens), NOT yoga children.
|
|
14
|
+
// The wire shape is `children` flat in TAB-MAJOR order (tab 0's stack root→top, then tab 1's, …)
|
|
15
|
+
// partitioned by `_lens` (per-tab stack lengths) with `_index` = selected tab. State is mutated
|
|
16
|
+
// BEFORE each command, so the host reconciles against the node; commands only name the native
|
|
17
|
+
// transition to play. Host-driven changes come back via `_hostDidSelect` / `_hostDidPop` — both
|
|
18
|
+
// idempotent no-ops when the logical state already matches.
|
|
19
|
+
|
|
20
|
+
/** Styles a pager accepts — layout box + drawable (background, borders, …). Pages style
|
|
21
|
+
* themselves; the pager only owns its own slot. */
|
|
22
|
+
export type UIPagerStyle = ElementStyle & DrawableStyle
|
|
23
|
+
|
|
24
|
+
/** The pagers whose screen is currently visible, outermost → innermost. `UIPager.current` is the
|
|
25
|
+
* last (innermost). Maintained by each owned screen's onOpen/onClose via _activate/_deactivate. */
|
|
26
|
+
const _activeStack: PagerElement[] = []
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The screen-navigation element: sibling **tabs** that swipe natively side to side, each with its
|
|
30
|
+
* own navigation **stack**. `push` opens a screen on top of the current tab (within the pager);
|
|
31
|
+
* `pop` / the native edge back-swipe unwinds it. A one-tab pager is a plain navigation stack.
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* const pager = UIPager([ HomeScreen(), ProfileScreen() ]) // two tabs, side by side
|
|
35
|
+
* Router.init(UIScreen([ pager.style({ flexGrow: 1 }), tabBar ]))
|
|
36
|
+
*
|
|
37
|
+
* // from anywhere inside a hosted screen — no reference threading:
|
|
38
|
+
* UIPager.push(PostScreen(id))
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* To open something over *everything* (above bars, above the pager), use `Router.push` instead.
|
|
42
|
+
*/
|
|
43
|
+
export interface UIPager {
|
|
44
|
+
readonly type: "pager",
|
|
45
|
+
style: Style<this, UIPagerStyle>,
|
|
46
|
+
animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>,
|
|
47
|
+
animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>,
|
|
48
|
+
|
|
49
|
+
/** Switch to tab `index`. **Instant by default** — the tab-bar convention (tabs are parallel
|
|
50
|
+
* modes, not neighbors); pass `animated: true` for the direction-aware slide of top-tab /
|
|
51
|
+
* pager UIs. Out-of-range and same-index calls are no-ops. Tabs keep their stacks: selecting
|
|
52
|
+
* away and back restores the tab exactly where it was, pushed screens included. */
|
|
53
|
+
select(index: number, animated?: boolean): this,
|
|
54
|
+
/** The selected tab index (0-based). */
|
|
55
|
+
readonly index: number,
|
|
56
|
+
/** Fires when the selected tab changes and has settled — a user swipe or `select()`. */
|
|
57
|
+
onSelect(callback: (index: number) => void): this,
|
|
58
|
+
|
|
59
|
+
/** Push `screen` on top of the **current tab** — slides in natively; the edge back-swipe (or
|
|
60
|
+
* `pop()`) removes it. Other tabs are unaffected. */
|
|
61
|
+
push(screen: UIScreen): this,
|
|
62
|
+
/** Pop the current tab's top screen (no-op at the tab root — the root never pops). The
|
|
63
|
+
* programmatic twin of the native edge back-swipe. */
|
|
64
|
+
pop(): this,
|
|
65
|
+
/** Unwind the current tab back to its root screen in one step. */
|
|
66
|
+
popToRoot(): this,
|
|
67
|
+
/** Swap the current tab's top screen for `screen` without growing the stack (at depth 1 this
|
|
68
|
+
* swaps the tab's root). */
|
|
69
|
+
replace(screen: UIScreen): this,
|
|
70
|
+
/** The current tab's screens, root → top (read-only snapshot). */
|
|
71
|
+
readonly stack: UIScreen[],
|
|
72
|
+
/** The current tab's stack depth — 1 means only the root. While `depth > 1`, swiping between
|
|
73
|
+
* tabs is disabled (the horizontal gesture belongs to the back-swipe). */
|
|
74
|
+
readonly depth: number,
|
|
75
|
+
/** Fires when the current tab's depth changes — push, pop, replace, or a back-swipe. */
|
|
76
|
+
onChange(callback: (depth: number) => void): this,
|
|
77
|
+
|
|
78
|
+
onLayout(onLayout: OnLayoutCallback): this,
|
|
79
|
+
getBoundingClientRect(): BoundingClientRect | null,
|
|
80
|
+
|
|
81
|
+
setClass(name: string, enabled: boolean): this,
|
|
82
|
+
toggleClass(name: string): this,
|
|
83
|
+
hasClass(name: string): boolean,
|
|
84
|
+
bindClass(name: string, fn: () => boolean): this,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class PagerElement extends ContainerElement<"pager"> {
|
|
88
|
+
/** Per-tab stack lengths — partitions the flat tab-major `children`. Named `_*` so chisel
|
|
89
|
+
* keeps it (the host reads it live). */
|
|
90
|
+
_lens: number[]
|
|
91
|
+
/** Selected tab. */
|
|
92
|
+
_index = 0
|
|
93
|
+
// >0 while a screen owned by this pager is the visible one (its onOpen fired, onClose hasn't).
|
|
94
|
+
// Only one owned screen is ever open, so this is effectively 0/1 — the count only makes the
|
|
95
|
+
// transient during a transition's old-close/new-open pair order-independent.
|
|
96
|
+
_openCount = 0
|
|
97
|
+
private scl: ((index: number) => void)[] = [] // onSelect listeners
|
|
98
|
+
private ncl: ((depth: number) => void)[] = [] // onChange listeners
|
|
99
|
+
|
|
100
|
+
constructor(style: UIPagerStyle | null, tabs: UIScreen[]) {
|
|
101
|
+
super("pager", style ?? {}, tabs as any)
|
|
102
|
+
this._lens = tabs.map(() => 1)
|
|
103
|
+
for (const s of this.children) this._track(s)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Start offset of tab `i`'s segment in the flat `children`. */
|
|
107
|
+
private _offset(i: number): number {
|
|
108
|
+
let off = 0
|
|
109
|
+
for (let t = 0; t < i; t++) off += this._lens[t]
|
|
110
|
+
return off
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get index(): number {
|
|
114
|
+
return this._index
|
|
115
|
+
}
|
|
116
|
+
get stack(): UIScreen[] {
|
|
117
|
+
const off = this._offset(this._index)
|
|
118
|
+
return this.children.slice(off, off + (this._lens[this._index] ?? 0))
|
|
119
|
+
}
|
|
120
|
+
get depth(): number {
|
|
121
|
+
return this._lens[this._index] ?? 0
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
onSelect(callback: (index: number) => void): this {
|
|
125
|
+
this.scl.push(callback)
|
|
126
|
+
return this
|
|
127
|
+
}
|
|
128
|
+
onChange(callback: (depth: number) => void): this {
|
|
129
|
+
this.ncl.push(callback)
|
|
130
|
+
return this
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
select(index: number, animated = false): this {
|
|
134
|
+
if (index < 0 || index >= this._lens.length || index === this._index) return this
|
|
135
|
+
this._index = index
|
|
136
|
+
if (this._id !== 0) _creatorUI.command(this, "select", animated)
|
|
137
|
+
this._emitSelect()
|
|
138
|
+
return this
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
push(screen: UIScreen): this {
|
|
142
|
+
if (this._lens.length === 0) {
|
|
143
|
+
console.warn("UIPager.push: pager has no tabs")
|
|
144
|
+
return this
|
|
145
|
+
}
|
|
146
|
+
this.children.splice(this._offset(this._index) + this._lens[this._index], 0, screen)
|
|
147
|
+
this._lens[this._index]++
|
|
148
|
+
this._track(screen)
|
|
149
|
+
if (this._id !== 0) _creatorUI.command(this, "push")
|
|
150
|
+
this._emitChange()
|
|
151
|
+
return this
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
pop(): this {
|
|
155
|
+
if ((this._lens[this._index] ?? 0) <= 1) return this
|
|
156
|
+
this.children.splice(this._offset(this._index) + this._lens[this._index] - 1, 1)
|
|
157
|
+
this._lens[this._index]--
|
|
158
|
+
if (this._id !== 0) _creatorUI.command(this, "pop")
|
|
159
|
+
this._emitChange()
|
|
160
|
+
return this
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
popToRoot(): this {
|
|
164
|
+
const len = this._lens[this._index] ?? 0
|
|
165
|
+
if (len <= 1) return this
|
|
166
|
+
this.children.splice(this._offset(this._index) + 1, len - 1)
|
|
167
|
+
this._lens[this._index] = 1
|
|
168
|
+
if (this._id !== 0) _creatorUI.command(this, "popToRoot")
|
|
169
|
+
this._emitChange()
|
|
170
|
+
return this
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
replace(screen: UIScreen): this {
|
|
174
|
+
if (this._lens.length === 0) {
|
|
175
|
+
console.warn("UIPager.replace: pager has no tabs")
|
|
176
|
+
return this
|
|
177
|
+
}
|
|
178
|
+
this.children[this._offset(this._index) + this._lens[this._index] - 1] = screen
|
|
179
|
+
this._track(screen)
|
|
180
|
+
if (this._id !== 0) _creatorUI.command(this, "replace")
|
|
181
|
+
this._emitChange()
|
|
182
|
+
return this
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Host-driven tab change (committed swipe): the native pager already moved — sync the logical
|
|
186
|
+
// index + fire onSelect. Named `_*` so chisel keeps it (hosts call it).
|
|
187
|
+
_hostDidSelect(index: number): void {
|
|
188
|
+
if (index < 0 || index >= this._lens.length || index === this._index) return
|
|
189
|
+
this._index = index
|
|
190
|
+
this._emitSelect()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Host-driven pop on the current tab (interactive edge back-swipe): the native stack already
|
|
194
|
+
// popped — sync the logical stack + fire onChange. Idempotent, safe after every transition.
|
|
195
|
+
_hostDidPop(depth: number): void {
|
|
196
|
+
if (depth < 1) depth = 1
|
|
197
|
+
const len = this._lens[this._index] ?? 0
|
|
198
|
+
if (depth >= len) return
|
|
199
|
+
this.children.splice(this._offset(this._index) + depth, len - depth)
|
|
200
|
+
this._lens[this._index] = depth
|
|
201
|
+
this._emitChange()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private _emitSelect(): void {
|
|
205
|
+
const i = this._index
|
|
206
|
+
for (const cb of this.scl) cb(i)
|
|
207
|
+
}
|
|
208
|
+
private _emitChange(): void {
|
|
209
|
+
const d = this._lens[this._index] ?? 0
|
|
210
|
+
for (const cb of this.ncl) cb(d)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Register a screen for `current` tracking: while a screen owned by this pager is the visible
|
|
214
|
+
// top, this pager is active. The hook reads screen._pagerOwner LIVE (not a captured `this`), so
|
|
215
|
+
// re-homing a screen into another pager activates the right one.
|
|
216
|
+
private _track(screen: any): void {
|
|
217
|
+
screen._pagerOwner = this
|
|
218
|
+
if (!screen._pagerHooked) {
|
|
219
|
+
screen._pagerHooked = true
|
|
220
|
+
screen.onOpen(() => screen._pagerOwner?._activate())
|
|
221
|
+
screen.onClose(() => screen._pagerOwner?._deactivate())
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
_activate(): void {
|
|
225
|
+
if (++this._openCount === 1 && _activeStack.indexOf(this) < 0) _activeStack.push(this)
|
|
226
|
+
}
|
|
227
|
+
_deactivate(): void {
|
|
228
|
+
if (--this._openCount <= 0) {
|
|
229
|
+
this._openCount = 0
|
|
230
|
+
const i = _activeStack.indexOf(this)
|
|
231
|
+
if (i >= 0) _activeStack.splice(i, 1)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const isScreenArg = (v: any): boolean => v != null && typeof v === "object" && v.type === "screen"
|
|
237
|
+
|
|
238
|
+
/** Callable factory + ambient statics. The name `UIPager` is a value (this factory) and a type
|
|
239
|
+
* (the instance interface above) — a standard value/type merge. */
|
|
240
|
+
interface UIPagerConstructor {
|
|
241
|
+
/** One tab — a plain navigation stack rooted at `root` (push/pop, no tab swiping). */
|
|
242
|
+
(root: UIScreen): UIPager
|
|
243
|
+
/** Sibling tabs, one per screen, in order — swipe (or `select`) between them. Tabs are fixed
|
|
244
|
+
* at construction; each starts as its own one-screen stack. */
|
|
245
|
+
(tabs: UIScreen[]): UIPager
|
|
246
|
+
/** Styled pager + content (a single root screen or an array of tab screens). */
|
|
247
|
+
(style: UIPagerStyle, content: UIScreen | UIScreen[]): UIPager
|
|
248
|
+
/** The pager that owns the currently visible screen, or `null` when the visible screen isn't
|
|
249
|
+
* hosted by one. With pagers nested inside pages, resolves to the **innermost** — so ambient
|
|
250
|
+
* calls act on the pager the user is actually looking at. */
|
|
251
|
+
readonly current: UIPager | null
|
|
252
|
+
/** `UIPager.current.push(screen)` — push onto the pager owning the visible screen, no
|
|
253
|
+
* reference threading. No-op with a console warning when there is no current pager. */
|
|
254
|
+
push(screen: UIScreen): void
|
|
255
|
+
/** `UIPager.current.pop()` — pop the visible pager's current tab (no-op at the tab root, or
|
|
256
|
+
* with a warning when there is no current pager). */
|
|
257
|
+
pop(): void
|
|
258
|
+
/** `UIPager.current.popToRoot()` — unwind the visible pager's current tab to its root. */
|
|
259
|
+
popToRoot(): void
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function pagerFactory(...args: any[]): UIPager {
|
|
263
|
+
if (args.length === 1) {
|
|
264
|
+
const a = args[0]
|
|
265
|
+
if (Array.isArray(a)) return new PagerElement(null, a) as unknown as UIPager
|
|
266
|
+
if (isScreenArg(a)) return new PagerElement(null, [a]) as unknown as UIPager
|
|
267
|
+
return new PagerElement(a, []) as unknown as UIPager
|
|
268
|
+
}
|
|
269
|
+
const content = args[1]
|
|
270
|
+
return new PagerElement(args[0] ?? null, Array.isArray(content) ? content : content ? [content] : []) as unknown as UIPager
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Create a pager — THE screen-navigation element: an array of screens becomes swipeable sibling
|
|
275
|
+
* tabs, a single screen becomes a plain stack. Navigate within it via `pager.push/pop/replace`
|
|
276
|
+
* (per-tab) and `pager.select(i)` (between tabs), or from any child screen via the ambient
|
|
277
|
+
* `UIPager.push` / `UIPager.pop` / `UIPager.current`. Pages are full `UIScreen`s (own
|
|
278
|
+
* `onOpen`/`onClose`, `makeScrollable()`); give the pager `flexGrow: 1` or a size so it doesn't
|
|
279
|
+
* collapse. `Router` stays the way to open a destination ON TOP of the pager.
|
|
280
|
+
*/
|
|
281
|
+
export const UIPager = pagerFactory as unknown as UIPagerConstructor
|
|
282
|
+
|
|
283
|
+
const _resolveCurrent = (): UIPager | null => {
|
|
284
|
+
const cur = _activeStack[_activeStack.length - 1]
|
|
285
|
+
if (!cur) {
|
|
286
|
+
console.warn("UIPager.push/pop: no active pager on the current screen")
|
|
287
|
+
return null
|
|
288
|
+
}
|
|
289
|
+
return cur as unknown as UIPager
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
Object.defineProperty(UIPager, "current", {
|
|
293
|
+
get: () => (_activeStack[_activeStack.length - 1] ?? null) as unknown as UIPager | null,
|
|
294
|
+
})
|
|
295
|
+
;(UIPager as any).push = (screen: UIScreen) => { _resolveCurrent()?.push(screen) }
|
|
296
|
+
;(UIPager as any).pop = () => { _resolveCurrent()?.pop() }
|
|
297
|
+
;(UIPager as any).popToRoot = () => { _resolveCurrent()?.popToRoot() }
|