UNPKG

mithril-materialized

Version:
1,193 lines (1,177 loc) 492 kB
'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.