Files
LEDMatrix/web_interface/static/v3/js/widgets
Chuck 33d023bbd5 docs(widgets): list the 20 undocumented built-in widgets
The widget registry README documented 3 widgets (file-upload,
checkbox-group, custom-feeds) but the directory contains 23 registered
widgets total. A plugin author reading this doc would think those 3
were the only built-in options and either reach for a custom widget
unnecessarily or settle for a generic text input.

Verified the actual list with:
  grep -h "register('" web_interface/static/v3/js/widgets/*.js \
    | sed -E "s|.*register\\('([^']+)'.*|\\1|" | sort -u

Added an "Other Built-in Widgets" section after the 3 detailed
sections, listing the remaining 20 with one-line descriptions
organized by category:
- Inputs (6): text-input, textarea, number-input, email-input,
  url-input, password-input
- Selectors (7): select-dropdown, radio-group, toggle-switch,
  slider, color-picker, font-selector, timezone-selector
- Date/time/scheduling (4): date-picker, day-selector, time-range,
  schedule-picker
- Composite/data-source (2): array-table, google-calendar-picker
- Internal (2): notification, base-widget

Pointed at the .js source files as the canonical source for each
widget's exact schema and options — keeps this list low-maintenance
since I'm not duplicating each widget's full options table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:52:14 -04:00
..
2026-01-29 10:23:56 -05:00

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:

{
  "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:

{
  "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:

{
  "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 (MonSun 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:

{
  "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):

// 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:

{
  "properties": {
    "my_field": {
      "type": "string",
      "description": "My custom field",
      "x-widget": "my-custom-widget",
      "default": ""
    }
  }
}

Step 3: Widget Loading

The widget will be automatically loaded when the plugin configuration form is rendered. The system will:

  1. Check if widget is registered in the core registry
  2. If not found, attempt to load from plugin directory: /static/plugin-widgets/[plugin-id]/[widget-name].js
  3. Render the widget using the registered render function

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 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

getValue(fieldId)

Returns: Current widget value

Set Value Function

setValue(fieldId, value)

Parameters:

  • fieldId (string): Field ID
  • value (*): Value to set

Event Handlers

Widgets can define custom event handlers in the handlers object:

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

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

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