lecodes-cli 0.7.2 → 0.8.1
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/dist/index.js +3005 -725
- package/package.json +2 -2
- 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 +7 -4
- 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 +113 -50
- 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 -207
- package/runtime/sdk/compile/compileProject.ts +0 -113
- 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) {
|
|
@@ -258,7 +266,7 @@ export type ClassStyles<T> = { [key: `$${string}`]: T & { duration?: number, del
|
|
|
258
266
|
|
|
259
267
|
// NB: do NOT annotate `this: R` here. `this` is a parameter position, so it makes R contravariant,
|
|
260
268
|
// which makes every node type INVARIANT in its own type params (and in `this`). That is what made
|
|
261
|
-
// `UIScreen<false>` unassignable to `UIScreen<boolean>`
|
|
269
|
+
// `UIScreen<false>` unassignable to `UIScreen<boolean>` (a navigator page), and would block any
|
|
262
270
|
// UINode subtype from being assignable to its base. Runtime `this` is already fixed by the
|
|
263
271
|
// `styleFunction.bind(this)` in the `style` getter — the annotation bought nothing and cost variance.
|
|
264
272
|
// R stays only in the (covariant) return, so `.style()` still returns the concrete node type for
|
|
@@ -276,13 +284,42 @@ export type OrientationStyles<T> = {
|
|
|
276
284
|
export type StyleFn <R, T extends object> = ((style: Reactive<T> & OrientationStyles<T> & ClassStyles<T>) => R)
|
|
277
285
|
export type Style <R, T extends object> = StyleFn<R,T> & T & OrientationStyles<T> & ClassStyles<T>
|
|
278
286
|
|
|
279
|
-
|
|
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"])
|
|
280
309
|
|
|
281
310
|
export type AppearStyle <R, T> = (arg: { from: T, duration?: number, delay?: number }) => R
|
|
282
311
|
export type DisappearStyle <R, T> = (arg: { to: T, duration?: number, delay?: number }) => R
|
|
283
312
|
|
|
284
313
|
export type OnLayoutCallback = (layout: { left: number, top: number, width: number, height: number }) => void
|
|
285
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
|
+
|
|
286
323
|
export class Element<T extends string> {
|
|
287
324
|
readonly type: T
|
|
288
325
|
/** Author-given semantic name (LeCodes `name`) — see BaseStyle.name. Extracted from the options
|
|
@@ -299,12 +336,21 @@ export class Element<T extends string> {
|
|
|
299
336
|
* is the effect's only strong owner, so discarding a subtree lets its bindings collect with it. */
|
|
300
337
|
_fx?: Map<string, any>
|
|
301
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.
|
|
302
347
|
constructor(type: T, style: any) {
|
|
303
348
|
this.type = type
|
|
304
349
|
if (style && typeof style.name === "string" && style.name.length > 0) {
|
|
305
350
|
this.name = style.name
|
|
306
351
|
delete style.name
|
|
307
352
|
}
|
|
353
|
+
if (style) this._mapStyle?.(style)
|
|
308
354
|
this._style = style
|
|
309
355
|
if (style) {
|
|
310
356
|
const fns = extractBindings(this, style)
|
|
@@ -331,8 +377,12 @@ export class Element<T extends string> {
|
|
|
331
377
|
|
|
332
378
|
animateTo(style: any): this{
|
|
333
379
|
_creatorUI.animateTo(this._id, style)
|
|
334
|
-
|
|
335
|
-
|
|
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
|
+
}
|
|
336
386
|
}
|
|
337
387
|
return this
|
|
338
388
|
}
|
|
@@ -380,7 +430,7 @@ export class Element<T extends string> {
|
|
|
380
430
|
}
|
|
381
431
|
|
|
382
432
|
protected ll?: OnLayoutCallback[]
|
|
383
|
-
onLayout(onLayout: OnLayoutCallback): this {
|
|
433
|
+
onLayout(onLayout: OnLayoutCallback): this {
|
|
384
434
|
if (this.ll) {
|
|
385
435
|
this.ll.push(onLayout)
|
|
386
436
|
} else {
|
|
@@ -388,6 +438,19 @@ export class Element<T extends string> {
|
|
|
388
438
|
}
|
|
389
439
|
return this
|
|
390
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
|
+
}
|
|
391
454
|
}
|
|
392
455
|
|
|
393
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() }
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { ModalElement, type UIModal, type UIModalStyle } from "./UIModal"
|
|
2
|
+
import { Presentable } from "./presentable"
|
|
3
|
+
import { device } from "../runtime/device"
|
|
4
|
+
import type { BoundingClientRect, ChildrenFn, UINodeChild } from "./UINode"
|
|
5
|
+
|
|
6
|
+
export type UIPopoverStyle = UIModalStyle
|
|
7
|
+
|
|
8
|
+
/** What a popover can anchor to: any element (it reads `getBoundingClientRect()`), or a raw
|
|
9
|
+
* `{x, y}` point — a long-press context menu anchors to the touch position. */
|
|
10
|
+
export type UIPopoverAnchor = { getBoundingClientRect(): BoundingClientRect | null } | { x: number, y: number }
|
|
11
|
+
|
|
12
|
+
const POPOVER_ANIM_MS = 120
|
|
13
|
+
const ANCHOR_GAP = 4 // between the anchor's edge and the popover
|
|
14
|
+
const VIEWPORT_MARGIN = 8 // minimum distance kept to every screen edge
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An anchored menu: a `UIModal` whose scrim is transparent (invisible but still intercepting —
|
|
18
|
+
* an outside tap dismisses, and nothing underneath can scroll while it's open) and whose
|
|
19
|
+
* position comes from the anchor passed to `show(anchor)`: below the anchor's left edge,
|
|
20
|
+
* flipped above when there's no room, clamped into the viewport. Position is computed once per
|
|
21
|
+
* show — anchored popovers don't follow their anchor. Backs dropdowns, context menus, and
|
|
22
|
+
* tooltips; create it **once** at module scope and reuse it.
|
|
23
|
+
*/
|
|
24
|
+
export interface UIPopover extends UIModal {
|
|
25
|
+
/** Position next to `anchor` (an element or an `{x, y}` point), attach to
|
|
26
|
+
* `Presentable.current` (unless an owner was set via `attachTo`), and play the entrance
|
|
27
|
+
* transition. Without an anchor the popover shows wherever its own style puts it. */
|
|
28
|
+
show(anchor?: UIPopoverAnchor): void,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class PopoverElement extends ModalElement {
|
|
32
|
+
private _anchorRect?: { left: number, top: number, right: number, bottom: number }
|
|
33
|
+
private _placedLeft?: number
|
|
34
|
+
private _placedTop?: number
|
|
35
|
+
|
|
36
|
+
constructor(style: UIPopoverStyle, children: UINodeChild[] | ChildrenFn) {
|
|
37
|
+
super({ overlayColor: "transparent", ...style }, children)
|
|
38
|
+
this.transition({ opacity: 0, duration: POPOVER_ANIM_MS })
|
|
39
|
+
// The flip/clamp decision needs the popover's measured size, which exists only after the
|
|
40
|
+
// mount lays it out — so placement is corrected on layout, inside the entrance fade.
|
|
41
|
+
this.onLayout(({ width, height }) => this._place(width, height))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
show(anchor?: UIPopoverAnchor): void {
|
|
45
|
+
if (anchor) {
|
|
46
|
+
const r = "getBoundingClientRect" in anchor
|
|
47
|
+
? anchor.getBoundingClientRect()
|
|
48
|
+
: { left: anchor.x, top: anchor.y, right: anchor.x, bottom: anchor.y }
|
|
49
|
+
if (r) {
|
|
50
|
+
this._anchorRect = { left: r.left, top: r.top, right: r.right, bottom: r.bottom }
|
|
51
|
+
this._placedLeft = undefined
|
|
52
|
+
this._placedTop = undefined
|
|
53
|
+
// Preliminary spot (below, left-aligned) so the first paint is already close; _place
|
|
54
|
+
// refines it once the size is known.
|
|
55
|
+
this.style({ left: r.left, top: r.bottom + ANCHOR_GAP })
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
this._anchorRect = undefined // unanchored show: the user's own style owns the position
|
|
59
|
+
}
|
|
60
|
+
// A menu belongs to the page it opened on: it hides with it and rides its transition. An
|
|
61
|
+
// explicit attachTo() owner (e.g. a scene HUD's popover) is respected.
|
|
62
|
+
if (!this._owner) this.attachTo(Presentable.current)
|
|
63
|
+
super.show()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private _place(width: number, height: number): void {
|
|
67
|
+
const anchor = this._anchorRect
|
|
68
|
+
if (!anchor || !this._open) return
|
|
69
|
+
const displayWidth = device.width, displayHeight = device.height // 0 on hosts without a size
|
|
70
|
+
let left = anchor.left
|
|
71
|
+
let top = anchor.bottom + ANCHOR_GAP
|
|
72
|
+
if (displayWidth > 0) {
|
|
73
|
+
left = Math.min(left, displayWidth - width - VIEWPORT_MARGIN)
|
|
74
|
+
left = Math.max(left, VIEWPORT_MARGIN)
|
|
75
|
+
}
|
|
76
|
+
if (displayHeight > 0 && top + height > displayHeight - VIEWPORT_MARGIN) {
|
|
77
|
+
const above = anchor.top - ANCHOR_GAP - height
|
|
78
|
+
top = above >= VIEWPORT_MARGIN ? above : Math.max(VIEWPORT_MARGIN, displayHeight - VIEWPORT_MARGIN - height)
|
|
79
|
+
}
|
|
80
|
+
// Idempotence guard: hosts re-fire onLayout on recalc, and writing the same position would
|
|
81
|
+
// trigger another recalc — only write when the target actually moved.
|
|
82
|
+
if (this._placedLeft !== undefined && Math.abs(left - this._placedLeft) < 0.5
|
|
83
|
+
&& this._placedTop !== undefined && Math.abs(top - this._placedTop) < 0.5) return
|
|
84
|
+
this._placedLeft = left
|
|
85
|
+
this._placedTop = top
|
|
86
|
+
this.style({ left, top })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function UIPopover(): UIPopover;
|
|
91
|
+
export function UIPopover(children: UINodeChild[] | ChildrenFn): UIPopover;
|
|
92
|
+
export function UIPopover(style: UIPopoverStyle): UIPopover;
|
|
93
|
+
export function UIPopover(style: UIPopoverStyle, children: UINodeChild[] | ChildrenFn): UIPopover;
|
|
94
|
+
export function UIPopover(...args: [] | [UINodeChild[] | ChildrenFn | UIPopoverStyle] | [UIPopoverStyle, children: UINodeChild[] | ChildrenFn]): UIPopover {
|
|
95
|
+
if (args.length === 0) {
|
|
96
|
+
return new PopoverElement({}, [])
|
|
97
|
+
} else if (args.length === 1) {
|
|
98
|
+
if (Array.isArray(args[0]) || typeof args[0] === "function") {
|
|
99
|
+
return new PopoverElement({}, args[0])
|
|
100
|
+
} else {
|
|
101
|
+
return new PopoverElement(args[0], [])
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
return new PopoverElement(args[0], args[1])
|
|
105
|
+
}
|
|
106
|
+
}
|