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 ( + '
' + Kit.esc(r.title) + '
' + + r.colors + .map(function (h) { + return ( + '' + ); + }) + .join("") + + "
" + ); + }) + .join("") + + '
' + + ["H", "S", "L"] + .map(function (k) { + return ( + '' + ); + }) + .join(""); + var box = pop.querySelector(".kit-color-hex input"), sample = pop.querySelector(".kit-color-hex span"); + var sliders = pop.querySelectorAll(".kit-color-slider input"), drop = pop.querySelector(".kit-color-dropper"); + var hint = pop.querySelector(".kit-color-hint"), loupe = null, tone = hsl(start); + var now = function () { + return VALUE.get.call(input).toUpperCase(); + }; + var show = function (h, from) { + sample.style.background = h; + if (from !== "hex") { + box.value = h; + box.classList.remove("input-error"); + } + if (from !== "slider") tone = hsl(h); + var v = { H: Math.round(tone.h), S: Math.round(tone.s * 100), L: Math.round(tone.l * 100) }; + Array.prototype.forEach.call(sliders, function (r) { + if (from !== "slider") r.value = v[r.dataset.k]; + r.nextElementSibling.textContent = v[r.dataset.k] + (r.dataset.k === "H" ? "" : "%"); + }); + sliders[0].style.background = + "linear-gradient(to right," + + [0, 60, 120, 180, 240, 300, 359] + .map(function (d) { + return "hsl(" + d + ",100%,50%)"; + }) + .join(",") + + ")"; + sliders[1].style.background = + "linear-gradient(to right,hsl(" + v.H + ",0%," + v.L + "%),hsl(" + v.H + ",100%," + v.L + "%))"; + sliders[2].style.background = "linear-gradient(to right,#000,hsl(" + v.H + "," + v.S + "%,50%),#fff)"; + Array.prototype.forEach.call(pop.querySelectorAll(".kit-color-chip"), function (c) { + c.classList.toggle("on", c.dataset.hex === h); + }); + }; + pop.addEventListener("click", function (e) { + var chip = e.target.closest(".kit-color-chip"); + if (!chip) return; + pick(input, chip.dataset.hex); + show(chip.dataset.hex); + }); + box.addEventListener("input", function () { + var h = normal(box.value); + box.classList.toggle("input-error", !h); + if (h) { + pick(input, h); + show(h, "hex"); + } + }); + box.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + e.preventDefault(); + close(true); + } + }); + Array.prototype.forEach.call(sliders, function (r) { + r.addEventListener("input", function () { + tone = { h: Number(sliders[0].value), s: Number(sliders[1].value) / 100, l: Number(sliders[2].value) / 100 }; + var h = normal(fromHsl(tone)); + pick(input, h); + show(h, "slider"); + }); + }); + + // The dropper: armed by its button, it shows the color under the pointer and picks it on a click. + drop.hidden = !(dropper && (dropper.when ? dropper.when() : dropper.image())); + var back = function () { + return dropper.backdrop ? dropper.backdrop() : [255, 255, 255]; + }; + var area = function () { + return dropper.area ? dropper.area() : dropper.image(); + }; + pop.stopDropping = function () { + if (!pop.dropping) return; + pop.dropping = false; + drop.setAttribute("aria-pressed", "false"); + hint.hidden = true; + var a = area(); + if (a) a.classList.remove("kit-color-target"); + document.removeEventListener("mousemove", pop.hover, true); + document.removeEventListener("mouseout", pop.away, true); + if (loupe) loupe.remove(); + loupe = null; + sample.style.background = now(); + }; + pop.hover = function (e) { + var img = dropper.image(), p = img && e.target === img && pixelAt(img, e); + if (!p) { + if (loupe) loupe.hidden = true; + sample.style.background = now(); + return; + } + var h = colorAt(img, p, back()); + if (!loupe) { + loupe = document.createElement("div"); + loupe.className = "kit-color-loupe"; + loupe.innerHTML = + ''; + document.body.appendChild(loupe); + } + drawLoupe(loupe, img, p, e, h, back()); + sample.style.background = h; + }; + pop.away = function (e) { + if (e.relatedTarget) return; + if (loupe) loupe.hidden = true; + sample.style.background = now(); + }; + drop.addEventListener("click", function () { + if (pop.dropping) { + pop.stopDropping(); + return; + } + pop.dropping = true; + drop.setAttribute("aria-pressed", "true"); + hint.hidden = false; + var a = area(); + if (a) a.classList.add("kit-color-target"); + document.addEventListener("mousemove", pop.hover, true); + document.addEventListener("mouseout", pop.away, true); + // Over the picture, as in a narrow window, the picker moves above its swatch. + if (!a) return; + var t = a.getBoundingClientRect(), b = pop.getBoundingClientRect(), at = input.kitSwatch.getBoundingClientRect(); + if (b.left < t.right && b.right > t.left && b.top < t.bottom && b.bottom > t.top && at.top - b.height - 6 >= 0) { + pop.style.top = at.top - b.height - 6 + window.scrollY + "px"; + } + }); + pop.outside = function (e) { + if (pop.dropping && !drop.contains(e.target)) { + var img = dropper.image(), p = e.button === 0 && img && e.target === img && pixelAt(img, e); + var h = p && colorAt(img, p, back()); + pop.stopDropping(); + if (h) { + // A pick, not a click away: the picker stays open on the new color. + e.preventDefault(); + pick(input, h); + show(h); + return; + } + // A near miss on the picture's area only stops the dropper. + var a = area(); + if (a && a.contains(e.target)) return; + } + if (!pop.contains(e.target) && !input.kitSwatch.contains(e.target)) close(true); + }; + pop.keys = function (e) { + if (e.key !== "Escape") return; + e.preventDefault(); + if (pop.dropping) pop.stopDropping(); + else close(false); + }; + document.addEventListener("mousedown", pop.outside, true); + document.addEventListener("keydown", pop.keys, true); + document.body.appendChild(pop); + var at = input.kitSwatch.getBoundingClientRect(); + var left = Math.max(8, Math.min(at.left, document.documentElement.clientWidth - pop.offsetWidth - 8)); + pop.style.left = left + window.scrollX + "px"; + pop.style.top = at.bottom + 6 + window.scrollY + "px"; + current = pop; + show(start); + box.focus(); + box.select(); + } + + function colorPicker(input, options) { + input.kitColor = options || {}; + var btn = document.createElement("button"); + btn.type = "button"; + btn.className = "kit-swatch"; + btn.id = input.id + "-swatch"; + btn.appendChild(document.createElement("span")); + var label = input.id && document.querySelector('label[for="' + input.id + '"]'); + if (label) { + label.htmlFor = btn.id; + btn.setAttribute("aria-label", label.textContent); + } + input.kitSwatch = btn; + input.hidden = true; + input.tabIndex = -1; + input.parentNode.insertBefore(btn, input); + // The page also sets the value from code, and the swatch follows. + Object.defineProperty(input, "value", { + configurable: true, + get: function () { + return VALUE.get.call(this); + }, + set: function (v) { + VALUE.set.call(this, v); + paint(this); + }, + }); + new MutationObserver(function () { + paint(input); + }).observe(input, { attributes: true, attributeFilter: ["class"] }); + btn.addEventListener("click", function () { + if (current && current.input === input) close(true); + else open(input); + }); + paint(input); + } + colorPicker.close = close; + + Kit.colorPicker = colorPicker; +})(Kit); diff --git a/template/src/lib/ui/combobox.css b/template/src/lib/ui/combobox.css new file mode 100644 index 0000000..33c93ba --- /dev/null +++ b/template/src/lib/ui/combobox.css @@ -0,0 +1,39 @@ +/* Kit.combobox: a searchable dropdown drawn as a compact select, its list in a popover under it. In a grid or flex + row it shrinks to its column; a page gives it a width through the combobox's style option. A page embeds this + after style.css, with combobox.js. */ + +.kit-combo { position: relative; display: inline-block; min-width: 0; } +/* Lifted while open, so its list clears the cards after it. */ +.kit-combo.open { z-index: 60; } +.kit-combo-trigger { + width: 100%; padding: 4px 24px 4px 7px; font: inherit; font-size: 12px; line-height: 18px; text-align: left; + border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); + background-color: var(--surface); background-image: var(--chevron); + background-repeat: no-repeat; background-position: right 7px center; background-size: 10px 6px; + cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.kit-combo-trigger.placeholder { color: var(--text-muted); } +.kit-combo-trigger:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15); } +.kit-combo-pop { + display: none; position: absolute; top: 100%; left: 0; right: 0; z-index: 70; min-width: 220px; + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16); +} +.kit-combo.open .kit-combo-pop { display: block; } +.kit-combo-pop.flip-right { left: auto; right: 0; } +.kit-combo-pop.flip-up { top: auto; bottom: 100%; } +/* Two classes, to outweigh the base input rule. */ +.kit-combo-pop .kit-combo-search { + padding: 5px 8px; font-size: 12px; box-shadow: none; + border: none; border-bottom: 1px solid var(--border); border-radius: var(--radius) var(--radius) 0 0; +} +.kit-combo-list { max-height: 180px; overflow-y: auto; } +.kit-combo-item { padding: 4px 8px; cursor: pointer; font-size: 12px; } +.kit-combo-item:hover, .kit-combo-item.active { background: var(--surface-hover); } +.kit-combo-item.selected { background: #dbeafe; } +.kit-combo-none { padding: 4px 8px; font-size: 12px; color: var(--text-muted); } +.kit-combo-section { + padding: 4px 8px 2px; font-size: 9.5px; font-weight: 700; letter-spacing: 0.6px; text-transform: uppercase; + color: #9ca3af; border-top: 1px solid var(--border); +} +.kit-combo-list > .kit-combo-section:first-child { border-top: none; } diff --git a/template/src/lib/ui/combobox.js b/template/src/lib/ui/combobox.js new file mode 100644 index 0000000..1e977c6 --- /dev/null +++ b/template/src/lib/ui/combobox.js @@ -0,0 +1,195 @@ +// A searchable dropdown, drawn as a compact select: a page that renders its HTML as a string puts the markup in, +// and drawing it again on the next render keeps it open, its search and its highlighted choice, by key. A page embeds +// it after page.js, with combobox.css after style.css. +// Kit.combobox(key, options) the control's markup. options: +// label the trigger's text; empty shows the placeholder +// placeholder the trigger's text with nothing picked +// title the trigger's tooltip, by default its label +// items() the choices, [{ value, label, section }], or null while they load; a section heads the +// choices after it +// value the picked choice's value, marked in the list +// search false for a list with no search box +// empty, loading what an empty list and a loading one say +// onPick(value, item) a choice was made; the list closes +// className, style on the control, as a width +// Kit.combobox.set(key, { label, value }) shows another pick on a drawn control without drawing the page again +// Kit.combobox.refresh(key) fills an open list again, as when its choices arrive +// Kit.combobox.close() closes the open list +// Escape closes the list, Up and Down move through it, Enter picks; a click outside it closes it. + +(function (Kit) { + var defs = {}; // key -> its latest options + var openKey = null; + var query = ""; + var active = -1; + + function control(key) { + var all = document.querySelectorAll("[data-kit-combo]"); + for (var i = 0; i < all.length; i++) if (all[i].getAttribute("data-kit-combo") === key) return all[i]; + return null; + } + + // The choices the search leaves, each with the section it is under. + function shown(key) { + var list = defs[key].items(); + if (!list) return null; + var q = query.toLowerCase(); + return list.filter(function (it) { + return !q || Kit.str(it.label).toLowerCase().indexOf(q) >= 0; + }); + } + + function listHtml(key) { + var def = defs[key]; + var list = shown(key); + if (!list) return '
' + Kit.esc(def.loading || "Loading…") + "
"; + if (!list.length) return '
' + Kit.esc(def.empty || "No matches") + "
"; + var section = null; + return list + .map(function (it, i) { + var head = ""; + if (it.section && it.section !== section) { + head = '
' + Kit.esc(it.section) + "
"; + section = it.section; + } + var cls = "kit-combo-item" + (Kit.str(it.value) === Kit.str(def.value) ? " selected" : "") + (i === active ? " active" : ""); + return head + '
' + Kit.esc(it.label) + "
"; + }) + .join(""); + } + + function combobox(key, options) { + defs[key] = options; + var isOpen = key === openKey; + var label = Kit.str(options.label); + var html = + '
" + + '" + + '
' + + (options.search === false ? "" : '') + + '
' + (isOpen ? listHtml(key) : "") + "
"; + // Drawn again while open, the search keeps the focus it had once the page has put this in. + if (isOpen) setTimeout(focusOpen, 0); + return html; + } + + function part(el, name) { + return el.querySelector(".kit-combo-" + name); + } + + function focusOpen() { + var el = openKey === null ? null : control(openKey); + if (!el) return; + var search = part(el, "search"); + if (search && document.activeElement !== search) search.focus(); + } + + // A list that would leave the pane opens to the left or upward instead. + function place(el) { + var pop = part(el, "pop"); + pop.classList.remove("flip-right", "flip-up"); + var r = pop.getBoundingClientRect(); + var t = part(el, "trigger").getBoundingClientRect(); + var room = document.documentElement; + if (r.right > room.clientWidth - 8) pop.classList.add("flip-right"); + var below = room.clientHeight - t.bottom; + if (r.height > below - 8 && t.top > below) pop.classList.add("flip-up"); + } + + function fill(key) { + var el = control(key); + if (!el) return; + part(el, "list").innerHTML = listHtml(key); + var on = part(el, "list").querySelector(".active"); + if (on) on.scrollIntoView({ block: "nearest" }); + place(el); + } + + function close() { + var el = openKey === null ? null : control(openKey); + openKey = null; + if (!el) return; + el.classList.remove("open"); + part(el, "trigger").setAttribute("aria-expanded", "false"); + } + + function open(key) { + close(); + var el = control(key); + if (!el) return; + openKey = key; + query = ""; + active = -1; + el.classList.add("open"); + part(el, "trigger").setAttribute("aria-expanded", "true"); + var search = part(el, "search"); + if (search) search.value = ""; + fill(key); + (search || part(el, "trigger")).focus(); + } + + function pick(key, index) { + var list = shown(key); + var it = list && list[index]; + if (!it) return; + close(); + defs[key].onPick(it.value, it); + } + + combobox.set = function (key, changes) { + var def = defs[key]; + var el = control(key); + if (!def) return; + if ("label" in changes) def.label = changes.label; + if ("value" in changes) def.value = changes.value; + if (!el) return; + var trigger = part(el, "trigger"); + var label = Kit.str(def.label); + trigger.textContent = label || def.placeholder || ""; + trigger.title = def.title || label; + trigger.classList.toggle("placeholder", !label); + if (key === openKey) fill(key); + }; + combobox.refresh = function (key) { + if (key === openKey) fill(key); + }; + combobox.close = close; + + document.addEventListener("click", function (e) { + var el = e.target.closest ? e.target.closest("[data-kit-combo]") : null; + var key = el ? el.getAttribute("data-kit-combo") : null; + if (key === null || !defs[key]) return close(); + if (e.target.closest(".kit-combo-trigger")) return key === openKey ? close() : open(key); + var item = e.target.closest(".kit-combo-item"); + if (item) pick(key, Number(item.getAttribute("data-index"))); + }); + document.addEventListener("input", function (e) { + if (openKey === null || !e.target.classList || !e.target.classList.contains("kit-combo-search")) return; + query = e.target.value; + active = -1; + fill(openKey); + }); + document.addEventListener("keydown", function (e) { + if (openKey === null || !e.target.closest || !e.target.closest("[data-kit-combo]")) return; + var key = openKey; + var count = (shown(key) || []).length; + if (e.key === "Escape") { + var el = control(key); + close(); + if (el) part(el, "trigger").focus(); + } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { + if (!count) return; + active = e.key === "ArrowDown" ? (active + 1) % count : (active <= 0 ? count : active) - 1; + fill(key); + } else if (e.key === "Enter") { + pick(key, active >= 0 ? active : count === 1 ? 0 : -1); + } else { + return; + } + e.preventDefault(); + }); + + Kit.combobox = combobox; +})(Kit); diff --git a/template/src/lib/ui/menu.css b/template/src/lib/ui/menu.css new file mode 100644 index 0000000..df1f740 --- /dev/null +++ b/template/src/lib/ui/menu.css @@ -0,0 +1,10 @@ +/* lib/ui/menu.js's split button: the main button and its arrow as one control, a line between them, and the menu the + arrow opens under it. The arrow keeps the main button's height and is narrower. */ +.kit-split { position: relative; display: inline-flex; align-self: center; } +.kit-split > .btn:first-child { border-top-right-radius: 0; border-bottom-right-radius: 0; } +.kit-split > .btn + .btn.kit-split-arrow { padding-left: 7px; padding-right: 7px; border-top-left-radius: 0; border-bottom-left-radius: 0; border-left: 1px solid rgba(255, 255, 255, 0.45); } +.kit-split > .btn + .btn-secondary.kit-split-arrow { border-left: 0; } +.kit-split-arrow::after { content: ""; border: 4px solid transparent; border-top-color: currentColor; border-bottom: 0; margin-top: 2px; } +.kit-menu { position: absolute; top: calc(100% + 4px); left: 0; z-index: 6; display: flex; flex-direction: column; min-width: 100%; padding: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); } +.kit-menu button { background: none; border: 0; border-radius: 4px; padding: 6px 10px; text-align: left; white-space: nowrap; font: inherit; font-size: 12px; color: var(--text); cursor: pointer; } +.kit-menu button:hover, .kit-menu button:focus-visible { background: var(--surface-hover); outline: none; } diff --git a/template/src/lib/ui/menu.js b/template/src/lib/ui/menu.js new file mode 100644 index 0000000..459cf0e --- /dev/null +++ b/template/src/lib/ui/menu.js @@ -0,0 +1,80 @@ +// A button with an arrow beside it that opens a menu of the other choices, for a page that draws its markup as +// strings and draws it again on every push: the open menu is kept by name, and a pick, a click elsewhere or Escape +// closes it. A page embeds it after page.js, and menu.css after style.css, the same way. +// Kit.splitButton(name, button, items, options) `button` (the main button's markup), its arrow and, while open, the +// menu of `items`, each [label, pick]; `button` alone when there are no items. options.label names the arrow +// for a screen reader, options.onChange() draws the page again after the menu opens or closes +// Kit.menu.isOpen(name), Kit.menu.close() + +(function (Kit) { + var menus = {}; + var openName = null; + + function changed(name) { + var m = menus[name]; + if (m && m.onChange) m.onChange(); + } + + function close() { + var was = openName; + openName = null; + if (was !== null) changed(was); + } + + function splitButton(name, button, items, options) { + if (!button || !items || !items.length) return button; + options = options || {}; + menus[name] = { items: items, onChange: options.onChange }; + var open = openName === name; + // The arrow takes the main button's own look. + var look = button.indexOf("btn-primary") >= 0 ? "btn-primary" : "btn-secondary"; + var n = Kit.esc(name); + return ( + '' + button + + '' + + (open + ? '' + + items + .map(function (item, i) { + return '"; + }) + .join("") + + "" + : "") + + "" + ); + } + + document.addEventListener("click", function (e) { + var t = e.target && e.target.closest ? e.target : null; + var arrow = t && t.closest("[data-kit-menu]"); + var item = !arrow && t && t.closest("[data-kit-menu-item]"); + if (arrow) { + var name = arrow.getAttribute("data-kit-menu"); + openName = openName === name ? null : name; + changed(name); + } else if (item) { + var m = menus[item.getAttribute("data-kit-menu-item")]; + var pick = m && m.items[Number(item.getAttribute("data-index"))]; + var was = openName; + openName = null; + if (pick) pick[1](); + changed(was); + } else { + close(); + } + }); + document.addEventListener("keydown", function (e) { + if (e.key === "Escape") close(); + }); + + Kit.splitButton = splitButton; + Kit.menu = { + isOpen: function (name) { + return openName === name; + }, + close: close, + }; +})(Kit); diff --git a/template/src/lib/ui/pickgrid.css b/template/src/lib/ui/pickgrid.css new file mode 100644 index 0000000..132263b --- /dev/null +++ b/template/src/lib/ui/pickgrid.css @@ -0,0 +1,18 @@ +/* Picking a picture: Kit.pickGrid's grid of .kit-pick cells, and Kit.dropZone's zone. A page embeds this after + style.css, with pickgrid.js. */ + +.kit-pickgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(56px, 1fr)); gap: 6px; max-height: 300px; overflow-y: auto; padding: 2px; } +.kit-pick { position: relative; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); height: 56px; display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; } +.kit-pick:hover { background: var(--surface-hover); } +.kit-pick.on { border-color: var(--accent); box-shadow: 0 0 0 2px #bfdbfe; } +.kit-pick img { width: 28px; height: 28px; } +.kit-pick.dark { background: #1f2a36; border-color: #1f2a36; } +.kit-pick.dark:hover { background: #2b3a4a; } +.kit-pick.dark img { width: 40px; height: 40px; } +.kit-pick.photo img { width: 44px; height: 44px; object-fit: contain; border-radius: 4px; } +.kit-pick-text { font-size: 9px; color: var(--text-muted); padding: 2px; word-break: break-all; text-align: center; } +.kit-pick-tag { position: absolute; right: 3px; bottom: 2px; font-size: 9px; color: var(--text-muted); background: var(--surface); border-radius: 3px; padding: 0 3px; } + +.kit-drop { flex: 1; border: 1.5px dashed var(--border); border-radius: var(--radius); padding: 18px 12px; text-align: center; font-size: 12px; background: var(--surface); } +.kit-drop.over { border-color: var(--accent); background: #eff6ff; } +.kit-drop .note { margin-top: 6px; } diff --git a/template/src/lib/ui/pickgrid.js b/template/src/lib/ui/pickgrid.js new file mode 100644 index 0000000..652b447 --- /dev/null +++ b/template/src/lib/ui/pickgrid.js @@ -0,0 +1,138 @@ +// Picking a picture: a grid of picture cells to pick one from, and a zone a file is dropped on, chosen into or pasted +// into. A page embeds it after page.js, with pickgrid.css after style.css. +// Kit.pickGrid(el, options) el becomes the grid; returns { fill(items, empty), set(item), mark(id), cell(id) }: +// set redraws one item's cell in place, and the picked id's cell stays marked +// onPick(id) a cell was picked +// empty what an empty grid says, unless fill names it +// An item is { id, title, src } for a picture or { id, title, text } for words in its place, with dark (a picture +// drawn on a dark tile), photo (one that fills the cell) and tag (a word in its corner). +// Kit.dropZone(el, options) el becomes the zone +// onFile(file) a file was dropped, chosen or pasted +// choose the button that opens the file chooser +// accept the chooser's accepted types +// pasteWhen() whether a picture pasted outside a text box goes to this zone now + +(function (Kit) { + function cellFor(item) { + var b = document.createElement("button"); + b.type = "button"; + b.className = "kit-pick" + (item.dark ? " dark" : "") + (item.photo ? " photo" : ""); + b.dataset.id = item.id; + b.title = item.title || item.id; + if (item.src) { + var img = document.createElement("img"); + img.alt = ""; + img.src = item.src; + b.appendChild(img); + } else { + var t = document.createElement("span"); + t.className = "kit-pick-text"; + t.textContent = item.text || item.id; + b.appendChild(t); + } + if (item.tag) { + var tag = document.createElement("span"); + tag.className = "kit-pick-tag"; + tag.textContent = item.tag; + b.appendChild(tag); + } + return b; + } + + function pickGrid(el, options) { + options = options || {}; + var picked = null; + el.classList.add("kit-pickgrid"); + function find(id) { + return el.querySelector('button[data-id="' + String(id).replace(/["\\]/g, "\\$&") + '"]'); + } + el.addEventListener("click", function (e) { + var b = e.target.closest("button[data-id]"); + if (b && el.contains(b) && options.onPick) options.onPick(b.dataset.id); + }); + function mark(id) { + picked = id == null ? null : String(id); + Array.prototype.forEach.call(el.querySelectorAll("button[data-id]"), function (b) { + b.classList.toggle("on", b.dataset.id === picked); + }); + } + return { + fill: function (items, empty) { + el.textContent = ""; + if (!items.length) { + var d = document.createElement("div"); + d.className = "empty"; + d.textContent = empty || options.empty || "Nothing here."; + el.appendChild(d); + return; + } + var frag = document.createDocumentFragment(); + items.forEach(function (item) { + frag.appendChild(cellFor(item)); + }); + el.appendChild(frag); + mark(picked); + }, + set: function (item) { + var old = find(item.id); + if (!old) return; + var cell = cellFor(item); + cell.classList.toggle("on", cell.dataset.id === picked); + el.insertBefore(cell, old); + el.removeChild(old); + }, + mark: mark, + cell: find, + }; + } + + function dropZone(el, options) { + options = options || {}; + el.classList.add("kit-drop"); + var file = document.createElement("input"); + file.type = "file"; + file.hidden = true; + if (options.accept) file.accept = options.accept; + el.appendChild(file); + file.addEventListener("change", function () { + var f = file.files && file.files[0]; + file.value = ""; + if (f) options.onFile(f); + }); + if (options.choose) { + options.choose.addEventListener("click", function () { + file.click(); + }); + } + ["dragenter", "dragover"].forEach(function (t) { + el.addEventListener(t, function (e) { + e.preventDefault(); + el.classList.add("over"); + }); + }); + ["dragleave", "drop"].forEach(function (t) { + el.addEventListener(t, function () { + el.classList.remove("over"); + }); + }); + el.addEventListener("drop", function (e) { + e.preventDefault(); + var f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; + if (f) options.onFile(f); + }); + if (options.pasteWhen) { + document.addEventListener("paste", function (e) { + if (!options.pasteWhen() || /^(INPUT|TEXTAREA)$/.test((e.target && e.target.tagName) || "")) return; + var item = Array.prototype.find.call((e.clipboardData && e.clipboardData.items) || [], function (i) { + return i.kind === "file" && /^image\//.test(i.type); + }); + if (!item) return; + e.preventDefault(); + options.onFile(item.getAsFile()); + }); + } + } + + Kit.pickGrid = pickGrid; + Kit.dropZone = dropZone; +})(Kit); diff --git a/template/src/lib/ui/style.css b/template/src/lib/ui/style.css index 8ac585b..2d8bff9 100644 --- a/template/src/lib/ui/style.css +++ b/template/src/lib/ui/style.css @@ -1,8 +1,9 @@ /* The look of a driver's custom Composer tab. A page embeds it first in its