mithril-materialized
Version:
A materialize library for mithril.
1,193 lines (1,177 loc) • 492 kB
JavaScript
'use strict';
var m = require('mithril');
/******************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
/* global Reflect, Promise, SuppressedError, Symbol, Iterator */
function __rest(s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
}
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
};
// Utility functions for the library
/**
* Create a unique ID
* @see https://stackoverflow.com/a/2117523/319711
*
* @returns id followed by 8 hexadecimal characters.
*/
const uniqueId = () => {
// tslint:disable-next-line:no-bitwise
return 'idxxxxxxxx'.replace(/[x]/g, () => ((Math.random() * 16) | 0).toString(16));
};
/**
* Create a GUID
* @see https://stackoverflow.com/a/2117523/319711
*
* @returns RFC4122 version 4 compliant GUID
*/
const uuid4 = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
// tslint:disable-next-line:no-bitwise
const r = (Math.random() * 16) | 0;
// tslint:disable-next-line:no-bitwise
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
};
/** Check if a string or number is numeric. @see https://stackoverflow.com/a/9716488/319711 */
const isNumeric = (n) => !isNaN(parseFloat(n)) && isFinite(n);
/**
* Sort options array based on sorting configuration
* @param options - Array of options to sort
* @param sortConfig - Sort configuration: 'asc', 'desc', 'none', or custom comparator function
* @returns Sorted array (or original if 'none' or undefined)
*/
const sortOptions = (options, sortConfig) => {
if (!sortConfig || sortConfig === 'none') {
return options;
}
const sorted = [...options]; // Create a copy to avoid mutating original
if (typeof sortConfig === 'function') {
return sorted.sort(sortConfig);
}
// Sort by label, fallback to id if no label
return sorted.sort((a, b) => {
const aLabel = (a.label || a.id.toString()).toLowerCase();
const bLabel = (b.label || b.id.toString()).toLowerCase();
const comparison = aLabel.localeCompare(bLabel);
return sortConfig === 'asc' ? comparison : -comparison;
});
};
/**
* Pad left, default width 2 with a '0'
*
* @see http://stackoverflow.com/a/10073788/319711
* @param {(string | number)} n
* @param {number} [width=2]
* @param {string} [z='0']
* @returns
*/
const padLeft = (n, width = 2, z = '0') => String(n).padStart(width, z);
// Keep only essential dropdown positioning styles
const getDropdownStyles = (inputRef, overlap = false, options, isDropDown = false) => {
if (!inputRef) {
return {
display: 'block',
opacity: 1,
position: 'absolute',
top: overlap ? 0 : '100%',
left: '0',
zIndex: 1000,
width: '100%',
};
}
const rect = inputRef.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Calculate dropdown height based on options
let estimatedHeight = 200; // Default fallback
const itemHeight = 52; // Standard height for dropdown items
if (options) {
const groupHeaderHeight = 52; // Height for group headers
// Count groups and total options
const groups = new Set();
let totalOptions = 0;
options
.filter((o) => !o.divider)
.forEach((option) => {
totalOptions++;
if (option.group) {
groups.add(option.group);
}
});
// Calculate total height: options + group headers + padding
estimatedHeight = totalOptions * itemHeight + groups.size * groupHeaderHeight;
}
const spaceBelow = viewportHeight - rect.bottom;
const spaceAbove = rect.top;
// If there's not enough space below and more space above, position dropdown above
const shouldPositionAbove = spaceBelow < estimatedHeight && spaceAbove > spaceBelow;
// Calculate available space and whether scrolling is needed
const availableSpace = shouldPositionAbove ? spaceAbove : spaceBelow;
// When positioning above, we need to consider the actual space from viewport top to input
let effectiveAvailableSpace = availableSpace;
if (shouldPositionAbove) {
effectiveAvailableSpace = rect.top - 10; // Space from viewport top to input, minus margin
}
const needsScrolling = estimatedHeight > effectiveAvailableSpace;
// Calculate the actual height the dropdown will take
const actualHeight = needsScrolling ? effectiveAvailableSpace : estimatedHeight;
// Calculate positioning when dropdown should appear above
let topOffset;
if (shouldPositionAbove) {
// Calculate how much space we actually have from top of viewport to top of input
const availableSpaceFromViewportTop = rect.top;
// If dropdown fits comfortably above input, use normal positioning
if (actualHeight <= availableSpaceFromViewportTop) {
topOffset = 12 - actualHeight + (isDropDown ? itemHeight : 0); // Bottom of dropdown aligns with top of input
}
else {
// If dropdown is too tall, position it at the very top of viewport
// This makes the dropdown use all available space from viewport top to input top
topOffset = -availableSpaceFromViewportTop + 5; // 5px margin from viewport top
}
}
else {
topOffset = overlap ? 0 : '100%';
}
const styles = {
display: 'block',
opacity: 1,
position: 'absolute',
top: typeof topOffset === 'number' ? `${topOffset}px` : topOffset,
left: '0',
zIndex: 1000,
width: `${rect.width}px`,
};
// Only add scrolling constraints when necessary
if (needsScrolling) {
styles.maxHeight = `${actualHeight}px`;
styles.overflowY = 'auto';
}
return styles;
};
/**
* Generate a range of numbers from a to and including b, i.e. [a, b]
* @example: console.log(range(5, 10)); // [5, 6, 7, 8, 9, 10]
*/
const range = (a, b) => Array.from({ length: b - a + 1 }, (_, i) => a + i);
// Global registry for portal containers
const portalContainers = new Map();
/**
* Creates or retrieves a portal container appended to document.body.
* Uses reference counting to manage container lifecycle.
*
* @param id - Unique identifier for the portal container
* @param zIndex - Z-index for the portal container (default: 1004, above modals at 1003)
* @returns The portal container element
*/
const getPortalContainer = (id, zIndex = 1004) => {
let container = portalContainers.get(id);
if (!container) {
const element = document.createElement('div');
element.id = id;
element.style.position = 'fixed';
element.style.top = '0';
element.style.left = '0';
element.style.width = '100%';
element.style.height = '100%';
element.style.pointerEvents = 'none'; // Allow clicks through to underlying elements
element.style.zIndex = zIndex.toString();
document.body.appendChild(element);
container = { element, refCount: 0 };
portalContainers.set(id, container);
}
container.refCount++;
return container.element;
};
/**
* Decrements reference count and removes portal container if no longer needed.
*
* @param id - Portal container identifier
*/
const releasePortalContainer = (id) => {
const container = portalContainers.get(id);
if (container) {
container.refCount--;
if (container.refCount <= 0) {
container.element.remove();
portalContainers.delete(id);
}
}
};
/**
* Renders a Mithril vnode into a portal container using m.render().
* This allows components to render outside their parent DOM hierarchy,
* useful for modals and pickers that need to escape stacking contexts.
*
* @param containerId - Portal container identifier
* @param vnode - Mithril vnode to render
* @param zIndex - Z-index for portal container (default: 1004)
*/
const renderToPortal = (containerId, vnode, zIndex = 1004) => {
const container = getPortalContainer(containerId, zIndex);
m.render(container, vnode);
};
/**
* Clears portal content and releases container reference.
* If this is the last reference, the container will be removed from the DOM.
*
* @param containerId - Portal container identifier
*/
const clearPortal = (containerId) => {
const container = portalContainers.get(containerId);
if (container) {
m.render(container.element, null);
releasePortalContainer(containerId);
}
};
// import './styles/input.css';
const Mandatory = { view: ({ attrs }) => m('span.mandatory', Object.assign({}, attrs), '*') };
/** Simple label element, used for most components. */
const Label = () => {
return {
view: (_a) => {
var _b = _a.attrs, { label, id, isMandatory, isActive, className, initialValue } = _b, params = __rest(_b, ["label", "id", "isMandatory", "isActive", "className", "initialValue"]);
return label
? m('label', Object.assign(Object.assign({}, params), { className: [className, isActive ? 'active' : ''].filter(Boolean).join(' ').trim() || undefined, for: id, oncreate: ({ dom }) => {
if (!initialValue)
return;
const labelEl = dom;
labelEl.classList.add('active');
} }), [m.trust(label), isMandatory ? m(Mandatory) : undefined])
: undefined;
},
};
};
/** Create a helper text, often used for displaying a small help text. May be replaced by the validation message. */
const HelperText = () => {
return {
view: ({ attrs: { helperText, dataError, dataSuccess, className } }) => {
return helperText || dataError || dataSuccess
? m('span.helper-text.left', { className, 'data-error': dataError, 'data-success': dataSuccess }, dataError ? m.trust(dataError) : dataSuccess ? m.trust(dataSuccess) : helperText ? m.trust(helperText) : '')
: undefined;
},
};
};
/** Component to auto complete your text input - Pure Mithril implementation */
const Autocomplete = () => {
const state = {
id: uniqueId(),
isActive: false,
internalValue: '',
isOpen: false,
suggestions: [],
selectedIndex: -1,
inputElement: null,
};
const isControlled = (attrs) => 'value' in attrs && typeof attrs.value !== 'undefined' && typeof attrs.oninput === 'function';
const filterSuggestions = (input, data, limit, minLength) => {
if (!input || input.length < minLength) {
return [];
}
const filtered = Object.entries(data || {})
.filter(([key]) => key.toLowerCase().includes(input.toLowerCase()))
.map(([key, value]) => ({ key, value }))
.slice(0, limit);
return filtered;
};
const selectSuggestion = (suggestion, attrs) => {
const controlled = isControlled(attrs);
// Update internal state for uncontrolled mode
if (!controlled) {
state.internalValue = suggestion.key;
}
state.isOpen = false;
state.selectedIndex = -1;
if (attrs.oninput) {
attrs.oninput(suggestion.key);
}
if (attrs.onchange) {
attrs.onchange(suggestion.key);
}
if (attrs.onAutocomplete) {
attrs.onAutocomplete(suggestion.key);
}
};
const handleKeydown = (e, attrs) => {
if (!state.isOpen)
return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
state.selectedIndex = Math.min(state.selectedIndex + 1, state.suggestions.length - 1);
break;
case 'ArrowUp':
e.preventDefault();
state.selectedIndex = Math.max(state.selectedIndex - 1, -1);
break;
case 'Enter':
e.preventDefault();
if (state.selectedIndex >= 0 && state.suggestions[state.selectedIndex]) {
state.isOpen = false;
selectSuggestion(state.suggestions[state.selectedIndex], attrs);
}
break;
case 'Escape':
e.preventDefault();
state.isOpen = false;
state.selectedIndex = -1;
break;
}
};
const closeDropdown = (e) => {
const target = e.target;
const autocompleteWrapper = target.closest('.autocomplete-wrapper');
const dropdownContent = target.closest('.autocomplete-content');
// Close if clicking outside both the input wrapper and dropdown content
if (!autocompleteWrapper && !dropdownContent) {
state.isOpen = false;
state.selectedIndex = -1;
}
m.redraw();
};
const getDropdownStyles = () => {
if (!state.inputElement) {
return {
display: 'block',
width: '100%',
height: `${state.suggestions.length * 50}px`,
transformOrigin: '0px 0px',
opacity: state.isOpen ? 1 : 0,
transform: 'scaleX(1) scaleY(1)',
};
}
const rect = state.inputElement.getBoundingClientRect();
const inputWidth = rect.width;
return {
display: 'block',
width: `${inputWidth}px`,
height: `${state.suggestions.length * 50}px`,
transformOrigin: '0px 0px',
opacity: state.isOpen ? 1 : 0,
transform: 'scaleX(1) scaleY(1)',
position: 'absolute',
top: '100%',
left: '0',
zIndex: 1000,
};
};
return {
oninit: ({ attrs }) => {
// Initialize internal value for uncontrolled mode
if (!isControlled(attrs)) {
state.internalValue = attrs.defaultValue || '';
}
document.addEventListener('click', closeDropdown);
},
onremove: () => {
document.removeEventListener('click', closeDropdown);
},
view: ({ attrs }) => {
const id = attrs.id || state.id;
const { label, helperText, onchange, newRow, className = 'col s12', style, iconName, isMandatory, data = {}, limit = Infinity, minLength = 1 } = attrs, params = __rest(attrs, ["label", "helperText", "onchange", "newRow", "className", "style", "iconName", "isMandatory", "data", "limit", "minLength"]);
const controlled = isControlled(attrs);
const currentValue = controlled ? attrs.value || '' : state.internalValue;
const cn = newRow ? className + ' clear' : className;
// Update suggestions when input changes
state.suggestions = filterSuggestions(currentValue, data, limit, minLength);
// Check if there's a perfect match (exact key match, case-insensitive)
const hasExactMatch = currentValue.length >= minLength &&
Object.keys(data).some((key) => key.toLowerCase() === currentValue.toLowerCase());
// Only open dropdown if there are suggestions and no perfect match
state.isOpen = state.suggestions.length > 0 && currentValue.length >= minLength && !hasExactMatch;
const replacer = new RegExp(`(${currentValue})`, 'i');
return m('.input-field.autocomplete-wrapper', {
className: cn,
style,
}, [
iconName ? m('i.material-icons.prefix', iconName) : '',
m('input', Object.assign(Object.assign({}, params), { className: 'autocomplete', type: 'text', tabindex: 0, id, value: currentValue, oncreate: (vnode) => {
state.inputElement = vnode.dom;
// Set initial value for uncontrolled mode
if (!controlled && attrs.defaultValue) {
vnode.dom.value = attrs.defaultValue;
}
}, oninput: (e) => {
const target = e.target;
const inputValue = target.value;
state.selectedIndex = -1;
// Update internal state for uncontrolled mode
if (!controlled) {
state.internalValue = inputValue;
}
// Call oninput and onchange if provided
if (attrs.oninput) {
attrs.oninput(inputValue);
}
if (onchange) {
onchange(inputValue);
}
}, onkeydown: (e) => {
handleKeydown(e, attrs);
// Call original onkeydown if provided
if (attrs.onkeydown) {
attrs.onkeydown(e, currentValue);
}
}, onfocus: () => {
state.isActive = true;
if (currentValue.length >= minLength) {
// Check for perfect match on focus too
const hasExactMatch = Object.keys(data).some((key) => key.toLowerCase() === currentValue.toLowerCase());
state.isOpen = state.suggestions.length > 0 && !hasExactMatch;
}
}, onblur: (e) => {
state.isActive = false;
if (!e.relatedTarget || !e.relatedTarget.closest('.autocomplete-content')) {
state.isOpen = false;
state.selectedIndex = -1;
}
} })),
// Autocomplete dropdown
state.isOpen &&
m('ul.autocomplete-content.dropdown-content', {
style: getDropdownStyles(),
}, state.suggestions.map((suggestion, index) => m('li', {
key: suggestion.key,
class: state.selectedIndex === index ? 'active' : '',
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
selectSuggestion(suggestion, attrs);
},
onmouseover: () => {
state.selectedIndex = index;
},
}, [
// Check if value contains image URL or icon
suggestion.value && suggestion.value.includes('http')
? m('img', {
src: suggestion.value,
class: 'right circle',
onerror: (e) => {
// Hide image if it fails to load
e.target.style.display = 'none';
},
})
: suggestion.value && suggestion.value.startsWith('icon:')
? m('i.material-icons', {
style: {
fontSize: '24px',
color: 'var(--md-grey-600)',
},
}, suggestion.value.replace('icon:', ''))
: null,
m('span', suggestion.key
? m.trust(suggestion.key.replace(replacer, (i) => `<span class="highlight">${i}</span>`))
: ''),
]))),
m(Label, {
label,
id,
isMandatory,
isActive: state.isActive || currentValue.length > 0 || !!attrs.placeholder || !!attrs.value,
}),
m(HelperText, { helperText }),
]);
},
};
};
/**
* Badge component
*
* Displays a badge anchored to a child element. Commonly used for notifications,
* counts, or status indicators. Supports flexible positioning, colors, and variants.
*
* @example
* ```typescript
* // Basic notification badge
* m(Badge, { badgeContent: 5 },
* m('button.btn', 'Messages')
* )
*
* // Dot badge on avatar
* m(Badge, {
* variant: 'dot',
* color: 'green',
* overlap: 'circular'
* },
* m('img.circle', { src: 'avatar.jpg' })
* )
* ```
*/
const Badge = () => {
return {
view: ({ attrs, children }) => {
const { badgeContent, max, anchorOrigin = { vertical: 'top', horizontal: 'right' }, overlap = 'rectangular', variant = 'standard', color = 'red', colorIntensity, invisible = false, showZero = false, 'aria-label': ariaLabel, badgeClassName = '', className = '' } = attrs, params = __rest(attrs, ["badgeContent", "max", "anchorOrigin", "overlap", "variant", "color", "colorIntensity", "invisible", "showZero", 'aria-label', "badgeClassName", "className"]);
// === VALIDATION: Single child element ===
const childArray = Array.isArray(children) ? children : children ? [children] : [];
if (childArray.length === 0) {
console.warn('Badge component requires a child element');
return null;
}
if (childArray.length > 1) {
console.warn('Badge component should only wrap a single child element. Using first child only.');
}
const child = childArray[0];
// === VISIBILITY LOGIC ===
// Hide badge if:
// 1. invisible prop is true, OR
// 2. For standard variant: badgeContent is undefined/null OR (badgeContent is 0 AND !showZero)
const shouldHideBadge = invisible ||
(variant === 'standard' &&
(badgeContent === undefined ||
badgeContent === null ||
(badgeContent === 0 && !showZero)));
// === BADGE CONTENT FORMATTING ===
// Apply max capping: if badgeContent > max, show "max+"
const getDisplayContent = () => {
if (variant === 'dot')
return '';
if (typeof badgeContent === 'number' && max !== undefined && badgeContent > max) {
return `${max}+`;
}
return String(badgeContent !== null && badgeContent !== void 0 ? badgeContent : '');
};
const displayContent = getDisplayContent();
// === CSS CLASS ASSEMBLY ===
// Wrapper classes
const wrapperClasses = ['badge-wrapper', className].filter(Boolean).join(' ').trim() || undefined;
// Badge element classes - using m-badge prefix to avoid Materialize conflicts
const positionClass = `m-badge--${anchorOrigin.vertical}-${anchorOrigin.horizontal}`;
const badgeClasses = [
'm-badge',
`m-badge--${variant}`,
positionClass,
`m-badge--${overlap}`,
`m-badge--${color}`,
colorIntensity ? `m-badge--${colorIntensity}` : '',
shouldHideBadge ? 'm-badge--invisible' : '',
badgeClassName,
]
.filter(Boolean)
.join(' ')
.trim();
// === ARIA ATTRIBUTES ===
const badgeAriaLabel = ariaLabel ||
(variant === 'dot'
? 'notification indicator'
: displayContent
? `${displayContent} notifications`
: 'notification badge');
// === RENDER ===
return m('.badge-wrapper', Object.assign(Object.assign({}, params), { className: wrapperClasses }), [
// Child element
child,
// Badge element - only render if not hidden
!shouldHideBadge
? m('span', {
className: badgeClasses,
'aria-label': badgeAriaLabel,
role: 'status',
'aria-live': 'polite',
}, variant === 'standard' ? displayContent : null)
: null,
]);
},
};
};
/**
* A simple material icon, defined by its icon name.
*
* @example m(Icon, { className: 'small' }, 'create') renders a small 'create' icon
* @example m(Icon, { className: 'prefix' }, iconName) renders the icon as a prefix
*/
const Icon = () => ({
view: (_a) => {
var _b = _a.attrs, { iconName } = _b, passThrough = __rest(_b, ["iconName"]);
return m('i.material-icons', passThrough, iconName);
},
});
/*!
* Waves Effect for Mithril Materialized
* Based on Waves v0.6.4 by Alfiana E. Sibuea
* Adapted for TypeScript and Mithril integration
*/
class WavesEffect {
static offset(elem) {
const rect = elem.getBoundingClientRect();
return {
top: rect.top + window.pageYOffset,
left: rect.left + window.pageXOffset
};
}
static createRipple(e, element) {
// Disable right click
if (e.button === 2) {
return;
}
// Create ripple element
const ripple = document.createElement('div');
ripple.className = 'waves-ripple';
// Get click position relative to element
const pos = this.offset(element);
const relativeY = e.pageY - pos.top;
const relativeX = e.pageX - pos.left;
// Calculate scale based on element size
const scale = (element.clientWidth / 100) * 10;
// Set initial ripple position and style
ripple.style.cssText = `
top: ${relativeY}px;
left: ${relativeX}px;
transform: scale(0);
opacity: 1;
`;
// Add ripple to element
element.appendChild(ripple);
// Force reflow and animate
ripple.offsetHeight;
ripple.style.transform = `scale(${scale})`;
ripple.style.opacity = '1';
// Store reference for cleanup
ripple.setAttribute('data-created', Date.now().toString());
}
static removeRipples(element) {
const ripples = element.querySelectorAll('.waves-ripple');
ripples.forEach((ripple) => {
const created = parseInt(ripple.getAttribute('data-created') || '0');
const age = Date.now() - created;
const fadeOut = () => {
ripple.style.opacity = '0';
setTimeout(() => {
if (ripple.parentNode) {
ripple.parentNode.removeChild(ripple);
}
}, this.duration);
};
if (age >= 350) {
fadeOut();
}
else {
setTimeout(fadeOut, 350 - age);
}
});
}
}
WavesEffect.duration = 750;
WavesEffect.onMouseDown = (e) => {
const element = e.currentTarget;
if (element && element.classList.contains('waves-effect')) {
WavesEffect.createRipple(e, element);
}
};
WavesEffect.onMouseUp = (e) => {
const element = e.currentTarget;
if (element && element.classList.contains('waves-effect')) {
WavesEffect.removeRipples(element);
}
};
WavesEffect.onMouseLeave = (e) => {
const element = e.currentTarget;
if (element && element.classList.contains('waves-effect')) {
WavesEffect.removeRipples(element);
}
};
WavesEffect.onTouchStart = (e) => {
const element = e.currentTarget;
if (element && element.classList.contains('waves-effect')) {
WavesEffect.createRipple(e, element);
}
};
WavesEffect.onTouchEnd = (e) => {
const element = e.currentTarget;
if (element && element.classList.contains('waves-effect')) {
WavesEffect.removeRipples(element);
}
};
/**
* A factory to create new buttons.
*
* @example FlatButton = ButtonFactory('a.waves-effect.waves-teal.btn-flat');
*/
const ButtonFactory = (element, defaultClassNames, type = '') => {
return () => {
return {
view: ({ attrs }) => {
const { tooltip, tooltipPosition, tooltipPostion, // Keep for backwards compatibility
iconName, iconClass, label, className, variant } = attrs, params = __rest(attrs, ["tooltip", "tooltipPosition", "tooltipPostion", "iconName", "iconClass", "label", "className", "variant"]);
// Use variant or fallback to factory type
const buttonType = variant || type || 'button';
const cn = [tooltip ? 'tooltipped' : '', defaultClassNames, className].filter(Boolean).join(' ').trim();
// Use tooltipPosition if available, fallback to legacy tooltipPostion
const position = tooltipPosition || tooltipPostion || 'top';
// Add waves effect event handlers if waves-effect class is present
const wavesHandlers = cn.includes('waves-effect') ? {
onmousedown: WavesEffect.onMouseDown,
onmouseup: WavesEffect.onMouseUp,
onmouseleave: WavesEffect.onMouseLeave,
ontouchstart: WavesEffect.onTouchStart,
ontouchend: WavesEffect.onTouchEnd
} : {};
return m(element, Object.assign(Object.assign(Object.assign({}, params), wavesHandlers), { className: cn, 'data-position': tooltip ? position : undefined, 'data-tooltip': tooltip || undefined, type: buttonType }), iconName ? m(Icon, { iconName, className: iconClass || 'left' }) : undefined, label ? label : undefined);
},
};
};
};
const Button = ButtonFactory('a', 'waves-effect waves-light btn', 'button');
const LargeButton = ButtonFactory('a', 'waves-effect waves-light btn-large', 'button');
const SmallButton = ButtonFactory('a', 'waves-effect waves-light btn-small', 'button');
const FlatButton = ButtonFactory('a', 'waves-effect waves-teal btn-flat', 'button');
const IconButton = ButtonFactory('button', 'btn-flat btn-icon waves-effect waves-teal', 'button');
const RoundIconButton = ButtonFactory('button', 'btn-floating btn-large waves-effect waves-light', 'button');
const SubmitButton = ButtonFactory('button', 'btn waves-effect waves-light', 'submit');
/**
* Materialize CSS Carousel component with dynamic positioning
* Port of the original MaterializeCSS carousel logic
*/
const Carousel = () => {
// Default options based on original Materialize CSS
const defaults = {
duration: 200, // ms
dist: -100, // zoom scale
shift: 0, // spacing for center image
padding: 0, // Padding between non center items
numVisible: 5, // Number of visible items in carousel
fullWidth: false, // Change to full width styles
indicators: false, // Toggle indicators
noWrap: false, // Don't wrap around and cycle through items
};
const state = {
// Carousel state
hasMultipleSlides: false,
showIndicators: false,
noWrap: false,
pressed: false,
dragged: false,
verticalDragged: false,
offset: 0,
target: 0,
center: 0,
// Touch/drag state
reference: 0,
referenceY: 0,
velocity: 0,
amplitude: 0,
frame: 0,
timestamp: 0,
// Item measurements
itemWidth: 0,
itemHeight: 0,
dim: 1, // Make sure dim is non zero for divisions
// Animation
ticker: null,
scrollingTimeout: null,
// Instance options (properly typed with defaults)
options: Object.assign({}, defaults),
};
// Utility functions
const xpos = (e) => {
// Touch event
if ('targetTouches' in e && e.targetTouches && e.targetTouches.length >= 1) {
return e.targetTouches[0].clientX;
}
// Mouse event
return e.clientX;
};
const ypos = (e) => {
// Touch event
if ('targetTouches' in e && e.targetTouches && e.targetTouches.length >= 1) {
return e.targetTouches[0].clientY;
}
// Mouse event
return e.clientY;
};
const wrap = (x, count) => {
return x >= count ? x % count : x < 0 ? wrap(count + (x % count), count) : x;
};
const track = () => {
const now = Date.now();
const elapsed = now - state.timestamp;
state.timestamp = now;
const delta = state.offset - state.frame;
state.frame = state.offset;
const v = (1000 * delta) / (1 + elapsed);
state.velocity = 0.8 * v + 0.2 * state.velocity;
};
const autoScroll = () => {
if (state.amplitude) {
const elapsed = Date.now() - state.timestamp;
const delta = state.amplitude * Math.exp(-elapsed / state.options.duration);
if (delta > 2 || delta < -2) {
scroll(state.target - delta);
requestAnimationFrame(autoScroll);
}
else {
scroll(state.target);
}
}
};
const updateItemStyle = (el, opacity, zIndex, transform) => {
el.style.transform = transform;
el.style.zIndex = zIndex.toString();
el.style.opacity = opacity.toString();
el.style.visibility = 'visible';
};
const scroll = (x, attrs) => {
const carouselEl = document.querySelector('.carousel');
if (!carouselEl)
return;
// Track scrolling state
if (!carouselEl.classList.contains('scrolling')) {
carouselEl.classList.add('scrolling');
}
if (state.scrollingTimeout != null) {
window.clearTimeout(state.scrollingTimeout);
}
state.scrollingTimeout = window.setTimeout(() => {
carouselEl.classList.remove('scrolling');
}, state.options.duration);
// Start actual scroll
const items = Array.from(carouselEl.querySelectorAll('.carousel-item'));
const count = items.length;
if (count === 0)
return;
const lastCenter = state.center;
const numVisibleOffset = 1 / state.options.numVisible;
state.offset = typeof x === 'number' ? x : state.offset;
state.center = Math.floor((state.offset + state.dim / 2) / state.dim);
const delta = state.offset - state.center * state.dim;
const dir = delta < 0 ? 1 : -1;
const tween = (-dir * delta * 2) / state.dim;
const half = count >> 1;
let alignment;
let centerTweenedOpacity;
if (state.options.fullWidth) {
alignment = 'translateX(0)';
centerTweenedOpacity = 1;
}
else {
alignment = `translateX(${(carouselEl.clientWidth - state.itemWidth) / 2}px) `;
alignment += `translateY(${(carouselEl.clientHeight - state.itemHeight) / 2}px)`;
centerTweenedOpacity = 1 - numVisibleOffset * tween;
}
// Set indicator active
if (state.showIndicators) {
const diff = state.center % count;
const indicators = carouselEl.querySelectorAll('.indicator-item');
indicators.forEach((indicator, index) => {
indicator.classList.toggle('active', index === diff);
});
}
// Center item
if (!state.noWrap || (state.center >= 0 && state.center < count)) {
const el = items[wrap(state.center, count)];
// Add active class to center item
items.forEach((item) => item.classList.remove('active'));
el.classList.add('active');
const transformString = `${alignment} translateX(${-delta / 2}px) translateX(${dir * state.options.shift * tween}px) translateZ(${state.options.dist * tween}px)`;
updateItemStyle(el, centerTweenedOpacity, 0, transformString);
}
// Side items
for (let i = 1; i <= half; ++i) {
let zTranslation;
let tweenedOpacity;
// Right side
if (state.options.fullWidth) {
zTranslation = state.options.dist;
tweenedOpacity = i === half && delta < 0 ? 1 - tween : 1;
}
else {
zTranslation = state.options.dist * (i * 2 + tween * dir);
tweenedOpacity = 1 - numVisibleOffset * (i * 2 + tween * dir);
}
if (!state.noWrap || state.center + i < count) {
const el = items[wrap(state.center + i, count)];
const transformString = `${alignment} translateX(${state.options.shift + (state.dim * i - delta) / 2}px) translateZ(${zTranslation}px)`;
updateItemStyle(el, tweenedOpacity, -i, transformString);
}
// Left side
if (state.options.fullWidth) {
zTranslation = state.options.dist;
tweenedOpacity = i === half && delta > 0 ? 1 - tween : 1;
}
else {
zTranslation = state.options.dist * (i * 2 - tween * dir);
tweenedOpacity = 1 - numVisibleOffset * (i * 2 - tween * dir);
}
if (!state.noWrap || state.center - i >= 0) {
const el = items[wrap(state.center - i, count)];
const transformString = `${alignment} translateX(${-state.options.shift + (-state.dim * i - delta) / 2}px) translateZ(${zTranslation}px)`;
updateItemStyle(el, tweenedOpacity, -i, transformString);
}
}
// onCycleTo callback
if (lastCenter !== state.center && attrs && attrs.onCycleTo) {
const currItem = items[wrap(state.center, count)];
if (currItem) {
const itemIndex = Array.from(items).indexOf(currItem);
attrs.onCycleTo(attrs.items[itemIndex], itemIndex, state.dragged);
}
}
};
const cycleTo = (n, callback, _attrs) => {
const items = document.querySelectorAll('.carousel-item');
const count = items.length;
if (count === 0)
return;
let diff = (state.center % count) - n;
// Account for wraparound
if (!state.noWrap) {
if (diff < 0) {
if (Math.abs(diff + count) < Math.abs(diff)) {
diff += count;
}
}
else if (diff > 0) {
if (Math.abs(diff - count) < diff) {
diff -= count;
}
}
}
state.target = state.dim * Math.round(state.offset / state.dim);
if (diff < 0) {
state.target += state.dim * Math.abs(diff);
}
else if (diff > 0) {
state.target -= state.dim * diff;
}
// Scroll
if (state.offset !== state.target) {
state.amplitude = state.target - state.offset;
state.timestamp = Date.now();
requestAnimationFrame(autoScroll);
}
};
// Event handlers
const handleCarouselTap = (e) => {
// Fixes firefox draggable image bug
if (e.type === 'mousedown' && e.target.tagName === 'IMG') {
e.preventDefault();
}
state.pressed = true;
state.dragged = false;
state.verticalDragged = false;
state.reference = xpos(e);
state.referenceY = ypos(e);
state.velocity = state.amplitude = 0;
state.frame = state.offset;
state.timestamp = Date.now();
if (state.ticker)
clearInterval(state.ticker);
state.ticker = setInterval(track, 100);
};
const handleCarouselDrag = (e, attrs) => {
if (state.pressed) {
const x = xpos(e);
const y = ypos(e);
const delta = state.reference - x;
const deltaY = Math.abs(state.referenceY - y);
if (deltaY < 30 && !state.verticalDragged) {
if (delta > 2 || delta < -2) {
state.dragged = true;
state.reference = x;
scroll(state.offset + delta, attrs);
}
}
else if (state.dragged) {
e.preventDefault();
e.stopPropagation();
return false;
}
else {
state.verticalDragged = true;
}
}
if (state.dragged) {
e.preventDefault();
e.stopPropagation();
return false;
}
return true;
};
const handleCarouselRelease = (e, _attrs) => {
if (state.pressed) {
state.pressed = false;
}
else {
return;
}
if (state.ticker)
clearInterval(state.ticker);
state.target = state.offset;
if (state.velocity > 10 || state.velocity < -10) {
state.amplitude = 0.9 * state.velocity;
state.target = state.offset + state.amplitude;
}
state.target = Math.round(state.target / state.dim) * state.dim;
// No wrap of items
if (state.noWrap) {
const items = document.querySelectorAll('.carousel-item');
if (state.target >= state.dim * (items.length - 1)) {
state.target = state.dim * (items.length - 1);
}
else if (state.target < 0) {
state.target = 0;
}
}
state.amplitude = state.target - state.offset;
state.timestamp = Date.now();
requestAnimationFrame(autoScroll);
if (state.dragged) {
e.preventDefault();
e.stopPropagation();
}
return false;
};
const handleCarouselClick = (e, attrs) => {
if (state.dragged) {
e.preventDefault();
e.stopPropagation();
return false;
}
else if (!state.options.fullWidth) {
const target = e.target.closest('.carousel-item');
if (target) {
const items = Array.from(document.querySelectorAll('.carousel-item'));
const clickedIndex = items.indexOf(target);
const diff = wrap(state.center, items.length) - clickedIndex;
if (diff !== 0) {
e.preventDefault();
e.stopPropagation();
}
cycleTo(clickedIndex);
}
}
return true;
};
const handleIndicatorClick = (e, attrs) => {
e.stopPropagation();
const indicator = e.target.closest('.indicator-item');
if (indicator) {
const indicators = Array.from(document.querySelectorAll('.indicator-item'));
const index = indicators.indexOf(indicator);
cycleTo(index);
}
};
return {
view: ({ attrs }) => {
const { items, indicators = false } = attrs;
if (!items || items.length === 0)
return undefined;
// Create instance-specific options without mutating globals
const instanceOptions = Object.assign(Object.assign({}, defaults), attrs);
// Update state options for this render
state.options = instanceOptions;
const supportTouch = typeof window.ontouchstart !== 'undefined';
return m('.carousel', {
oncreate: ({ attrs, dom }) => {
const carouselEl = dom;
const items = carouselEl.querySelectorAll('.carousel-item');
state.hasMultipleSlides = items.length > 1;
state.showIndicators = instanceOptions.indicators && state.hasMultipleSlides;
state.noWrap = instanceOptions.noWrap || !state.hasMultipleSlides;
if (items.length > 0) {
const firstItem = items[0];
state.itemWidth = firstItem.offsetWidth;
state.itemHeight = firstItem.offsetHeight;
state.dim = state.itemWidth * 2 + instanceOptions.padding || 1;
}
// Cap numVisible at count
instanceOptions.numVisible = Math.min(items.length, instanceOptions.numVisible);
state.options = instanceOptions;
// Initial scroll
scroll(state.offset, attrs);
},
onmousedown: (e) => handleCarouselTap(e),
onmousemove: (e) => handleCarouselDrag(e, attrs),
onmouseup: (e) => handleCarouselRelease(e),
onmouseleave: (e) => handleCarouselRelease(e),
onclick: (e) => handleCarouselClick(e),
ontouchstart: supportTouch ? (e) => handleCarouselTap(e) : undefined,
ontouchmove: supportTouch ? (e) => handleCarouselDrag(e, attrs) : undefined,
ontouchend: supportTouch ? (e) => handleCarouselRelease(e) : undefined,
}, [
// Carousel items
...items.map((item) => m('a.carousel-item', {
// key: index,
href: item.href,
style: 'visibility: hidden;', // Initially hidden, will be shown by scroll
}, m('img', { src: item.src, alt: item.alt }))),
// Indicators
indicators &&
items.length > 1 &&
m('ul.indicators', items.map((_, index) => m('li.indicator-item', {
key: `indicator-${index}`,
className: index === 0 ? 'active' : '',
onclick: (e) => handleIndicatorClick(e),
}))),
]);
},
};
};
const iconPaths = {
caret: [
'M7 10l5 5 5-5z', // arrow
'M0 0h24v24H0z', // background
],
close: [
'M18.3 5.71a1 1 0 0 0-1.41 0L12 10.59 7.11 5.7A1 1 0 0 0 5.7 7.11L10.59 12l-4.89 4.89a1 1 0 1 0 1.41 1.41L12 13.41l4.89 4.89a1 1 0 0 0 1.41-1.41L13.41 12l4.89-4.89a1 1 0 0 0 0-1.4z',
'M0 0h24v24H0z',
],
chevron: [
'M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z', // chevron down
'M0 0h24v24H0z', // background
],
chevron_left: [
'M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z', // chevron left
'M0 0h24v24H0z', // background
],
chevron_right: [
'M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z', // chevron right
'M0 0h24v24H0z', // background
],
menu: [
'M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z', // hamburger menu
'M0 0h24v24H0z', // background
],
expand: [
'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z', // plus
'M0 0h24v24H0z', // background
],
collapse: [
'M19 13H5v-2h14v2z', // minus
'M0 0h24v24H0z', // background
],
check: [
'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z', // checkmark
'M0 0h24v24H0z', // background
],
radio_checked: [
'M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zm0-5C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z', // radio button checked
'M0 0h24v24H0z', // background
],
radio_unchecked: [
'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z', // radio button unchecked
'M0 0h24v24H0z', // background
],
light_mode: [
'M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5M2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1m18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1M11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1m0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1M5.99 4.58a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41zm12.4 12.4a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.