LEDMatrixWidgets.loadPluginWidget has always fetched /static/plugin-widgets/<plugin>/<widget>.js, and docs/widget-guide.md has always documented that path, but nothing served it. soccer-scoreboard has shipped a 17KB widgets/custom-leagues.js since August that could never load. Both halves were missing, not just the route: - serve_plugin_widget serves the script from the plugin's widgets/ directory as text/javascript. The manifest is the allowlist -- only a widget the plugin declares is reachable -- so installing a plugin does not publish everything it ships. Path handling mirrors the sibling serve_plugin_web_ui: allowlist regexes, os.path.basename, resolve() + relative_to() containment, and the ledmatrix- prefix fallback. The declared script name is guarded too, since it comes from the plugin rather than the request. - The config form never requested one. Its x-widget dispatch is a hardcoded list of core widget names, so a plugin's own widget fell through to a plain text input. An unrecognised x-widget on a string field now asks ensureWidget() for it. The text input stays as the fallback and is removed only once the widget has actually rendered, so a missing or broken widget costs the user an editor rather than their configured value on the next save. - manifest_schema.json gains "widgets", so the declaration is validated rather than merely tolerated by additionalProperties. Verified in a browser against the real partial: a declared widget loads, registers and renders, and its field posts exactly one value; a field whose widget 404s keeps its text input and still posts its value. Not addressed: loadPluginWidgetsFromManifest still has no caller. The per-field ensureWidget path is lazier and is what the form now uses, so that bulk helper is dead weight -- worth removing, but left alone here rather than inventing a call site for it. Known limitation, documented: only string-typed fields take this path. object/array/boolean/number fields and enums are dispatched by the template's own branches, which still only know core widgets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
16 KiB
Widget Development Guide
Overview
The LEDMatrix Widget Registry system allows plugins to use reusable UI components for configuration forms. This enables:
- Reusable Components: Use existing widgets (file upload, checkboxes, etc.) without custom code
- Custom Widgets: Create plugin-specific widgets without modifying the LEDMatrix codebase
- Backwards Compatibility: Existing plugins continue to work without changes
Available Core Widgets
Plugin File Manager Widget (plugin-file-manager)
Full inline file management UI for plugins that manage files via the web_ui_actions system. Renders a card grid, upload zone, create/delete modals, and an entry table editor — entirely inline, no iframe.
plugin_id is automatically injected from template context. File operations call /api/v3/plugins/action immediately on user action; no Save Configuration needed.
Schema Configuration:
{
"file_manager": {
"type": "null",
"title": "Data Files",
"x-widget": "plugin-file-manager",
"x-widget-config": {
"actions": {
"list": "list-files",
"get": "get-file",
"save": "save-file",
"upload": "upload-file",
"delete": "delete-file",
"create": "create-file",
"toggle": "toggle-category"
},
"upload_hint": "JSON files with day numbers 1–365 as keys",
"directory_label": "my_data/",
"create_fields": [
{ "key": "category_name", "label": "Category Name",
"placeholder": "e.g., my_words", "pattern": "^[a-z0-9_]+$",
"hint": "Lowercase letters, numbers, underscores" },
{ "key": "display_name", "label": "Display Name",
"placeholder": "e.g., My Words", "hint": "Optional" }
]
}
}
}
list is required — the widget calls it on render to populate the file grid; omitting it leaves the widget stuck in a loading state. All other actions are optional — omit any key to hide its UI element (e.g., no create = no New File button, no toggle = no enable/disable switch).
The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea.
Used by: of-the-day
Time Picker Widget (time-picker)
Single time selection using the browser's native time input. Returns a string in HH:MM (24-hour) format. Generic — works in any plugin without configuration.
Schema Configuration:
{
"target_time": {
"type": "string",
"x-widget": "time-picker",
"default": "00:00",
"x-options": {
"placeholder": "Select time",
"clearable": true
}
}
}
Used by: countdown
File Upload Single Widget (file-upload-single)
Single-image upload for string fields. Uploads to the plugin's asset folder (assets/plugins/<plugin_id>/uploads/) and sets the string field value to the returned relative path. Shows a thumbnail preview and a clear button. The plugin_id is automatically injected from the template context — no need to specify it in the schema.
Schema Configuration:
{
"image_path": {
"type": "string",
"x-widget": "file-upload-single",
"x-upload-config": {
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"],
"max_size_mb": 5
}
}
}
Note: Unlike file-upload (array-level), this widget is for a single string field. It is ideal for per-item images inside array-table rows.
Used by: countdown
File Upload Widget (file-upload)
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
Schema Configuration:
{
"type": "array",
"x-widget": "file-upload",
"x-upload-config": {
"plugin_id": "my-plugin",
"max_files": 10,
"max_size_mb": 5,
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"]
}
}
Used by: static-image, news plugins
Checkbox Group Widget (checkbox-group)
Multi-select checkboxes for array fields with enum items.
Schema Configuration:
{
"type": "array",
"x-widget": "checkbox-group",
"items": {
"type": "string",
"enum": ["option1", "option2", "option3"]
},
"x-options": {
"labels": {
"option1": "Option 1 Label",
"option2": "Option 2 Label"
}
}
}
Used by: odds-ticker, news plugins
Custom Feeds Widget (custom-feeds)
Table-based RSS feed editor with logo uploads.
Schema Configuration:
{
"type": "array",
"x-widget": "custom-feeds",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"url": { "type": "string", "format": "uri" },
"enabled": { "type": "boolean" },
"logo": { "type": "object" }
}
},
"maxItems": 50
}
Used by: news plugin (for custom RSS feeds)
Using Existing Widgets
To use an existing widget in your plugin's config_schema.json, simply add the x-widget property:
{
"properties": {
"my_images": {
"type": "array",
"x-widget": "file-upload",
"x-upload-config": {
"plugin_id": "my-plugin",
"max_files": 5
}
},
"enabled_leagues": {
"type": "array",
"x-widget": "checkbox-group",
"items": {
"type": "string",
"enum": ["nfl", "nba", "mlb"]
},
"x-options": {
"labels": {
"nfl": "NFL",
"nba": "NBA",
"mlb": "MLB"
}
}
}
}
}
The widget will be automatically rendered when the plugin configuration form is loaded.
Labelling Enum Options (x-options.labels)
A plain enum renders as a dropdown whose option text is the value with
underscores replaced and title case applied — day_first becomes "Day First".
That is fine for values that read as their own label, and wrong for values that
do not: vs becomes "Vs", and abbrev says nothing about the Sep 19 it
actually produces.
Supply x-options.labels to set the visible text. This is the same convention
the checkbox-group widget uses:
{
"date_format": {
"type": "string",
"enum": ["abbrev", "numeric", "day_first"],
"default": "abbrev",
"x-options": {
"labels": {
"abbrev": "Sep 19",
"numeric": "9/19",
"day_first": "19 Sep"
}
}
}
}
Labels are display only — the stored value is still the enum value, so
adding them never changes a saved config. The map may be partial: any value
without a label keeps the humanised fallback. Older cores that predate this
support ignore x-options and render the fallback for every option, so a
plugin can ship labels without requiring a core upgrade.
Array-table columns (x-widget: array-table) accept the same
x-options.labels on a column definition, but their fallback is the raw
value rather than the humanised one, because those columns hold values such
as ticker symbols where aapl → "Aapl" would be wrong. Rows added in the
browser use the labels too (array-table.js), so a column reads the same
before and after a page reload.
Marking Fields as Advanced (x-advanced)
Add "x-advanced": true to any top-level, non-object property to move it out
of the main form and into a single collapsed Advanced Settings section at
the bottom of the plugin's configuration page:
{
"properties": {
"city": {
"type": "string",
"title": "City"
},
"request_timeout": {
"type": "integer",
"default": 10,
"description": "HTTP timeout in seconds",
"x-advanced": true
}
}
}
Guidelines:
- Use it for fine-tuning knobs most users never touch (timeouts, retry behavior, cache TTLs, styling overrides). Anything a first-time user must set to get the plugin working should stay basic.
- Nothing is hidden permanently — the section expands on click, and the settings search finds and auto-expands advanced fields like any others.
- The flag is ignored on
object-type properties (they already render as their own collapsible sections) and is safely ignored by older cores, so adding it never breaks compatibility.
Creating Custom Widgets
Step 1: Create Widget File
Create a JavaScript file in your plugin's widgets/ directory, named
widgets/[widget-name].js. The directory is not optional: it is the only
place the core will serve a widget from.
// Ensure LEDMatrixWidgets registry is available
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('LEDMatrixWidgets registry not found');
return;
}
// Register your widget
window.LEDMatrixWidgets.register('my-custom-widget', {
name: 'My Custom Widget',
version: '1.0.0',
/**
* Render the widget HTML
* @param {HTMLElement} container - Container element to render into
* @param {Object} config - Widget configuration from schema
* @param {*} value - Current value
* @param {Object} options - Additional options (fieldId, pluginId, etc.)
*/
render: function(container, config, value, options) {
const fieldId = options.fieldId || container.id;
// Always escape HTML to prevent XSS
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
container.innerHTML = `
<div class="my-custom-widget">
<input type="text"
id="${fieldId}_input"
value="${escapeHtml(value || '')}"
class="w-full px-3 py-2 border border-gray-300 rounded">
</div>
`;
// Attach event listeners
const input = container.querySelector('input');
input.addEventListener('change', (e) => {
this.handlers.onChange(fieldId, e.target.value);
});
},
/**
* Get current value from widget
*/
getValue: function(fieldId) {
const input = document.querySelector(`#${fieldId}_input`);
return input ? input.value : null;
},
/**
* Set value programmatically
*/
setValue: function(fieldId, value) {
const input = document.querySelector(`#${fieldId}_input`);
if (input) {
input.value = value || '';
}
},
/**
* Event handlers
*/
handlers: {
onChange: function(fieldId, value) {
// Trigger form change event
const event = new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true
});
document.dispatchEvent(event);
}
}
});
Step 2: Declare the Widget in manifest.json
The manifest is the allowlist. A widget is served only if the plugin declares
it, so shipping a file under widgets/ does not by itself publish it:
{
"widgets": [
{
"name": "my-custom-widget",
"script": "my-custom-widget.js",
"description": "What this widget is for"
}
]
}
name is what you use in x-widget and in the URL. script is optional and
defaults to [name].js; it must be a plain filename directly inside
widgets/ (no paths). Both are validated against
schema/manifest_schema.json.
Step 3: Reference Widget in Schema
In your plugin's config_schema.json:
{
"properties": {
"my_field": {
"type": "string",
"description": "My custom field",
"x-widget": "my-custom-widget",
"default": ""
}
}
}
Step 4: Widget Loading
The widget is loaded on demand when the plugin's configuration form renders a field that references it. The system will:
- Check whether the widget is already registered in the core registry.
- If not, fetch it from
/static/plugin-widgets/[plugin-id]/[widget-name].js. That route serves the declaredscriptfrom your plugin'swidgets/directory, astext/javascript. - Render it by calling the
renderfunction your script registered.
The fetch uses a dynamic import(), so the file must parse as an ES module.
A plain IIFE does — modules are strict mode, so avoid sloppy-mode constructs.
If the widget fails to load (not declared, file missing, script throws, or
it never calls register), the field falls back to a plain text input holding
the current value. This is deliberate: a broken widget costs the user an
editor, not their configured value.
Limitation: the on-demand path applies to string-typed fields (the
default branch of the config-form renderer). Fields typed object, array,
boolean, integer or number, and fields whose enum is set, are
dispatched by the server-side template to its own built-in renderers, so a
plugin-supplied x-widget on one of those is ignored today.
Widget API Reference
Widget Definition Object
{
name: string, // Human-readable widget name
version: string, // Widget version
render: function, // Required: Render function
getValue: function, // Optional: Get current value
setValue: function, // Optional: Set value programmatically
handlers: object // Optional: Event handlers
}
Render Function
render(container, config, value, options)
Parameters:
container(HTMLElement): Container element to render intoconfig(Object): Widget configuration from schemavalue(*): Current field valueoptions(Object): Additional optionsfieldId(string): Field IDpluginId(string): Plugin IDfullKey(string): Full field key path
Get Value Function
getValue(fieldId)
Returns: Current widget value
Set Value Function
setValue(fieldId, value)
Parameters:
fieldId(string): Field IDvalue(*): Value to set
Examples
See web_interface/static/v3/js/widgets/example-color-picker.js for a complete example of a custom color picker widget.
Best Practices
Security
- Always escape HTML: Use
escapeHtml()ortextContentto prevent XSS - Validate inputs: Validate user input before processing
- Sanitize values: Clean values before storing
Performance
- Lazy loading: Load widget scripts only when needed
- Event delegation: Use event delegation for dynamic content
- Debounce: Debounce frequent events (e.g., input changes)
Accessibility
- Labels: Always associate labels with inputs
- ARIA attributes: Use appropriate ARIA attributes
- Keyboard navigation: Ensure keyboard accessibility
Troubleshooting
Widget Not Loading
- Check browser console for errors
- Verify widget file path is correct
- Ensure
LEDMatrixWidgets.register()is called - Check that widget name matches schema
x-widgetvalue
Widget Not Rendering
- Verify
renderfunction is defined - Check container element exists
- Ensure widget is registered before form loads
- Check for JavaScript errors in console
Value Not Saving
- Ensure widget triggers
widget-changeevent - Verify form submission includes widget value
- Check
getValuefunction returns correct type - Verify field name matches schema property
Current Implementation Status
Phase 1 Complete:
- ✅ Widget registry system created
- ✅ Core widgets extracted to separate files
- ✅ Widget handlers available globally (backwards compatible)
- ✅ Plugin widget loading system implemented
Current Behavior:
- Core widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
- Widget handlers are registered and available globally
- Custom widgets can be created, declared in
manifest.json, and are served and rendered on demand forstring-typed fields - Plugin widgets on non-string fields are not dispatched yet (see Step 4)
Backwards Compatibility:
- All existing plugins using widgets continue to work without changes
- Server-side rendering remains the primary method
- Widget registry provides foundation for future enhancements
See Also
- Widget README - Complete widget development guide with examples
- Plugin Development Guide - General plugin development
- Plugin Configuration Guide - Configuration setup