Files
LEDMatrix/web_interface/static/v3/js/widgets
Chuck d0ad2031c8 fix(ui): wrap plugin tabs to new lines instead of scrolling (#201)
* fix(ui): wrap plugin tabs to new lines instead of scrolling

Change plugin tabs row from overflow-x-auto to flex-wrap so that
when many plugins are installed, tabs break to new lines instead
of becoming smaller or requiring horizontal scrolling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): use gap-x instead of space-x for proper wrapped row alignment

Switch from space-x-* to gap-x-* utilities so wrapped rows align
correctly without indentation on subsequent lines.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): add missing flex-wrap and gap utilities to CSS

The project uses hand-written Tailwind-like CSS, not actual Tailwind.
Added missing utility classes needed for plugin tabs wrapping:
- flex-wrap
- gap-x-4, gap-x-6, gap-x-8, gap-y-2
- lg:gap-x-6, xl:gap-x-8 responsive variants

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): apply flex-wrap to system tabs row

Apply the same wrapping behavior to the system tabs row (Overview,
General, WiFi, etc.) so they also wrap to new lines on smaller
viewports instead of scrolling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): constrain tab container width to enable flex-wrap

Add max-w-full and overflow-hidden to tab row containers to properly
constrain their width, allowing flex-wrap to work correctly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): remove overflow-hidden that was hiding tabs

Revert the max-w-full overflow-hidden approach as it was hiding
content. Keep both rows using flex-wrap with gap utilities.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: Add custom-leagues widget support for soccer plugin

- Add server-side template rendering for x-widget="custom-leagues"
- Renders table with Name, League Code, Priority, Enabled columns
- Includes inline JavaScript for add/remove row functionality
- Uses indexed field naming for proper array serialization
- Shows common ESPN league codes as hint

This enables the soccer scoreboard plugin's custom leagues feature
to work properly in the web UI.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): reduce tab gap spacing for tighter layout

Reduce horizontal gap between tabs from gap-x-4/6/8 to gap-x-2/3/4
for a more compact appearance.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(widget): Replace custom-leagues with generic array-table widget

- Add generic array-table widget that reads columns from schema
- Support x-columns to specify which columns to display
- Auto-detect columns from items.properties if x-columns not specified
- Remove hardcoded custom-leagues implementation
- Any plugin can now use x-widget: "array-table" for array-of-objects

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): use data attributes for array table button to avoid JSON escaping issues

Move JSON blobs (item_properties and display_columns) from inline onclick
to data-* attributes with proper HTML entity escaping via Jinja's |e filter.
Update addArrayTableRow() to read and parse these data attributes.

This fixes HTML attribute breakage caused by tojson emitting double quotes
inside the onclick attribute value.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): update Add button state when array table rows change

Add updateAddButtonState() helper that toggles the Add button's disabled
attribute and opacity based on current row count vs maxItems.

Called after addArrayTableRow() and removeArrayTableRow(), and also on
page load to ensure correct initial state.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): add try/catch for JSON parsing in addArrayTableRow

Wrap JSON.parse calls for data-item-properties and data-display-columns
in try/catch blocks with fallback to {} and [] respectively. Logs error
with raw attribute values to help debug malformed JSON.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(array-table): Fix getValue input name validation and setValue Add button state sync

- Fix getValue to use early-continue guard preventing errors on inputs without names
- Add updateAddButtonState call in setValue to refresh Add button state after repopulating rows

* fix(ui): make Configure button larger than Uninstall in plugin manager

Swapped button sizes in installed plugins section - Configure button is now
the largest (flex-2), Update is medium (flex-1), and Uninstall is smallest
(no flex class). This prioritizes the Configure action over Uninstall.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(ui): correct forEach continue and plugin button flex sizing

- Replace invalid continue with return in array-table forEach callback
- Remove redundant hidden input type check in array-table getValue
- Fix plugin button sizing using inline flex styles instead of invalid flex-2 class
- Configure button now properly sized at flex: 2, Update and Uninstall at flex: 1

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* refactor(ui): reorganize plugin buttons into two-row layout

Configure button now takes full width on first row, while Update and
Uninstall buttons share the second row evenly. This makes Configure
more prominent and separates destructive actions to a second row.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(ui): override inline-flex on Configure button to enable full width

The .btn class uses display: inline-flex which prevents w-full from working.
Added inline style to override with display: flex and width: 100% so the
Configure button properly takes the full width of its row.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(ui): use inline styles for plugin action buttons layout

Replace Tailwind classes with explicit inline styles to ensure proper
two-row layout for plugin action buttons. Configure button on first row
at full width, Update and Uninstall sharing second row evenly.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 10:40:13 -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

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