ControlTile
MoleculeAsync controls: a frame (`ControlTile`) plus `ControlButton`, `ControlSwitch`, `ControlSlider` and `ControlSetpoint`. Each takes the bound `value` and a write handler that returns a Promise, and shows idle -> pending -> confirmed / mismatch / failed with the previous value restored on failure. The slider writes once on release. Hand the write to the control with `root.__kuiControl.onCommit = fn`, the `onCommit` global-function name, or the `kui:control-commit` DOM event. `ControlButton` takes an optional `pressed` for a toggle-like button: `true` draws it pressed and announces `aria-pressed="true"`, `false` announces `aria-pressed="false"`, absent is an ordinary button. Same extended props as kui-react: `framed`, `disabledReason`, `confirm`; switch `label` / `icon` / `layout` / `colors` / `size` / `renderLayout`; slider `layout` / `tickMarks` / `orientation` / `thickness` / `thumbSize` / `colors` / `commitMode`; set point `layout` (`stepper`, `dial`) and colours; button `layout` (`icon`, `fab`, `tile`) / `icon` / `variant` / `size` / `align`. No transport knowledge; all text is overridable through `messages`.
Heater
<%- include('modules/ui/ControlTile/ControlSwitch', { id: 'heater', title: 'Heater', value: false }) %>
<script>
document.getElementById('heater').__kuiControl.onCommit = (next) => api.setHeater(next);
</script>
Heater
<%- include('modules/ui/ControlTile/ControlSwitch', { id: 'heater', title: 'Heater', value: false }) %>
<script>
document.getElementById('heater').__kuiControl.onCommit = () => Promise.reject(new Error('Device offline'));
</script>
Heater
<%- include('modules/ui/ControlTile/ControlSwitch', { id: 'heater', title: 'Heater', value: false, reported: false, timeoutMs: 2500 }) %>
<script>
const heater = document.getElementById('heater').__kuiControl;
heater.onCommit = write;
deviceEvents.on('heater', (state) => heater.setReported(state));
</script>
Fan speed
<%- include('modules/ui/ControlTile/ControlSlider', { title: 'Fan speed', value: 40, min: 0, max: 100, step: 5, unit: '%' }) %>
Target temperature
<%- include('modules/ui/ControlTile/ControlSetpoint', { title: 'Target temperature', value: 21, min: 5, max: 30, step: 0.5, unit: '°C', decimals: 1 }) %>
<%- include('modules/ui/ControlTile/ControlButton', { title: 'Gateway', label: 'Reboot', confirm: 'typed', confirmText: 'reboot' }) %>
<%- include('modules/ui/ControlTile/ControlButton', { title: 'Fan', label: 'Fan', pressed: fanOn }) %>
Pump
Fan
Are you sure?
<%- include('modules/ui/ControlTile/ControlSwitch', { title: 'Pump', value: true, label: 'Main pump', icon: '<i class="fa-solid fa-water"></i>', layout: 'right', colors: { on: 'var(--success)' } }) %>
<%- include('modules/ui/ControlTile/ControlSwitch', { title: 'Fan', value: false, label: 'Fan', layout: 'centered', confirm: 'confirm' }) %>
Brightness
Volume
<%- include('modules/ui/ControlTile/ControlSlider', { title: 'Brightness', value: 60, step: 10, unit: '%', layout: 'extended', tickMarks: 5, tickLabels: true }) %>
Target
Current: 21.0 °C
Target
Current: 21 °C
<%- include('modules/ui/ControlTile/ControlSetpoint', { title: 'Target', value: 21, min: 5, max: 30, unit: '°C', layout: 'dial' }) %>
<%- include('modules/ui/ControlTile/ControlButton', { title: 'Power', label: 'Power', layout: 'icon', icon: '<i class="fa-solid fa-power-off"></i>' }) %>
<%- include('modules/ui/ControlTile/ControlButton', { title: 'Pump', label: 'Start', disabledReason: 'The device is offline.' }) %>
Heater
Any control goes in the slot.
<%- include('modules/ui/ControlTile/ControlSwitch', { title: 'Heater', value: false, readOnly: true }) %>
<%
// ControlTile — the frame every async control shares: title, the control slot,
// and one live status line (pending / confirmed / mismatch / failed / read-only).
// One place, so the controls cannot drift on what "pending" or "failed" looks
// like. Mirror of kui-react modules/ui/ControlTile/ControlTile.tsx.
//
// The status line is rendered by the runtime (scripts/control-bind.js) as the
// control's state changes; this partial renders the idle state (the read-only
// notice, when set). Locals:
// id — root id (auto-generated when omitted)
// title — optional heading
// body — pre-rendered HTML of the control (trusted include output)
// after — pre-rendered HTML right after the control, outside the
// disabled-reason tooltip (the confirmation step)
// kind — 'switch' | 'slider' | 'setpoint' | 'button' (runtime binder)
// config — JSON-serialisable settings for the binder
// readOnly — show the read-only notice (the user may not use the control)
// reportedText — quiet line under the control (the slider keeps it live)
// messages — { pending, confirmed, mismatch, failed, readOnly, retry, ... }.
// A host-written `failed` leads and the write's reason follows it
// in brackets ("Could not switch (Offline)"); without it the reason
// alone is shown, else the default text.
// framed — draw the tile's own frame (border, background, padding). Default
// true. Pass false when the control sits inside a host card that
// already frames it: the tile then fills its parent as a column.
// disabledReason — why the control is disabled (no command, old data …), shown in
// a tooltip over the control. The control itself must also be
// disabled (every Control* partial does that when it gets one).
// bodyAlign — unframed only: 'center' (default) centres the control in the
// space, 'end' puts it at the bottom.
// className — extra classes on the frame
//
// DEFAULT_CONTROL_MESSAGES (same strings as kui-react):
var DEFAULT_MESSAGES = {
pending: 'Sending…',
confirmed: 'Done',
mismatch: 'The device reports a different value.',
failed: 'The change failed.',
readOnly: 'You do not have permission to use this control.',
retry: 'Retry',
};
var _m = Object.assign({}, DEFAULT_MESSAGES, locals.messages || {});
// The runtime must know whether `failed` is the host's own text (kui-react reads `messages?.failed`).
if (locals.messages && locals.messages.failed) _m.failedByHost = true;
var _id = locals.id || ('control-' + Math.random().toString(36).substr(2, 9));
var _title = locals.title || '';
var _readOnly = !!locals.readOnly;
var _reportedText = locals.reportedText || '';
var _kind = locals.kind || '';
var _framed = locals.framed !== false;
var _disabledReason = typeof locals.disabledReason === 'string' ? locals.disabledReason : '';
var _bodyAlign = locals.bodyAlign === 'end' ? 'end' : 'center';
var body = locals.body || '';
var _after = locals.after || '';
var _reportedHolder = !!locals.reportedHolder; // slider: a live (initially hidden) reported line
function esc(t) { return String(t).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
var _rootClass = _framed ? 'rounded-lg border border-border bg-surface-raised p-3' : 'flex h-full min-h-0 flex-col';
var _bodyClass = _framed ? 'space-y-2' : 'space-y-1.5 flex min-h-0 flex-1 flex-col ' + (_bodyAlign === 'end' ? 'justify-end' : 'justify-center');
var _statusClass = _framed ? 'min-h-5 text-xs' : 'min-h-4 text-[11px] leading-4';
// `body` from here on is the control (inside the disabled-reason tooltip) and its confirmation step.
body = (_disabledReason
? include('../Tooltip', {
id: _id + '-reason', content: esc(_disabledReason), children: body,
className: 'flex w-full [&>span:first-child]:min-w-0 [&>span:first-child]:flex-1',
})
: body) + _after;
%>
<div
id="<%= _id %>"
class="<%= _rootClass %><%= locals.className ? ' ' + locals.className : '' %>"
<% if (_disabledReason) { %>data-disabled-reason="<%= _disabledReason %>"<% } %>
<% if (_kind) { %>data-kui-control="<%= _kind %>"
data-kui-control-config="<%= JSON.stringify(locals.config || {}) %>"
data-kui-messages="<%= JSON.stringify(_m) %>"<% } %>
>
<% if (_title) { %><h3 class="mb-2 text-sm font-semibold text-text-primary"><%= _title %></h3><% } %>
<div class="<%= _bodyClass %>">
<%- body %>
<% if (_reportedText || _reportedHolder) { %><p class="text-xs text-text-secondary" data-kui-control-reported<%= _reportedText ? '' : ' hidden' %>><%= _reportedText %></p><% } %>
<div class="flex items-center gap-2 <%= _statusClass %>" aria-live="polite" data-kui-control-status>
<% if (_readOnly) { %><span class="text-text-secondary"><%= _m.readOnly %></span><% } %>
</div>
</div>
</div>
<%
// ControlSwitch — a bound boolean written on toggle; shows the requested value
// while pending, rolls back on failure. Mirror of kui-react ControlSwitch.
//
// Locals:
// id, title
// value — the bound state. Anything asBoolean cannot read is "unknown" and shows off.
// reported — the state the other side reports, if it reports one; "pending"
// lasts until it matches. Omit it when nothing reports back.
// onCommit — NAME of a global function (next: boolean, confirmation?) => Promise, the write.
// Reject to fail and roll the thumb back. Or listen for the DOM event
// 'kui:control-commit' (detail.respond(promise)) — see scripts/control-bind.js.
// confirm — 'none' (default) | 'confirm' ("Are you sure?") | 'typed' (also type `confirmText`);
// the write then carries { confirmed: true, typed? } as its second argument.
// confirmText — the text to type when confirm = 'typed'
// onLabel ('On'), offLabel ('Off'), unknownLabel ('State unknown')
// label — text naming what the switch controls, beside it (also its accessible name).
// Without it the on / off text stands alone.
// labelStyle — inline style of `label` (object with camelCase keys, or CSS text)
// icon — trusted HTML of an icon before the label (e.g. '<i class="fa-solid fa-fan"></i>')
// layout — 'left' (default): switch, then text. 'right': text, switch at the far end.
// 'centered': stacked, large switch.
// showStateText — show the on / off text (default true). The unknown text is always shown.
// stateStyles — { on, off, unknown }: inline styles of the state text per state
// colors — { on, off, disabled, thumb, thumbOn, thumbOff, thumbDisabled }: any CSS colour
// or theme token; unset parts use the theme (primary on, sunken off)
// size — 'sm' | 'md' | 'lg' (default md; lg for the centered layout)
// renderLayout — a server-side function (parts) => HTML that arranges the parts itself:
// { control, state: 'on'|'off'|'unknown', stateText, stateId, pending, locked }.
// Put `stateId` on the element showing the state: the switch is described by it
// and the runtime keeps its text live. The status line, the confirmation step
// and the disabled tooltip stay the control's.
// layoutPartial, layoutLocals — the same as a partial: the path of an EJS partial (relative to this file) that is
// included with `layoutLocals` and `parts` (the object above) and returns the arrangement. Used when
// there is no `renderLayout`.
// stateViews — true: the layout draws every state's own view (an element per state marked
// `data-kui-state="on|off|unknown"`, the others `hidden`) and the runtime only shows the one that
// matches, instead of writing the state text into the `stateId` element. For a layout whose
// look (colour, icon, pill) changes with the state.
// readOnly — the user may not use the control
// disabled, disabledReason (a tooltip over the switch; implies disabled)
// framed — draw the tile frame (default true)
// timeoutMs (10000), messages, className
// loadRuntime — default true (see _runtime.ejs)
//
// Runtime: document.getElementById(id).__kuiControl.setValue(v) / .setReported(v) / .onCommit = fn
var L = {};
include('./_logic', { L: L });
var P = {};
include('./_parts', { P: P });
var esc = P.esc;
var _id = locals.id || ('control-switch-' + Math.random().toString(36).substr(2, 9));
var _bound = L.asBoolean(locals.value);
var _known = _bound !== null;
var _shown = _bound === null ? false : _bound;
var _onLabel = locals.onLabel || 'On';
var _offLabel = locals.offLabel || 'Off';
var _unknownLabel = locals.unknownLabel || 'State unknown';
var _readOnly = !!locals.readOnly;
var _disabled = !!locals.disabled;
var _reason = typeof locals.disabledReason === 'string' && locals.disabledReason ? locals.disabledReason : '';
var _locked = _disabled || _readOnly || !!_reason;
var _confirm = ['confirm', 'typed'].indexOf(locals.confirm) !== -1 ? locals.confirm : 'none';
var _layout = ['right', 'centered'].indexOf(locals.layout) !== -1 ? locals.layout : 'left';
var _label = typeof locals.label === 'string' ? locals.label : '';
var _showStateText = locals.showStateText !== false;
var _render = typeof locals.renderLayout === 'function' ? locals.renderLayout
: typeof locals.layoutPartial === 'string' && locals.layoutPartial
? function (parts) { return include(locals.layoutPartial, Object.assign({}, locals.layoutLocals || {}, { parts: parts })); }
: null;
var _inputId = _id + '-input';
var _hintId = _id + '-hint';
var SIZES = {
sm: { track: 'h-4 w-7', thumb: 'h-3 w-3', on: 'translate-x-3' },
md: { track: 'h-5 w-9', thumb: 'h-4 w-4', on: 'translate-x-4' },
lg: { track: 'h-7 w-12', thumb: 'h-6 w-6', on: 'translate-x-5' },
};
var _size = SIZES[locals.size] ? locals.size : (_layout === 'centered' ? 'lg' : 'md');
var sz = SIZES[_size];
var _colors = {};
var _rawColors = locals.colors || {};
['on', 'off', 'disabled', 'thumb', 'thumbOn', 'thumbOff', 'thumbDisabled'].forEach(function (k) {
var c = P.cssValue(_rawColors[k]);
if (c) _colors[k] = c;
});
var _stateStyles = {};
['on', 'off', 'unknown'].forEach(function (k) {
var s = P.styleText((locals.stateStyles || {})[k]);
if (s) _stateStyles[k] = s;
});
var _trackColor = _locked ? _colors.disabled : _shown ? _colors.on : _colors.off;
var _thumbColor = (_locked ? _colors.thumbDisabled : _shown ? _colors.thumbOn : _colors.thumbOff) || _colors.thumb;
var _stateText = !_known ? _unknownLabel : _shown ? _onLabel : _offLabel;
var _stateKey = !_known ? 'unknown' : _shown ? 'on' : 'off';
var _showState = _showStateText || !_known || !!_render;
var _cursor = _locked ? 'cursor-not-allowed' : 'cursor-pointer';
var _aria = _label || locals.title || '';
var _trackClass = 'block rounded-full transition-colors duration-200'
+ ' peer-focus-visible:ring-2 peer-focus-visible:ring-border-focus peer-focus-visible:ring-offset-1 peer-focus-visible:ring-offset-surface-base'
+ ' ' + sz.track
+ (_trackColor ? '' : (_shown ? ' bg-primary' : ' border border-border bg-surface-sunken'))
+ (_locked && !_colors.disabled ? ' opacity-50' : '');
var _thumbClass = 'absolute left-0.5 top-1/2 -translate-y-1/2 rounded-full shadow-sm transition-transform duration-200'
+ (_thumbColor ? '' : ' bg-primary-fg')
+ ' ' + sz.thumb
+ (_shown ? ' ' + sz.on : '');
var control = '<span class="relative inline-flex shrink-0 items-center">'
+ '<input id="' + esc(_inputId) + '" type="checkbox" role="switch" data-kui-control-switch'
+ (_shown ? ' checked' : '') + (_locked ? ' disabled' : '')
+ ' aria-checked="' + String(_shown) + '"'
+ (_aria ? ' aria-label="' + esc(_aria) + '"' : '')
+ (_showState ? ' aria-describedby="' + esc(_hintId) + '"' : '')
+ ' class="peer sr-only">'
+ '<span aria-hidden="true" data-kui-control-track class="' + _trackClass + '"'
+ (_trackColor ? ' style="background: ' + esc(_trackColor) + '"' : '') + '></span>'
+ '<span aria-hidden="true" data-kui-control-thumb class="' + _thumbClass + '"'
+ (_thumbColor ? ' style="background: ' + esc(_thumbColor) + '"' : '') + '></span>'
+ '</span>';
var _stateClass = (_label && _layout !== 'centered' ? 'text-xs' : 'text-sm font-medium')
+ ' ' + (!_known || _label ? 'text-text-secondary' : 'text-text-primary');
var iconEl = locals.icon ? '<span aria-hidden="true" class="inline-flex shrink-0 items-center">' + locals.icon + '</span>' : '';
var _labelStyle = P.styleText(locals.labelStyle);
var labelEl = _label ? '<span class="truncate text-sm font-medium text-text-primary"' + (_labelStyle ? ' style="' + esc(_labelStyle) + '"' : '') + '>' + esc(_label) + '</span>' : '';
// Always in the page so the runtime can show it when the state becomes unknown; hidden when not shown.
var stateEl = '<span id="' + esc(_hintId) + '" class="' + _stateClass + '" data-kui-control-state'
+ (_stateStyles[_stateKey] ? ' style="' + esc(_stateStyles[_stateKey]) + '"' : '')
+ (_showState ? '' : ' hidden') + '>' + esc(_stateText) + '</span>';
var text = '<span class="flex min-w-0 items-center gap-2">' + iconEl + '<span class="flex min-w-0 flex-col">' + labelEl + stateEl + '</span></span>';
var body;
if (_render) {
body = _render({
control: '<label for="' + esc(_inputId) + '" class="inline-flex ' + _cursor + '" data-kui-control-label>' + control + '</label>',
state: _stateKey, stateText: _stateText, stateId: _hintId, pending: false, locked: _locked,
});
} else {
var _wrapClass = _layout === 'centered'
? 'flex items-center flex-col justify-center gap-2'
: 'flex items-center gap-3' + (_layout === 'right' ? ' justify-between' : '');
body = '<label for="' + esc(_inputId) + '" class="' + _wrapClass + ' ' + _cursor + '" data-kui-control-label>'
+ (_layout === 'right' ? text + control : _layout === 'centered' ? iconEl + labelEl + control + stateEl : control + text)
+ '</label>';
}
%>
<%- include('./ControlTile', {
id: _id, title: locals.title, kind: 'switch', readOnly: _readOnly, messages: Object.assign({}, P.CONFIRM_DEFAULTS, locals.messages || {}),
className: locals.className, framed: locals.framed, disabledReason: _reason,
body: body, after: P.confirmPrompt(_id, _confirm, locals.confirmText, locals.messages),
config: {
value: locals.value, reported: locals.reported, timeoutMs: locals.timeoutMs, readOnly: _readOnly,
disabled: _disabled || !!_reason, onLabel: _onLabel, offLabel: _offLabel, unknownLabel: _unknownLabel,
onCommit: locals.onCommit, confirm: _confirm, confirmText: locals.confirmText || '',
colors: _colors, stateStyles: _stateStyles, size: _size, showStateText: _showStateText,
custom: !!_render, stateViews: !!_render && locals.stateViews === true, labelled: !!_label, centered: _layout === 'centered',
},
}) %>
<% if (locals.loadRuntime !== false) { %><%- include('./_runtime') %><% } %>