diff --git a/Makefile b/Makefile
index a4854c5..d86ac98 100644
--- a/Makefile
+++ b/Makefile
@@ -59,7 +59,7 @@ render-crypto: ## Check the render's c4_shim resolves its FFI crypto backend
render-ui-js: ## Check the render's lib/ui scripts under Node, each one the render ships
@if [ -f "$(RENDER)/src/lib/ui/page.js" ]; then \
- for kit in page variables expressions; do \
+ for kit in page variables expressions collapse menu combobox color pickgrid; do \
if [ -f "$(RENDER)/src/lib/ui/$$kit.js" ]; then \
UI_DIR="$(RENDER)/src/lib/ui" node --test "tests/ui_$${kit}_js.test.mjs" || exit 1; \
fi; \
diff --git a/copier.yml b/copier.yml
index 75a94bd..6c29c3b 100644
--- a/copier.yml
+++ b/copier.yml
@@ -90,7 +90,7 @@ icon_style:
vendor_modules:
type: str
- help: "Space-separated optional vendor modules (available: bitn crypto bthome noiseprotocol protobuf oauth expressions ui). Note: the crypto/noiseprotocol/protobuf/bthome bundles are core builds that require bitn on the path; include bitn alongside them (crypto also for noiseprotocol). oauth ships Control4's OAuth authorization-code grant (drivers-common-public module/auth_code_grant.lua) and its short-link helper (global/make_short_link.lua). ui ships the kit for a custom Composer tab in src/lib/ui: the house style.css and page.js for a page to embed, the variable browser (variables.js and .css, lib.ui.devices), with expressions the expression editor (expressions.js and .css, lib.ui.expression-tab), and the driver side (lib.ui.page, lib.ui.page-params, lib.ui.tab-properties)."
+ help: "Space-separated optional vendor modules (available: bitn crypto bthome noiseprotocol protobuf oauth expressions ui). Note: the crypto/noiseprotocol/protobuf/bthome bundles are core builds that require bitn on the path; include bitn alongside them (crypto also for noiseprotocol). oauth ships Control4's OAuth authorization-code grant (drivers-common-public module/auth_code_grant.lua) and its short-link helper (global/make_short_link.lua). ui ships the kit for a custom Composer tab in src/lib/ui: the house style.css and page.js for a page to embed, the variable browser (variables.js and .css, lib.ui.devices), with expressions the expression editor (expressions.js and .css, lib.ui.expression-tab), the widgets (collapse, menu, combobox, color and pickgrid, each a .js and .css pair), and the driver side (lib.ui.page, lib.ui.page-params, lib.ui.tab-properties)."
default: ""
cloud_diagnostics:
diff --git a/template/CONTRIBUTING.md.jinja b/template/CONTRIBUTING.md.jinja
index 42096a8..56cf863 100644
--- a/template/CONTRIBUTING.md.jinja
+++ b/template/CONTRIBUTING.md.jinja
@@ -672,8 +672,10 @@ end
The library clears the property at once, keeps its value out of the dispatcher's
Trace output, and prints its answer to the Lua output whatever Log Mode is.
While the window is open, Log Level also offers Trace and Ultra (see
-[Log Level](#log-level)). A driver that shows more while the window is open
-(extra properties) passes `supportCode:onChange(function(open) ... end)`.
+[Log Level](#log-level)). A web tab's properties card follows the window by
+itself, once the driver calls `lib.ui.tab-properties`' `serve()`. A driver that
+shows more elsewhere while the window is open passes
+`supportCode:onChange(function(open) ... end)`.
### Log Level
@@ -836,7 +838,9 @@ such as `drivers/tplink_light/www/documentation/index.md` in
others, so a page keeps only its own views and its driver only its own commands:
- `style.css`: the house look, from the tab bar and cards to form fields,
- tables, chips, the segmented switch and a list beside its editor
+ tables, chips, the segmented switch and a list beside its editor, and a set of
+ `.kit-*` widgets and layout helpers to use as needed, as with a CSS framework:
+ states, tags, counts, a form grid, a switch, popovers and more
- `page.js`: the page's link to its driver, the live-link and demo banners, the
tabs, the properties card and demo mode
- `variables.js` and `variables.css`: the variable browser, with
@@ -846,6 +850,9 @@ others, so a page keeps only its own views and its driver only its own commands:
highlighting, help and live preview, and `lib.ui.expression-tab` on the
driver's side
{%- endif %}
+- `collapse.js`, `menu.js`, `combobox.js`, `color.js` and `pickgrid.js`, each
+ with its stylesheet: groups that fold with Expand all, a button with a menu, a
+ searchable dropdown, a color picker, and a picture grid with a drop zone
- `lib.ui.page`, `lib.ui.page-params` and `lib.ui.tab-properties`: the driver's
side of the link and the properties card
diff --git a/template/src/lib/support-code.lua b/template/src/lib/support-code.lua
index c17a306..bc4683e 100644
--- a/template/src/lib/support-code.lua
+++ b/template/src/lib/support-code.lua
@@ -15,6 +15,7 @@ OPC.suppressDebug["Support Code"] = true
--- @field _secret string The build secret the day's code comes from.
--- @field _open boolean Whether this code opened the Lua window.
--- @field _onChange? fun(open: boolean) Called as the code opens and closes the window.
+--- @field _followers fun(open: boolean)[] The libraries told the same, after the driver.
local SupportCode = {}
SupportCode.__index = SupportCode
@@ -40,6 +41,7 @@ function SupportCode:new(secret)
local instance = setmetatable({}, self)
instance._secret = secret or SECRET
instance._open = false
+ instance._followers = {}
return instance
end
@@ -81,8 +83,8 @@ function SupportCode:accepts(value)
return false
end
---- Tells the driver's `onChange` callback, if any. A raise there would reach the dispatcher's error print, which shows
---- the typed value, so it is caught and reported without it.
+--- Tells the driver's `onChange` callback, if any, then each follower. A raise there would reach the dispatcher's
+--- error print, which shows the typed value, so it is caught and reported without it.
--- @param open boolean Whether the window is now open.
function SupportCode:_tell(open)
if self._onChange then
@@ -91,6 +93,12 @@ function SupportCode:_tell(open)
print("The Support Code's onChange failed: " .. tostring(err))
end
end
+ for _, follower in ipairs(self._followers) do
+ local ok, err = pcall(follower, open)
+ if not ok then
+ print("A Support Code follower failed: " .. tostring(err))
+ end
+ end
end
--- Lets Log Level reach Trace and Ultra, or stops it at Debug. Caught as `_tell` is, since a raise would print the
@@ -110,6 +118,13 @@ function SupportCode:onChange(callback)
self._onChange = callback
end
+--- Calls `callback(open)` as the window opens and closes, alongside the driver's `onChange`, for a library whose view
+--- changes with the window.
+--- @param callback fun(open: boolean)
+function SupportCode:follow(callback)
+ table.insert(self._followers, callback)
+end
+
--- Takes what was typed into the Support Code property. The property is cleared at once, so the code is never left
--- in the project. Today's code opens the Lua window and Log Level's Trace and Ultra for an hour, or closes them while
--- open.
diff --git a/template/src/lib/ui/collapse.css b/template/src/lib/ui/collapse.css
new file mode 100644
index 0000000..dc5beb3
--- /dev/null
+++ b/template/src/lib/ui/collapse.css
@@ -0,0 +1,11 @@
+/* lib/ui/collapse.js's groups: a head that turns its body on a click, its caret turned while open, and the Expand
+ all / Collapse all button that sits in a card's heading. */
+.kit-collapse-head { cursor: pointer; border-radius: 5px; }
+.kit-collapse-head:hover { background: var(--surface-hover); }
+.kit-collapse-body { display: none; }
+.kit-collapse.open > .kit-collapse-body { display: block; }
+.kit-caret { width: 9px; font-size: 9px; line-height: 1; color: var(--text-muted); display: inline-flex; justify-content: center; transition: transform 0.14s ease; }
+.kit-collapse.open > .kit-collapse-head .kit-caret { transform: rotate(90deg); }
+.kit-caret.none { visibility: hidden; }
+.kit-collapse-all { text-transform: none; letter-spacing: 0; font-weight: 500; font-size: 11px; color: var(--accent); background: none; border: none; cursor: pointer; padding: 2px; white-space: nowrap; }
+.kit-collapse-all:hover { text-decoration: underline; }
diff --git a/template/src/lib/ui/collapse.js b/template/src/lib/ui/collapse.js
new file mode 100644
index 0000000..5af0928
--- /dev/null
+++ b/template/src/lib/ui/collapse.js
@@ -0,0 +1,80 @@
+// Groups that fold under a caret, for a page that draws its markup as strings and draws it again on every push: each
+// set keeps its groups' open state by key, a click on a head turns it, and Expand all / Collapse all follows what is
+// on screen. A page embeds it after page.js, and collapse.css after style.css, the same way.
+// Kit.collapse(name, options) a set of groups; options.open is the default state (true), options.onChange() draws
+// the page again after a click
+// set.isOpen(key) a group's state: its own once clicked, else the set's default
+// set.anyOpen(keys) whether any of these groups is open
+// set.head(key) the attributes for the page's own head element, which also takes kit-collapse-head
+// set.group(key, head, body, bodyClass) a group: its head's markup, then its body, shown while it is open
+// set.toggleAll(keys) the Expand all / Collapse all button for these groups, or "" when there are none
+// set.toggle(key), set.setAll(open)
+// Kit.collapse.caret(none) the caret a head shows; none keeps its place on a row with nothing to open
+
+(function (Kit) {
+ var sets = {};
+
+ function collapse(name, options) {
+ options = options || {};
+ var own = {};
+ var all = options.open !== false;
+
+ function isOpen(key) {
+ return Object.prototype.hasOwnProperty.call(own, key) ? own[key] : all;
+ }
+
+ var set = {
+ onChange: options.onChange || function () {},
+ isOpen: isOpen,
+ anyOpen: function (keys) {
+ return keys.some(isOpen);
+ },
+ toggle: function (key) {
+ own[key] = !isOpen(key);
+ },
+ // Every group follows the new default, so a group opened by hand closes with the rest.
+ setAll: function (open) {
+ own = {};
+ all = !!open;
+ },
+ head: function (key) {
+ return 'data-kit-collapse="' + Kit.esc(name) + '" data-key="' + Kit.esc(key) + '"';
+ },
+ group: function (key, head, body, bodyClass) {
+ return (
+ '
' + head +
+ '
' + body + "
"
+ );
+ },
+ // It offers what a click would do to what is on screen: with any group open, Collapse all.
+ toggleAll: function (keys) {
+ if (!keys.length) return "";
+ var open = set.anyOpen(keys);
+ return (
+ '"
+ );
+ },
+ };
+ sets[name] = set;
+ return set;
+ }
+
+ collapse.caret = function (none) {
+ return '' : '">▸') + "";
+ };
+
+ document.addEventListener("click", function (e) {
+ var t = e.target;
+ if (!t || !t.closest) return;
+ var all = t.closest("[data-kit-collapse-all]");
+ var head = all ? null : t.closest("[data-kit-collapse]");
+ var set = all ? sets[all.getAttribute("data-kit-collapse-all")] : head ? sets[head.getAttribute("data-kit-collapse")] : null;
+ if (!set) return;
+ if (all) set.setAll(all.getAttribute("data-open") === "true");
+ else set.toggle(head.getAttribute("data-key"));
+ set.onChange();
+ });
+
+ Kit.collapse = collapse;
+})(Kit);
diff --git a/template/src/lib/ui/color.css b/template/src/lib/ui/color.css
new file mode 100644
index 0000000..c14fb68
--- /dev/null
+++ b/template/src/lib/ui/color.css
@@ -0,0 +1,34 @@
+/* The color picker: Kit.colorPicker's swatch button in place of each color input, and its popover on .kit-pop. A row
+ that shows a color input and its value is .kit-color-row. A page embeds this after style.css, with color.js. */
+
+.kit-color-row { display: flex; align-items: center; gap: 8px; }
+.kit-color-row code { font-size: 12px; color: var(--text-muted); }
+
+.kit-swatch { width: 44px; height: 28px; padding: 4px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); cursor: pointer; flex: none; }
+.kit-swatch span { display: block; width: 100%; height: 100%; border-radius: 3px; border: 1px solid rgba(0, 0, 0, 0.12); }
+.kit-swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
+.kit-swatch.no-color span { background: linear-gradient(to top right, transparent calc(50% - 1px), #9ca3af calc(50% - 1px), #9ca3af calc(50% + 1px), transparent calc(50% + 1px)), #fff !important; }
+
+.kit-color { width: 264px; }
+.kit-color-label { font-size: 11px; font-weight: 600; color: var(--text-muted); margin-bottom: 4px; }
+.kit-color-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
+.kit-color-chip { width: 22px; height: 22px; padding: 0; border-radius: 4px; border: 1px solid rgba(0, 0, 0, 0.18); cursor: pointer; }
+.kit-color-chip:focus-visible, .kit-color-chip.on { outline: 2px solid var(--accent); outline-offset: 1px; }
+.kit-color-hex { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
+.kit-color-hex span { width: 28px; height: 28px; border-radius: 4px; border: 1px solid rgba(0, 0, 0, 0.18); flex: none; }
+.kit-color-hex input { flex: 1; min-width: 0; font-family: var(--mono); }
+.kit-color-slider { display: grid; grid-template-columns: 14px minmax(0, 1fr) 34px; align-items: center; gap: 8px; margin: 6px 0 0; font-size: 11px; color: var(--text-muted); }
+.kit-color-slider input { -webkit-appearance: none; appearance: none; width: 100%; height: 12px; border-radius: 6px; border: 1px solid rgba(0, 0, 0, 0.15); margin: 0; }
+.kit-color-slider input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: #fff; border: 2px solid #374151; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); cursor: pointer; }
+.kit-color-slider code { text-align: right; font-size: 11px; }
+.kit-color-dropper { width: 30px; height: 30px; padding: 6px; flex: none; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); cursor: pointer; }
+.kit-color-dropper[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }
+.kit-color-dropper svg { display: block; width: 100%; height: 100%; }
+.kit-color-hint { margin: -4px 0 10px; font-size: 11px; color: var(--text-muted); }
+
+/* While the dropper is armed: the picture's area outlined, and the loupe beside the pointer. */
+.kit-color-target { outline: 2px solid var(--accent); outline-offset: 3px; }
+.kit-color-target img { cursor: crosshair; }
+.kit-color-loupe { position: fixed; z-index: 60; pointer-events: none; padding: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16); text-align: center; }
+.kit-color-loupe canvas { display: block; width: 99px; height: 99px; border-radius: 4px; image-rendering: pixelated; }
+.kit-color-loupe code { display: block; margin-top: 3px; font-size: 11px; }
diff --git a/template/src/lib/ui/color.js b/template/src/lib/ui/color.js
new file mode 100644
index 0000000..9f1406c
--- /dev/null
+++ b/template/src/lib/ui/color.js
@@ -0,0 +1,418 @@
+// The color picker, in place of the browser's own: Composer embeds Edge, whose picker's dropper reads the wrong place
+// in Composer's window. Each color input gets a swatch button that opens a popover with the colors a page offers, the
+// recent ones, a hex box, hue, saturation and lightness sliders and, given a picture, a dropper with a loupe. A page
+// embeds it after page.js, with color.css after style.css.
+// Kit.color normal(text) to #RRGGBB or null; rgb(hex), hex([r, g, b]), hsl(hex) to
+// { h, s, l } and fromHsl({ h, s, l })
+// Kit.colorPicker(input, options) takes over an ; Kit.colorPicker.close(keep) closes the
+// open one, keeping its color or, with keep false, undoing it as Esc does
+// offer() rows shown first, [{ title, colors }], each color a hex
+// recentKey the localStorage key the recent colors are kept under; "kitRecentColors" by default
+// dropper { when(): whether to offer it, image(): the it reads, area(): what that sits in,
+// backdrop(): [r, g, b] under the image's see-through pixels }
+// onOpen(), onCancel(start) told as it opens, and on Esc instead of putting the input back to start
+// The input keeps the value: setting it from code repaints the swatch, a pick sets it and fires its input event, and
+// a no-color class on it crosses the swatch out.
+
+(function (Kit) {
+ var VALUE = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value");
+ var LOUPE_CELLS = 11;
+ var LOUPE_ZOOM = 9;
+ var DROPPER_ICON =
+ '';
+
+ function normal(v) {
+ var m = /^#?([0-9a-f]{6}|[0-9a-f]{3})$/i.exec(String(v == null ? "" : v).trim());
+ if (!m) return null;
+ var h = m[1].length === 3 ? m[1].replace(/./g, "$&$&") : m[1];
+ return "#" + h.toUpperCase();
+ }
+ function rgb(h) {
+ return [1, 3, 5].map(function (i) {
+ return parseInt(h.slice(i, i + 2), 16);
+ });
+ }
+ function hex(c) {
+ return (
+ "#" +
+ c
+ .map(function (v) {
+ return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0");
+ })
+ .join("")
+ .toUpperCase()
+ );
+ }
+ function hsl(h) {
+ var c = rgb(h).map(function (v) {
+ return v / 255;
+ });
+ var mx = Math.max.apply(0, c), mn = Math.min.apply(0, c);
+ var l = (mx + mn) / 2, d = mx - mn, s = 0, hue = 0;
+ if (d) {
+ s = d / (1 - Math.abs(2 * l - 1));
+ hue = mx === c[0] ? ((c[1] - c[2]) / d) % 6 : mx === c[1] ? (c[2] - c[0]) / d + 2 : (c[0] - c[1]) / d + 4;
+ hue *= 60;
+ if (hue < 0) hue += 360;
+ }
+ return { h: hue, s: Math.min(1, s), l: l };
+ }
+ function fromHsl(o) {
+ var c = (1 - Math.abs(2 * o.l - 1)) * o.s, x = c * (1 - Math.abs(((o.h / 60) % 2) - 1)), m = o.l - c / 2;
+ var r = [[c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x]][Math.floor(o.h / 60) % 6];
+ return hex(
+ r.map(function (v) {
+ return (v + m) * 255;
+ })
+ );
+ }
+ Kit.color = { normal: normal, rgb: rgb, hex: hex, hsl: hsl, fromHsl: fromHsl };
+
+ function recent(key) {
+ try {
+ return (JSON.parse(localStorage.getItem(key)) || []).map(normal).filter(Boolean).slice(0, 10);
+ } catch (e) {
+ return [];
+ }
+ }
+ function remember(key, h) {
+ var list = [h].concat(
+ recent(key).filter(function (c) {
+ return c !== h;
+ })
+ );
+ try {
+ localStorage.setItem(key, JSON.stringify(list.slice(0, 10)));
+ } catch (e) {}
+ }
+
+ function paint(input) {
+ var btn = input.kitSwatch;
+ if (!btn) return;
+ btn.firstChild.style.background = VALUE.get.call(input);
+ btn.classList.toggle("no-color", input.classList.contains("no-color"));
+ }
+ // Set as a person picking would, so the page's own handler runs.
+ function pick(input, h) {
+ if (VALUE.get.call(input).toUpperCase() === h) return;
+ VALUE.set.call(input, h.toLowerCase());
+ paint(input);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ }
+
+ // The picture's pixel under the pointer, kept inside it where a scaled edge falls between pixels.
+ function pixelAt(img, e) {
+ var r = img.getBoundingClientRect();
+ if (!img.naturalWidth || !r.width || !r.height) return null;
+ var at = function (v, from, size, n) {
+ return Math.max(0, Math.min(n - 1, Math.floor(((v - from) / size) * n)));
+ };
+ return { x: at(e.clientX, r.left, r.width, img.naturalWidth), y: at(e.clientY, r.top, r.height, img.naturalHeight) };
+ }
+ function canvasOf(img) {
+ if (!img.kitPixels) {
+ var c = document.createElement("canvas");
+ c.width = img.naturalWidth;
+ c.height = img.naturalHeight;
+ c.getContext("2d", { willReadFrequently: true }).drawImage(img, 0, 0);
+ img.kitPixels = c;
+ }
+ return img.kitPixels;
+ }
+ function colorAt(img, p, back) {
+ var d = canvasOf(img).getContext("2d").getImageData(p.x, p.y, 1, 1).data, a = d[3] / 255;
+ return hex(
+ back.map(function (v, i) {
+ return d[i] * a + v * (1 - a);
+ })
+ );
+ }
+ // The pixels around the pointer, magnified, beside it and inside the window.
+ function drawLoupe(loupe, img, p, e, h, back) {
+ var n = LOUPE_CELLS, z = LOUPE_ZOOM, half = (n - 1) / 2, ctx = loupe.firstChild.getContext("2d");
+ ctx.imageSmoothingEnabled = false;
+ ctx.fillStyle = hex(back);
+ ctx.fillRect(0, 0, n * z, n * z);
+ ctx.drawImage(canvasOf(img), p.x - half, p.y - half, n, n, 0, 0, n * z, n * z);
+ ctx.strokeStyle = "#000000";
+ ctx.strokeRect(half * z - 0.5, half * z - 0.5, z + 1, z + 1);
+ ctx.strokeStyle = "#FFFFFF";
+ ctx.strokeRect(half * z + 0.5, half * z + 0.5, z - 1, z - 1);
+ loupe.lastChild.textContent = h;
+ loupe.hidden = false;
+ var w = loupe.offsetWidth, ht = loupe.offsetHeight, gap = 18, page = document.documentElement;
+ loupe.style.left = (e.clientX + gap + w > page.clientWidth ? e.clientX - gap - w : e.clientX + gap) + "px";
+ loupe.style.top = (e.clientY + gap + ht > page.clientHeight ? e.clientY - gap - ht : e.clientY + gap) + "px";
+ }
+
+ var current = null;
+
+ function close(keep) {
+ var pop = current;
+ if (!pop) return;
+ current = null;
+ pop.stopDropping();
+ pop.remove();
+ document.removeEventListener("mousedown", pop.outside, true);
+ document.removeEventListener("keydown", pop.keys, true);
+ var input = pop.input, o = input.kitColor;
+ if (keep !== false) {
+ var now = VALUE.get.call(input).toUpperCase();
+ if (now !== pop.start) remember(o.recentKey || "kitRecentColors", now);
+ } else if (o.onCancel) {
+ o.onCancel(pop.start);
+ } else {
+ pick(input, pop.start);
+ }
+ input.kitSwatch.focus();
+ }
+
+ function open(input) {
+ close(true);
+ var o = input.kitColor, key = o.recentKey || "kitRecentColors", dropper = o.dropper;
+ var start = VALUE.get.call(input).toUpperCase();
+ if (o.onOpen) o.onOpen();
+ var pop = document.createElement("div");
+ pop.className = "kit-pop kit-color";
+ pop.setAttribute("role", "dialog");
+ pop.setAttribute("aria-label", "Choose a color");
+ pop.input = input;
+ pop.start = start;
+ var rows = (o.offer ? o.offer() : []).concat([{ title: "Recent", colors: recent(key) }]);
+ pop.innerHTML =
+ rows
+ .filter(function (r) {
+ return r.colors && r.colors.length;
+ })
+ .map(function (r) {
+ return (
+ '