lecodes-cli 0.8.2 → 0.9.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 +745 -164
- package/package.json +36 -36
- package/runtime/sdk/inject.ts +2 -1
- package/runtime/sdk/ui/UI.ts +2 -1
- package/runtime/sdk/ui/UIBottomSheet.ts +149 -0
- package/runtime/sdk/ui/UIInput.ts +24 -1
- package/runtime/sdk/ui/UINode.ts +7 -6
- package/runtime/sdk/ui/UIPager.ts +71 -3
- package/runtime/sdk/ui/UIScreen.ts +7 -57
- package/runtime/sdk/ui/UIScrollable.ts +27 -6
- package/runtime/sdk/ui/UIVirtualizedList.ts +16 -3
- package/runtime/sdk/ui/UIWidget.ts +8 -2
- package/runtime/sdk/ui/presentable.ts +2 -1
- package/runtime/sdk/ui/router.ts +4 -2
- package/runtime/sdk/ui/theme.ts +34 -7
- package/runtime/sdk-types.json +1 -1
package/package.json
CHANGED
|
@@ -1,42 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lecodes-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
"
|
|
8
|
-
"lecodes",
|
|
9
|
-
"
|
|
10
|
-
|
|
3
|
+
"version": "0.9.1",
|
|
4
|
+
"dependencies": {
|
|
5
|
+
"@letary/chisel": "^0.5.1"
|
|
6
|
+
},
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"lecodes-design": "workspace:*",
|
|
9
|
+
"lecodes-renderer": "workspace:*",
|
|
10
|
+
"qrcode-terminal": "^0.12.0",
|
|
11
|
+
"lecodes-3d-editor": "workspace:*",
|
|
12
|
+
"sdk": "workspace:*"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"lecodes-design": "^0.3.2",
|
|
16
|
+
"lecodes-renderer": "^0.7.1",
|
|
17
|
+
"lecodes-3d-editor": "^0.1.0"
|
|
18
|
+
},
|
|
11
19
|
"bin": {
|
|
12
20
|
"lecodes": "dist/index.js"
|
|
13
21
|
},
|
|
22
|
+
"description": "Clone, edit and push LeCodes projects from your machine.",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
14
26
|
"files": [
|
|
15
27
|
"dist",
|
|
16
28
|
"runtime",
|
|
17
29
|
"README.md"
|
|
18
30
|
],
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"vendor-runtime": "bun scripts/vendor-runtime.ts",
|
|
25
|
-
"sync-peers": "bun scripts/sync-peer-versions.ts",
|
|
26
|
-
"build": "bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external @letary/chisel --external lecodes-renderer --external lecodes-design --external lecodes-3d-editor --banner=\"#!/usr/bin/env node\"",
|
|
27
|
-
"build:bin": "bun scripts/build-bin.ts",
|
|
28
|
-
"typecheck": "tsc --noEmit",
|
|
29
|
-
"dev": "bun run src/index.ts",
|
|
30
|
-
"prepublishOnly": "bun run sync-peers && bun run build"
|
|
31
|
-
},
|
|
32
|
-
"dependencies": {
|
|
33
|
-
"@letary/chisel": "^0.4.1"
|
|
34
|
-
},
|
|
35
|
-
"peerDependencies": {
|
|
36
|
-
"lecodes-design": "^0.3.2",
|
|
37
|
-
"lecodes-renderer": "^0.6.2",
|
|
38
|
-
"lecodes-3d-editor": "^0.1.0"
|
|
39
|
-
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"lecodes",
|
|
33
|
+
"cli"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT",
|
|
40
36
|
"peerDependenciesMeta": {
|
|
41
37
|
"lecodes-design": {
|
|
42
38
|
"optional": true
|
|
@@ -48,11 +44,15 @@
|
|
|
48
44
|
"optional": true
|
|
49
45
|
}
|
|
50
46
|
},
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"lecodes-3d-editor"
|
|
56
|
-
"
|
|
57
|
-
|
|
47
|
+
"scripts": {
|
|
48
|
+
"icon": "bun scripts/make-icon.ts",
|
|
49
|
+
"vendor-runtime": "bun scripts/vendor-runtime.ts",
|
|
50
|
+
"sync-peers": "bun scripts/sync-peer-versions.ts",
|
|
51
|
+
"build": "bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external @letary/chisel --external lecodes-renderer --external lecodes-design --external lecodes-3d-editor --banner=\"#!/usr/bin/env node\"",
|
|
52
|
+
"build:bin": "bun scripts/build-bin.ts",
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"dev": "bun run src/index.ts",
|
|
55
|
+
"prepublishOnly": "bun run sync-peers && bun run build"
|
|
56
|
+
},
|
|
57
|
+
"type": "module"
|
|
58
58
|
}
|
package/runtime/sdk/inject.ts
CHANGED
|
@@ -54,7 +54,7 @@ export { Texture2D } from "./g2/Texture2D"
|
|
|
54
54
|
|
|
55
55
|
// ---- UI (creator-ui) ----
|
|
56
56
|
export {
|
|
57
|
-
UIScreen, type UIScreenStyle,
|
|
57
|
+
UIScreen, type UIScreenStyle,
|
|
58
58
|
UIRow, UIColumn, UIBox, type UIContainerStyle,
|
|
59
59
|
UIText, type UITextStyle,
|
|
60
60
|
UIButton, type UIButtonStyle,
|
|
@@ -65,6 +65,7 @@ export {
|
|
|
65
65
|
UIPager, type UIPagerStyle,
|
|
66
66
|
UIWidget, type UIWidgetStyle,
|
|
67
67
|
UIModal, type UIModalStyle,
|
|
68
|
+
UIBottomSheet, type UIBottomSheetStyle,
|
|
68
69
|
UIPopover, type UIPopoverStyle,
|
|
69
70
|
UISpacer, type UISpacerStyle,
|
|
70
71
|
UIVirtualizedList,
|
package/runtime/sdk/ui/UI.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Renderer-side contract + per-renderer status (DOM / canvas-ui / desktop) for every component
|
|
2
2
|
// below is tracked in packages/canvas-ui/COMPONENTS.md.
|
|
3
3
|
export { UIButton, type UIButtonStyle } from './UIButton'
|
|
4
|
-
export { UIScreen, type UIScreenStyle
|
|
4
|
+
export { UIScreen, type UIScreenStyle } from './UIScreen'
|
|
5
5
|
export { UISpacer, type UISpacerStyle } from './UISpacer'
|
|
6
6
|
export { UIText, type UITextStyle } from './UIText'
|
|
7
7
|
export { registerFont } from './fonts'
|
|
@@ -11,6 +11,7 @@ export { UIInput, UITextArea, type UIInputStyle } from './UIInput'
|
|
|
11
11
|
|
|
12
12
|
export { UIWidget, type UIWidgetStyle } from './UIWidget'
|
|
13
13
|
export { UIModal, type UIModalStyle } from './UIModal'
|
|
14
|
+
export { UIBottomSheet, type UIBottomSheetStyle } from './UIBottomSheet'
|
|
14
15
|
export { UIPopover, type UIPopoverStyle, type UIPopoverAnchor } from './UIPopover'
|
|
15
16
|
|
|
16
17
|
export { UIRow, UIColumn, UIBox, type UIContainerStyle } from './UIContainer'
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { ModalElement, type UIModalStyle } from "./UIModal"
|
|
2
|
+
import type { UIWidget } from "./UIWidget"
|
|
3
|
+
import type { ChildrenFn, UINodeChild } from "./UINode"
|
|
4
|
+
|
|
5
|
+
export type UIBottomSheetStyle = UIModalStyle
|
|
6
|
+
|
|
7
|
+
const SHEET_ANIM_MS = 250
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A bottom sheet: a `UIWidget` pinned to the bottom edge. By default it is **content-sized** —
|
|
11
|
+
* as tall as what's inside (capped at the screen), one position, drag down to dismiss (the
|
|
12
|
+
* action-sheet shape). Call `detents()` for the multi-position map-app model instead: snap
|
|
13
|
+
* positions the user drags between on native hosts. Ships as a widget node on the wire (no new
|
|
14
|
+
* node type — docs/bottom-sheet-plan.md): hosts without sheet support show a static bottom panel.
|
|
15
|
+
*
|
|
16
|
+
* With detents, content lays out at the HIGHEST detent's size; lower detents show the top
|
|
17
|
+
* slice. The scrim, `onOpen`/`onClose`, back-button and scrim-tap dismissal all come from the
|
|
18
|
+
* modal machinery.
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* const actions = UIBottomSheet({ bgColor: "#1c1c1e", borderRadius: 16 }, [rows]) // content-sized
|
|
22
|
+
*
|
|
23
|
+
* const mapSheet = UIBottomSheet({ bgColor: "#1c1c1e", borderRadius: 16 }, [results])
|
|
24
|
+
* .detents([0.3, 0.6, 1])
|
|
25
|
+
* .onDetentChange(i => map.setPadding(...))
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export interface UIBottomSheet extends UIWidget {
|
|
29
|
+
/** Snap positions as fractions of screen height, ascending. Also sizes the sheet: its box is
|
|
30
|
+
* the last (highest) detent; set this before `show()`. Without a `detents()` call the sheet
|
|
31
|
+
* is CONTENT-SIZED instead — one position, as tall as its children (up to the screen). */
|
|
32
|
+
detents(list: number[]): this,
|
|
33
|
+
/** Snap to a detent (animated). Clamped to the detents range; fires `onDetentChange`. */
|
|
34
|
+
setDetent(index: number): void,
|
|
35
|
+
/** Current detent index. */
|
|
36
|
+
readonly detent: number,
|
|
37
|
+
/** Fires on every settle: a finger snap on native hosts, or a `setDetent()` call. */
|
|
38
|
+
onDetentChange(callback: (index: number) => void): this,
|
|
39
|
+
|
|
40
|
+
/** Mount and slide in to the current detent. No-op while already open. */
|
|
41
|
+
show(): void,
|
|
42
|
+
/** Slide out, fade the scrim, then unmount. No-op while closed. */
|
|
43
|
+
hide(): void,
|
|
44
|
+
/** True from `show()` until `hide()` starts. */
|
|
45
|
+
readonly isOpen: boolean,
|
|
46
|
+
|
|
47
|
+
/** Called when `show()` mounts the sheet. */
|
|
48
|
+
onOpen(callback: () => void): this,
|
|
49
|
+
/** Called when the sheet starts closing — scrim tap, back button, drag-dismiss, or `hide()`. */
|
|
50
|
+
onClose(callback: () => void): this,
|
|
51
|
+
/** `dismissible(false)`: scrim taps, the back button and dragging below the lowest detent no
|
|
52
|
+
* longer close the sheet (a drag collapses to the lowest detent instead). Default `true`. */
|
|
53
|
+
dismissible(enabled: boolean): this,
|
|
54
|
+
|
|
55
|
+
append(...nodes: UINodeChild[]): this
|
|
56
|
+
insert(index: number, ...nodes: UINodeChild[]): this
|
|
57
|
+
remove(...nodes: UINodeChild[]): this
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class BottomSheetElement extends ModalElement {
|
|
61
|
+
_detents: number[] = [1]
|
|
62
|
+
_detent = 0
|
|
63
|
+
/** Host-facing (the `sl`/`otl` contract): hosts read `dtl` at mount and call it with the
|
|
64
|
+
* settled detent index for GESTURE settles only — programmatic `setDetent` fires JS-side.
|
|
65
|
+
* `-1` = dragged below the lowest detent while dismissible. */
|
|
66
|
+
readonly dtl: any[] = []
|
|
67
|
+
readonly _detentListeners: ((index: number) => void)[] = []
|
|
68
|
+
|
|
69
|
+
constructor(style: UIBottomSheetStyle, children: UINodeChild[] | ChildrenFn) {
|
|
70
|
+
// No default height: an absolute node with only bottom set is CONTENT-sized by yoga. A
|
|
71
|
+
// single detent makes the geometry height-independent on every host (shown = offset 0,
|
|
72
|
+
// hidden = own height), so content mode needs no host support beyond the detents path.
|
|
73
|
+
super({ left: 0, right: 0, bottom: 0, maxHeight: "100%", sheetDetents: "1", ...style } as UIModalStyle, children)
|
|
74
|
+
// The inherited show/hide pose animates ONLY the scrim: sheet geometry rides the
|
|
75
|
+
// sheetDetent style key, never the pose system.
|
|
76
|
+
this._transition = { duration: SHEET_ANIM_MS }
|
|
77
|
+
this.dtl.push((index: number) => {
|
|
78
|
+
if (index < 0) { this.hide(); return }
|
|
79
|
+
this._detent = index
|
|
80
|
+
for (const callback of this._detentListeners) callback(index)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
detents(list: number[]): this {
|
|
85
|
+
const sorted = [...list].filter(d => d > 0).sort((a, b) => a - b)
|
|
86
|
+
if (sorted.length === 0) return this
|
|
87
|
+
this._detents = sorted
|
|
88
|
+
if (this._detent >= sorted.length) this._detent = sorted.length - 1
|
|
89
|
+
this.style({
|
|
90
|
+
height: `${sorted[sorted.length - 1] * 100}%`,
|
|
91
|
+
sheetDetents: sorted.join(","),
|
|
92
|
+
} as UIBottomSheetStyle)
|
|
93
|
+
return this
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get detent(): number {
|
|
97
|
+
return this._detent
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setDetent(index: number): void {
|
|
101
|
+
const clamped = Math.max(0, Math.min(Math.floor(index), this._detents.length - 1))
|
|
102
|
+
this._detent = clamped
|
|
103
|
+
if (!this._open) return // applied by the next show()
|
|
104
|
+
this.style({ sheetDetent: clamped } as UIBottomSheetStyle)
|
|
105
|
+
for (const callback of this._detentListeners) callback(clamped)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
onDetentChange(callback: (index: number) => void): this {
|
|
109
|
+
this._detentListeners.push(callback)
|
|
110
|
+
return this
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
show(): void {
|
|
114
|
+
if (this._open) return
|
|
115
|
+
// Before the mount, so the built node carries the target and hosts play the entrance to it.
|
|
116
|
+
this.style({ sheetDetent: this._detent } as UIBottomSheetStyle)
|
|
117
|
+
super.show()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
hide(): void {
|
|
121
|
+
if (!this._open) return
|
|
122
|
+
this.style({ sheetDetent: -1 } as UIBottomSheetStyle) // hosts slide out; unmount follows
|
|
123
|
+
super.hide()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
dismissible(enabled: boolean): this {
|
|
127
|
+
super.dismissible(enabled)
|
|
128
|
+
this.style({ sheetDismissible: enabled ? 1 : 0 } as UIBottomSheetStyle)
|
|
129
|
+
return this
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function UIBottomSheet(): UIBottomSheet;
|
|
134
|
+
export function UIBottomSheet(children: UINodeChild[] | ChildrenFn): UIBottomSheet;
|
|
135
|
+
export function UIBottomSheet(style: UIBottomSheetStyle): UIBottomSheet;
|
|
136
|
+
export function UIBottomSheet(style: UIBottomSheetStyle, children: UINodeChild[] | ChildrenFn): UIBottomSheet;
|
|
137
|
+
export function UIBottomSheet(...args: [] | [UINodeChild[] | ChildrenFn | UIBottomSheetStyle] | [UIBottomSheetStyle, children: UINodeChild[] | ChildrenFn]): UIBottomSheet {
|
|
138
|
+
if (args.length === 0) {
|
|
139
|
+
return new BottomSheetElement({}, [])
|
|
140
|
+
} else if (args.length === 1) {
|
|
141
|
+
if (Array.isArray(args[0]) || typeof args[0] === "function") {
|
|
142
|
+
return new BottomSheetElement({}, args[0])
|
|
143
|
+
} else {
|
|
144
|
+
return new BottomSheetElement(args[0], [])
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
return new BottomSheetElement(args[0], args[1])
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -6,8 +6,14 @@ type InputStyle = {
|
|
|
6
6
|
/**
|
|
7
7
|
* Keyboard hint, NOT a validator — `number` does not block pasted letters; validate in your own
|
|
8
8
|
* onChange. The phone-pad value is `phone` (LeCodes has no `tel`).
|
|
9
|
+
*
|
|
10
|
+
* `date` / `time` are PICKER kinds, not keyboards: the host opens its native picker in the
|
|
11
|
+
* keyboard slot (iOS wheels / browser calendar) and free typing is disabled. `value` is always
|
|
12
|
+
* canonical — `YYYY-MM-DD` for `date`, `HH:MM` (24h) for `time` — while the field displays a
|
|
13
|
+
* localized string; set/compare values in the canonical form only.
|
|
9
14
|
*/
|
|
10
|
-
type?: "text" | "password" | "search" | "phone" | "email" | "number" | "decimal" | "url"
|
|
15
|
+
type?: "text" | "password" | "search" | "phone" | "email" | "number" | "decimal" | "url"
|
|
16
|
+
| "date" | "time",
|
|
11
17
|
/**
|
|
12
18
|
* What the layout does while the keyboard is up (decided by the FOCUSED input). `true` (default):
|
|
13
19
|
* the layout viewport shrinks to the area above the keyboard — one relayout. `false`: the keyboard
|
|
@@ -16,6 +22,15 @@ type InputStyle = {
|
|
|
16
22
|
* reflow is worse than overlap.
|
|
17
23
|
*/
|
|
18
24
|
keyboardShrink?: boolean,
|
|
25
|
+
/**
|
|
26
|
+
* Whether a tap on a non-interactive area dismisses the keyboard while THIS input is focused
|
|
27
|
+
* (like `keyboardShrink`, the policy of the focused input). `true` (default): tapping dead
|
|
28
|
+
* space blurs — but a tap on a button or another input NEVER dismisses; the control acts and
|
|
29
|
+
* focus stays. `false`: taps never dismiss — the chat-composer policy. `blur()`, the return
|
|
30
|
+
* key (per `enterKey`), and the platform's own gestures (iOS drag-down over the keyboard)
|
|
31
|
+
* still work in both modes. Scrolling never dismisses the keyboard on any setting.
|
|
32
|
+
*/
|
|
33
|
+
keyboardDismiss?: boolean,
|
|
19
34
|
/** Hard cap on the text length — enforced by the host while typing/pasting. */
|
|
20
35
|
maxLength?: number,
|
|
21
36
|
/** Auto-capitalization hint; omitted = platform default. */
|
|
@@ -148,6 +163,14 @@ export function UIInput(style?: UIInputStyle): UIInput {
|
|
|
148
163
|
return new InputElement("input", { minHeight: "1.2em", ...style })
|
|
149
164
|
}
|
|
150
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Multi-line text field. Enter is a newline (submit lives on UIInput / your own button).
|
|
168
|
+
*
|
|
169
|
+
* Auto-grow: with no explicit `height` the field measures its own content and grows line by
|
|
170
|
+
* line — clamp the range with `minHeight` / `maxHeight` (past `maxHeight` the text scrolls
|
|
171
|
+
* inside). A fixed `height` opts out and scrolls internally from the start. Chat composers are
|
|
172
|
+
* the canonical use: a bare textarea in a padded pill, `maxHeight` at ~5 lines.
|
|
173
|
+
*/
|
|
151
174
|
export function UITextArea(): UITextArea;
|
|
152
175
|
export function UITextArea(style: UIInputStyle): UITextArea;
|
|
153
176
|
export function UITextArea(style?: UIInputStyle): UITextArea {
|
package/runtime/sdk/ui/UINode.ts
CHANGED
|
@@ -68,11 +68,6 @@ export type ContainerStyle = {
|
|
|
68
68
|
flexWrap?: "nowrap" | "wrap" | "wrap-reverse"
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
export type ScrollStyle = {
|
|
72
|
-
scrollX?: "auto" | "all" | "none",
|
|
73
|
-
scrollY?: "auto" | "all" | "none"
|
|
74
|
-
}
|
|
75
|
-
|
|
76
71
|
type UIValue = number |
|
|
77
72
|
`${number}px` |
|
|
78
73
|
`${number}vw` |
|
|
@@ -130,6 +125,8 @@ export type PositionStyle = {
|
|
|
130
125
|
left?: UIValue | `${number}%` | 'safe-left' | 'comfort-left',
|
|
131
126
|
bottom?: UIValue | `${number}%` | 'safe-bottom' | 'comfort-bottom',
|
|
132
127
|
right?: UIValue | `${number}%` | 'safe-right' | 'comfort-right',
|
|
128
|
+
/** Shorthand for all four edges at one value. A longhand (`top`, `left`, …) overrides it. */
|
|
129
|
+
inset?: UIValue | `${number}%` | "auto",
|
|
133
130
|
width?: UIValue | `${number}%` | "auto",
|
|
134
131
|
height?: UIValue | `${number}%` | "auto",
|
|
135
132
|
maxWidth?: UIValue | `${number}%` | "auto",
|
|
@@ -158,7 +155,11 @@ export type TextStyle = {
|
|
|
158
155
|
fontStyle?: "normal" | "italic",
|
|
159
156
|
color?: Color,
|
|
160
157
|
textDecoration?: "underline" | "line-through" | "none",
|
|
161
|
-
letterSpacing?: UIValue
|
|
158
|
+
letterSpacing?: UIValue,
|
|
159
|
+
/** Cap the text at N lines; the box is measured to the clamped height. 0 / omitted = unbounded. */
|
|
160
|
+
lineClamp?: number,
|
|
161
|
+
/** How a clamped line ends. Only meaningful together with `lineClamp`. Default "ellipsis". */
|
|
162
|
+
textOverflow?: "ellipsis" | "clip"
|
|
162
163
|
}
|
|
163
164
|
|
|
164
165
|
// Pulls function-valued props out of `style` (mutating it — they must never reach the host bridge)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { ContainerElement, type AnimateStyle, type BaseStyle, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type BoundingClientRect, type Style } from "./UINode"
|
|
1
|
+
import { ContainerElement, type AnimateStyle, type BaseStyle, type ChildrenFn, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type BoundingClientRect, type Style, type UINodeChild } from "./UINode"
|
|
2
|
+
import { disposeBinding } from "../core/signals"
|
|
2
3
|
import type { UIScreen } from "./UIScreen"
|
|
3
4
|
|
|
4
5
|
// UIPager — the system's ONE screen-navigation element (docs/pager-component-plan.md): sibling
|
|
@@ -46,6 +47,11 @@ export interface UIPager {
|
|
|
46
47
|
animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>,
|
|
47
48
|
animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>,
|
|
48
49
|
|
|
50
|
+
/** Replace the pager's tabs — one screen per tab, in order. Every tab becomes a fresh
|
|
51
|
+
* one-screen stack (pushed screens are dropped) and the selection is clamped into range;
|
|
52
|
+
* screens that were already tabs keep their state. Pass a function for reactive tabs. */
|
|
53
|
+
setContent(tabs: UIScreen[] | (() => UIScreen[])): this,
|
|
54
|
+
|
|
49
55
|
/** Switch to tab `index`. **Instant by default** — the tab-bar convention (tabs are parallel
|
|
50
56
|
* modes, not neighbors); pass `animated: true` for the direction-aware slide of top-tab /
|
|
51
57
|
* pager UIs. Out-of-range and same-index calls are no-ops. Tabs keep their stacks: selecting
|
|
@@ -182,6 +188,54 @@ export class PagerElement extends ContainerElement<"pager"> {
|
|
|
182
188
|
return this
|
|
183
189
|
}
|
|
184
190
|
|
|
191
|
+
// ── Children ARE the tabs ──────────────────────────────────────────────────────────────────
|
|
192
|
+
// Every inherited container mutation would send a generic setContent/insertNode/removeNode wire
|
|
193
|
+
// op, and a host must never run those on a pager: its children are page ROOTS the host owns
|
|
194
|
+
// (each laid out independently at the pager's box), not yoga children — the generic path frees
|
|
195
|
+
// the live pages and rebuilds them as ordinary children stacked on the first tab. So each one is
|
|
196
|
+
// overridden to mutate the element first (children, `_lens`, `_index`) and then notify over the
|
|
197
|
+
// same command channel every other pager op uses, where the host reconciles against the node.
|
|
198
|
+
//
|
|
199
|
+
// Content set this way is always one screen per tab: a per-tab STACK only ever grows through
|
|
200
|
+
// push/replace, so `_lens` resets to all-ones here.
|
|
201
|
+
private _rebuild(children: any[]): this {
|
|
202
|
+
const tabs = children.filter(Boolean)
|
|
203
|
+
const prevIndex = this._index
|
|
204
|
+
this.children.length = 0
|
|
205
|
+
this.children.push(...tabs)
|
|
206
|
+
this._lens = tabs.map(() => 1)
|
|
207
|
+
if (this._index >= tabs.length) this._index = Math.max(0, tabs.length - 1)
|
|
208
|
+
for (const s of tabs) this._track(s)
|
|
209
|
+
if (this._id !== 0) _creatorUI.command(this, "content")
|
|
210
|
+
if (this._index !== prevIndex) this._emitSelect()
|
|
211
|
+
this._emitChange()
|
|
212
|
+
return this
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
setContent(children: UINodeChild[] | ChildrenFn): this {
|
|
216
|
+
if (typeof children === "function") {
|
|
217
|
+
this._bindChildren(children) // reactive children land in _reconcile below
|
|
218
|
+
return this
|
|
219
|
+
}
|
|
220
|
+
disposeBinding(this, "#children") // static content overrides a reactive binding
|
|
221
|
+
return this._rebuild(children as any[])
|
|
222
|
+
}
|
|
223
|
+
append(...nodes: UINodeChild[]): this {
|
|
224
|
+
return this._rebuild([...this.children, ...nodes])
|
|
225
|
+
}
|
|
226
|
+
insert(index: number, ...nodes: UINodeChild[]): this {
|
|
227
|
+
const next = this.children.slice()
|
|
228
|
+
next.splice(index, 0, ...(nodes as any[]))
|
|
229
|
+
return this._rebuild(next)
|
|
230
|
+
}
|
|
231
|
+
remove(...nodesToDelete: UIScreen[]): this {
|
|
232
|
+
const drop = new Set<any>(nodesToDelete)
|
|
233
|
+
return this._rebuild(this.children.filter((c: any) => !drop.has(c)))
|
|
234
|
+
}
|
|
235
|
+
_reconcile(next: UINodeChild[]): void {
|
|
236
|
+
this._rebuild(next as any[])
|
|
237
|
+
}
|
|
238
|
+
|
|
185
239
|
// Host-driven tab change (committed swipe): the native pager already moved — sync the logical
|
|
186
240
|
// index + fire onSelect. Named `_*` so chisel keeps it (hosts call it).
|
|
187
241
|
_hostDidSelect(index: number): void {
|
|
@@ -214,6 +268,7 @@ export class PagerElement extends ContainerElement<"pager"> {
|
|
|
214
268
|
// top, this pager is active. The hook reads screen._pagerOwner LIVE (not a captured `this`), so
|
|
215
269
|
// re-homing a screen into another pager activates the right one.
|
|
216
270
|
private _track(screen: any): void {
|
|
271
|
+
console.log("_track()")
|
|
217
272
|
screen._pagerOwner = this
|
|
218
273
|
if (!screen._pagerHooked) {
|
|
219
274
|
screen._pagerHooked = true
|
|
@@ -222,13 +277,18 @@ export class PagerElement extends ContainerElement<"pager"> {
|
|
|
222
277
|
}
|
|
223
278
|
}
|
|
224
279
|
_activate(): void {
|
|
225
|
-
|
|
280
|
+
console.log("openCount: " + this._openCount)
|
|
281
|
+
if (++this._openCount === 1 && _activeStack.indexOf(this) < 0) {
|
|
282
|
+
console.log("activate")
|
|
283
|
+
_activeStack.push(this)
|
|
284
|
+
}
|
|
226
285
|
}
|
|
227
286
|
_deactivate(): void {
|
|
228
287
|
if (--this._openCount <= 0) {
|
|
229
288
|
this._openCount = 0
|
|
230
289
|
const i = _activeStack.indexOf(this)
|
|
231
290
|
if (i >= 0) _activeStack.splice(i, 1)
|
|
291
|
+
console.log(`Deactivate. Curent stack is ${_activeStack.length}`)
|
|
232
292
|
}
|
|
233
293
|
}
|
|
234
294
|
}
|
|
@@ -275,7 +335,7 @@ function pagerFactory(...args: any[]): UIPager {
|
|
|
275
335
|
* tabs, a single screen becomes a plain stack. Navigate within it via `pager.push/pop/replace`
|
|
276
336
|
* (per-tab) and `pager.select(i)` (between tabs), or from any child screen via the ambient
|
|
277
337
|
* `UIPager.push` / `UIPager.pop` / `UIPager.current`. Pages are full `UIScreen`s (own
|
|
278
|
-
* `onOpen`/`onClose
|
|
338
|
+
* `onOpen`/`onClose`); give the pager `flexGrow: 1` or a size so it doesn't
|
|
279
339
|
* collapse. `Router` stays the way to open a destination ON TOP of the pager.
|
|
280
340
|
*/
|
|
281
341
|
export const UIPager = pagerFactory as unknown as UIPagerConstructor
|
|
@@ -289,6 +349,14 @@ const _resolveCurrent = (): UIPager | null => {
|
|
|
289
349
|
return cur as unknown as UIPager
|
|
290
350
|
}
|
|
291
351
|
|
|
352
|
+
// The ambient statics. These are top-level statements in a `sideEffects: false` module, so they only
|
|
353
|
+
// survive bundling because chisel treats a statement that *mutates a binding this module declares*
|
|
354
|
+
// as part of that binding's declaration (`ItemKind::AttachedTo`, chisel >= 0.4.2) — kept iff
|
|
355
|
+
// `UIPager` is reached, shaken away with it otherwise. Under chisel <= 0.4.1 they were dropped
|
|
356
|
+
// outright and `UIPager.push` arrived as `undefined` at runtime, in bundled builds only; don't lower
|
|
357
|
+
// the pinned `@letary/chisel` back past 0.4.2. Covered by sdk/tests/compile.test.ts.
|
|
358
|
+
// `Object.defineProperty` for `current`, not `Object.assign({ get current() {} })` — Object.assign
|
|
359
|
+
// *invokes* the getter and copies the resulting value, freezing it at null forever.
|
|
292
360
|
Object.defineProperty(UIPager, "current", {
|
|
293
361
|
get: () => (_activeStack[_activeStack.length - 1] ?? null) as unknown as UIPager | null,
|
|
294
362
|
})
|
|
@@ -1,22 +1,16 @@
|
|
|
1
1
|
import { TouchStartEvent } from "../runtime/touch"
|
|
2
2
|
import { _bumpNavEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "./presentable"
|
|
3
|
-
import { ContainerElement, type AnimateStyle, type BaseStyle, type
|
|
3
|
+
import { ContainerElement, type AnimateStyle, type BaseStyle, type ContainerStyle, type DrawableStyle, type OnLayoutCallback, type BoundingClientRect, type PaddingStyle, type Style, type UINodeChild, type ChildrenFn, } from "./UINode"
|
|
4
4
|
|
|
5
|
-
export type UIScreenStyle = ContainerStyle & DrawableStyle & PaddingStyle &
|
|
5
|
+
export type UIScreenStyle = ContainerStyle & DrawableStyle & PaddingStyle & BaseStyle
|
|
6
6
|
|
|
7
|
-
// A screen — the root the router/host mounts, sized to its slot.
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// `
|
|
12
|
-
//
|
|
13
|
-
// This is a subtype split, NOT a `UIScreen<Scrollable>` generic: a generic can't work while `style`
|
|
14
|
-
// is `Style<this,…>`, because `this` is then contravariant and the type becomes invariant in its own
|
|
15
|
-
// params — that was the old `UIScreen<false>`-not-assignable-to-`UIScreen<boolean>` bug. See the StyleFn
|
|
16
|
-
// note in UINode.ts; the contract is pinned by tests/ui-types.test.ts.
|
|
7
|
+
// A screen — the root the router/host mounts, sized to its slot. A screen NEVER scrolls: it is
|
|
8
|
+
// fixed chrome (header, tab bar) plus, when the content overflows, one `UIScrollable` body child
|
|
9
|
+
// (`flexGrow: 1`). Scroll events and pull-to-refresh live on the scroll containers
|
|
10
|
+
// (`UIScrollable`, `UIVirtualizedList`) — see docs/scroll-forms-plan.md (the former
|
|
11
|
+
// `makeScrollable()` screen mode was removed 2026-07-22).
|
|
17
12
|
export interface UIScreen {
|
|
18
13
|
readonly type: "screen",
|
|
19
|
-
readonly scrollable: boolean,
|
|
20
14
|
style: Style<this, UIScreenStyle>,
|
|
21
15
|
animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>
|
|
22
16
|
animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>
|
|
@@ -33,8 +27,6 @@ export interface UIScreen {
|
|
|
33
27
|
|
|
34
28
|
readonly children: UINodeChild[],
|
|
35
29
|
|
|
36
|
-
/** Upgrade this screen into a scroll view, unlocking `onScroll`/`onOverscroll`/`onRefresh`. */
|
|
37
|
-
makeScrollable(): UIScrollableScreen,
|
|
38
30
|
onBackPressed(callback: () => void): this
|
|
39
31
|
|
|
40
32
|
onOpen(callback: () => void): this
|
|
@@ -62,25 +54,7 @@ export interface UIScreen {
|
|
|
62
54
|
onTouchStart(callback: (ev: TouchStartEvent<UIScreen>) => void): this,
|
|
63
55
|
}
|
|
64
56
|
|
|
65
|
-
/**
|
|
66
|
-
* A `UIScreen` in scroll mode — what `makeScrollable()` returns. Adds the scroll lifecycle that a
|
|
67
|
-
* plain screen doesn't carry. A subtype of `UIScreen`, so it's accepted anywhere a screen is
|
|
68
|
-
* (router pages, `UIPager`).
|
|
69
|
-
*/
|
|
70
|
-
export interface UIScrollableScreen extends UIScreen {
|
|
71
|
-
readonly scrollable: true,
|
|
72
|
-
/** Fires as the content scrolls; the offset is logical px from the start edge. Chainable. */
|
|
73
|
-
onScroll(callback: (scrollPosition: number) => void): this,
|
|
74
|
-
/** Fires while the user drags past an edge (rubber-band). Chainable. */
|
|
75
|
-
onOverscroll(callback: (scrollPosition: number) => void): this,
|
|
76
|
-
/** Pull-to-refresh: the spinner stays until the returned promise resolves. Chainable. */
|
|
77
|
-
onRefresh(callback: () => void | Promise<void>): this,
|
|
78
|
-
/** Already scrollable — returns itself. */
|
|
79
|
-
makeScrollable(): UIScrollableScreen,
|
|
80
|
-
}
|
|
81
|
-
|
|
82
57
|
export class ScreenElement extends ContainerElement<"screen"> {
|
|
83
|
-
scrollable = false
|
|
84
58
|
open(options?: PresentOptions): void {
|
|
85
59
|
_bumpNavEpoch()
|
|
86
60
|
if (_navSupported()) {
|
|
@@ -140,35 +114,11 @@ export class ScreenElement extends ContainerElement<"screen"> {
|
|
|
140
114
|
}
|
|
141
115
|
return null
|
|
142
116
|
}
|
|
143
|
-
makeScrollable(): UIScrollableScreen {
|
|
144
|
-
this.scrollable = true
|
|
145
|
-
|
|
146
|
-
Object.assign(this, {
|
|
147
|
-
sl: [] as any[],
|
|
148
|
-
osl: [] as any[],
|
|
149
|
-
onScroll(callback: any) {
|
|
150
|
-
this.sl.push(callback)
|
|
151
|
-
return this
|
|
152
|
-
},
|
|
153
|
-
onOverscroll(callback: any) {
|
|
154
|
-
this.osl.push(callback)
|
|
155
|
-
return this
|
|
156
|
-
},
|
|
157
|
-
onRefresh(callback: any) {
|
|
158
|
-
(this as any)._refreshCallback = callback
|
|
159
|
-
return this
|
|
160
|
-
}
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
return this as any as UIScrollableScreen
|
|
164
|
-
}
|
|
165
117
|
_backButtonCallback?: () => void
|
|
166
118
|
onBackPressed(callback: any): this {
|
|
167
119
|
this._backButtonCallback = callback
|
|
168
120
|
return this
|
|
169
121
|
}
|
|
170
|
-
// Set by the onRefresh method that makeScrollable() grafts on; read by the host at mount.
|
|
171
|
-
declare _refreshCallback: any
|
|
172
122
|
}
|
|
173
123
|
|
|
174
124
|
export function UIScreen(): UIScreen;
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import type { UIContainerStyle } from "./UIContainer"
|
|
2
|
-
import { type Style, type AnimateStyle, type UINodeChild, type ChildrenFn, type OnLayoutCallback, type BoundingClientRect, ContainerElement } from "./UINode"
|
|
2
|
+
import { type Style, type AnimateStyle, type UINodeChild, type ChildrenFn, type OnLayoutCallback, type BoundingClientRect, type Color, ContainerElement } from "./UINode"
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// refreshControlColor tints the native pull-to-refresh spinner (pairs with onRefresh).
|
|
5
|
+
// keyboardDismissMode: how a scroll gesture in THIS scrollable dismisses the keyboard.
|
|
6
|
+
// "interactive" (default) — dragging over the keyboard slides it away (chat composer feel);
|
|
7
|
+
// "scroll" — any drag dismisses the moment it starts (search-results feel);
|
|
8
|
+
// "none" — scrolling never dismisses (tap-outside policy still applies).
|
|
9
|
+
// snap: paging — when the drag ends, rest on a direct child's boundary along the scroll axis
|
|
10
|
+
// (a carousel/pager is a horizontal scrollable of full-width children + snap:"start"). The value
|
|
11
|
+
// picks where the child sits in the viewport; "none" (default) is free scrolling. Snap targets
|
|
12
|
+
// are the children themselves, so item widths can differ (card-carousel-with-peek).
|
|
13
|
+
export type ScrollStyle = { showScrollbar?: boolean, overscrollMode?: "none" | "absorb" | "default", refreshControlColor?: Color, keyboardDismissMode?: "interactive" | "scroll" | "none", snap?: "none" | "start" | "center" | "end" }
|
|
5
14
|
|
|
6
15
|
export type UIScrollableStyle = UIContainerStyle & ScrollStyle & { scrollDirection?: "horizontal" | "vertical" }
|
|
7
16
|
|
|
@@ -27,6 +36,9 @@ export interface UIScrollable {
|
|
|
27
36
|
onScroll(callback: (scrollPosition: number) => void): UIScrollable,
|
|
28
37
|
onScrollRelease(callback: () => void): UIScrollable,
|
|
29
38
|
onOverscroll(callback: (delta: number) => void): UIScrollable
|
|
39
|
+
/** Pull-to-refresh: the spinner stays until the returned promise settles. Attach BEFORE the
|
|
40
|
+
* node mounts (hosts read it at creation). Vertical only; native hosts — no-op on web. */
|
|
41
|
+
onRefresh(callback: () => void | Promise<void>): UIScrollable
|
|
30
42
|
|
|
31
43
|
readonly children: UINodeChild[]
|
|
32
44
|
}
|
|
@@ -55,6 +67,13 @@ class ScrollableElement extends ContainerElement<"scrollable"> {
|
|
|
55
67
|
this.osl.push(callback)
|
|
56
68
|
return this
|
|
57
69
|
}
|
|
70
|
+
|
|
71
|
+
// Read by the host at mount (same contract the scrollable screen had before 2026-07-22).
|
|
72
|
+
declare _refreshCallback: any
|
|
73
|
+
onRefresh (callback: any): this {
|
|
74
|
+
this._refreshCallback = callback
|
|
75
|
+
return this
|
|
76
|
+
}
|
|
58
77
|
}
|
|
59
78
|
|
|
60
79
|
export function UIScrollable(): UIScrollable
|
|
@@ -62,15 +81,17 @@ export function UIScrollable(children: UINodeChild[] | ChildrenFn): UIScrollable
|
|
|
62
81
|
export function UIScrollable(style: UIScrollableStyle): UIScrollable
|
|
63
82
|
export function UIScrollable(style: UIScrollableStyle, children: UINodeChild[]): UIScrollable
|
|
64
83
|
export function UIScrollable(...args: [] | [UINodeChild[] | ChildrenFn | UIScrollableStyle] | [UIScrollableStyle, children: UINodeChild[] | ChildrenFn]): UIScrollable {
|
|
84
|
+
// flexShrink: 1 default (SDK-owned, like UIRow's flexDirection): without it a scrollable in a
|
|
85
|
+
// flex parent grows to its content height and overflows instead of scrolling.
|
|
65
86
|
if (args.length === 0) {
|
|
66
|
-
return new ScrollableElement("scrollable", {}, [])
|
|
87
|
+
return new ScrollableElement("scrollable", { flexShrink: 1 }, [])
|
|
67
88
|
} else if (args.length === 1) {
|
|
68
89
|
if (Array.isArray(args[0]) || typeof args[0] === "function") {
|
|
69
|
-
return new ScrollableElement("scrollable", {}, args[0])
|
|
90
|
+
return new ScrollableElement("scrollable", { flexShrink: 1 }, args[0])
|
|
70
91
|
} else {
|
|
71
|
-
return new ScrollableElement("scrollable", args[0], [])
|
|
92
|
+
return new ScrollableElement("scrollable", { flexShrink: 1, ...args[0] }, [])
|
|
72
93
|
}
|
|
73
94
|
} else {
|
|
74
|
-
return new ScrollableElement("scrollable", args[0], args[1])
|
|
95
|
+
return new ScrollableElement("scrollable", { flexShrink: 1, ...args[0] }, args[1])
|
|
75
96
|
}
|
|
76
97
|
}
|