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/UI.ts
CHANGED
|
@@ -10,10 +10,12 @@ export { UIVideo, type UIVideoStyle } from './UIVideo'
|
|
|
10
10
|
export { UIInput, UITextArea, type UIInputStyle } from './UIInput'
|
|
11
11
|
|
|
12
12
|
export { UIWidget, type UIWidgetStyle } from './UIWidget'
|
|
13
|
+
export { UIModal, type UIModalStyle } from './UIModal'
|
|
14
|
+
export { UIPopover, type UIPopoverStyle, type UIPopoverAnchor } from './UIPopover'
|
|
13
15
|
|
|
14
16
|
export { UIRow, UIColumn, UIBox, type UIContainerStyle } from './UIContainer'
|
|
15
17
|
export { UIScrollable, type UIScrollableStyle } from './UIScrollable'
|
|
16
|
-
export {
|
|
18
|
+
export { UIPager, type UIPagerStyle } from './UIPager'
|
|
17
19
|
|
|
18
20
|
export { UIVirtualizedList } from './UIVirtualizedList'
|
|
19
21
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ClickEvent, TouchStartEvent, LongPressEvent } from "../runtime/touch"
|
|
2
2
|
import type { ClickEvent as ClickEventType, TouchStartEvent as TouchStartEventType, LongPressEvent as LongPressEventType } from "../runtime/touch"
|
|
3
|
-
import { type ElementStyle, type ContainerStyle, type Style, ContainerElement, type DrawableStyle, type UINodeChild, type ChildrenFn, type OnLayoutCallback, type BaseStyle, type AnimateStyle, type Color } from "./UINode"
|
|
3
|
+
import { type ElementStyle, type ContainerStyle, type Style, ContainerElement, type DrawableStyle, type UINodeChild, type ChildrenFn, type OnLayoutCallback, type BoundingClientRect, type BaseStyle, type AnimateStyle, type Color } from "./UINode"
|
|
4
4
|
|
|
5
5
|
export type UIButtonStyle = ElementStyle & DrawableStyle & ContainerStyle & { rippleColor?: Color | "default" }
|
|
6
6
|
|
|
@@ -25,6 +25,7 @@ export interface UIButton {
|
|
|
25
25
|
setContent(nodes: UINodeChild[] | ChildrenFn): this
|
|
26
26
|
|
|
27
27
|
onLayout(onLayout: OnLayoutCallback): this
|
|
28
|
+
getBoundingClientRect(): BoundingClientRect | null
|
|
28
29
|
|
|
29
30
|
setClass(name: string, enabled: boolean): this
|
|
30
31
|
toggleClass(name: string): this
|
|
@@ -94,20 +95,24 @@ class ButtonElement extends ContainerElement<"button"> {
|
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
// Buttons are row + centered by default: the icon+label button is the norm, and yoga's column
|
|
99
|
+
// default was a recurring authoring/codegen trap.
|
|
100
|
+
const BUTTON_DEFAULTS = { flexDirection: "row", justifyContent: "center", alignItems: "center" } as const
|
|
101
|
+
|
|
97
102
|
export function UIButton(): UIButton;
|
|
98
103
|
export function UIButton(children: UINodeChild[] | ChildrenFn): UIButton;
|
|
99
104
|
export function UIButton(style: UIButtonStyle): UIButton;
|
|
100
105
|
export function UIButton(style: UIButtonStyle, children: UINodeChild[]): UIButton;
|
|
101
106
|
export function UIButton(...args: [] | [UINodeChild[] | ChildrenFn | UIButtonStyle] | [UIButtonStyle, children: UINodeChild[] | ChildrenFn]): UIButton {
|
|
102
107
|
if (args.length === 0) {
|
|
103
|
-
return new ButtonElement("button", {}, [])
|
|
108
|
+
return new ButtonElement("button", { ...BUTTON_DEFAULTS }, [])
|
|
104
109
|
} else if (args.length === 1) {
|
|
105
110
|
if (Array.isArray(args[0]) || typeof args[0] === "function") {
|
|
106
|
-
return new ButtonElement("button", {}, args[0])
|
|
111
|
+
return new ButtonElement("button", { ...BUTTON_DEFAULTS }, args[0])
|
|
107
112
|
} else {
|
|
108
|
-
return new ButtonElement("button", args[0], [])
|
|
113
|
+
return new ButtonElement("button", { ...BUTTON_DEFAULTS, ...args[0] }, [])
|
|
109
114
|
}
|
|
110
115
|
} else {
|
|
111
|
-
return new ButtonElement("button", args[0], args[1])
|
|
116
|
+
return new ButtonElement("button", { ...BUTTON_DEFAULTS, ...args[0] }, args[1])
|
|
112
117
|
}
|
|
113
118
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ContainerElement, type AnimateStyle, type AppearStyle, type ChildrenFn, type ContainerStyle, type DisappearStyle, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type Style, type StyleFn, type UINode, type UINodeChild } from "./UINode"
|
|
1
|
+
import { ContainerElement, type AnimateStyle, type AppearStyle, type ChildrenFn, type ContainerStyle, type DisappearStyle, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type BoundingClientRect, type Style, type StyleFn, type UINode, type UINodeChild } from "./UINode"
|
|
2
2
|
|
|
3
3
|
export type UIContainerStyle = ElementStyle & DrawableStyle & ContainerStyle
|
|
4
4
|
|
|
@@ -14,6 +14,7 @@ export interface UIColumn {
|
|
|
14
14
|
setContent(nodes: UINodeChild[] | ChildrenFn): this
|
|
15
15
|
|
|
16
16
|
onLayout(onLayout: OnLayoutCallback): this
|
|
17
|
+
getBoundingClientRect(): BoundingClientRect | null
|
|
17
18
|
|
|
18
19
|
setClass(name: string, enabled: boolean): this
|
|
19
20
|
toggleClass(name: string): this
|
|
@@ -52,6 +53,7 @@ export interface UIRow {
|
|
|
52
53
|
setContent(nodes: UINodeChild[] | ChildrenFn): this
|
|
53
54
|
|
|
54
55
|
onLayout(onLayout: OnLayoutCallback): this
|
|
56
|
+
getBoundingClientRect(): BoundingClientRect | null
|
|
55
57
|
|
|
56
58
|
setClass(name: string, enabled: boolean): this
|
|
57
59
|
toggleClass(name: string): this
|
|
@@ -60,20 +62,21 @@ export interface UIRow {
|
|
|
60
62
|
|
|
61
63
|
readonly children: UINodeChild[],
|
|
62
64
|
}
|
|
65
|
+
export function UIRow(): UIRow
|
|
63
66
|
export function UIRow(children: UINodeChild[] | ChildrenFn): UIRow
|
|
64
67
|
export function UIRow(style: UIContainerStyle): UIRow
|
|
65
68
|
export function UIRow(style: UIContainerStyle, children: UINodeChild[]): UIRow
|
|
66
69
|
export function UIRow(...args: [] | [UINodeChild[] | ChildrenFn | UIContainerStyle] | [UIContainerStyle, children: UINodeChild[] | ChildrenFn]): UIRow {
|
|
67
70
|
if (args.length === 0) {
|
|
68
|
-
return new ContainerElement("row", {}, [])
|
|
71
|
+
return new ContainerElement("row", { flexDirection: "row" }, [])
|
|
69
72
|
} else if (args.length === 1) {
|
|
70
73
|
if (Array.isArray(args[0]) || typeof args[0] === "function") {
|
|
71
|
-
return new ContainerElement("row", {}, args[0])
|
|
74
|
+
return new ContainerElement("row", { flexDirection: "row" }, args[0])
|
|
72
75
|
} else {
|
|
73
|
-
return new ContainerElement("row", args[0], [])
|
|
76
|
+
return new ContainerElement("row", { flexDirection: "row", ...args[0] }, [])
|
|
74
77
|
}
|
|
75
78
|
} else {
|
|
76
|
-
return new ContainerElement("row", args[0], args[1])
|
|
79
|
+
return new ContainerElement("row", { flexDirection: "row", ...args[0] }, args[1])
|
|
77
80
|
}
|
|
78
81
|
}
|
|
79
82
|
|
|
@@ -89,6 +92,7 @@ export interface UIBox {
|
|
|
89
92
|
setContent(nodes: UINodeChild[] | ChildrenFn): this
|
|
90
93
|
|
|
91
94
|
onLayout(onLayout: OnLayoutCallback): this
|
|
95
|
+
getBoundingClientRect(): BoundingClientRect | null
|
|
92
96
|
|
|
93
97
|
setClass(name: string, enabled: boolean): this
|
|
94
98
|
toggleClass(name: string): this
|
|
@@ -97,19 +101,22 @@ export interface UIBox {
|
|
|
97
101
|
|
|
98
102
|
readonly children: UINodeChild[],
|
|
99
103
|
}
|
|
104
|
+
const BOX_DEFAULTS = { justifyContent: "center", alignItems: "center" } as const
|
|
105
|
+
|
|
106
|
+
export function UIBox(): UIBox
|
|
100
107
|
export function UIBox(children: UINodeChild[] | ChildrenFn): UIBox
|
|
101
108
|
export function UIBox(style: UIContainerStyle): UIBox
|
|
102
109
|
export function UIBox(style: UIContainerStyle, children: UINodeChild[]): UIBox
|
|
103
110
|
export function UIBox(...args: [] | [UINodeChild[] | ChildrenFn | UIContainerStyle] | [UIContainerStyle, children: UINodeChild[] | ChildrenFn]): UIBox {
|
|
104
111
|
if (args.length === 0) {
|
|
105
|
-
return new ContainerElement("box", {}, [])
|
|
112
|
+
return new ContainerElement("box", { ...BOX_DEFAULTS }, [])
|
|
106
113
|
} else if (args.length === 1) {
|
|
107
114
|
if (Array.isArray(args[0]) || typeof args[0] === "function") {
|
|
108
|
-
return new ContainerElement("box", {}, args[0])
|
|
115
|
+
return new ContainerElement("box", { ...BOX_DEFAULTS }, args[0])
|
|
109
116
|
} else {
|
|
110
|
-
return new ContainerElement("box", args[0], [])
|
|
117
|
+
return new ContainerElement("box", { ...BOX_DEFAULTS, ...args[0] }, [])
|
|
111
118
|
}
|
|
112
119
|
} else {
|
|
113
|
-
return new ContainerElement("box", args[0], args[1])
|
|
120
|
+
return new ContainerElement("box", { ...BOX_DEFAULTS, ...args[0] }, args[1])
|
|
114
121
|
}
|
|
115
122
|
}
|
|
@@ -2,7 +2,7 @@ import type { FetchResponse } from "../runtime/fetch"
|
|
|
2
2
|
import type { SvgSourceValue as SvgSource } from "../runtime/misc"
|
|
3
3
|
import type { File } from "../runtime/fetch"
|
|
4
4
|
import { Canvas, isCanvas } from "../canvas/Canvas"
|
|
5
|
-
import { Element, type AnimateStyle, type Color, type ElementStyle, type OnLayoutCallback, type Style } from "./UINode"
|
|
5
|
+
import { Element, type AnimateStyle, type Color, type ElementStyle, type OnLayoutCallback, type BoundingClientRect, type Style } from "./UINode"
|
|
6
6
|
|
|
7
7
|
export type UIImageStyle = ElementStyle & { borderRadius?: number, objectFit?: "cover" | "contain" | "fill", tintColor?: Color }
|
|
8
8
|
|
|
@@ -14,7 +14,9 @@ export type CanvasImageSource = { canvasSurface: number }
|
|
|
14
14
|
export type ImageSource = string | FetchResponse | File | SvgSource | Canvas
|
|
15
15
|
|
|
16
16
|
const toSrc = (src: ImageSource): string | FetchResponse | File | SvgSource | CanvasImageSource =>
|
|
17
|
-
|
|
17
|
+
// _bakeSurfaceForUI (not _bakeSurface) also flags the canvas UI-bound, so canvas.update() later
|
|
18
|
+
// refreshes this image node in place — that's what makes an animated UIImage(canvas) update.
|
|
19
|
+
isCanvas(src) ? { canvasSurface: src._bakeSurfaceForUI() } : src
|
|
18
20
|
|
|
19
21
|
/** The stored/resolved image source (a Canvas is resolved to a surface marker). */
|
|
20
22
|
export type ResolvedImageSource = string | FetchResponse | File | SvgSource | CanvasImageSource
|
|
@@ -35,6 +37,7 @@ export interface UIImage {
|
|
|
35
37
|
setSourceRect(x: number, y: number, w: number, h: number): this
|
|
36
38
|
|
|
37
39
|
onLayout(onLayout: OnLayoutCallback): this
|
|
40
|
+
getBoundingClientRect(): BoundingClientRect | null
|
|
38
41
|
|
|
39
42
|
setClass(name: string, enabled: boolean): this
|
|
40
43
|
toggleClass(name: string): this
|
|
@@ -1,21 +1,60 @@
|
|
|
1
|
-
import { type ElementStyle, type DrawableStyle, type Style, type StyleFn, type AppearStyle, type DisappearStyle, Element, type TextStyle, type Color, type OnLayoutCallback, type BaseStyle, type AnimateStyle } from "./UINode";
|
|
2
|
-
|
|
3
|
-
type InputStyle = {
|
|
1
|
+
import { type ElementStyle, type DrawableStyle, type Style, type StyleFn, type AppearStyle, type DisappearStyle, Element, type TextStyle, type Color, type OnLayoutCallback, type BoundingClientRect, type BaseStyle, type AnimateStyle } from "./UINode";
|
|
2
|
+
|
|
3
|
+
type InputStyle = {
|
|
4
|
+
placeholder?: string,
|
|
5
|
+
placeholderColor?: Color,
|
|
6
|
+
/**
|
|
7
|
+
* Keyboard hint, NOT a validator — `number` does not block pasted letters; validate in your own
|
|
8
|
+
* onChange. The phone-pad value is `phone` (LeCodes has no `tel`).
|
|
9
|
+
*/
|
|
10
|
+
type?: "text" | "password" | "search" | "phone" | "email" | "number" | "decimal" | "url",
|
|
11
|
+
/**
|
|
12
|
+
* What the layout does while the keyboard is up (decided by the FOCUSED input). `true` (default):
|
|
13
|
+
* the layout viewport shrinks to the area above the keyboard — one relayout. `false`: the keyboard
|
|
14
|
+
* overlays the UI with NO layout recalculation — for chat composers that manage their own inset
|
|
15
|
+
* (pair with `app.keyboardHeight` / the app `"keyboard"` event) and full-screen canvases where a
|
|
16
|
+
* reflow is worse than overlap.
|
|
17
|
+
*/
|
|
18
|
+
keyboardShrink?: boolean,
|
|
19
|
+
/** Hard cap on the text length — enforced by the host while typing/pasting. */
|
|
20
|
+
maxLength?: number,
|
|
21
|
+
/** Auto-capitalization hint; omitted = platform default. */
|
|
22
|
+
autocapitalize?: "none" | "words" | "sentences" | "characters",
|
|
23
|
+
/** Enable/disable the platform autocorrection & suggestions; omitted = platform default. */
|
|
24
|
+
autocorrect?: boolean,
|
|
25
|
+
}
|
|
4
26
|
export type UIInputStyle = ElementStyle & DrawableStyle & TextStyle & InputStyle
|
|
5
27
|
|
|
28
|
+
/** UIInput-only (a UITextArea's Enter is a newline, period): the return-key label. */
|
|
29
|
+
type SubmitStyle = {
|
|
30
|
+
/**
|
|
31
|
+
* Return-key label. On submit the keyboard dismisses UNLESS `enterKey` is `"next"` — then the
|
|
32
|
+
* app is expected to call `otherInput.focus()` from `onSubmit`, so the keyboard never blinks.
|
|
33
|
+
*/
|
|
34
|
+
enterKey?: "done" | "go" | "next" | "search" | "send",
|
|
35
|
+
}
|
|
36
|
+
|
|
6
37
|
export interface UIInput {
|
|
7
38
|
readonly type: "input",
|
|
8
39
|
|
|
9
40
|
value: string
|
|
10
41
|
|
|
11
|
-
style: Style<this, UIInputStyle & { onFocused?: UIInputStyle & { duration?: number } }>,
|
|
42
|
+
style: Style<this, UIInputStyle & SubmitStyle & { onFocused?: UIInputStyle & { duration?: number } }>,
|
|
12
43
|
animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>
|
|
13
44
|
animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>
|
|
14
45
|
|
|
15
46
|
onChange(callback: (value: string) => void): UIInput
|
|
16
47
|
onFocus(callback: () => void): UIInput
|
|
17
48
|
onBlur(callback: () => void): UIInput
|
|
49
|
+
/** The keyboard's return key was pressed. `value` is the current text. See `enterKey`. */
|
|
50
|
+
onSubmit(callback: (value: string) => void): UIInput
|
|
18
51
|
onLayout(onLayout: OnLayoutCallback): this
|
|
52
|
+
getBoundingClientRect(): BoundingClientRect | null
|
|
53
|
+
|
|
54
|
+
/** Focus the input (opens the keyboard). No-op before mount. */
|
|
55
|
+
focus(): void
|
|
56
|
+
/** Release focus (dismisses the keyboard when this input held it). */
|
|
57
|
+
blur(): void
|
|
19
58
|
|
|
20
59
|
setClass(name: string, enabled: boolean): this
|
|
21
60
|
toggleClass(name: string): this
|
|
@@ -39,6 +78,11 @@ export interface UITextArea {
|
|
|
39
78
|
onFocus(callback: () => void): UITextArea
|
|
40
79
|
onBlur(callback: () => void): UITextArea
|
|
41
80
|
|
|
81
|
+
/** Focus the text area (opens the keyboard). No-op before mount. */
|
|
82
|
+
focus(): void
|
|
83
|
+
/** Release focus (dismisses the keyboard when this text area held it). */
|
|
84
|
+
blur(): void
|
|
85
|
+
|
|
42
86
|
setClass(name: string, enabled: boolean): this
|
|
43
87
|
toggleClass(name: string): this
|
|
44
88
|
hasClass(name: string): boolean
|
|
@@ -46,10 +90,11 @@ export interface UITextArea {
|
|
|
46
90
|
}
|
|
47
91
|
|
|
48
92
|
class InputElement<T extends "input" | "textarea"> extends Element<T> {
|
|
49
|
-
|
|
93
|
+
|
|
50
94
|
readonly cl: Function[] = []
|
|
51
95
|
readonly fl: Function[] = []
|
|
52
96
|
readonly bl: Function[] = []
|
|
97
|
+
readonly sl: Function[] = []
|
|
53
98
|
|
|
54
99
|
get value() {
|
|
55
100
|
return _creatorUI.getTextValue(this._id)
|
|
@@ -60,6 +105,12 @@ class InputElement<T extends "input" | "textarea"> extends Element<T> {
|
|
|
60
105
|
}
|
|
61
106
|
}
|
|
62
107
|
|
|
108
|
+
// `phone` is SDK-surface only — hosts (and the web's verbatim `el.type`) keep HTML's `tel` as
|
|
109
|
+
// the wire format, so the rename never reaches them.
|
|
110
|
+
_mapStyle(style: any): void {
|
|
111
|
+
if (style.type === "phone") style.type = "tel"
|
|
112
|
+
}
|
|
113
|
+
|
|
63
114
|
onChange (callback: (value: string) => void): this {
|
|
64
115
|
this.cl.push(callback)
|
|
65
116
|
return this
|
|
@@ -72,17 +123,33 @@ class InputElement<T extends "input" | "textarea"> extends Element<T> {
|
|
|
72
123
|
this.bl.push(callback)
|
|
73
124
|
return this
|
|
74
125
|
}
|
|
126
|
+
onSubmit(callback: (value: string) => void): this {
|
|
127
|
+
this.sl.push(callback)
|
|
128
|
+
return this
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Focus rides the existing value channel (updateNode "focus") — no new ABI method.
|
|
132
|
+
focus(): void {
|
|
133
|
+
if (this._id !== 0) {
|
|
134
|
+
_creatorUI.updateNode(this._id, "focus", true)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
blur(): void {
|
|
138
|
+
if (this._id !== 0) {
|
|
139
|
+
_creatorUI.updateNode(this._id, "focus", false)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
75
142
|
}
|
|
76
143
|
|
|
77
144
|
export function UIInput(): UIInput;
|
|
78
145
|
export function UIInput(style: UIInputStyle): UIInput;
|
|
79
146
|
export function UIInput(style?: UIInputStyle): UIInput {
|
|
80
|
-
|
|
147
|
+
// One text line as the floor — an unstyled input must never collapse to 0 height.
|
|
148
|
+
return new InputElement("input", { minHeight: "1.2em", ...style })
|
|
81
149
|
}
|
|
82
150
|
|
|
83
151
|
export function UITextArea(): UITextArea;
|
|
84
152
|
export function UITextArea(style: UIInputStyle): UITextArea;
|
|
85
153
|
export function UITextArea(style?: UIInputStyle): UITextArea {
|
|
86
|
-
return new InputElement("textarea", style
|
|
154
|
+
return new InputElement("textarea", { minHeight: "1.2em", ...style })
|
|
87
155
|
}
|
|
88
|
-
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { type UIWidget, type UIWidgetStyle, WidgetElement } from "./UIWidget"
|
|
2
|
+
import type { BaseStyle, ChildrenFn, Color, DrawableStyle, UINodeChild } from "./UINode"
|
|
3
|
+
|
|
4
|
+
export type UIModalStyle = UIWidgetStyle
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The modal's hidden pose: `show()` animates from it, `hide()` animates to it. `duration` (ms)
|
|
8
|
+
* applies to both directions.
|
|
9
|
+
*/
|
|
10
|
+
export type UIModalTransition = DrawableStyle & BaseStyle & {
|
|
11
|
+
overlayColor?: Color | null,
|
|
12
|
+
duration?: number,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MODAL_ANIM_MS = 200
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A dialog: a `UIWidget` with the modal boilerplate built in. Comes with a scrim
|
|
19
|
+
* (`overlayColor: "rgba(0, 0, 0, 0.5)"` unless overridden), animates on `show()`/`hide()`
|
|
20
|
+
* (a 200 ms fade by default — swap the pose via `transition()`; the exit plays with
|
|
21
|
+
* `commit: false`, so the style is intact for the next show), and closes itself on a scrim tap
|
|
22
|
+
* or the Android back button (disable via `dismissible(false)`). Everything else is a plain
|
|
23
|
+
* widget — same styling, children, touch and `attachTo()` surface; create it **once** at module
|
|
24
|
+
* scope and reuse it.
|
|
25
|
+
*/
|
|
26
|
+
export interface UIModal extends UIWidget {
|
|
27
|
+
/** Mount as an overlay and play the entrance transition. No-op while already open. */
|
|
28
|
+
show(): void,
|
|
29
|
+
/** Play the exit transition, then unmount. No-op while closed or already closing. */
|
|
30
|
+
hide(): void,
|
|
31
|
+
/** True from `show()` until `hide()` starts (already false during the exit animation). */
|
|
32
|
+
readonly isOpen: boolean,
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Replace the show/hide animation. `hidden` is the off-screen pose — `show()` animates from
|
|
36
|
+
* it, `hide()` animates to it. Default `{ opacity: 0, duration: 200 }` (a fade); the pose
|
|
37
|
+
* replaces it wholesale, so a slide without a fade is just the slide. The scrim keeps its own
|
|
38
|
+
* fade unless the pose sets `overlayColor` itself.
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* sheet.transition({ transform: "translateY(480px)", duration: 250 }) // bottom sheet
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
transition(hidden: UIModalTransition): this,
|
|
45
|
+
|
|
46
|
+
/** Called when `show()` mounts the modal. */
|
|
47
|
+
onOpen(callback: () => void): this,
|
|
48
|
+
/** Called when the modal starts closing — scrim tap, back button, or a `hide()` call. */
|
|
49
|
+
onClose(callback: () => void): this,
|
|
50
|
+
/** `dismissible(false)` keeps scrim taps and the back button from closing the modal
|
|
51
|
+
* (forced-choice dialogs). Default `true`. */
|
|
52
|
+
dismissible(enabled: boolean): this,
|
|
53
|
+
|
|
54
|
+
append(...nodes: UINodeChild[]): this
|
|
55
|
+
insert(index: number, ...nodes: UINodeChild[]): this
|
|
56
|
+
remove(...nodes: UINodeChild[]): this
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class ModalElement extends WidgetElement {
|
|
60
|
+
_open = false
|
|
61
|
+
_dismissible = true
|
|
62
|
+
_transition: UIModalTransition = { opacity: 0 }
|
|
63
|
+
_closeTimer?: ReturnType<typeof setTimeout>
|
|
64
|
+
readonly _openListeners: (() => void)[] = []
|
|
65
|
+
readonly _closeListeners: (() => void)[] = []
|
|
66
|
+
|
|
67
|
+
constructor(style: UIModalStyle, children: UINodeChild[] | ChildrenFn) {
|
|
68
|
+
super("widget", { overlayColor: "rgba(0, 0, 0, 0.5)", ...style }, children)
|
|
69
|
+
this.onOverlayTap(() => { if (this._dismissible) this.hide() })
|
|
70
|
+
this.onBackPressed(() => { if (this._dismissible) this.hide() })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The full animate argument: hidden pose + duration, plus the scrim fade — only when the
|
|
74
|
+
// modal actually has a scrim (overlayColor: null opts out; animating a null scrim would
|
|
75
|
+
// create the intercepting layer) and the pose doesn't drive the scrim itself.
|
|
76
|
+
private _pose(extra?: Record<string, unknown>): Record<string, unknown> {
|
|
77
|
+
const pose: Record<string, unknown> = { duration: MODAL_ANIM_MS, ...this._transition, ...extra }
|
|
78
|
+
const overlayColor = (this._style as UIModalStyle).overlayColor
|
|
79
|
+
if (pose.overlayColor === undefined && overlayColor !== undefined && overlayColor !== null) {
|
|
80
|
+
pose.overlayColor = "transparent"
|
|
81
|
+
}
|
|
82
|
+
return pose
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private get _duration(): number {
|
|
86
|
+
return this._transition.duration ?? MODAL_ANIM_MS
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
transition(hidden: UIModalTransition): this {
|
|
90
|
+
this._transition = hidden
|
|
91
|
+
return this
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
show(): void {
|
|
95
|
+
if (this._open) return
|
|
96
|
+
if (this._closeTimer !== undefined) { // reopened mid exit: finish that hide now
|
|
97
|
+
clearTimeout(this._closeTimer)
|
|
98
|
+
this._closeTimer = undefined
|
|
99
|
+
super.hide()
|
|
100
|
+
}
|
|
101
|
+
this._open = true
|
|
102
|
+
super.show()
|
|
103
|
+
this.animateFrom(this._pose())
|
|
104
|
+
for (const callback of this._openListeners) callback()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
hide(): void {
|
|
108
|
+
if (!this._open) return
|
|
109
|
+
this._open = false
|
|
110
|
+
this.animateTo(this._pose({ commit: false }))
|
|
111
|
+
this._closeTimer = setTimeout(() => { this._closeTimer = undefined; super.hide() }, this._duration)
|
|
112
|
+
for (const callback of this._closeListeners) callback()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get isOpen(): boolean {
|
|
116
|
+
return this._open
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
onOpen(callback: () => void): this {
|
|
120
|
+
this._openListeners.push(callback)
|
|
121
|
+
return this
|
|
122
|
+
}
|
|
123
|
+
onClose(callback: () => void): this {
|
|
124
|
+
this._closeListeners.push(callback)
|
|
125
|
+
return this
|
|
126
|
+
}
|
|
127
|
+
dismissible(enabled: boolean): this {
|
|
128
|
+
this._dismissible = enabled
|
|
129
|
+
return this
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function UIModal(): UIModal;
|
|
134
|
+
export function UIModal(children: UINodeChild[] | ChildrenFn): UIModal;
|
|
135
|
+
export function UIModal(style: UIModalStyle): UIModal;
|
|
136
|
+
export function UIModal(style: UIModalStyle, children: UINodeChild[] | ChildrenFn): UIModal;
|
|
137
|
+
export function UIModal(...args: [] | [UINodeChild[] | ChildrenFn | UIModalStyle] | [UIModalStyle, children: UINodeChild[] | ChildrenFn]): UIModal {
|
|
138
|
+
if (args.length === 0) {
|
|
139
|
+
return new ModalElement({}, [])
|
|
140
|
+
} else if (args.length === 1) {
|
|
141
|
+
if (Array.isArray(args[0]) || typeof args[0] === "function") {
|
|
142
|
+
return new ModalElement({}, args[0])
|
|
143
|
+
} else {
|
|
144
|
+
return new ModalElement(args[0], [])
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
return new ModalElement(args[0], args[1])
|
|
148
|
+
}
|
|
149
|
+
}
|