Mithril Materialized Components is a development Claude Skill built by Erik Vullings. Best for: TypeScript developers building performant Material Design UIs with Mithril.js who need expert guidance on component architecture, lifecycle management, and validation patterns..
Build Material Design UI components in Mithril.js with zero external JavaScript dependencies using factory patterns and controlled/uncontrolled modes.
Description: Expert skill for developing and maintaining the mithril-materialized library - a TypeScript-based Mithril.js component library implementing Material Design without external JavaScript dependencies.
When to use: When working with Mithril Materialized components, creating new components, fixing bugs, implementing features, or helping users integrate the library.
Mithril Materialized is a zero external JavaScript dependency component library that wraps Material Design functionality in Mithril.js components. This monorepo uses pnpm workspaces and consists of:
packages/lib/ - The main library published to npm as mithril-materializedpackages/example/ - Example application serving as documentation and live demo siteAll components use the FactoryComponent pattern for performance and proper lifecycle management:
import m, { FactoryComponent, Attributes } from 'mithril';
export interface MyComponentAttrs extends Attributes {
label?: string;
value?: string;
onchange?: (value: string) => void;
// ... other attributes
}
export const MyComponent: FactoryComponent<MyComponentAttrs> = () => {
// State is defined in the factory closure (persists across redraws)
const state = {
id: uniqueId(),
internalValue: '',
hasInteracted: false,
};
return {
oninit: ({ attrs }) => {
// Initialize state
},
onremove: () => {
// Cleanup resources
},
view: ({ attrs }) => {
// Render component
return m('.my-component', { ... });
},
};
};
Components MUST support both controlled and uncontrolled modes:
Controlled Mode (parent manages state):
m(TextInput, {
value: this.state.username,
oninput: (value) => this.state.username = value
})
Uncontrolled Mode (component manages state):
m(TextInput, {
defaultValue: 'initial',
onchange: (value) => console.log(value)
})
Implementation Pattern:
const isControlled = (attrs: InputAttrs<T>) =>
attrs.value !== undefined &&
(attrs.oninput !== undefined || attrs.onchange !== undefined);
// In oninit:
if (attrs.value !== undefined && !isControlled(attrs) && !isNonInteractive) {
console.warn(
`Component received 'value' without handler. ` +
`Use 'defaultValue' for uncontrolled or add handler for controlled.`
);
}
// In view:
let currentValue: T;
if (isControlled(attrs)) {
currentValue = attrs.value;
} else if (isNonInteractive) {
currentValue = attrs.defaultValue ?? attrs.value ?? '';
} else {
currentValue = state.internalValue ?? attrs.defaultValue ?? '';
}
Components with validation should follow this pattern:
export interface ValidatorFunction<T> {
(value: T, element?: HTMLInputElement): ValidationResult;
}
export type ValidationResult = true | false | '' | string;
Implementation in component:
// In component attrs:
export interface InputAttrs<T> extends Attributes {
value?: T;
validate?: ValidatorFunction<T>;
dataError?: string;
dataSuccess?: string;
// ... other props
}
// In component view - onblur handler:
onblur: (e: FocusEvent) => {
const target = e.target as HTMLInputElement;
state.hasInteracted = true;
// Skip validation for readonly/disabled inputs
if (attrs.readonly || attrs.disabled) {
if (attrs.onblur) attrs.onblur(e);
if (onchange) onchange(getValue(target));
return;
}
// Custom validation
if (validate) {
const value = getValue(target);
// Only validate if user has entered something
if (value && String(value).length > 0) {
const validationResult = validate(value, target);
state.isValid = typeof validationResult === 'boolean'
? validationResult
: validationResult === '';
// Set HTML5 validation message
if (typeof validationResult === 'boolean') {
target.setCustomValidity(validationResult ? '' : 'Validation failed');
if (validationResult) {
target.classList.add('valid');
target.classList.remove('invalid');
} else {
target.classList.add('invalid');
target.classList.remove('valid');
}
} else if (typeof validationResult === 'string') {
target.setCustomValidity(validationResult);
target.classList.add('invalid');
target.classList.remove('valid');
state.isValid = false;
}
} else {
// Clear validation state if no text
target.classList.remove('valid', 'invalid');
state.isValid = true;
}
}
// Call parent handlers
if (attrs.onblur) attrs.onblur(e);
if (onchange) onchange(getValue(target));
}
Validation Examples:
// Boolean validation (simple pass/fail)
m(TextInput, {
label: 'Username',
validate: (value) => value.length >= 3,
dataError: 'Username must be at least 3 characters'
})
// String validation (custom error message)
m(TextInput, {
label: 'Username',
validate: (value) => {
if (value.length < 3) return 'Too short (min 3 chars)';
if (value.length > 20) return 'Too long (max 20 chars)';
if (!/^[a-zA-Z0-9_]+$/.test(value)) return 'Only alphanumeric and underscore allowed';
return true; // or return '' for success
}
})
// Validation with HTML element access
m(NumberInput, {
label: 'Age',
validate: (value, element) => {
const num = Number(value);
if (isNaN(num)) return 'Must be a number';
if (num < 0) return 'Must be positive';
if (num > 120) return 'Must be realistic';
// Can also access element properties
if (element && !element.validity.valid) {
return element.validationMessage;
}
return true;
}
})
// Async validation (using onchange instead)
m(TextInput, {
label: 'Email',
value: email,
oninput: (v) => email = v,
onchange: async (value) => {
// Perform async validation on blur
const isAvailable = await checkEmailAvailability(value);
if (!isAvailable) {
// Handle error state
}
},
validate: (value) => {
// Synchronous email format check
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || 'Invalid email format';
}
})
Components can include Material icons using either:
Icon Component (for material-icons font):
import { Icon } from './icon';
m(Icon, { iconName: 'search', className: 'left' })
MaterialIcon Component (for custom SVG icons):
import { MaterialIcon } from './material-icon';
m(MaterialIcon, { name: 'close', className: 'input-clear-btn' })
Form components should use the Label and HelperText components:
import { Label, HelperText } from './label';
m(Label, {
label,
id,
isMandatory,
isActive: currentValue || placeholder || state.active,
initialValue: currentValue !== '',
}),
m(HelperText, {
helperText,
dataError: state.hasInteracted && !state.isValid ? dataError : undefined,
dataSuccess: state.hasInteracted && state.isValid ? dataSuccess : undefined,
})
Located in: packages/lib/src/input.ts, autocomplete.ts, chip.ts
Located in: select.ts, search-select.ts, radio.ts, switch.ts, dropdown.ts, likert-scale.ts
The LikertScale component is a purpose-built solution for survey questions and rating scales that eliminates the need for RadioButtons workarounds.
Key Features:
Basic Usage:
import { LikertScale } from 'mithril-materialized';
// Simple rating scale
m(LikertScale, {
label: 'How happy are you?',
min: 1,
max: 5,
value: happiness,
onchange: (v) => { happiness = v; },
startLabel: 'Very Unhappy',
endLabel: 'Very Happy',
})
Multi-Question Survey Pattern:
// Aligned survey questions
m('.survey-section', [
m('h5', 'Employee Satisfaction Survey'),
m(LikertScale, {
label: 'How happy are you?',
min: 1,
max: 5,
value: q1,
onchange: (v) => { q1 = v; },
startLabel: 'Unhappy',
endLabel: 'Happy',
alignLabels: true, // Enables grid alignment
}),
m(LikertScale, {
label: 'How satisfied are you with your work?',
min: 1,
max: 5,
value: q2,
onchange: (v) => { q2 = v; },
startLabel: 'Dissatisfied',
endLabel: 'Satisfied',
alignLabels: true,
}),
m(LikertScale, {
label: 'How engaged do you feel?',
min: 1,
max: 5,
value: q3,
onchange: (v) => { q3 = v; },
startLabel: 'Disengaged',
endLabel: 'Engaged',
alignLabels: true,
}),
])
Advanced Features:
// With all features
m(LikertScale, {
label: 'Rate your satisfaction',
description: 'Please rate from 1 (Very Dissatisfied) to 7 (Very Satisfied)',
min: 1,
max: 7,
value: satisfaction,
onchange: (v) => { satisfaction = v; },
// Scale anchors
startLabel: 'Very Dissatisfied',
middleLabel: 'Neutral', // Optional middle anchor
endLabel: 'Very Satisfied',
// Tooltips (hover to see descriptive text)
showTooltips: true,
tooltipLabels: [
'Very Dissatisfied',
'Dissatisfied',
'Somewhat Dissatisfied',
'Neutral',
'Somewhat Satisfied',
'Satisfied',
'Very Satisfied',
],
// Display options
showNumbers: false, // Hide numbers (default)
density: 'comfortable', // compact | standard | comfortable
size: 'medium', // small | medium | large
layout: 'horizontal', // horizontal | vertical | responsive
// Form integration
name: 'satisfaction',
isMandatory: true,
})
Component Interface:
interface LikertScaleAttrs<T extends number = number> extends Attributes {
// Scale configuration
min?: number; // default: 1
max?: number; // default: 5
step?: number; // default: 1
// State management (consistent with Rating)
value?: T; // controlled mode
defaultValue?: T; // uncontrolled mode
onchange?: (value: T) => void;
// Labels
label?: string; // question/prompt
description?: string; // helper text
startLabel?: string; // anchor for min value
middleLabel?: string; // anchor for middle value (optional)
endLabel?: string; // anchor for max value
// Display options
showNumbers?: boolean; // default: false
showTooltips?: boolean; // default: false
tooltipLabels?: string[]; // custom tooltip per value
// Size and density
density?: 'compact' | 'standard' | 'comfortable'; // default: 'standard'
size?: 'small' | 'medium' | 'large'; // default: 'medium'
// Layout
layout?: 'horizontal' | 'vertical' | 'responsive'; // default: 'responsive'
// Form integration
id?: string;
name?: string; // for form submission
disabled?: boolean;
readonly?: boolean;
isMandatory?: boolean;
// Accessibility
'aria-label'?: string;
ariaLabel?: string;
// Styling
className?: string;
style?: any;
// Multi-question alignment
alignLabels?: boolean; // use CSS grid for alignment (default: false)
}
When to Use LikertScale vs RadioButtons vs Rating:
| Component | Best For | Use Case | | ---------------- | ------------------------------------- | ---------------------------------------------------- | | LikertScale | Survey questions with semantic scales | "How satisfied are you?" with 1-5 scale and anchors | | RadioButtons | Multiple-choice questions | "What is your favorite color?" with distinct options | | Rating | Star/icon ratings and reviews | Product ratings, skill levels, movie reviews |
Styling:
The LikertScale component uses the same radio button styling as RadioButtons (16x16px core circle) and supports all Material Design color theming via CSS custom properties.
// Size variants affect touch targets
.likert-scale--small .likert-scale__label { min-width: 36px; min-height: 36px; }
.likert-scale--medium .likert-scale__label { min-width: 48px; min-height: 48px; }
.likert-scale--large .likert-scale__label { min-width: 56px; min-height: 56px; }
// Density variants affect spacing
.likert-scale--compact .likert-scale__scale { gap: 4px; }
.likert-scale--standard .likert-scale__scale { gap: 12px; }
.likert-scale--comfortable .likert-scale__scale { gap: 20px; }
Located in: button.ts, floating-action-button.ts
Located in: datepicker.ts, timepicker.ts, time-range-picker.ts
Located in: modal.ts, tooltip.ts, toast.ts, badge.ts, material-box.ts
Located in: sidenav.ts, breadcrumb.ts, tabs.ts, pagination.ts
Located in: masonry.ts, image-list.ts, timeline.ts, carousel.ts, parallax.ts
Located in: datatable.ts, treeview.ts, rating.ts, wizard.ts
Located in: collection.ts, collapsible.ts
New in v3.13: Collection items now support rich content via the content property, allowing you to use Mithril vnodes instead of just plain text:
m(Collection, {
items: [
{
title: 'User Profile',
content: m('.custom-content', [
m('p', 'Rich HTML content'),
m('span.badge', 'New'),
]),
},
],
})
Located in: theme-switcher.ts, file-upload.ts, code-block.ts
Located in: packages/lib/src/types.ts
// Size and positioning
type ComponentSize = 'tiny' | 'small' | 'medium' | 'large';
type MaterialPosition = 'top' | 'bottom' | 'left' | 'right';
type ExtendedPosition = MaterialPosition | 'top-left' | 'top-right' | ...;
// Validation
type ValidationSuccess = true | '';
type ValidationError = false | string;
type ValidationResult = ValidationSuccess | ValidationError;
interface ValidatorFunction<T> {
(value: T, element?: HTMLInputElement): ValidationResult;
}
// Input types
type InputType = 'text' | 'email' | 'password' | 'number' | 'range' | ...;
type InputValue<T extends InputType> = ...;
// Buttons
type ButtonVariant = 'button' | 'submit' | 'reset';
// Theme
type ThemeVariant = 'light' | 'dark' | 'auto';
// Material Design colors
type MaterialColor = 'red' | 'pink' | 'purple' | ...;
type ColorIntensity = 'lighten-5' | 'lighten-4' | ... | 'darken-4';
type MaterialColorSpec = MaterialColor | `${MaterialColor} ${ColorIntensity}`;
// Makes specified keys required
type RequiredKeys<T, K extends keyof T> = T & Required<Pick<T, K>>;
// Makes specified keys optional
type OptionalKeys<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// Deep readonly
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
// Type guards
const isValidationSuccess = (result: ValidationResult): result is ValidationSuccess =>
result === true || result === '';
The library uses modular CSS for tree-shaking:
core.css (18KB) - Essential foundation (normalize, grid, typography, variables)components.css - Interactive components (buttons, dropdowns, modals, tabs)forms.css - Form components (inputs, selects, switches)pickers.css - Date and time pickersadvanced.css - Specialized components (carousel, sidenav)utilities.css - Visual utilities (badges, cards, icons, toast)Dark/light theme support using CSS custom properties. The library defines 50+ CSS variables for complete theme customization.
Light Theme (:root):
:root {
/* Primary & Secondary Colors */
--mm-primary-color: #26a69a;
--mm-primary-color-light: #80cbc4;
--mm-primary-color-dark: #00695c;
--mm-secondary-color: #ff6f00;
--mm-secondary-color-light: #ffa726;
--mm-secondary-color-dark: #ef6c00;
/* Background Colors */
--mm-background-color: #ffffff;
--mm-surface-color: #ffffff;
--mm-card-background: #ffffff;
/* Text Colors */
--mm-text-primary: rgba(0, 0, 0, 0.87);
--mm-text-secondary: rgba(0, 0, 0, 0.6);
--mm-text-disabled: rgba(0, 0, 0, 0.38);
--mm-text-hint: rgba(0, 0, 0, 0.38);
/* Border & Divider Colors */
--mm-border-color: rgba(0, 0, 0, 0.12);
--mm-divider-color: rgba(0, 0, 0, 0.12);
/* Input Colors */
--mm-input-background: #ffffff;
--mm-input-border: rgba(0, 0, 0, 0.42);
--mm-input-border-focus: var(--mm-primary-color);
--mm-input-text: var(--mm-text-primary);
/* Button Colors */
--mm-button-background: var(--mm-primary-color);
--mm-button-text: #ffffff;
--mm-button-flat-text: var(--mm-primary-color);
/* Navigation Colors */
--mm-nav-background: var(--mm-primary-color);
--mm-nav-text: #ffffff;
--mm-nav-active-text: #ffffff;
/* Modal & Overlay Colors */
--mm-modal-background: #ffffff;
--mm-overlay-background: rgba(0, 0, 0, 0.5);
/* Shadow Colors */
--mm-shadow-color: rgba(0, 0, 0, 0.16);
--mm-shadow-umbra: rgba(0, 0, 0, 0.2);
--mm-shadow-penumbra: rgba(0, 0, 0, 0.14);
--mm-shadow-ambient: rgba(0, 0, 0, 0.12);
/* Chip Colors */
--mm-chip-bg: #e4e4e4;
--mm-chip-text: var(--mm-text-secondary);
/* Dropdown Colors */
--mm-dropdown-hover: #eee;
--mm-dropdown-focus: #ddd;
--mm-dropdown-selected: #e3f2fd;
/* Table & Collection Colors */
--mm-row-hover: rgba(0, 0, 0, 0.04);
--mm-table-striped-color: rgba(0, 0, 0, 0.05);
/* Switch Colors */
--mm-switch-checked-track: rgba(38, 166, 154, 0.3);
--mm-switch-checked-thumb: #26a69a;
--mm-switch-unchecked-track: rgba(0, 0, 0, 0.6);
--mm-switch-unchecked-thumb: #f5f5f5;
--mm-switch-disabled-track: rgba(0, 0, 0, 0.12);
--mm-switch-disabled-thumb: #bdbdbd;
}
Dark Theme ([data-theme="dark"]):
[data-theme="dark"] {
/* Primary & Secondary Colors */
--mm-primary-color: #80cbc4;
--mm-primary-color-light: #b2dfdb;
--mm-primary-color-dark: #4db6ac;
--mm-secondary-color: #ffa726;
--mm-secondary-color-light: #ffcc02;
--mm-secondary-color-dark: #ff8f00;
/* Background Colors */
--mm-background-color: #121212;
--mm-surface-color: #1e1e1e;
--mm-card-background: #2d2d2d;
/* Text Colors */
--mm-text-primary: rgba(255, 255, 255, 0.87);
--mm-text-secondary: rgba(255, 255, 255, 0.6);
--mm-text-disabled: rgba(255, 255, 255, 0.38);
--mm-text-hint: rgba(255, 255, 255, 0.38);
/* Border & Divider Colors */
--mm-border-color: rgba(255, 255, 255, 0.12);
--mm-divider-color: rgba(255, 255, 255, 0.12);
/* Input Colors */
--mm-input-background: #2d2d2d;
--mm-input-border: rgba(255, 255, 255, 0.42);
--mm-input-border-focus: var(--mm-primary-color);
--mm-input-text: var(--mm-text-primary);
/* Button Colors */
--mm-button-background: var(--mm-primary-color);
--mm-button-text: #000000; /* Dark text on light primary */
--mm-button-flat-text: var(--mm-primary-color);
/* Navigation Colors */
--mm-nav-background: #1e1e1e;
--mm-nav-text: #ffffff;
--mm-nav-active-text: #ffffff;
/* Modal & Overlay Colors */
--mm-modal-background: #2d2d2d;
--mm-overlay-background: rgba(0, 0, 0, 0.8);
/* Shadow Colors */
--mm-shadow-color: rgba(0, 0, 0, 0.5);
--mm-shadow-umbra: rgba(0, 0, 0, 0.5);
--mm-shadow-penumbra: rgba(0, 0, 0, 0.36);
--mm-shadow-ambient: rgba(0, 0, 0, 0.3);
/* Chip Colors */
--mm-chip-bg: #424242;
--mm-chip-text: var(--mm-text-secondary);
/* Dropdown Colors */
--mm-dropdown-hover: #444;
--mm-dropdown-focus: #555;
--mm-dropdown-selected: #1e3a8a;
/* Table & Collection Colors */
--mm-row-hover: rgba(255, 255, 255, 0.04);
--mm-row-stripe: rgba(255, 255, 255, 0.02);
--mm-table-striped-color: rgba(255, 255, 255, 0.05);
/* Switch Colors */
--mm-switch-checked-track: rgba(128, 203, 196, 0.3);
--mm-switch-checked-thumb: #80cbc4;
--mm-switch-unchecked-track: rgba(255, 255, 255, 0.6);
--mm-switch-unchecked-thumb: #616161;
--mm-switch-disabled-track: rgba(255, 255, 255, 0.12);
--mm-switch-disabled-thumb: #424242;
}
Auto Dark Mode (prefers-color-scheme):
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
/* Automatically applies dark theme variables */
/* when user's OS is in dark mode and no explicit theme is set */
}
}
TypeScript API:
import { ThemeManager } from 'mithril-materialized';
// Set theme explicitly
ThemeManager.setTheme('dark'); // 'light' | 'dark' | 'auto'
ThemeManager.setTheme('light');
ThemeManager.setTheme('auto'); // Respects OS preference
// Toggle between light and dark
ThemeManager.toggle();
// Get current theme
const currentTheme = ThemeManager.getTheme(); // Returns 'light' | 'dark' | 'auto'
Override CSS variables to create custom themes:
/* Custom brand theme */
:root {
--mm-primary-color: #1976d2; /* Blue primary */
--mm-primary-color-light: #63a4ff;
--mm-primary-color-dark: #004ba0;
--mm-secondary-color: #ff4081; /* Pink accent */
}
/* Custom dark theme colors */
[data-theme="dark"] {
--mm-primary-color: #90caf9;
--mm-background-color: #0a0a0a; /* Deeper black */
--mm-surface-color: #1a1a1a;
}
packages/
lib/
src/
*.ts # Component source files
*.scss # Modular SCSS files
index.ts # Main export file
types.ts # Shared TypeScript types
dist/ # Build output
rollup.config.mjs
package.json
example/
src/ # Example/documentation app
webpack.config.js
Root level:
pnpm start # Start dev servers for both packages
npm run build # Build library only
npm run build:example # Build example only
npm run build:domain # Clean, build both, generate docs
npm run clean # Clean all artifacts
Library (packages/lib/):
npm run dev # Watch mode build
npm run build # Production build
npm run typedoc # Generate TypeScript docs
npm run patch-release # Version bump (patch), build, publish
npm run minor-release # Version bump (minor), build, publish
npm run major-release # Version bump (major), build, publish
Example (packages/example/):
npm start # Start webpack dev server on localhost
npm run build # Production webpack build
Library Build (microbundle + rollup):
mithril (marked as external)Example Build (webpack):
The library uses automated versioning:
npm run patch-release # Bug fixes
npm run minor-release # New features (backward compatible)
npm run major-release # Breaking changes
Each release:
// 1. Imports
import m, { FactoryComponent, Attributes } from 'mithril';
import { uniqueId } from './utils';
import { Label, HelperText } from './label';
// 2. Type definitions
export interface MyComponentAttrs extends Attributes {
/** JSDoc documentation for each prop */
label?: string;
value?: T;
defaultValue?: T;
oninput?: (value: T) => void;
onchange?: (value: T) => void;
validate?: ValidatorFunction<T>;
// ... more props
}
// 3. Component factory
export const MyComponent: FactoryComponent<MyComponentAttrs> = () => {
// 4. State definition
const state = {
id: uniqueId(),
internalValue: undefined as T | undefined,
hasInteracted: false,
isValid: true,
};
// 5. Helper functions
const isControlled = (attrs: MyComponentAttrs) => { ... };
// 6. Lifecycle hooks
return {
oninit: ({ attrs }) => { ... },
onremove: () => { ... },
view: ({ attrs }) => { ... },
};
};
label - Text label for the componentvalue - Current value (controlled mode)defaultValue - Initial value (uncontrolled mode)oninput - Called on every input changeonchange - Called on blur or when value commitshelperText - Helper text below componentdataError - Error message for validationdataSuccess - Success message for validationvalidate - Validation functionclassName - Additional CSS classesdisabled - Disable the componentreadonly - Make component read-onlyisMandatory - Show required asteriskstate object in factory closure for persistent statestate.internalValue for uncontrolled modestate.hasInteracted for validation timingStandard Event Handlers:
// Input changes (oninput fires on every keystroke)
oninput: (e: Event) => {
const target = e.target as HTMLInputElement;
const value = getValue(target);
// Update internal state if uncontrolled
if (!isControlled(attrs)) {
state.internalValue = value;
}
// Call parent handler with clean value
if (attrs.oninput) {
attrs.oninput(value);
}
// Don't validate on input - wait for blur
// Clear invalid state if user is actively fixing
if (validate && target.classList.contains('invalid')) {
const validationResult = validate(value, target);
if (typeof validationResult === 'boolean' && validationResult) {
target.classList.remove('invalid');
target.classList.add('valid');
state.isValid = true;
}
}
}
// Change (fires on blur after value changed)
onblur: (e: FocusEvent) => {
const target = e.target as HTMLInputElement;
state.active = false;
state.hasInteracted = true;
// Perform validation (see validation section)
if (attrs.validate && !attrs.readonly && !attrs.disabled) {
const value = getValue(target);
const result = attrs.validate(value, target);
// Update validity state
}
// Call parent handlers
if (attrs.onblur) attrs.onblur(e);
if (attrs.onchange) attrs.onchange(getValue(target));
}
// Focus
onfocus: () => {
state.active = true;
}
// Keyboard events (with typed values)
onkeyup: onkeyup
? (ev: KeyboardEvent) => {
const value = getValue(ev.target as HTMLInputElement);
onkeyup(ev, value);
}
: undefined,
onkeydown: onkeydown
? (ev: KeyboardEvent) => {
const value = getValue(ev.target as HTMLInputElement);
onkeydown(ev, value);
}
: undefined,
Event Handler Examples:
// Real-time input handling
m(TextInput, {
label: 'Search',
oninput: (value) => {
console.log('User is typing:', value);
// Update search results in real-time
performSearch(value);
}
})
// Change on blur only
m(TextInput, {
label: 'Name',
defaultValue: user.name,
onchange: (value) => {
console.log('Final value:', value);
// Save to backend on blur
updateUser({ name: value });
}
})
// Keyboard shortcuts
m(TextInput, {
label: 'Command',
onkeydown: (event, value) => {
if (event.key === 'Enter') {
event.preventDefault();
executeCommand(value);
}
if (event.key === 'Escape') {
clearCommand();
}
}
})
// Combining multiple event handlers
m(TextInput, {
label: 'Message',
value: message,
oninput: (v) => {
message = v;
updateCharacterCount(v);
},
onchange: (v) => {
saveMessageDraft(v);
},
onkeydown: (ev, v) => {
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
sendMessage(v);
}
}
})
// Focus and blur handling
m(TextInput, {
label: 'Email',
onfocus: () => {
console.log('Input focused');
showEmailSuggestions();
},
onblur: (event) => {
console.log('Input blurred');
hideEmailSuggestions();
}
})
Range Input Event Handling:
// Single value range
m(RangeInput, {
label: 'Volume',
min: 0,
max: 100,
value: volume,
oninput: (value) => {
// Called while dragging
volume = value;
updateVolumeDisplay(value);
},
onchange: (value) => {
// Called when drag ends
saveVolumePreference(value);
}
})
// Double-thumb range (min-max)
m(RangeInput, {
label: 'Price Range',
min: 0,
max: 1000,
minmax: true,
minValue: priceMin,
maxValue: priceMax,
oninput: (min, max) => {
// Both values provided for minmax mode
priceMin = min;
priceMax = max;
updateProductFilter(min, max);
},
onchange: (min, max) => {
saveFilterPreferences({ priceMin: min, priceMax: max });
}
})
Select/Dropdown Event Handling:
// Simple select
m(Select, {
label: 'Country',
options: countries,
value: selectedCountry,
onchange: (value) => {
selectedCountry = value;
loadStates(value);
}
})
// Multiple select
m(Select, {
label: 'Tags',
options: availableTags,
multiple: true,
value: selectedTags,
onchange: (values) => {
// values is an array for multiple select
selectedTags = values;
filterItems(values);
}
})
FactoryComponent for optimal performanceonremovepackages/lib/src/my-component.tsAttributes, document with JSDocpackages/lib/src/index.ts.scss filevalidate function implementationstate.hasInteracted is tracked correctlyFactoryComponentonremove)viewimport m from 'mithril';
import { TextInput, EmailInput, NumberInput, Button } from 'mithril-materialized';
const MyForm = () => {
let name = '';
let email = '';
let age = 0;
return {
view: () => m('form', [
m(TextInput, {
label: 'Name',
value: name,
oninput: (v) => name = v,
validate: (v) => v.length >= 3 || 'Name must be at least 3 characters',
isMandatory: true,
}),
m(EmailInput, {
label: 'Email',
value: email,
oninput: (v) => email = v,
isMandatory: true,
}),
m(NumberInput, {
label: 'Age',
value: age,
oninput: (v) => age = v,
min: 0,
max: 120,
}),
m(Button, {
label: 'Submit',
variant: 'submit',
onclick: () => console.log({ name, email, age }),
}),
]),
};
};
import { DataTable } from 'mithril-materialized';
m(DataTable, {
data: users,
columns: [
{ field: 'name', label: 'Name', sortable: true },
{ field: 'email', label: 'Email', sortable: true },
{ field: 'role', label: 'Role', sortable: false },
],
selectable: true,
onselection: (selectedIds) => console.log(selectedIds),
pagination: true,
pageSize: 10,
})
import { ThemeSwitcher, ThemeToggle } from 'mithril-materialized';
// In navigation
m('.nav-wrapper', [
m('.right', m(ThemeToggle)),
])
// Or with dropdown
m(ThemeSwitcher, {
onThemeChange: (theme) => {
console.log('Theme changed to:', theme);
// Persist to localStorage, etc.
},
})
onremoveuniqueId() for component IDsvalidate prop is a function returning correct typesstate.hasInteracted is set on blurimport 'mithril-materialized/index.css'Attributes from mithrilpackages/lib/src/types.ts - All TypeScript type definitionspackages/lib/src/utils.ts - Utility functions (uniqueId, etc.)packages/lib/src/input-options.ts - Shared input attribute typespackages/lib/src/label.ts - Label and HelperText componentspackages/lib/src/index.ts - Main export filepackages/lib/src/likert-scale.ts - LikertScale component (v3.13)packages/lib/src/rating.ts - Rating component with tooltips (v3.13)packages/lib/src/collection.ts - Collection with rich content (v3.13)uniqueId() - Generate unique component IDThemeManager.setTheme() - Programmatically set themeThemeManager.toggle() - Toggle between light/darkNew Components:
Enhanced Components:
showTooltips: true and tooltipLabels are providedcontent property (Mithril vnodes)Use Cases:
This skill provides comprehensive guidance for developing, maintaining, and troubleshooting the mithril-materialized library. Use it as a reference when working with any aspect of the library.
/plugin install mithril-materialized-components@erikvullingsRequires Claude Code CLI.
TypeScript developers building performant Material Design UIs with Mithril.js who need expert guidance on component architecture, lifecycle management, and validation patterns.
No reviews yet. Be the first to review this skill.
Erik Vullings
@erikvullings