@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.
@@ -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
+ };