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..
- What it does
- Build Material Design UI components in Mithril.js with zero external JavaScript dependencies using factory patterns and controlled/uncontrolled modes.
- Category
- development
- Created by
- Erik Vullings
- Last updated
Mithril Materialized Components
Build Material Design UI components in Mithril.js with zero external JavaScript dependencies using factory patterns and controlled/uncontrolled modes.
Skill instructions
Mithril Materialized UI Development Skill
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.
Project Overview
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 asmithril-materializedpackages/example/- Example application serving as documentation and live demo site
Key Architectural Principles
- Zero External JS Dependencies: No materialize-css JavaScript, jQuery, or other runtime dependencies
- CSS-Only Styling: All styling is done via CSS, no JavaScript initialization required
- Factory Component Pattern: Uses Mithril's FactoryComponent for optimal performance
- Controlled/Uncontrolled Support: Components support both controlled and uncontrolled modes
- TypeScript First: Full TypeScript support with comprehensive type definitions
- Modular CSS: Tree-shakable CSS modules for optimal bundle sizes
Component Architecture Patterns
1. Factory Component Pattern
All 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', { ... });
},
};
};
2. Controlled vs Uncontrolled Components
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 ?? '';
}
3. Validation Pattern
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';
}
})
4. Icon Integration
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' })
5. Label and Helper Text Pattern
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,
})
Component Categories
Input Components
Located in: packages/lib/src/input.ts, autocomplete.ts, chip.ts
- TextInput, PasswordInput, NumberInput, UrlInput, ColorInput, EmailInput
- TextArea (with auto-resize)
- RangeInput (with vertical, double-thumb support)
- FileInput
- AutoComplete
- Chips
Selection Components
Located in: select.ts, search-select.ts, radio.ts, switch.ts, dropdown.ts, likert-scale.ts
- Select
- SearchSelect (searchable dropdown)
- RadioButtons
- LikertScale (survey rating scales with semantic anchors)
- Switch
- Dropdown
LikertScale Component (New in v3.13)
The LikertScale component is a purpose-built solution for survey questions and rating scales that eliminates the need for RadioButtons workarounds.
Key Features:
- Horizontal/Vertical/Responsive Layouts: Automatically adapts to desktop (horizontal) and mobile (vertical)
- Scale Anchors: Optional start, middle, and end labels for semantic meaning (e.g., "Very Unhappy" → "Neutral" → "Very Happy")
- Multi-Question Alignment: Grid-based layout for vertically aligned survey forms
- Optional Tooltips: Show descriptive text on hover (similar to Rating component)
- Optional Number Display: Show/hide numeric values (default: false)
- Size Variants: Small (36px), Medium (48px), Large (56px) touch targets
- Density Variants: Compact (4px), Standard (12px), Comfortable (20px) spacing
- Full Accessibility: Keyboard navigation, ARIA labels, screen reader support
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; }
Button Components
Located in: button.ts, floating-action-button.ts
- Button, FlatButton, IconButton, RoundIconButton, SubmitButton, LargeButton, SmallButton, ConfirmButton
- FloatingActionButton
Picker Components
Located in: datepicker.ts, timepicker.ts, time-range-picker.ts
- DatePicker (with date range support, week numbers)
- TimePicker (with inline mode, AM/PM/24h)
- TimeRangePicker
Display Components
Located in: modal.ts, tooltip.ts, toast.ts, badge.ts, material-box.ts
- ModalPanel
- Tooltip
- Toast (with action support)
- Badge
- MaterialBox (lightbox)
Navigation Components
Located in: sidenav.ts, breadcrumb.ts, tabs.ts, pagination.ts
- Sidenav
- Breadcrumb
- Tabs
- Pagination, PaginationControls
Layout Components
Located in: masonry.ts, image-list.ts, timeline.ts, carousel.ts, parallax.ts
- Masonry (Pinterest-style grid)
- ImageList (responsive galleries)
- Timeline (vertical event display)
- Carousel
- Parallax
Data Components
Located in: datatable.ts, treeview.ts, rating.ts, wizard.ts
- DataTable (sorting, filtering, pagination)
- TreeView (hierarchical data with expand/collapse)
- Rating (configurable star/icon rating with tooltip support)
- Wizard (multi-step stepper)
Collection Components
Located in: collection.ts, collapsible.ts
- Collection (basic, link, avatar with rich content support)
- Collapsible (accordion)
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'),
]),
},
],
})
Utility Components
Located in: theme-switcher.ts, file-upload.ts, code-block.ts
- ThemeSwitcher, ThemeToggle (light/dark theme)
- FileUpload (drag-and-drop)
- CodeBlock
TypeScript Type System
Core Types
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}`;
Utility Types
// 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 === '';
CSS Architecture
Modular CSS Structure
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)
Theme System
Dark/light theme support using CSS custom properties. The library defines 50+ CSS variables for complete theme customization.
Complete CSS Variables Reference
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 */
}
}
Programmatic Theme Control
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'
Custom Theme Colors
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;
}
Development Workflow
Directory Structure
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
Common Development Commands
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
Build System Details
Library Build (microbundle + rollup):
- Outputs: ESM, CommonJS, UMD formats
- External dependencies:
mithril(marked as external) - CSS: Compiled from SCSS, generates modular CSS files
- TypeScript: Full type definitions generated
Example Build (webpack):
- Hot reload dev server
- TypeScript transpilation
- CSS processing
Release Process
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:
- Cleans build artifacts
- Builds library
- Bumps version in package.json
- Creates git tag
- Publishes to npm
- Pushes tags to GitHub
Best Practices for Component Development
1. Component Structure
// 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 }) => { ... },
};
};
2. Prop Naming Conventions
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 asterisk
3. State Management
- Use
stateobject in factory closure for persistent state - Support both controlled and uncontrolled modes
- Warn developers about improper usage (value without handler)
- Use
state.internalValuefor uncontrolled mode - Track
state.hasInteractedfor validation timing
4. Event Handling
Standard 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);
}
})
5. Accessibility
- Always use proper semantic HTML
- Include ARIA attributes where needed
- Support keyboard navigation
- Use proper label associations (id/for)
- Support focus management (autofocus, tabindex)
- Provide meaningful error messages
6. Performance Optimization
- Use
FactoryComponentfor optimal performance - Avoid unnecessary redraws
- Use proper cleanup in
onremove - Minimize DOM operations
- Use CSS for animations/transitions
Common Development Tasks
Adding a New Component
- Create component file:
packages/lib/src/my-component.ts - Define types: Extend
Attributes, document with JSDoc - Implement factory: Follow the standard pattern
- Export from index: Add to
packages/lib/src/index.ts - Add styles: Create or update relevant
.scssfile - Test manually: Add to example app
- Document: Update README, add example usage
Modifying Existing Component
- Read the component: Understand current implementation
- Check types: Ensure TypeScript types are correct
- Test both modes: Verify controlled and uncontrolled work
- Maintain backward compatibility: Don't break existing APIs
- Update docs: Update JSDoc comments
Fixing Validation Issues
- Check
validatefunction implementation - Verify validation runs on correct events (blur, not input)
- Ensure
state.hasInteractedis tracked correctly - Test with both custom validators and built-in HTML5 validation
- Verify error messages display correctly
Debugging CSS Issues
- Check which CSS module includes the styles
- Verify CSS custom properties for theming
- Test in both light and dark themes
- Check MaterializeCSS compatibility
- Verify responsive behavior
Performance Issues
- Check for unnecessary redraws (add console.log in view)
- Verify proper use of
FactoryComponent - Look for memory leaks (cleanup in
onremove) - Check for heavy computations in
view - Profile with browser dev tools
Integration Examples
Basic Form
import 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 }),
}),
]),
};
};
Advanced Data Table
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,
})
Theme Integration
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.
},
})
Testing Guidelines
Manual Testing Checklist
- [ ] Test controlled mode with value + oninput
- [ ] Test uncontrolled mode with defaultValue
- [ ] Test readonly and disabled states
- [ ] Test validation (both success and error)
- [ ] Test keyboard navigation
- [ ] Test with and without labels
- [ ] Test error messages display correctly
- [ ] Test in both light and dark themes
- [ ] Test responsive behavior
- [ ] Test browser compatibility
Common Pitfalls
- Forgetting to support uncontrolled mode: Always implement both modes
- Validating on input instead of blur: Validate on blur for better UX
- Not tracking hasInteracted: Prevents showing errors before user interaction
- Missing cleanup: Always clean up in
onremove - Hardcoding IDs: Use
uniqueId()for component IDs - Breaking controlled mode: Never modify props.value directly
- Not documenting types: Always add JSDoc to public APIs
Troubleshooting
Component Not Updating
- Check if component is controlled (value prop requires oninput/onchange)
- Verify m.redraw() is called after state changes
- Ensure factory returns view function correctly
Validation Not Working
- Verify
validateprop is a function returning correct types - Check
state.hasInteractedis set on blur - Ensure validation runs in onblur, not oninput
Styles Not Applied
- Verify CSS is imported:
import 'mithril-materialized/index.css' - Check correct CSS module is imported for modular approach
- Verify className prop is passed through correctly
- Check for CSS specificity issues
TypeScript Errors
- Ensure component attrs extend
Attributesfrom mithril - Check types are exported from component file
- Verify types are re-exported from index.ts
- Check for conflicts with HTML attributes
Quick Reference
Must-Know Files
packages/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)
Key Functions
uniqueId()- Generate unique component IDThemeManager.setTheme()- Programmatically set themeThemeManager.toggle()- Toggle between light/dark
Common Patterns
- Factory component closure for state
- Controlled/uncontrolled detection
- Validation on blur, not input
- Label integration with isActive tracking
- Helper text with error/success states
What's New in v3.13
New Components:
- LikertScale: Purpose-built for survey questions with semantic scales
- Horizontal/vertical/responsive layouts
- Scale anchors (start/middle/end labels)
- Multi-question alignment for professional surveys
- Optional tooltips and number display
- Size and density variants
Enhanced Components:
- Rating: Tooltips now display correctly on hover when
showTooltips: trueandtooltipLabelsare provided - Collection: Items support rich content via
contentproperty (Mithril vnodes)
Use Cases:
- Survey forms with aligned Likert-scale questions
- Product/service satisfaction ratings with descriptive anchors
- Employee engagement surveys
- Customer feedback forms
- Multi-question assessments
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.
Use this skill
Most skills are portable instruction packages. Claude Code supports SKILL.md directly. Other agents can use adapted files like AGENTS.md, .cursorrules, and GEMINI.md.
Claude Code
Save SKILL.md into your Claude Skills folder, then restart Claude Code.
mkdir -p ~/.claude/skills/mithril-materialized-components && curl -L "https://raw.githubusercontent.com/erikvullings/mithril-materialized/51fa62a6f71f59ed1834fe365b792d58b5d780c2/SKILL.md" -o ~/.claude/skills/mithril-materialized-components/SKILL.mdInstalls to ~/.claude/skills/mithril-materialized-components/SKILL.md.
Use cases
TypeScript developers building performant Material Design UIs with Mithril.js who need expert guidance on component architecture, lifecycle management, and validation patterns.
Reviews
No reviews yet. Be the first to review this skill.
No signup required
Stats
Creator
EErik Vullings
@erikvullings