@liquefy/cascade.ui 1.0.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/LICENSE +15 -0
- package/README.md +73 -0
- package/package.json +46 -0
- package/src/BasicTheme.js +390 -0
- package/src/Layout.js +120 -0
- package/src/Overlay.js +85 -0
- package/src/OverlayFrame.js +137 -0
- package/src/Popover.js +158 -0
- package/src/Portal.js +132 -0
- package/src/colorScheme.js +201 -0
- package/src/index.js +17 -0
- package/src/widgets.js +109 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Robert Renbris
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# cascade.ui
|
|
2
|
+
|
|
3
|
+
Themed widgets, layout, overlays, portals, popovers and color schemes for
|
|
4
|
+
[Cascade](https://github.com/erobwen/liquefy#readme) - and its basic theme.
|
|
5
|
+
|
|
6
|
+
```console
|
|
7
|
+
npm install @liquefy/cascade.ui @liquefy/cascade.dom @liquefy/cascade.component @liquefy/cascade.reactive
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Themed widgets
|
|
11
|
+
|
|
12
|
+
Nothing in an app names a theme when it builds a widget: `button(...)` asks
|
|
13
|
+
the render context's service locator for one, and whichever theme is in it
|
|
14
|
+
provides it. Switching the theme replaces whole components, not just their
|
|
15
|
+
style - with all app state kept where it was. A part of an app can have a
|
|
16
|
+
theme of its own (`serviceProvider()`).
|
|
17
|
+
|
|
18
|
+
`button`, `icon`, `iconButton`, `card`, `controlPanel`, `alert`, `listItem`,
|
|
19
|
+
`dialog`, `textField`, `checkbox`, `colorField`, `tabBar` - each with a
|
|
20
|
+
property contract every theme implements (see `src/widgets.js`).
|
|
21
|
+
|
|
22
|
+
Two themes to choose from: `basicTheme`, from this package - plain HTML, in
|
|
23
|
+
tones of one base color, softly rounded - and `materialTheme`, from
|
|
24
|
+
[@liquefy/cascade.ui.material](https://github.com/erobwen/liquefy/tree/main/cascade.ui.material#readme).
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
import { CompoundServiceLocator } from "@liquefy/cascade.component";
|
|
28
|
+
import { DOMServiceLocator } from "@liquefy/cascade.dom";
|
|
29
|
+
import { basicTheme } from "@liquefy/cascade.ui";
|
|
30
|
+
|
|
31
|
+
const services = new CompoundServiceLocator(new DOMServiceLocator(), basicTheme);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
To switch themes while the app runs, use an `ObservableCompoundServiceLocator`
|
|
35
|
+
and replace the theme in it: everything built with a themed widget rebuilds
|
|
36
|
+
with the new one.
|
|
37
|
+
|
|
38
|
+
The icons are Google's icon fonts - link them in your page:
|
|
39
|
+
|
|
40
|
+
```html
|
|
41
|
+
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet">
|
|
42
|
+
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
(Material Symbols for the basic theme, Material Icons for the Material theme.)
|
|
46
|
+
|
|
47
|
+
## Color schemes
|
|
48
|
+
|
|
49
|
+
A theme is its colors too: a base and an accent, and every other color made
|
|
50
|
+
from them - tones of their hue, at preset brightness levels. They're CSS
|
|
51
|
+
variables (`themeColor.page` is `var(--cascade-page, ...)`): put a theme's
|
|
52
|
+
variables on an element with `colorSchemeScope()`, and everything in it takes
|
|
53
|
+
its colors - change the base color, and only that element's variables are
|
|
54
|
+
rewritten.
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
import { text } from "@liquefy/cascade.dom";
|
|
58
|
+
import { button, currentColorScheme } from "@liquefy/cascade.ui";
|
|
59
|
+
|
|
60
|
+
// In a build(): the scheme of the theme in the render context - and a
|
|
61
|
+
// button giving it another base color. Everything inside a
|
|
62
|
+
// colorSchemeScope() of that theme follows (put one around your app).
|
|
63
|
+
const scheme = currentColorScheme();
|
|
64
|
+
return button({ key: "green" }, text({ key: "greenText", text: "Go green" }), () => { scheme.base = "#2e7d32"; });
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Layout and the rest
|
|
68
|
+
|
|
69
|
+
- `row`, `column`, `filler`, `centerMiddle`, `zStack`, ... and their styles
|
|
70
|
+
(`fillerStyle`, `fitContainerStyle`, ...).
|
|
71
|
+
- `overlayFrame()` and `overlay()` - modal content over the app.
|
|
72
|
+
- `popover()` - beside an element, following it when it moves.
|
|
73
|
+
- `portal()` and `portalContents()` - content placed somewhere else in the tree.
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@liquefy/cascade.ui",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Themed widgets, layout, overlays, portals, popovers and color schemes for Cascade - and its basic theme.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"reactive",
|
|
7
|
+
"ui",
|
|
8
|
+
"widgets",
|
|
9
|
+
"themes",
|
|
10
|
+
"cascade"
|
|
11
|
+
],
|
|
12
|
+
"author": "Robert Renbris",
|
|
13
|
+
"license": "ISC",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./src/index.js",
|
|
16
|
+
"module": "./src/index.js",
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"!src/test",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "mocha ./src/test"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/erobwen/liquefy.git",
|
|
29
|
+
"directory": "cascade.ui"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/erobwen/liquefy/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/erobwen/liquefy/tree/main/cascade.ui#readme",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@liquefy/cascade.component": "^1.0.0",
|
|
37
|
+
"@liquefy/cascade.dom": "^1.0.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"mocha": "^10.8.2",
|
|
41
|
+
"jsdom": "^24.1.1"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { Component, frozen, callback } from "@liquefy/cascade.component";
|
|
2
|
+
import { button as htmlButton, input as htmlInput, label as htmlLabel, span, div, text } from "@liquefy/cascade.dom";
|
|
3
|
+
import { row, column, filler, wrapper } from "./Layout.js";
|
|
4
|
+
import { icon, iconButton, alertSeverities } from "./widgets.js";
|
|
5
|
+
import { themeColor, ColorScheme, defaultColors } from "./colorScheme.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The basic theme - plain HTML, lightly styled (ported from
|
|
9
|
+
* flow.ui/basic's own buttons.js, card.js, alert.js, listItem.js and
|
|
10
|
+
* dialogue.js). A service locator (see cascade.component's
|
|
11
|
+
* ServiceLocator.js) answering `{ type: "widget", name }` queries for the
|
|
12
|
+
* widgets in widgets.js (see there for each one's property contract),
|
|
13
|
+
* built out of ordinary HTML elements - which are themselves looked up the
|
|
14
|
+
* same way, so a DOM-level locator further along still decides what an
|
|
15
|
+
* HTML `button` actually is. Widgets used inside other widgets (the icon
|
|
16
|
+
* in an alert, ...) are asked for the same way too, so they come from
|
|
17
|
+
* whatever theme is in the context.
|
|
18
|
+
*
|
|
19
|
+
* The look: tones of one base color - slate blue, unless changed (see
|
|
20
|
+
* colorScheme.js: every color here is one of its CSS variables) - soft
|
|
21
|
+
* corners - square, just rounded a little (6px on controls, 8px on
|
|
22
|
+
* surfaces; the Material theme's are pill shaped) - and one discreet
|
|
23
|
+
* shadow for whatever lies on the page. Buttons are white chips on it.
|
|
24
|
+
*
|
|
25
|
+
* What changes on hover, focus or press can't be written as inline style,
|
|
26
|
+
* so those widgets get their look from a small stylesheet of the theme's
|
|
27
|
+
* own, added to the document the first time one is made (see
|
|
28
|
+
* ensureStyleSheet()) - and inline style is left for what the caller gives.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// The theme's colors - its scheme's CSS variables - and its shadows.
|
|
32
|
+
const c = { ...themeColor, error: "#c0392b" };
|
|
33
|
+
export const basicShadow = "0 1px 2px rgba(30, 40, 55, 0.10), 0 2px 6px rgba(30, 40, 55, 0.08)";
|
|
34
|
+
const dialogShadow = "0 12px 32px rgba(30, 40, 55, 0.35)";
|
|
35
|
+
|
|
36
|
+
const styleSheet = `
|
|
37
|
+
.cb-button {
|
|
38
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
|
39
|
+
box-sizing: border-box; min-height: 32px; padding: 0 14px; margin: 0;
|
|
40
|
+
font: inherit; font-size: 14px; font-weight: 500; line-height: 1.2; white-space: nowrap;
|
|
41
|
+
color: ${c.text}; background: ${c.surface}; border: 1px solid ${c.border}; border-radius: 6px;
|
|
42
|
+
box-shadow: ${basicShadow}; cursor: pointer; user-select: none;
|
|
43
|
+
transition: background-color 0.15s, border-color 0.15s, box-shadow 0.15s;
|
|
44
|
+
}
|
|
45
|
+
.cb-button:hover:not(:disabled) { background: ${c.surfaceHover}; border-color: ${c.borderStrong}; }
|
|
46
|
+
.cb-button:active:not(:disabled) { background: ${c.filled}; box-shadow: none; }
|
|
47
|
+
.cb-button.cb-filled { color: ${c.onChrome}; background: ${c.chrome}; border-color: ${c.chrome}; }
|
|
48
|
+
.cb-button.cb-filled:hover:not(:disabled) { background: ${c.chromeLight}; border-color: ${c.chromeLight}; }
|
|
49
|
+
.cb-button.cb-filled:active:not(:disabled) { background: ${c.chromeDark}; }
|
|
50
|
+
.cb-button:disabled { opacity: 0.5; cursor: default; box-shadow: none; }
|
|
51
|
+
.cb-button .cb-icon { font-size: 20px; }
|
|
52
|
+
.cb-icon { font-size: 24px; line-height: 1; user-select: none; }
|
|
53
|
+
.cb-icon-button {
|
|
54
|
+
display: inline-flex; align-items: center; justify-content: center; flex: none;
|
|
55
|
+
width: 36px; height: 36px; padding: 0; margin: 0; border: none; border-radius: 6px;
|
|
56
|
+
color: inherit; background: transparent; cursor: pointer; transition: background-color 0.15s;
|
|
57
|
+
}
|
|
58
|
+
.cb-icon-button:hover { background: rgba(127, 150, 175, 0.18); }
|
|
59
|
+
.cb-icon-button:active { background: rgba(127, 150, 175, 0.32); }
|
|
60
|
+
.cb-list-item { padding: 8px 12px; border-radius: 6px; cursor: pointer; user-select: none; transition: background-color 0.15s; }
|
|
61
|
+
.cb-list-item:hover { background: ${c.accentFaint}; }
|
|
62
|
+
.cb-list-item.cb-active { background: ${c.accentSoft}; font-weight: bold; }
|
|
63
|
+
.cb-input {
|
|
64
|
+
box-sizing: border-box; height: 32px; padding: 0 10px; min-width: 0; font: inherit;
|
|
65
|
+
color: ${c.text}; background: ${c.surface}; border: 1px solid ${c.border}; border-radius: 6px;
|
|
66
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
67
|
+
}
|
|
68
|
+
.cb-input:focus { outline: none; border-color: ${c.accent}; box-shadow: 0 0 0 3px ${c.accentRing}; }
|
|
69
|
+
.cb-input.cb-invalid { border-color: ${c.error}; background: #fdf1f0; }
|
|
70
|
+
.cb-color {
|
|
71
|
+
box-sizing: border-box; width: 40px; height: 32px; padding: 3px; margin: 0; flex: none;
|
|
72
|
+
background: ${c.surface}; border: 1px solid ${c.border}; border-radius: 6px; cursor: pointer;
|
|
73
|
+
}
|
|
74
|
+
.cb-color:hover { border-color: ${c.borderStrong}; }
|
|
75
|
+
.cb-color::-webkit-color-swatch-wrapper { padding: 0; }
|
|
76
|
+
.cb-color::-webkit-color-swatch { border: none; border-radius: 4px; }
|
|
77
|
+
.cb-color::-moz-color-swatch { border: none; border-radius: 4px; }
|
|
78
|
+
.cb-tab-bar { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid ${c.border}; }
|
|
79
|
+
.cb-tab {
|
|
80
|
+
padding: 8px 16px; margin: 0 0 -1px 0; font: inherit; font-weight: 500; color: ${c.textSoft};
|
|
81
|
+
background: transparent; border: none; border-bottom: 2px solid transparent; border-radius: 6px 6px 0 0;
|
|
82
|
+
cursor: pointer; transition: background-color 0.15s, color 0.15s;
|
|
83
|
+
}
|
|
84
|
+
.cb-tab:hover { color: ${c.text}; background: ${c.accentFaint}; }
|
|
85
|
+
.cb-tab.cb-selected { color: ${c.accent}; border-bottom-color: ${c.accent}; }
|
|
86
|
+
.cb-button:focus-visible, .cb-icon-button:focus-visible, .cb-list-item:focus-visible, .cb-tab:focus-visible, .cb-color:focus-visible {
|
|
87
|
+
outline: 2px solid ${c.accent}; outline-offset: 2px;
|
|
88
|
+
}
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
// Per document - each gets its own copy.
|
|
92
|
+
const documentsStyled = new WeakSet();
|
|
93
|
+
|
|
94
|
+
function ensureStyleSheet() {
|
|
95
|
+
if (typeof(document) === "undefined" || !document.head || documentsStyled.has(document)) return;
|
|
96
|
+
documentsStyled.add(document);
|
|
97
|
+
const element = document.createElement("style");
|
|
98
|
+
element.setAttribute("data-cascade-theme", "basic");
|
|
99
|
+
element.textContent = styleSheet;
|
|
100
|
+
document.head.appendChild(element);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const cardStyles = {
|
|
104
|
+
elevated: { background: c.surface, boxShadow: basicShadow },
|
|
105
|
+
filled: { background: c.filled },
|
|
106
|
+
outlined: { background: c.surface, border: "1px solid " + c.border },
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const onClickOf = (onClick) => (onClick ? { onclick: onClick } : {});
|
|
110
|
+
|
|
111
|
+
// A square button (rounded a little) with just an icon in it. Its color is
|
|
112
|
+
// inherited, so it fits on a dark bar as well as a light page.
|
|
113
|
+
class BasicIconButton extends Component {
|
|
114
|
+
setProperties({ icon, onClick, style, children, ...rest }) {
|
|
115
|
+
this.icon = icon;
|
|
116
|
+
this.onClick = onClick || null;
|
|
117
|
+
this.style = frozen(style || null);
|
|
118
|
+
this.rest = frozen(rest);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
build() {
|
|
122
|
+
ensureStyleSheet();
|
|
123
|
+
return htmlButton(
|
|
124
|
+
{ ...this.rest, key: "button", class: "cb-icon-button", ...onClickOf(this.onClick), style: this.style || {} },
|
|
125
|
+
icon({ key: "icon", name: this.icon }),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
class BasicAlert extends Component {
|
|
131
|
+
setProperties({ severity, style, children }) {
|
|
132
|
+
this.severity = alertSeverities[severity] ? severity : "info";
|
|
133
|
+
this.style = frozen(style || null);
|
|
134
|
+
this.alertChildren = frozen(children || []);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
build() {
|
|
138
|
+
const severity = alertSeverities[this.severity];
|
|
139
|
+
return row(
|
|
140
|
+
{
|
|
141
|
+
key: "alert",
|
|
142
|
+
style: {
|
|
143
|
+
gap: "12px", padding: "12px 16px", borderRadius: "8px", alignItems: "center", boxSizing: "border-box",
|
|
144
|
+
background: severity.background, color: severity.color, ...this.style,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
icon({ key: "icon", name: severity.icon, style: { fontSize: "32px", color: severity.iconColor, flex: "none" } }),
|
|
148
|
+
wrapper({ key: "message", style: { flex: "1 1 auto", minWidth: 0 } }, this.alertChildren),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
class BasicDialog extends Component {
|
|
154
|
+
setProperties({ title, close, fullScreen, style, children }) {
|
|
155
|
+
this.title = title || "";
|
|
156
|
+
this.close = close || null;
|
|
157
|
+
this.fullScreen = !!fullScreen;
|
|
158
|
+
this.style = frozen(style || null);
|
|
159
|
+
this.dialogChildren = frozen(children || []);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
build() {
|
|
163
|
+
const close = callback("close", () => this.close && this.close());
|
|
164
|
+
const frame = this.fullScreen
|
|
165
|
+
? { width: "100%", height: "100%" }
|
|
166
|
+
: { borderRadius: "10px", boxShadow: dialogShadow };
|
|
167
|
+
return column(
|
|
168
|
+
{
|
|
169
|
+
key: "dialog",
|
|
170
|
+
style: { background: c.surface, color: c.text, boxSizing: "border-box", overflow: "hidden", pointerEvents: "auto", ...frame, ...this.style },
|
|
171
|
+
},
|
|
172
|
+
row(
|
|
173
|
+
{
|
|
174
|
+
key: "titleBar",
|
|
175
|
+
style: {
|
|
176
|
+
padding: this.fullScreen ? "6px 16px 6px 6px" : "6px 6px 6px 16px",
|
|
177
|
+
...(this.fullScreen ? { minHeight: "48px" } : {}),
|
|
178
|
+
background: c.chromeDark, color: c.onChrome, flex: "none", alignItems: "center", gap: "8px", fontWeight: "bold",
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
iconButton({ key: "back", icon: "arrow_back", title: "Back", onClick: close }).show(this.fullScreen),
|
|
182
|
+
// text(): a lone string starting lowercase (a file name, say)
|
|
183
|
+
// would be taken for an implicit key.
|
|
184
|
+
filler({ key: "title" }, text({ key: "titleText", text: this.title })),
|
|
185
|
+
iconButton({ key: "close", icon: "close", title: "Close", onClick: close }).show(!this.fullScreen),
|
|
186
|
+
),
|
|
187
|
+
// Sized by its content, scrolling once the dialog has a height of its own.
|
|
188
|
+
column({ key: "body", style: { flex: "1 1 auto", minHeight: 0, overflow: "auto" } }, this.dialogChildren),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// A label above an input (and its unit, if any), and the error under it,
|
|
194
|
+
// if there is one.
|
|
195
|
+
class BasicTextField extends Component {
|
|
196
|
+
setProperties({ label, placeholder, value, onInput, type, unit, error, style }) {
|
|
197
|
+
this.label = label || "";
|
|
198
|
+
this.placeholder = placeholder || "";
|
|
199
|
+
this.value = value === undefined || value === null ? "" : value;
|
|
200
|
+
this.onInput = onInput || null;
|
|
201
|
+
this.type = type || "text";
|
|
202
|
+
this.unit = unit || null;
|
|
203
|
+
this.error = error || null;
|
|
204
|
+
this.style = frozen(style || null);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
build() {
|
|
208
|
+
ensureStyleSheet();
|
|
209
|
+
return htmlLabel(
|
|
210
|
+
{ key: "field", style: { display: "flex", flexDirection: "column", gap: "4px", ...this.style } },
|
|
211
|
+
span({ key: "label", style: { fontSize: "13px", opacity: 0.8 } }, text({ key: "labelText", text: this.label })).show(!!this.label),
|
|
212
|
+
row(
|
|
213
|
+
{ key: "inputRow", style: { alignItems: "center", gap: "6px" } },
|
|
214
|
+
htmlInput({
|
|
215
|
+
key: "input",
|
|
216
|
+
class: this.error ? "cb-input cb-invalid" : "cb-input",
|
|
217
|
+
type: this.type,
|
|
218
|
+
placeholder: this.placeholder,
|
|
219
|
+
value: this.value,
|
|
220
|
+
oninput: callback("input", (event) => this.onInput && this.onInput(event.target.value)),
|
|
221
|
+
style: this.type === "number" ? { width: "88px" } : { flex: "1 1 auto" },
|
|
222
|
+
}),
|
|
223
|
+
this.unit ? span({ key: "unit" }, text({ key: "unitText", text: this.unit })) : null,
|
|
224
|
+
),
|
|
225
|
+
div({ key: "error", style: { fontSize: "12px", color: c.error } }, text({ key: "errorText", text: this.error || "" })).show(!!this.error),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
class BasicCheckbox extends Component {
|
|
231
|
+
setProperties({ label, checked, onChange, style }) {
|
|
232
|
+
this.label = label || "";
|
|
233
|
+
this.checked = !!checked;
|
|
234
|
+
this.onChange = onChange || null;
|
|
235
|
+
this.style = frozen(style || null);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
build() {
|
|
239
|
+
return htmlLabel(
|
|
240
|
+
{ key: "field", style: { display: "flex", alignItems: "center", gap: "8px", cursor: "pointer", userSelect: "none", ...this.style } },
|
|
241
|
+
htmlInput({
|
|
242
|
+
key: "input",
|
|
243
|
+
type: "checkbox",
|
|
244
|
+
checked: this.checked,
|
|
245
|
+
onchange: callback("change", (event) => this.onChange && this.onChange(event.target.checked)),
|
|
246
|
+
style: { width: "18px", height: "18px", margin: 0, accentColor: c.chrome },
|
|
247
|
+
}),
|
|
248
|
+
text({ key: "labelText", text: this.label }),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// A color, its label beside it - the browser's own picker opens on a click.
|
|
254
|
+
class BasicColorField extends Component {
|
|
255
|
+
setProperties({ label, value, onInput, style }) {
|
|
256
|
+
this.label = label || "";
|
|
257
|
+
this.value = value || "#000000";
|
|
258
|
+
this.onInput = onInput || null;
|
|
259
|
+
this.style = frozen(style || null);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
build() {
|
|
263
|
+
ensureStyleSheet();
|
|
264
|
+
return htmlLabel(
|
|
265
|
+
{ key: "field", style: { display: "flex", alignItems: "center", gap: "8px", cursor: "pointer", ...this.style } },
|
|
266
|
+
htmlInput({
|
|
267
|
+
key: "input",
|
|
268
|
+
class: "cb-color",
|
|
269
|
+
type: "color",
|
|
270
|
+
value: this.value,
|
|
271
|
+
oninput: callback("input", (event) => this.onInput && this.onInput(event.target.value)),
|
|
272
|
+
}),
|
|
273
|
+
text({ key: "labelText", text: this.label }),
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Tabs, underlined in the accent color when selected.
|
|
279
|
+
class BasicTabBar extends Component {
|
|
280
|
+
setProperties({ tabs, selected, onSelect, style }) {
|
|
281
|
+
this.tabs = frozen(tabs || []);
|
|
282
|
+
this.selected = selected;
|
|
283
|
+
this.onSelect = onSelect || null;
|
|
284
|
+
this.style = frozen(style || null);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
build() {
|
|
288
|
+
ensureStyleSheet();
|
|
289
|
+
return div(
|
|
290
|
+
{ key: "tabs", class: "cb-tab-bar", role: "tablist", style: this.style || {} },
|
|
291
|
+
this.tabs.map((tab) => htmlButton(
|
|
292
|
+
{
|
|
293
|
+
key: tab.key,
|
|
294
|
+
class: tab.key === this.selected ? "cb-tab cb-selected" : "cb-tab",
|
|
295
|
+
role: "tab",
|
|
296
|
+
onclick: callback("select" + tab.key, () => this.onSelect && this.onSelect(tab.key)),
|
|
297
|
+
},
|
|
298
|
+
text({ key: tab.key + "Title", text: tab.title }),
|
|
299
|
+
)),
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const widgets = {
|
|
305
|
+
// A chip: white, on the page - or, `variant: "filled"`, slate, for the
|
|
306
|
+
// one thing to do.
|
|
307
|
+
button({ onClick, variant, style, children, ...rest }) {
|
|
308
|
+
ensureStyleSheet();
|
|
309
|
+
return htmlButton({
|
|
310
|
+
...rest,
|
|
311
|
+
class: variant === "filled" ? "cb-button cb-filled" : "cb-button",
|
|
312
|
+
...onClickOf(onClick),
|
|
313
|
+
style: style || {},
|
|
314
|
+
children,
|
|
315
|
+
});
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
// Google's Material Symbols font: the icon's name, drawn as a ligature.
|
|
319
|
+
// A function, not a component - so the key of its text is derived from
|
|
320
|
+
// its own: a fixed one would collide between icons built by one build.
|
|
321
|
+
icon({ key, name, style, ...rest }) {
|
|
322
|
+
ensureStyleSheet();
|
|
323
|
+
return span(
|
|
324
|
+
{ ...rest, key, class: "material-symbols-outlined cb-icon", style: style || {} },
|
|
325
|
+
text({ key: key ? key + "Name" : undefined, text: name || "" }),
|
|
326
|
+
);
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
iconButton: (properties) => new BasicIconButton(properties),
|
|
330
|
+
|
|
331
|
+
card({ variant, style, children, ...rest }) {
|
|
332
|
+
return wrapper({
|
|
333
|
+
...rest,
|
|
334
|
+
style: { padding: "16px", borderRadius: "8px", boxSizing: "border-box", ...(cardStyles[variant] || cardStyles.elevated), ...style },
|
|
335
|
+
children,
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
// Controls in a row, on a surface of their own - wrapping when there
|
|
340
|
+
// isn't room for them all.
|
|
341
|
+
controlPanel({ style, children, ...rest }) {
|
|
342
|
+
return wrapper({
|
|
343
|
+
...rest,
|
|
344
|
+
style: {
|
|
345
|
+
display: "flex", flexWrap: "wrap", alignItems: "center", gap: "8px 12px", flex: "none",
|
|
346
|
+
padding: "10px 16px", borderRadius: "8px", boxSizing: "border-box", overflow: "visible",
|
|
347
|
+
background: c.surface, boxShadow: basicShadow, ...style,
|
|
348
|
+
},
|
|
349
|
+
children,
|
|
350
|
+
});
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
alert: (properties) => new BasicAlert(properties),
|
|
354
|
+
|
|
355
|
+
listItem({ active, onClick, style, children, ...rest }) {
|
|
356
|
+
ensureStyleSheet();
|
|
357
|
+
return wrapper({
|
|
358
|
+
...rest,
|
|
359
|
+
class: active ? "cb-list-item cb-active" : "cb-list-item",
|
|
360
|
+
...onClickOf(onClick),
|
|
361
|
+
style: style || {},
|
|
362
|
+
children,
|
|
363
|
+
});
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
dialog: (properties) => new BasicDialog(properties),
|
|
367
|
+
|
|
368
|
+
textField: (properties) => new BasicTextField(properties),
|
|
369
|
+
|
|
370
|
+
checkbox: (properties) => new BasicCheckbox(properties),
|
|
371
|
+
|
|
372
|
+
colorField: (properties) => new BasicColorField(properties),
|
|
373
|
+
|
|
374
|
+
tabBar: (properties) => new BasicTabBar(properties),
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
export class BasicThemeServiceLocator {
|
|
378
|
+
constructor() {
|
|
379
|
+
this.colorScheme = new ColorScheme(defaultColors);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
locate(query) {
|
|
383
|
+
if (query.type === "colorScheme") return this.colorScheme;
|
|
384
|
+
if (query.type !== "widget") return undefined;
|
|
385
|
+
const build = widgets[query.name];
|
|
386
|
+
return build ? build(query.properties) : undefined;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export const basicTheme = new BasicThemeServiceLocator();
|
package/src/Layout.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { toPropertiesWithChildren } from "@liquefy/cascade.component";
|
|
2
|
+
import { div } from "@liquefy/cascade.dom";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Layout - ported from flow.ui/basic/src/Layout.js: a small kit of style
|
|
6
|
+
* constants plus the styled-div functions built from them, all following
|
|
7
|
+
* the same unwritten flow convention every component's own doc in this
|
|
8
|
+
* codebase now points back to (see cascade.DOM/src/applyStyle.js): every
|
|
9
|
+
* one of these takes an optional `style` property, merged as
|
|
10
|
+
* `{...ownDefault, ...style}` - the caller's own value always wins - and
|
|
11
|
+
* applied straight onto the one div each of these builds, not onto some
|
|
12
|
+
* further wrapper. That's the actual mechanism behind "extra divs are
|
|
13
|
+
* usually unnecessary": a creator styles its child directly, including
|
|
14
|
+
* fitting it into a flex row/column or making it fill its container,
|
|
15
|
+
* instead of wrapping it in a div dedicated to just that.
|
|
16
|
+
*
|
|
17
|
+
* Two style families here, not one "fillStyle": fitContainerStyle sizes by
|
|
18
|
+
* *absolute* dimensions (width/height: 100% of the parent's own box, no
|
|
19
|
+
* flex participation implied), fillerStyle sizes by *flex* participation
|
|
20
|
+
* (flexGrow/Shrink: 1, flexBasis: 0 - takes an equal share of whatever
|
|
21
|
+
* room a flex row/column has left). Reach for fitContainerStyle when the
|
|
22
|
+
* parent isn't a flex container (or you want 100% regardless of flex
|
|
23
|
+
* distribution) and fillerStyle when it is and you want this child to
|
|
24
|
+
* share/absorb the remaining space with its siblings.
|
|
25
|
+
*
|
|
26
|
+
* One deliberate correction from flow's own copy: flow.ui/basic/src/Layout.js's
|
|
27
|
+
* own flexContainerStyle sets `justifyContent: "flexStart"` - not valid CSS
|
|
28
|
+
* (camelCase is for the *property* name in a JS style object, not a
|
|
29
|
+
* property's *value* - the real value is the kebab-case string
|
|
30
|
+
* "flex-start"). Harmless there in practice (an invalid value is simply
|
|
31
|
+
* ignored, and justify-content's own initial value, "normal", already packs
|
|
32
|
+
* items at the start for ordinary content) but not worth reproducing
|
|
33
|
+
* knowingly - fixed here to the real value.
|
|
34
|
+
*
|
|
35
|
+
* flow's own componentTypeName argument to styledDiv() (purely a debug-id
|
|
36
|
+
* label, baked into flow.core's own aggregateToString()-style element id)
|
|
37
|
+
* isn't ported - cascade.DOM's DOMElementComponent doesn't have an equivalent
|
|
38
|
+
* hook yet (setProperties() has no special case for it, so passing one
|
|
39
|
+
* through today would just land as a meaningless "componenttypename" DOM
|
|
40
|
+
* attribute instead). Every element built here still gets a real, unique
|
|
41
|
+
* debug id from DOMNodeRenderComponent's own aggregateToString() fallback
|
|
42
|
+
* (this.constructor.name-based) - just not one that says "row" vs "column".
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
// --- Basic layout styles ---
|
|
46
|
+
|
|
47
|
+
export const flexContainerStyle = {
|
|
48
|
+
overflow: "hidden",
|
|
49
|
+
boxSizing: "border-box",
|
|
50
|
+
display: "flex",
|
|
51
|
+
alignItems: "stretch",
|
|
52
|
+
justifyContent: "flex-start",
|
|
53
|
+
whiteSpace: "normal",
|
|
54
|
+
userSelect: "none",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const rowStyle = { ...flexContainerStyle, flexDirection: "row" };
|
|
58
|
+
export const columnStyle = { ...flexContainerStyle, flexDirection: "column" };
|
|
59
|
+
|
|
60
|
+
export const centerStyle = { ...rowStyle, justifyContent: "center", alignItems: "stretch" };
|
|
61
|
+
export const middleStyle = { ...columnStyle, justifyContent: "center", alignItems: "stretch" };
|
|
62
|
+
export const centerMiddleStyle = { ...rowStyle, justifyContent: "center", alignItems: "center" };
|
|
63
|
+
|
|
64
|
+
// --- Basic element styles ---
|
|
65
|
+
|
|
66
|
+
// For a bottom-up component inside a scroll container - sized by its own
|
|
67
|
+
// content, not stretched or shrunk to fit.
|
|
68
|
+
export const naturalSizeStyle = { overflow: "visible", flexGrow: "0", flexShrink: "0", flexBasis: "auto" };
|
|
69
|
+
|
|
70
|
+
// Fill 100% of the parent's own box - absolute sizing, no flex participation
|
|
71
|
+
// implied (works inside or outside a flex container).
|
|
72
|
+
export const fitContainerStyle = { overflow: "hidden", boxSizing: "border-box", width: "100%", height: "100%" };
|
|
73
|
+
|
|
74
|
+
// Grow/shrink to take an equal share of whatever room is left in a flex
|
|
75
|
+
// row/column, ignoring its own content's natural size - scroll panels,
|
|
76
|
+
// equal space distribution.
|
|
77
|
+
export const fillerStyle = { overflow: "hidden", boxSizing: "border-box", flexGrow: "1", flexShrink: "1", flexBasis: "0" };
|
|
78
|
+
|
|
79
|
+
// Same, but based on its own content's natural size rather than an equal
|
|
80
|
+
// 0-basis share - grows/shrinks around whatever it would naturally be.
|
|
81
|
+
export const autoFillerStyle = { overflow: "hidden", boxSizing: "border-box", flexGrow: "1", flexShrink: "1", flexBasis: "auto" };
|
|
82
|
+
|
|
83
|
+
// Visualizes a component's own bounds during development - not meant to
|
|
84
|
+
// ship.
|
|
85
|
+
export const layoutBorderStyle = { borderStyle: "solid", borderColor: "light-gray", borderWidth: "1px", boxSizing: "border-box" };
|
|
86
|
+
|
|
87
|
+
// Opts back out of fitContainerStyle/fillerStyle's own overflow: hidden -
|
|
88
|
+
// for content that must be allowed to overflow its own box (an animation,
|
|
89
|
+
// a popover, ...).
|
|
90
|
+
export const overflowVisibleStyle = { overflow: "visible" };
|
|
91
|
+
|
|
92
|
+
// --- styledDiv: the shared mechanism behind every function below ---
|
|
93
|
+
|
|
94
|
+
// A div (asked for through the service locator, like any div() call) with
|
|
95
|
+
// `defaultStyle` merged underneath whatever `style` property is given, so
|
|
96
|
+
// the caller's own value always wins (see this file's own class doc).
|
|
97
|
+
function styledDiv(defaultStyle, parameters) {
|
|
98
|
+
const properties = toPropertiesWithChildren(parameters);
|
|
99
|
+
properties.style = { ...defaultStyle, ...properties.style };
|
|
100
|
+
return div(properties);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- Basic layout containers ---
|
|
104
|
+
|
|
105
|
+
export const wrapper = (...parameters) => styledDiv({}, parameters);
|
|
106
|
+
export const row = (...parameters) => styledDiv(rowStyle, parameters);
|
|
107
|
+
export const column = (...parameters) => styledDiv(columnStyle, parameters);
|
|
108
|
+
export const center = (...parameters) => styledDiv(centerStyle, parameters);
|
|
109
|
+
export const middle = (...parameters) => styledDiv(middleStyle, parameters);
|
|
110
|
+
export const centerMiddle = (...parameters) => styledDiv(centerMiddleStyle, parameters);
|
|
111
|
+
|
|
112
|
+
// --- Basic layout fillers ---
|
|
113
|
+
|
|
114
|
+
export const filler = (...parameters) => styledDiv(fillerStyle, parameters);
|
|
115
|
+
|
|
116
|
+
// --- zStack: children stacked on top of each other, edge to edge ---
|
|
117
|
+
|
|
118
|
+
export const zStackElementStyle = { ...fitContainerStyle, position: "absolute", top: 0, left: 0, width: "100%", height: "100%" };
|
|
119
|
+
|
|
120
|
+
export const zStack = (...parameters) => styledDiv({ position: "relative" }, parameters);
|