@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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Component, observable, locateService, toPropertiesWithChildren } from "@liquefy/cascade.component";
|
|
2
|
+
import { div } from "@liquefy/cascade.dom";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Color schemes - a theme's colors, all from two: a base color (the app's
|
|
6
|
+
* frame, its surfaces and its text, in tones of it) and an accent (what
|
|
7
|
+
* stands out: emphasis, focus, the active item).
|
|
8
|
+
*
|
|
9
|
+
* The tones are made in HSB: the color's hue kept, its brightness set to a
|
|
10
|
+
* preset level for each role - and its saturation lowered for the light
|
|
11
|
+
* ones, which would otherwise glare - then back to RGB. Any base gives a
|
|
12
|
+
* frame dark enough for white text on it, and a page light enough to read
|
|
13
|
+
* on.
|
|
14
|
+
*
|
|
15
|
+
* Everything that has a color uses them as CSS variables - `themeColor.page`
|
|
16
|
+
* is `var(--cascade-page, ...)` - set on an element by whoever gives a part
|
|
17
|
+
* of the app its colors (colorSchemeScope() below, or the app's own frame):
|
|
18
|
+
* changing a color rewrites that one element's variables, and nothing else
|
|
19
|
+
* has to be built again. Each variable falls back to the default scheme's
|
|
20
|
+
* color, so a component shown outside any scheme still looks right.
|
|
21
|
+
*
|
|
22
|
+
* A theme provides its ColorScheme through the service locator, as
|
|
23
|
+
* `{ type: "colorScheme" }` - so what gives colors asks for the scheme of
|
|
24
|
+
* whatever theme is in its render context, and a part of the app with a
|
|
25
|
+
* theme of its own gets that theme's colors too.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// --- Color conversion ---
|
|
29
|
+
|
|
30
|
+
export function hexToRgb(hex) {
|
|
31
|
+
let value = String(hex).replace("#", "").trim();
|
|
32
|
+
if (value.length === 3) value = value.split("").map((digit) => digit + digit).join("");
|
|
33
|
+
const number = parseInt(value, 16);
|
|
34
|
+
if (value.length !== 6 || isNaN(number)) return { r: 0, g: 0, b: 0 };
|
|
35
|
+
return { r: (number >> 16) & 255, g: (number >> 8) & 255, b: number & 255 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function rgbToHex({ r, g, b }) {
|
|
39
|
+
const part = (value) => Math.round(Math.max(0, Math.min(255, value))).toString(16).padStart(2, "0");
|
|
40
|
+
return "#" + part(r) + part(g) + part(b);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Hue in degrees (0-360), saturation and brightness from 0 to 1.
|
|
44
|
+
export function rgbToHsb({ r, g, b }) {
|
|
45
|
+
const [red, green, blue] = [r / 255, g / 255, b / 255];
|
|
46
|
+
const max = Math.max(red, green, blue);
|
|
47
|
+
const min = Math.min(red, green, blue);
|
|
48
|
+
const delta = max - min;
|
|
49
|
+
let h = 0;
|
|
50
|
+
if (delta > 0) {
|
|
51
|
+
if (max === red) h = ((green - blue) / delta) % 6;
|
|
52
|
+
else if (max === green) h = (blue - red) / delta + 2;
|
|
53
|
+
else h = (red - green) / delta + 4;
|
|
54
|
+
h *= 60;
|
|
55
|
+
if (h < 0) h += 360;
|
|
56
|
+
}
|
|
57
|
+
return { h, s: max === 0 ? 0 : delta / max, b: max };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function hsbToRgb({ h, s, b }) {
|
|
61
|
+
const chroma = b * s;
|
|
62
|
+
const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
63
|
+
const m = b - chroma;
|
|
64
|
+
const [red, green, blue] =
|
|
65
|
+
h < 60 ? [chroma, x, 0] : h < 120 ? [x, chroma, 0] : h < 180 ? [0, chroma, x] :
|
|
66
|
+
h < 240 ? [0, x, chroma] : h < 300 ? [x, 0, chroma] : [chroma, 0, x];
|
|
67
|
+
return { r: (red + m) * 255, g: (green + m) * 255, b: (blue + m) * 255 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const clamp = (value) => Math.max(0, Math.min(1, value));
|
|
71
|
+
|
|
72
|
+
// A color's hue, at a saturation and brightness of its own.
|
|
73
|
+
function tone(hsb, saturation, brightness) {
|
|
74
|
+
return rgbToHex(hsbToRgb({ h: hsb.h, s: clamp(saturation), b: clamp(brightness) }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function translucent(hex, alpha) {
|
|
78
|
+
const { r, g, b } = hexToRgb(hex);
|
|
79
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Every color a theme uses, from its base and its accent.
|
|
84
|
+
*/
|
|
85
|
+
export function deriveColors(base, accent) {
|
|
86
|
+
const B = rgbToHsb(hexToRgb(base));
|
|
87
|
+
const A = rgbToHsb(hexToRgb(accent));
|
|
88
|
+
// The frame: the base itself, unless it's too bright or too saturated
|
|
89
|
+
// for white text on it.
|
|
90
|
+
const frameSaturation = Math.min(B.s, 0.65);
|
|
91
|
+
const frameBrightness = Math.min(B.b, 0.42);
|
|
92
|
+
const accentHex = rgbToHex(hsbToRgb(A));
|
|
93
|
+
return {
|
|
94
|
+
base: rgbToHex(hsbToRgb(B)),
|
|
95
|
+
chrome: tone(B, frameSaturation, frameBrightness),
|
|
96
|
+
chromeDark: tone(B, frameSaturation, frameBrightness * 0.84),
|
|
97
|
+
chromeLight: tone(B, frameSaturation, frameBrightness * 1.18),
|
|
98
|
+
onChrome: "#ffffff",
|
|
99
|
+
text: tone(B, Math.min(B.s, 0.45), 0.30),
|
|
100
|
+
textSoft: tone(B, Math.min(B.s, 0.25), 0.45),
|
|
101
|
+
page: tone(B, Math.min(B.s * 0.12, 0.08), 0.965),
|
|
102
|
+
surface: "#ffffff",
|
|
103
|
+
surfaceHover: tone(B, Math.min(B.s * 0.06, 0.04), 0.985),
|
|
104
|
+
filled: tone(B, Math.min(B.s * 0.2, 0.12), 0.935),
|
|
105
|
+
border: tone(B, Math.min(B.s * 0.3, 0.16), 0.88),
|
|
106
|
+
borderStrong: tone(B, Math.min(B.s * 0.35, 0.2), 0.8),
|
|
107
|
+
accent: accentHex,
|
|
108
|
+
accentDark: tone(A, A.s, A.b * 0.6),
|
|
109
|
+
accentLight: tone(A, A.s * 0.2, 0.97),
|
|
110
|
+
accentFaint: translucent(accentHex, 0.08),
|
|
111
|
+
accentSoft: translucent(accentHex, 0.16),
|
|
112
|
+
accentRing: translucent(accentHex, 0.25),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const kebab = (name) => name.replace(/[A-Z]/g, (letter) => "-" + letter.toLowerCase());
|
|
117
|
+
|
|
118
|
+
// The colors, as the CSS variables they're used through.
|
|
119
|
+
export function colorVariables(colors) {
|
|
120
|
+
const variables = {};
|
|
121
|
+
for (const name in colors) variables["--cascade-" + kebab(name)] = colors[name];
|
|
122
|
+
return variables;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const defaultColors = { base: "#34495e", accent: "#2e86c1" };
|
|
126
|
+
|
|
127
|
+
// Each color, as used in a style: its variable - falling back to the
|
|
128
|
+
// default scheme's color.
|
|
129
|
+
export const themeColor = {};
|
|
130
|
+
{
|
|
131
|
+
const fallback = deriveColors(defaultColors.base, defaultColors.accent);
|
|
132
|
+
for (const name in fallback) themeColor[name] = `var(--cascade-${kebab(name)}, ${fallback[name]})`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A theme's color scheme: its base and accent colors (observable - change
|
|
137
|
+
* them, and whatever shows them follows), what they were to begin with,
|
|
138
|
+
* and the CSS variables they give. `extraVariables(base, accent)` adds
|
|
139
|
+
* variables of the theme's own (the Material theme: mdui's).
|
|
140
|
+
*/
|
|
141
|
+
export class ColorScheme {
|
|
142
|
+
constructor({ base, accent, extraVariables }) {
|
|
143
|
+
this.initialColors = Object.freeze({ base, accent });
|
|
144
|
+
this.colors = observable({ base, accent });
|
|
145
|
+
this.extraVariables = extraVariables || null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
get base() { return this.colors.base; }
|
|
149
|
+
set base(value) { this.colors.base = value; }
|
|
150
|
+
get accent() { return this.colors.accent; }
|
|
151
|
+
set accent(value) { this.colors.accent = value; }
|
|
152
|
+
|
|
153
|
+
reset() {
|
|
154
|
+
this.colors.base = this.initialColors.base;
|
|
155
|
+
this.colors.accent = this.initialColors.accent;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
isChanged() {
|
|
159
|
+
return this.colors.base !== this.initialColors.base || this.colors.accent !== this.initialColors.accent;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
variables() {
|
|
163
|
+
const { base, accent } = this.colors;
|
|
164
|
+
return {
|
|
165
|
+
...colorVariables(deriveColors(base, accent)),
|
|
166
|
+
...(this.extraVariables ? this.extraVariables(base, accent) : {}),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The color scheme of the theme in the render context of the component
|
|
172
|
+
// building - or null, if there's none.
|
|
173
|
+
const noScheme = { locate: () => null };
|
|
174
|
+
export function currentColorScheme() {
|
|
175
|
+
return locateService({ type: "colorScheme" }, noScheme) || null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* colorSchemeScope({ key, style, children }) - a div giving what's in it
|
|
180
|
+
* the colors of the theme in its render context: its scheme's variables,
|
|
181
|
+
* and its text color. A part of the app with a theme of its own
|
|
182
|
+
* (serviceProvider()) puts one around itself, to have its colors as well.
|
|
183
|
+
*/
|
|
184
|
+
export function colorSchemeScope(...parameters) {
|
|
185
|
+
return new ColorSchemeScope(toPropertiesWithChildren(parameters));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export class ColorSchemeScope extends Component {
|
|
189
|
+
setProperties({ style, children }) {
|
|
190
|
+
this.style = style || null;
|
|
191
|
+
this.scopeChildren = children || [];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
build() {
|
|
195
|
+
const scheme = currentColorScheme();
|
|
196
|
+
return div(
|
|
197
|
+
{ key: "scope", style: { ...(scheme ? scheme.variables() : {}), color: themeColor.text, ...this.style } },
|
|
198
|
+
this.scopeChildren,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { OverlayFrame, overlayFrame } from "./OverlayFrame.js";
|
|
2
|
+
export { Overlay, overlay } from "./Overlay.js";
|
|
3
|
+
export {
|
|
4
|
+
flexContainerStyle, rowStyle, columnStyle,
|
|
5
|
+
centerStyle, middleStyle, centerMiddleStyle,
|
|
6
|
+
naturalSizeStyle, fitContainerStyle, fillerStyle, autoFillerStyle,
|
|
7
|
+
layoutBorderStyle, overflowVisibleStyle, zStackElementStyle,
|
|
8
|
+
wrapper, row, column, center, middle, centerMiddle, filler, zStack,
|
|
9
|
+
} from "./Layout.js";
|
|
10
|
+
export { widget, button, icon, iconButton, card, controlPanel, alert, listItem, dialog, textField, checkbox, colorField, tabBar, alertSeverities, toButtonProperties } from "./widgets.js";
|
|
11
|
+
export { popover } from "./Popover.js";
|
|
12
|
+
export { portal, portalContents, Portal, PortalContents } from "./Portal.js";
|
|
13
|
+
export { BasicThemeServiceLocator, basicTheme, basicShadow } from "./BasicTheme.js";
|
|
14
|
+
export {
|
|
15
|
+
ColorScheme, ColorSchemeScope, colorSchemeScope, currentColorScheme, themeColor, deriveColors, colorVariables, defaultColors,
|
|
16
|
+
hexToRgb, rgbToHex, rgbToHsb, hsbToRgb,
|
|
17
|
+
} from "./colorScheme.js";
|
package/src/widgets.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { toProperties, toPropertiesWithChildren, findImplicitChildren, locateService } from "@liquefy/cascade.component";
|
|
2
|
+
import { defaultDOMServiceLocator } from "@liquefy/cascade.dom";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Themed widgets - ported from flow.ui/themed: app code calls `button(...)`
|
|
6
|
+
* and gets whatever the current theme provides for it, found through the
|
|
7
|
+
* service locator in the render context (see cascade.component's
|
|
8
|
+
* ServiceLocator.js) as `{ type: "widget", name: "button", properties }`.
|
|
9
|
+
* Which theme that is, is decided by whoever set up the render context -
|
|
10
|
+
* the app, or a subtree overriding it (serviceProvider()) - never by the
|
|
11
|
+
* component using the widget.
|
|
12
|
+
*
|
|
13
|
+
* Each widget has a property contract every theme implements, so switching
|
|
14
|
+
* themes changes appearance, not meaning:
|
|
15
|
+
*
|
|
16
|
+
* - button: `key`, `onClick`, `disabled`, `variant` ("filled" for the
|
|
17
|
+
* main thing to do - what else a theme knows is up to it), `style`, `children` - with
|
|
18
|
+
* flow's own argument convention, a loose function argument is
|
|
19
|
+
* `onClick` and anything else loose is a child:
|
|
20
|
+
* `button("Save", () => save())`.
|
|
21
|
+
* - icon: `key`, `name` (a Material icon name - "info", "close",
|
|
22
|
+
* "shopping_cart", ...), `style` (fontSize sets its size). Both themes
|
|
23
|
+
* draw icons with Google's icon fonts, which the app links (see
|
|
24
|
+
* cascade.application/demo/index.html).
|
|
25
|
+
* - iconButton: `key`, `icon` (an icon name), `onClick` (or a loose
|
|
26
|
+
* function), `title`, `style` - a round button showing just an icon.
|
|
27
|
+
* - card: `key`, `variant` ("elevated" - the default - "filled" or
|
|
28
|
+
* "outlined"), `style`, `children` - a surface to group content on.
|
|
29
|
+
* - controlPanel: `key`, `style`, `children` - controls in a row (buttons,
|
|
30
|
+
* fields, a label), on a surface of their own, spaced evenly and
|
|
31
|
+
* wrapping when there isn't room: what a page puts its controls in, so
|
|
32
|
+
* they sit apart from what they control.
|
|
33
|
+
* - alert: `key`, `severity` ("info" - the default - "success",
|
|
34
|
+
* "warning" or "error"), `style`, `children` - a message with its
|
|
35
|
+
* severity's icon and colors (see alertSeverities).
|
|
36
|
+
* - listItem: `key`, `active`, `onClick` (or a loose function),
|
|
37
|
+
* `style`, `children` - one clickable row of a list, marked when active.
|
|
38
|
+
* - dialog: `key`, `title`, `close` (called by its close button),
|
|
39
|
+
* `fullScreen`, `style`, `children` - a dialog's own box: title bar,
|
|
40
|
+
* close button and body. Full screen, it fills whatever it's placed in,
|
|
41
|
+
* as a phone app's screen does: no corners, no shadow, and a back arrow
|
|
42
|
+
* at the top left (calling `close`) instead of the close button. Where it's shown (docked, or modal over the app) is up to
|
|
43
|
+
* whoever places it - see cascade.ui's overlay().
|
|
44
|
+
* - textField: `key`, `label` (optional), `placeholder`, `value`, `onInput` (called with the new
|
|
45
|
+
* value, as text), `type` ("text" - the default - or "number"), `unit`
|
|
46
|
+
* (shown after the value: "kg"), `error` (a message, shown under it,
|
|
47
|
+
* marking it as wrong), `style` - a labelled input field.
|
|
48
|
+
* - checkbox: `key`, `label`, `checked`, `onChange` (called with whether
|
|
49
|
+
* it's now checked), `style`.
|
|
50
|
+
* - colorField: `key`, `label`, `value` (a hex color: "#34495e"),
|
|
51
|
+
* `onInput` (called with the new one, as it's being picked), `style` - a
|
|
52
|
+
* color, and the browser's own picker for it.
|
|
53
|
+
* - tabBar: `key`, `tabs` (`[{ key, title }]`), `selected` (a tab's key),
|
|
54
|
+
* `onSelect` (called with the key of the tab clicked), `style` - a row of
|
|
55
|
+
* tabs, one of them selected. What each shows is up to whoever places it.
|
|
56
|
+
*
|
|
57
|
+
* Widgets that are more than one element (an alert: icon plus message) are
|
|
58
|
+
* components of their own in each theme, so the keys inside them are
|
|
59
|
+
* scoped to them rather than to whoever builds them - two alerts on one
|
|
60
|
+
* page don't collide.
|
|
61
|
+
*
|
|
62
|
+
* No theme in the context providing a widget falls back to the DOM
|
|
63
|
+
* default, which ends in a visible, warning placeholder (see
|
|
64
|
+
* DOMDebugServiceLocator) rather than an error.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
export function toButtonProperties(parameters) {
|
|
68
|
+
const properties = toProperties(parameters);
|
|
69
|
+
const content = properties.componentContent;
|
|
70
|
+
if (content) {
|
|
71
|
+
const handlers = content.filter((item) => typeof(item) === "function");
|
|
72
|
+
if (handlers.length > 1) throw new Error("button(): only one loose onClick function allowed.");
|
|
73
|
+
if (handlers.length === 1) {
|
|
74
|
+
if (properties.onClick) throw new Error("button(): onClick given both loosely and in properties.");
|
|
75
|
+
properties.onClick = handlers[0];
|
|
76
|
+
}
|
|
77
|
+
const rest = content.filter((item) => typeof(item) !== "function");
|
|
78
|
+
if (rest.length > 0) properties.componentContent = rest;
|
|
79
|
+
else delete properties.componentContent;
|
|
80
|
+
}
|
|
81
|
+
findImplicitChildren(properties);
|
|
82
|
+
return properties;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function widget(name, properties) {
|
|
86
|
+
return locateService({ type: "widget", name, properties }, defaultDOMServiceLocator);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const button = (...parameters) => widget("button", toButtonProperties(parameters));
|
|
90
|
+
export const icon = (...parameters) => widget("icon", toProperties(parameters));
|
|
91
|
+
export const iconButton = (...parameters) => widget("iconButton", toButtonProperties(parameters));
|
|
92
|
+
export const card = (...parameters) => widget("card", toPropertiesWithChildren(parameters));
|
|
93
|
+
export const controlPanel = (...parameters) => widget("controlPanel", toPropertiesWithChildren(parameters));
|
|
94
|
+
export const alert = (...parameters) => widget("alert", toPropertiesWithChildren(parameters));
|
|
95
|
+
export const listItem = (...parameters) => widget("listItem", toButtonProperties(parameters));
|
|
96
|
+
export const dialog = (...parameters) => widget("dialog", toPropertiesWithChildren(parameters));
|
|
97
|
+
export const textField = (...parameters) => widget("textField", toProperties(parameters));
|
|
98
|
+
export const checkbox = (...parameters) => widget("checkbox", toProperties(parameters));
|
|
99
|
+
export const colorField = (...parameters) => widget("colorField", toProperties(parameters));
|
|
100
|
+
export const tabBar = (...parameters) => widget("tabBar", toProperties(parameters));
|
|
101
|
+
|
|
102
|
+
// Each alert severity's icon and colors - the same in every theme, so a
|
|
103
|
+
// warning reads as a warning whichever one is in use.
|
|
104
|
+
export const alertSeverities = {
|
|
105
|
+
info: { icon: "info", background: "#f0f8ff", iconColor: "rgb(2, 136, 209)", color: "rgb(1, 67, 97)" },
|
|
106
|
+
success: { icon: "check_circle", background: "#edf7ed", iconColor: "rgb(46, 125, 50)", color: "rgb(30, 70, 32)" },
|
|
107
|
+
warning: { icon: "warning", background: "#fff4e5", iconColor: "rgb(237, 108, 2)", color: "rgb(102, 60, 0)" },
|
|
108
|
+
error: { icon: "error", background: "#fdeded", iconColor: "rgb(211, 47, 47)", color: "rgb(95, 33, 32)" },
|
|
109
|
+
};
|