UNPKG

material-inspired-component-library

Version:

The Material-Inspired Component Library (MICL) offers a collection of beautifully crafted components leveraging native HTML markup, designed to align with the Material Design 3 guidelines.

417 lines (365 loc) 15.1 kB
// // Copyright © 2025 Hermana AS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. export const timepickerSelector = 'dialog.micl-dialog.micl-timepicker'; type ValueElement = HTMLInputElement | HTMLButtonElement; interface TimeLimits { max: number, min: number } export default (() => { const uses12HourFormat = (() => { try { const hourCycle = new Intl.DateTimeFormat(undefined, { hour: 'numeric' }).resolvedOptions().hourCycle; return hourCycle === 'h11' || hourCycle === 'h12'; } catch (error) { return false; } })(); const getElement = <T extends Element>(parent: Element, selector: string): T | null => { return parent.querySelector(selector) as T | null; }; const isValueElement = (element: Element | null): element is ValueElement => { return element instanceof HTMLInputElement || element instanceof HTMLButtonElement; }; const isVisible = (element: Element | null): boolean => { return !!element && !element.classList.contains('micl-hidden'); }; const toggleSelection = (element: Element, force: boolean): void => { element.classList.toggle('micl-timepicker--selected', force); }; const getTimeLimits = (name: string): TimeLimits => { if (name === 'hour') { return { min: uses12HourFormat ? 1 : 0, max: uses12HourFormat ? 12 : 23 }; } return { min: 0, max: 59 }; }; const formatValue = (element: HTMLInputElement): void => { const { max, min } = getTimeLimits(element.name); let value = parseInt(element.value, 10); if (isNaN(value)) value = min; if (value > max) value = max; if (value < 0) value = min; element.value = String(value).padStart(2, '0'); }; const setInputAttributes = (input: HTMLInputElement): void => { const { min, max } = getTimeLimits(input.name); let pattern: string; if (input.name === 'hour') { pattern = uses12HourFormat ? '(0[1-9]|1[0-2])' : '(0[0-9]|1[0-9]|2[0-3])'; } else { pattern = '(0[0-9]|[1-5][0-9])'; } const attributes: Record<string, string> = { maxlength: '2', pattern: pattern, inputmode: 'numeric', autocomplete: 'off', role: 'spinbutton', min: String(min), max: String(max) }; for (const key in attributes) { input.setAttribute(key, attributes[key]); } }; const setDial = (dial: HTMLElement, name: string, value: string): void => { dial.querySelectorAll('data').forEach( e => e.classList.remove('micl-timepicker__time--selected') ); const mark = dial.querySelector(`data[data-${name}][value="${value}"]`); let angle = ''; if (mark) { angle = window.getComputedStyle(mark).getPropertyValue('--micl-angle'); mark.classList.add('micl-timepicker__time--selected'); } else if (name === 'minute') { angle = `${Math.round((parseInt(value, 10) * 360 / 60) - 90)}deg`; } !!angle && dial.style.setProperty('--micl-angle', angle); }; const setInputValue = ( dialog : HTMLElement, name : string, value? : string, setampm?: boolean, setdial : boolean = true ): void => { let numeric = parseInt(value || '0', 10); if (isNaN(numeric)) { return; } const input = getElement<HTMLInputElement>(dialog, `input[name=${name}]`); if (!input) { return; } if (name === 'hour' && setampm && uses12HourFormat) { const am = dialog.querySelector('.micl-timepicker__am') as HTMLInputElement; const pm = dialog.querySelector('.micl-timepicker__pm') as HTMLInputElement; if (numeric > 12) { if (pm) { pm.checked = true; } numeric -= 12; } else if (am) { am.checked = true; } } input.value = `${numeric}`.padStart(2, '0'); if (setdial) { const dial = getElement<HTMLElement>(dialog, '.micl-timepicker__dial'); if (!dial) { return; } setDial(dial, name, input.value); } } const addMarks = (dialog: HTMLElement, dial: HTMLElement): void => { let angle = uses12HourFormat ? 300 : 270; for (let i = (uses12HourFormat ? 1 : 0); i <= (uses12HourFormat ? 12 : 23); i++) { const mark = document.createElement('data') as HTMLDataElement; mark.value = `${i}`.padStart(2, '0'); mark.textContent = `${i}`; mark.dataset.hour = `${i}`; mark.style.setProperty('--micl-angle', `${angle}deg`); if (!uses12HourFormat && i >= 12) { mark.classList.add('micl-timepicker__dial-inner'); } else { mark.dataset.minute = `${(i * 5) % 60}`; } dial.appendChild(mark); angle = (angle + 30) % 360; } const track: HTMLSpanElement = document.createElement('span'); track.classList.add('micl-timepicker__track'); dial.appendChild(track); }; const showDialMarks = (dial: HTMLElement, name: string): void => { dial.querySelectorAll<HTMLDataElement>('data').forEach(mark => { if (!!mark.dataset[name]) { mark.textContent = mark.dataset[name]; mark.value = mark.dataset[name].padStart(2, '0'); } if (mark.classList.contains('micl-timepicker__dial-inner')) { mark.classList[name === 'hour' ? 'remove' : 'add']('micl-hidden'); } }); }; const handleSpinning = (dialog: HTMLElement, input: HTMLInputElement, event: KeyboardEvent): void => { if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') { return; } event.preventDefault(); const { max, min } = getTimeLimits(input.name); let value = parseInt(input.value, 10) || 0; value += event.key === 'ArrowUp' ? 1 : -1; if (value < min || value > max) { value = (value < min) ? max : min; if (input.name === 'hour' && uses12HourFormat) { const e = dialog.querySelector('input[name=period]:not(:checked)') as HTMLInputElement; e?.click(); } } setInputValue(dialog, input.name, `${value}`); }; return { initialize: (dialog: HTMLDialogElement): void => { if (dialog.dataset.miclinitialized) { return; } const form = getElement<HTMLFormElement>(dialog, 'form'); const mode = getElement<HTMLElement>(dialog, '.micl-timepicker__inputmode'); const dial = getElement<HTMLElement>(dialog, '.micl-timepicker__dial'); const inputs = [ getElement<HTMLInputElement>(dialog, 'input[name=hour]'), getElement<HTMLInputElement>(dialog, 'input[name=minute]') ].filter((input): input is HTMLInputElement => input !== null); if (!form || inputs.length < 2) { return; } dialog.dataset.miclinitialized = '1'; inputs.forEach((input, i) => { setInputAttributes(input); if (dial) { input.toggleAttribute('readonly', isVisible(dial)); } input.addEventListener('keydown', handleSpinning.bind(null, dialog, input)); input.addEventListener('focus', () => { toggleSelection(inputs[i === 0 ? 1 : 0], false); toggleSelection(input, true); if (dial) { showDialMarks(dial, input.name); setDial(dial, input.name, input.value); } }); input.addEventListener('blur', () => { if (!isVisible(dial)) { formatValue(input); toggleSelection(input, false); } }); }); const period = dialog.querySelector('.micl-timepicker__period'); if (period && uses12HourFormat) { ['am', 'pm'].forEach(ampm => { let e = document.createElement('input') as HTMLInputElement; e.type = 'radio'; e.name = 'period'; e.classList.add(`micl-timepicker__${ampm}`); e.value = ampm; e.ariaLabel = ampm.toUpperCase(); period.appendChild(e); }); period.classList.toggle('micl-hidden', !uses12HourFormat); } mode?.addEventListener('click', () => { const icon = mode.textContent; mode.textContent = mode.dataset.miclalt || icon; mode.dataset.miclalt = icon; dial?.classList.toggle('micl-hidden'); inputs.forEach(input => { input.toggleAttribute('readonly', isVisible(dial)); }); if (isVisible(dial)) { inputs[0].focus(); } }); if (dial) { addMarks(dialog, dial); const handleSelection = (clientX: number, clientY: number) => { const target = document.elementFromPoint(clientX, clientY); if (target && target.tagName === 'DATA') { setInputValue(dialog, !dialog.querySelector( 'input[name=hour].micl-timepicker--selected' ) ? 'minute' : 'hour', (target as HTMLDataElement).value); } }; dial.addEventListener('pointerdown', (event: PointerEvent) => { dial.classList.add('micl-timepicker__dial--dragging'); handleSelection(event.clientX, event.clientY); dial.setPointerCapture(event.pointerId); }); dial.addEventListener('pointermove', (event: PointerEvent) => { if (dial.classList.contains('micl-timepicker__dial--dragging')) { handleSelection(event.clientX, event.clientY); } }); const stopDragging = (event: PointerEvent) => { dial.classList.remove('micl-timepicker__dial--dragging'); dial.releasePointerCapture(event.pointerId); }; dial.addEventListener('pointerup', stopDragging); dial.addEventListener('pointercancel', stopDragging); } dialog.addEventListener('beforetoggle', (event): void => { if (event.oldState === 'open') { return; } let invoker = document.activeElement; if ( !isValueElement(invoker) || (!invoker.dataset.timepicker && !invoker.popoverTargetElement && !(invoker as any).commandForElement) ) { invoker = document.querySelector( `[data-timepicker="${dialog.id}"],[popovertarget="${dialog.id}"],[commandfor="${dialog.id}"]` ); } if (!isValueElement(invoker)) { return; } (dialog as any)._miclInvoker = invoker; const time = (invoker.value || invoker.textContent).split(':'); if (time.length === 2) { setInputValue(dialog, 'hour', time[0], true); setInputValue(dialog, 'minute', time[1], false, false); } }); dialog.addEventListener('close', (): void => { if (!dialog.returnValue) { return; } let invoker = (dialog as any)._miclInvoker; if (!invoker) { invoker = document.querySelector( `[data-timepicker="${dialog.id}"],[popovertarget="${dialog.id}"],[commandfor="${dialog.id}"]` ); } if (!isValueElement(invoker)) { return; } const inputs = form.elements; let h = parseInt((inputs.namedItem('hour') as HTMLInputElement)?.value || '0', 10); if (isNaN(h)) { return; } if (uses12HourFormat && (inputs.namedItem('period') as RadioNodeList)?.value === 'pm') { h += 12; } const m = parseInt((inputs.namedItem('minute') as HTMLInputElement)?.value || '0', 10); if (isNaN(m)) { return; } const time = `${h}`.padStart(2, '0') + ':' + `${m}`.padStart(2, '0'); invoker.value = time; if (invoker instanceof HTMLInputElement) { invoker.dispatchEvent(new Event('change', { bubbles: true })); invoker.dispatchEvent(new Event('input', { bubbles: true })); } else { invoker.textContent = time; } }); } }; })();