Files
LEDMatrix/web_interface/static/v3/js/widgets/README.md
T
ChuckandClaude Opus 5 a694b23cd1 feat(web): serve plugin-declared widgets, and actually ask for them
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>
2026-09-10 10:34:51 -04:00

613 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# LEDMatrix Widget Development Guide
## Overview
The LEDMatrix Widget Registry system allows plugins to use reusable UI components (widgets) for configuration forms. This system 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
### 1. File Upload Widget (`file-upload`)
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
**Schema Configuration:**
```json
{
"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"]
}
}
```
**Features:**
- Drag and drop file upload
- Image preview with thumbnails
- Delete functionality
- Schedule images to show at specific times
- Progress indicators during upload
### 2. Checkbox Group Widget (`checkbox-group`)
Multi-select checkboxes for array fields with enum items.
**Schema Configuration:**
```json
{
"type": "array",
"x-widget": "checkbox-group",
"items": {
"type": "string",
"enum": ["option1", "option2", "option3"]
},
"x-options": {
"labels": {
"option1": "Option 1 Label",
"option2": "Option 2 Label"
}
}
}
```
**Features:**
- Multiple selection from enum list
- Custom labels for each option
- Automatic JSON array serialization
### 3. Custom Feeds Widget (`custom-feeds`)
Table-based RSS feed editor with logo uploads.
**Schema Configuration:**
```json
{
"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
}
```
**Features:**
- Add/remove feed rows
- Logo upload per feed
- Enable/disable individual feeds
- Automatic row re-indexing
### Other Built-in Widgets
In addition to the three documented above, these widgets are
registered and ready to use via `x-widget`:
**Inputs:**
- `text-input` — Plain text field with optional length constraints
- `textarea` — Multi-line text input
- `number-input` — Numeric input with min/max validation
- `email-input` — Email field with format validation
- `url-input` — URL field with format validation
- `password-input` — Password field with show/hide toggle
**Selectors:**
- `select-dropdown` — Single-select dropdown for `enum` fields
- `radio-group` — Radio buttons for `enum` fields (alternative to dropdown)
- `toggle-switch` — Boolean toggle (alternative to a checkbox)
- `slider` — Numeric range slider for `integer`/`number` with `min`/`max`
- `color-picker` — RGB color picker; outputs `[r, g, b]` arrays
- `font-selector` — Picks from fonts in `assets/fonts/` (TTF + BDF)
- `timezone-selector` — IANA timezone picker
**Date / time / scheduling:**
- `date-picker` — Single date input
- `day-selector` — Days-of-week multi-select (Mon–Sun checkboxes)
- `time-range` — Start/end time pair (e.g. for dim schedules)
- `schedule-picker` — Full cron-style or weekday/time schedule editor
**Composite / data-source:**
- `array-table` — Generic table editor for arrays of objects
- `google-calendar-picker` — Picks from the user's authenticated Google
Calendars (used by the calendar plugin)
**Internal (typically not used directly by plugins):**
- `notification` — Toast notification helper
- `base-widget` — Base class other widgets extend
The canonical source for each widget's exact schema and options is the
file in this directory (e.g., `slider.js`, `color-picker.js`). If you
need a feature one of these doesn't support, see "Creating Custom
Widgets" below.
## Using Existing Widgets
To use an existing widget in your plugin's `config_schema.json`, simply add the `x-widget` property to your field definition:
```json
{
"properties": {
"my_images": {
"type": "array",
"x-widget": "file-upload",
"x-upload-config": {
"plugin_id": "my-plugin",
"max_files": 5
}
}
}
}
```
The widget will be automatically rendered when the plugin configuration form is loaded.
## Creating Custom Widgets
### Step 1: Create Widget File
Create a JavaScript file in your plugin directory (e.g., `widgets/my-widget.js`):
```javascript
// 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;
// Sanitize fieldId for safe use in DOM IDs and selectors
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const safeFieldId = sanitizeId(fieldId);
const html = `
<div class="my-custom-widget">
<input type="text"
id="${safeFieldId}_input"
value="${this.escapeHtml(value || '')}"
class="w-full px-3 py-2 border border-gray-300 rounded">
</div>
`;
container.innerHTML = html;
// Attach event listeners
const input = container.querySelector(`#${safeFieldId}_input`);
if (input) {
input.addEventListener('change', (e) => {
this.handlers.onChange(fieldId, e.target.value);
});
}
},
/**
* Get current value from widget
* @param {string} fieldId - Field ID
* @returns {*} Current value
*/
getValue: function(fieldId) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const safeFieldId = sanitizeId(fieldId);
const input = document.querySelector(`#${safeFieldId}_input`);
return input ? input.value : null;
},
/**
* Set value programmatically
* @param {string} fieldId - Field ID
* @param {*} value - Value to set
*/
setValue: function(fieldId, value) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const safeFieldId = sanitizeId(fieldId);
const input = document.querySelector(`#${safeFieldId}_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);
}
},
/**
* Helper: Escape HTML to prevent XSS
*/
escapeHtml: function(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
/**
* Helper: Sanitize identifier for use in DOM IDs and CSS selectors
*/
sanitizeId: function(id) {
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
}
});
```
### Step 2: Reference Widget in Schema
In your plugin's `config_schema.json`:
```json
{
"properties": {
"my_field": {
"type": "string",
"description": "My custom field",
"x-widget": "my-custom-widget",
"default": ""
}
}
}
```
### Step 3: 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:
```json
{
"widgets": [
{ "name": "my-custom-widget", "script": "my-custom-widget.js" }
]
}
```
`script` is optional and defaults to `[name].js`. It must be a plain filename
directly inside the plugin's `widgets/` directory.
### Step 4: Widget Loading
The widget is loaded on demand when the config form renders a field that
references it. The system will:
1. Check if the widget is registered in the core registry
2. If not, fetch `/static/plugin-widgets/[plugin-id]/[widget-name].js`, which
serves the declared script from the plugin's `widgets/` directory
3. Render the widget using the registered `render` function
The fetch is a dynamic `import()`, so the file must parse as an ES module (a
plain IIFE does). If anything fails, the field falls back to a plain text input
holding the current value, so a broken widget never costs the user their
configured value.
Only `string`-typed fields take this path today; see `docs/widget-guide.md`
for the full details and limitations.
## Widget API Reference
### Widget Definition Object
```javascript
{
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
```javascript
render(container, config, value, options)
```
**Parameters:**
- `container` (HTMLElement): Container element to render into
- `config` (Object): Widget configuration from schema (`x-widget-config` or schema properties)
- `value` (*): Current field value
- `options` (Object): Additional options
- `fieldId` (string): Field ID
- `pluginId` (string): Plugin ID
- `fullKey` (string): Full field key path
### Get Value Function
```javascript
getValue(fieldId)
```
**Returns:** Current widget value
### Set Value Function
```javascript
setValue(fieldId, value)
```
**Parameters:**
- `fieldId` (string): Field ID
- `value` (*): Value to set
### Event Handlers
Widgets can define custom event handlers in the `handlers` object:
```javascript
handlers: {
onChange: function(fieldId, value) {
// Handle value change
},
onFocus: function(fieldId) {
// Handle focus
}
}
```
## Best Practices
### Security
1. **Always escape HTML**: Use `escapeHtml()` or `textContent` to prevent XSS
2. **Validate inputs**: Validate user input before processing
3. **Sanitize values**: Clean values before storing
4. **Sanitize identifiers**: Always sanitize identifiers (like `fieldId`) used as element IDs and in CSS selectors to prevent selector injection/XSS:
- Use `sanitizeId()` helper function (available in BaseWidget) or create your own
- Allow only safe characters: `[A-Za-z0-9_-]`
- Replace or remove invalid characters before using in:
- `getElementById()`, `querySelector()`, `querySelectorAll()`
- Setting `id` attributes
- Building CSS selectors
- Never interpolate raw `fieldId` into HTML strings or selectors without sanitization
- Example: `const safeId = fieldId.replace(/[^a-zA-Z0-9_-]/g, '_');`
### Performance
1. **Lazy loading**: Load widget scripts only when needed
2. **Event delegation**: Use event delegation for dynamic content
3. **Debounce**: Debounce frequent events (e.g., input changes)
### Accessibility
1. **Labels**: Always associate labels with inputs
2. **ARIA attributes**: Use appropriate ARIA attributes
3. **Keyboard navigation**: Ensure keyboard accessibility
### Error Handling
1. **Graceful degradation**: Handle missing dependencies
2. **User feedback**: Show clear error messages
3. **Logging**: Log errors for debugging
## Examples
### Example 1: Color Picker Widget
```javascript
window.LEDMatrixWidgets.register('color-picker', {
name: 'Color Picker',
version: '1.0.0',
render: function(container, config, value, options) {
const fieldId = options.fieldId;
// Sanitize fieldId for safe use in DOM IDs and selectors
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
container.innerHTML = `
<div class="flex items-center space-x-2">
<input type="color"
id="${sanitizedFieldId}_color"
value="${value || '#000000'}"
class="h-10 w-20">
<input type="text"
id="${sanitizedFieldId}_hex"
value="${value || '#000000'}"
pattern="^#[0-9A-Fa-f]{6}$"
class="px-2 py-1 border rounded">
</div>
`;
const colorInput = container.querySelector(`#${sanitizedFieldId}_color`);
const hexInput = container.querySelector(`#${sanitizedFieldId}_hex`);
if (colorInput && hexInput) {
colorInput.addEventListener('change', (e) => {
hexInput.value = e.target.value;
this.handlers.onChange(fieldId, e.target.value);
});
hexInput.addEventListener('change', (e) => {
if (/^#[0-9A-Fa-f]{6}$/.test(e.target.value)) {
colorInput.value = e.target.value;
this.handlers.onChange(fieldId, e.target.value);
}
});
}
},
getValue: function(fieldId) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
const colorInput = document.querySelector(`#${sanitizedFieldId}_color`);
return colorInput ? colorInput.value : null;
},
setValue: function(fieldId, value) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
const colorInput = document.querySelector(`#${sanitizedFieldId}_color`);
const hexInput = document.querySelector(`#${sanitizedFieldId}_hex`);
if (colorInput && hexInput) {
colorInput.value = value;
hexInput.value = value;
}
},
handlers: {
onChange: function(fieldId, value) {
const event = new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true
});
document.dispatchEvent(event);
}
}
});
```
### Example 2: Slider Widget
```javascript
window.LEDMatrixWidgets.register('slider', {
name: 'Slider Widget',
version: '1.0.0',
render: function(container, config, value, options) {
const fieldId = options.fieldId;
// Sanitize fieldId for safe use in DOM IDs and selectors
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
const min = config.minimum || 0;
const max = config.maximum || 100;
const step = config.step || 1;
const currentValue = value !== undefined ? value : (config.default || min);
container.innerHTML = `
<div class="slider-widget">
<input type="range"
id="${sanitizedFieldId}_slider"
min="${min}"
max="${max}"
step="${step}"
value="${currentValue}"
class="w-full">
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>${min}</span>
<span id="${sanitizedFieldId}_value">${currentValue}</span>
<span>${max}</span>
</div>
</div>
`;
const slider = container.querySelector(`#${sanitizedFieldId}_slider`);
const valueDisplay = container.querySelector(`#${sanitizedFieldId}_value`);
if (slider && valueDisplay) {
slider.addEventListener('input', (e) => {
valueDisplay.textContent = e.target.value;
this.handlers.onChange(fieldId, parseFloat(e.target.value));
});
}
},
getValue: function(fieldId) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
const slider = document.querySelector(`#${sanitizedFieldId}_slider`);
return slider ? parseFloat(slider.value) : null;
},
setValue: function(fieldId, value) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
const slider = document.querySelector(`#${sanitizedFieldId}_slider`);
const valueDisplay = document.querySelector(`#${sanitizedFieldId}_value`);
if (slider) {
slider.value = value;
if (valueDisplay) {
valueDisplay.textContent = value;
}
}
},
handlers: {
onChange: function(fieldId, value) {
const event = new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true
});
document.dispatchEvent(event);
}
}
});
```
## Troubleshooting
### Widget Not Loading
1. Check browser console for errors
2. Verify widget file path is correct
3. Ensure `LEDMatrixWidgets.register()` is called
4. Check that widget name matches schema `x-widget` value
### Widget Not Rendering
1. Verify `render` function is defined
2. Check container element exists
3. Ensure widget is registered before form loads
4. Check for JavaScript errors in console
### Value Not Saving
1. Ensure widget triggers `widget-change` event
2. Verify form submission includes widget value
3. Check `getValue` function returns correct type
4. Verify field name matches schema property
## Migration from Server-Side Rendering
Currently, widgets are server-side rendered via Jinja2 templates. The registry system provides:
1. **Backwards Compatibility**: Existing server-side rendered widgets continue to work
2. **Future Enhancement**: Client-side rendering support for custom widgets
3. **Handler Availability**: All widget handlers are available globally
Future versions may support full client-side rendering, but server-side rendering remains the primary method for core widgets.
## Support
For questions or issues:
- Check existing widget implementations for examples
- Review browser console for errors
- Test with simple widget first before complex implementations