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.
702 lines (613 loc) • 25.8 kB
text/typescript
//
// 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 datepickerSelector = 'dialog.micl-dialog.micl-datepicker';
type ValueElement = HTMLInputElement | HTMLButtonElement;
interface DatePickerState {
invoker : ValueElement | null;
selected: Date;
viewDate: Date; // the month/year currently being viewed
min : Date;
max : Date;
}
const stateMap = new WeakMap<HTMLDialogElement, DatePickerState>();
const locale = new Intl.DateTimeFormat().resolvedOptions().locale;
const formatters = {
input: new Intl.DateTimeFormat(locale, {
year : 'numeric',
month: '2-digit',
day : '2-digit'
}),
header: new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'short' }),
monthLong: new Intl.DateTimeFormat(undefined, { month: 'long' }),
monthShort: new Intl.DateTimeFormat(locale, { month: 'short' }),
weekdayNarrow: new Intl.DateTimeFormat(locale, { weekday: 'narrow' }),
weekdayLong: new Intl.DateTimeFormat(locale, { weekday: 'long' })
};
const toLocalMidnight = (date: Date): Date =>
{
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
};
const formatToInputDateValue = (d: Date): string =>
{
const month = (d.getMonth() + 1).toString().padStart(2, '0');
const day = d.getDate().toString().padStart(2, '0');
return `${d.getFullYear()}-${month}-${day}`;
};
const getDateFormat = (): string =>
{
return formatters.input.formatToParts(new Date(2025, 0, 15)).map(part =>
{
switch (part.type) {
case 'day' : return 'DD';
case 'month' : return 'MM';
case 'year' : return 'YYYY';
case 'literal': return part.value;
default: return '';
}
}).join('').trim();
};
const getFirstDayOfWeek = (): number =>
{
try {
const info = (new Intl.Locale(locale) as any).getWeekInfo?.();
if (info) {
return info.firstDay === 7 ? 0 : info.firstDay;
}
}
catch {}
return /US|CA|MX/i.test(locale) ? 0 : 1; // Sunday for USA, Mexico and Canada, Monday as default
};
const firstDayOfWeek = getFirstDayOfWeek();
const isValidDate = (date: Date): boolean => !isNaN(date.getTime());
const setText = (parent: Element | null, text: string): void =>
{
if (!parent) {
return;
}
if (parent.firstElementChild) {
let node = parent.firstChild;
while (node) {
if (node.nodeType === Node.TEXT_NODE) {
node.nodeValue = text;
return;
}
node = node.nextSibling;
}
parent.appendChild(document.createTextNode(text));
}
else {
parent.textContent = text;
}
};
const getCalendarDays = (
year : number,
month: number
): Array<{ date: Date, val: string, isCurrentMonth: boolean }> => {
const firstOfMonth = new Date(year, month, 1);
const dayOfWeek = firstOfMonth.getDay();
const offset = (dayOfWeek - firstDayOfWeek + 7) % 7;
const startDate = new Date(year, month, 1 - offset);
return Array.from({ length: 42 }, (_, i) => {
const current = new Date(startDate);
current.setDate(startDate.getDate() + i);
return {
date : current,
val : formatToInputDateValue(current),
isCurrentMonth: current.getMonth() === month
};
});
};
const renderCalendarHeader = (): DocumentFragment =>
{
const tempDate = new Date();
const startOffset = tempDate.getDay() - firstDayOfWeek;
tempDate.setDate(tempDate.getDate() - startOffset);
const fragment = document.createDocumentFragment();
for (let i = 0; i < 7; i++) {
const span = document.createElement('span');
span.style.gridArea = `1 / ${i + 1}`;
span.textContent = formatters.weekdayNarrow.format(tempDate);
span.title = formatters.weekdayLong.format(tempDate);
fragment.appendChild(span);
tempDate.setDate(tempDate.getDate() + 1);
}
return fragment;
};
const populateContainerWithDays = (
container: HTMLElement,
days : Array<{ date: Date, val: string, isCurrentMonth: boolean }>,
state : DatePickerState,
isEmpty : boolean = false
): void => {
if (isEmpty) {
const fragment = renderCalendarHeader();
days.forEach((_, index) => {
const time = document.createElement('time');
const row = Math.floor(index / 7) + 2;
const col = (index % 7) + 1;
time.style.gridArea = `${row} / ${col}`;
fragment.appendChild(time);
});
container.appendChild(fragment);
}
const today = toLocalMidnight(new Date());
container.querySelectorAll('time').forEach((el, index) =>
{
const day = days[index];
el.dateTime = day.val;
el.textContent = day.date.getDate().toString();
const isSelected = day.date.getTime() === state.selected.getTime();
const isToday = day.date.getTime() === today.getTime();
el.className = '';
if (!day.isCurrentMonth) el.classList.add('micl-datepicker__outside');
if (isSelected) el.classList.add('micl-datepicker__selected');
if (isToday) el.classList.add('micl-datepicker__today');
});
};
const renderCalendar = (
dialog: HTMLDialogElement,
state : DatePickerState,
amount: number = 0
): void => {
const content = dialog.querySelector<HTMLElement>('.micl-dialog__content');
const calendars = content?.querySelector<HTMLElement>('.micl-datepicker__calendars');
if (!calendars) {
return;
}
const startClass = 'micl-startleft';
const endClass = 'micl-startright';
const moveLeftClass = 'micl-moveleft';
const moveRightClass = 'micl-moveright';
calendars.classList.remove(moveLeftClass, moveRightClass, startClass, endClass);
void calendars.offsetWidth;
if (amount !== 0) {
const oldCalendar = calendars.querySelector<HTMLElement>('.micl-datepicker__calendar');
if (!oldCalendar) {
return;
}
const newCalendar = document.createElement('div');
const newCalendarInner = document.createElement('div');
newCalendar.classList.add('micl-datepicker__calendar');
newCalendarInner.classList.add('micl-datepicker__calendar-inner');
const days = getCalendarDays(state.viewDate.getFullYear(), state.viewDate.getMonth());
populateContainerWithDays(newCalendarInner, days, state, true);
const isNextMonth = amount > 0;
const startPositionClass = isNextMonth ? startClass : endClass;
const endTransformClass = isNextMonth ? moveLeftClass : moveRightClass;
newCalendar.appendChild(newCalendarInner);
if (isNextMonth) {
calendars.appendChild(newCalendar);
}
else {
calendars.prepend(newCalendar);
}
calendars.classList.add('micl-no-transition', startPositionClass);
void calendars.offsetWidth;
requestAnimationFrame(() => {
calendars.classList.remove('micl-no-transition', startPositionClass);
calendars.classList.add(endTransformClass);
});
const onTransitionEnd = () =>
{
calendars.removeEventListener('transitionend', onTransitionEnd);
setTimeout(() =>
{
calendars.classList.remove(endTransformClass);
if (oldCalendar.parentElement === calendars) {
oldCalendar.remove();
}
calendars.classList.add('micl-no-transition', startClass);
void calendars.offsetWidth;
calendars.classList.remove('micl-no-transition', startClass);
}, 0);
};
calendars.addEventListener('transitionend', onTransitionEnd);
}
else {
let calendar = calendars.querySelector<HTMLElement>('.micl-datepicker__calendar');
if (!calendar) {
calendar = document.createElement('div');
calendar.classList.add('micl-datepicker__calendar');
calendar = calendars.appendChild(calendar);
}
let inner = calendar.querySelector<HTMLElement>('.micl-datepicker__calendar-inner');
if (!inner) {
inner = document.createElement('div');
inner.classList.add('micl-datepicker__calendar-inner');
calendar.appendChild(inner);
}
const days = getCalendarDays(state.viewDate.getFullYear(), state.viewDate.getMonth());
populateContainerWithDays(inner, days, state, inner.querySelectorAll('time').length === 0);
}
const input = content?.querySelector<HTMLInputElement>('.micl-datepicker__input input');
if (input) {
input.value = formatters.input.format(state.selected);
if (input.value) {
input.dataset.miclvalue = '1';
}
else {
delete input.dataset.miclvalue;
}
if (!input.dataset.micldateformat) {
input.dataset.micldateformat = getDateFormat();
}
}
setText(dialog.querySelector('h1, h2, h3, h4, h5, h6, .micl-heading'), formatters.header.format(state.selected));
setText(dialog.querySelector('.micl-datepicker__month'), formatters.monthShort.format(state.viewDate));
setText(
dialog.querySelector('.micl-datepicker__year'),
state.viewDate.toLocaleDateString(locale, dialog.classList.contains('micl-dialog--docked') ?
{ year: 'numeric' } : { month: 'long', year: 'numeric' })
);
const monthInput = dialog.querySelector<HTMLInputElement>(`.micl-datepicker__months input[value="${state.viewDate.getMonth()}"]`);
if (monthInput) monthInput.checked = true;
const yearInput = dialog.querySelector<HTMLInputElement>(`.micl-datepicker__years input[value="${state.viewDate.getFullYear()}"]`);
if (yearInput) yearInput.checked = true;
};
const initPeriodPickers = (dialog: HTMLDialogElement, min: Date, max: Date): void =>
{
const minYear = min.getFullYear();
const maxYear = max.getFullYear();
['months', 'years'].forEach(period =>
{
const container = dialog.querySelector(`.micl-datepicker__${period}`);
if (!container) {
return;
}
container.innerHTML = '';
const frag = document.createDocumentFragment();
const maxMonth = max.getMonth();
if (period === 'months') {
const months: number[] = [];
let current = new Date(min.getFullYear(), min.getMonth(), 1);
while (
current <= max
|| (current.getMonth() === maxMonth && current.getFullYear() === maxYear)
) {
months.push(current.getMonth());
current.setMonth(current.getMonth() + 1);
}
[...new Set(months.sort((a, b) => a - b))].forEach(m => {
const label = document.createElement('label');
label.innerHTML = `<span class="material-symbols-outlined">check</span><input type="radio" name="miclmonth" value="${m}"> ${formatters.monthLong.format(new Date(2000, m, 1))}`;
frag.appendChild(label);
});
}
else {
for (let y = minYear; y <= maxYear; y++) {
const label = document.createElement('label');
label.innerHTML = `<input type="radio" name="miclyear" value="${y}"> ${y}`;
frag.appendChild(label);
}
}
const inner = document.createElement('div');
inner.classList.add(`micl-datepicker__${period}-inner`);
container.appendChild(inner).appendChild(frag);
});
};
const toggleView = (dialog: HTMLDialogElement, view: 'calendars' | 'months' | 'years' | 'input'): void =>
{
if (view === 'months' || view === 'years') {
if (!dialog.querySelector(`.micl-datepicker__${view}.micl-datepicker__view-hidden`)) {
view = 'calendars';
}
}
['calendars', 'input', 'month-selector', 'year-selector'].forEach(name =>
{
let doHide = view === 'input';
if (name === 'calendars' || name === 'input') {
doHide = view !== name;
}
dialog.querySelector(`.micl-datepicker__${name}`)?.classList.toggle(
'micl-datepicker__view-hidden',
doHide
);
});
const content = dialog.querySelector<HTMLElement>('.micl-dialog__content');
if (!content) {
return;
}
const contentHeight = parseInt(window.getComputedStyle(content).getPropertyValue('max-block-size'), 10);
['.micl-datepicker__months', '.micl-datepicker__years'].forEach(selector =>
{
const period = content.querySelector<HTMLElement>(selector);
if (!period) {
return;
}
const selected = period.querySelector<HTMLInputElement>('input:checked');
const height = 48;
let doHide: boolean | null = false;
if (selected && (selector.substring(18) === view)) {
const property = window.getComputedStyle(period).getPropertyValue('transition-duration');
const duration = parseFloat(property) * (property.includes('ms') ? 1 : 1000);
const maxScrollDistance = period.scrollHeight - contentHeight;
const centerTop = (contentHeight - height) / 2;
if (selected.offsetTop > centerTop) {
let scrollDistance = selected.offsetTop - centerTop - (height / 2);
if (scrollDistance > maxScrollDistance) {
scrollDistance = maxScrollDistance;
}
const startTime = performance.now();
const animateScroll = (currentTime: number) => {
const progress = Math.min((currentTime - startTime) / duration, 1);
content.scrollTop = scrollDistance * progress;
if (progress < 1) {
requestAnimationFrame(animateScroll);
}
};
period.classList.remove('micl-datepicker__view-hidden');
requestAnimationFrame(animateScroll);
doHide = null;
period.addEventListener('transitionend', function handler(event)
{
if (event.propertyName === 'height' || event.propertyName === 'block-size') {
content.scrollTop = scrollDistance;
period.removeEventListener('transitionend', handler);
}
});
}
}
else {
doHide = true;
}
if (doHide !== null) {
period.classList.toggle('micl-datepicker__view-hidden', doHide);
}
});
const mode = dialog.querySelector<HTMLElement>('.micl-datepicker__inputmode[data-miclalt]');
if (mode) {
if (!mode.dataset.miclalticon) {
mode.dataset.miclalticon = mode.textContent;
}
mode.textContent = (view === 'input' ? mode.dataset.miclalt : mode.dataset.miclalticon) || '';
}
};
const changePeriod = (dialog: HTMLDialogElement, amount: number, unit: 'month' | 'year'): void =>
{
const state = stateMap.get(dialog);
if (!state) {
return;
}
const newDate = new Date(state.viewDate);
if (unit === 'month') {
newDate.setMonth(newDate.getMonth() + amount);
}
else {
newDate.setFullYear(newDate.getFullYear() + amount);
}
const belowMin = state.min && newDate < state.min;
const aboveMax = state.max && newDate > state.max;
if (belowMin || aboveMax) {
dialog.querySelector('.micl-datepicker__calendars')?.animate([
{ transform: 'translateX(0)' },
{ transform: `translateX(${belowMin ? 8 : -8}px)` },
{ transform: 'translateX(0)' }
], { duration: 500, easing: 'ease-in-out' });
return;
}
state.viewDate = newDate;
renderCalendar(dialog, state, unit === 'month' ? amount : 0);
};
const selectDate = (dialog: HTMLDialogElement, dateStr: string, isLocaleFormatted = false): void =>
{
const state = stateMap.get(dialog);
if (!state) {
return;
}
let parts: number[] = [];
if (isLocaleFormatted) {
const dateformat = getDateFormat();
if (dateStr.length === dateformat.length) {
let d = '';
let m = '';
let y = '';
for (let i = 0; i < dateformat.length; i++) {
switch (dateformat[i]) {
case 'D': d += dateStr[i]; break;
case 'M': m += dateStr[i]; break;
case 'Y': y += dateStr[i]; break;
default:
}
}
parts = [parseInt(y, 10), parseInt(m, 10) - 1, parseInt(d, 10)];
}
}
else {
parts = dateStr.split('-').map(Number);
parts[1]--;
}
if (parts.length === 3) {
state.selected = new Date(parts[0], parts[1], parts[2]);
state.viewDate = new Date(state.selected);
renderCalendar(dialog, state);
}
};
export default (() =>
{
return {
keydown: (event: Event): void =>
{
if (
!(event instanceof KeyboardEvent)
|| !(event.target instanceof Element)
) {
return;
}
const dialog = event.target.closest(datepickerSelector) as HTMLDialogElement;
if (!dialog) {
return;
}
switch (event.key) {
case 'Enter':
case ' ':
if (event.target instanceof HTMLInputElement && event.target.type === 'date') {
event.preventDefault();
}
break;
case 'M':
toggleView(dialog, 'months');
break;
case 'Y':
toggleView(dialog, 'years');
break;
case 'PageUp':
case 'PageDown':
changePeriod(dialog, event.key === 'PageUp' ? 1 : -1, event.shiftKey ? 'year' : 'month');
break;
default:
}
},
initialize: (dialog: HTMLDialogElement): void =>
{
if (dialog.dataset.miclinitialized) {
return;
}
const form = dialog.querySelector('form');
const content = dialog.querySelector('.micl-dialog__content');
if (!form || !content) {
return;
}
dialog.dataset.miclinitialized = '1';
dialog.addEventListener('click', event =>
{
const target = event.target as HTMLElement;
const btn = target.closest('button');
if (btn) {
const forMonth = btn.parentElement?.classList.contains('micl-datepicker__month-selector');
const isNext = btn.classList.contains('micl-datepicker__next');
const isPrev = btn.classList.contains('micl-datepicker__previous');
if (isNext || isPrev) {
changePeriod(dialog, isNext ? 1 : -1, forMonth ? 'month' : 'year');
return;
}
}
if (target.closest('.micl-datepicker__month')) toggleView(dialog, 'months');
if (target.closest('.micl-datepicker__year')) toggleView(dialog, 'years');
const mode = target.closest('.micl-datepicker__inputmode') as HTMLElement;
if (mode) {
toggleView(dialog, !dialog.querySelector(
'.micl-datepicker__input.micl-datepicker__view-hidden'
) ? 'calendars' : 'input');
}
const time = target.closest('time');
if (time && time.dateTime) {
selectDate(dialog, time.dateTime);
}
if (
target instanceof HTMLInputElement
&& (target.name === 'miclmonth' || target.name === 'miclyear')
) {
const state = stateMap.get(dialog);
if (state) {
const value = parseInt(target.value, 10);
if (target.name === 'miclmonth') {
state.viewDate.setMonth(value);
}
else {
state.viewDate.setFullYear(value);
}
if (state.viewDate < state.min) {
state.viewDate = state.min;
}
else if (state.viewDate > state.max) {
state.viewDate = state.max;
}
renderCalendar(dialog, state);
toggleView(dialog, 'calendars');
}
}
});
dialog.addEventListener('beforetoggle', (event: any): void =>
{
if (event.newState !== 'open') {
return;
}
const isInvoker = (e: Element | null): e is ValueElement => e instanceof HTMLInputElement || e instanceof HTMLButtonElement;
let invoker = document.activeElement;
if (
!isInvoker(invoker)
|| (!invoker.dataset.datepicker && !invoker.popoverTargetElement && !(invoker as any).commandForElement)
) {
invoker = document.querySelector(
`[data-datepicker="${dialog.id}"],[popovertarget="${dialog.id}"],[commandfor="${dialog.id}"]`
);
}
if (!isInvoker(invoker)) {
return;
}
let initialDate = new Date();
let min = new Date(1900, 0, 1);
let max = new Date(2099, 11, 31);
if (invoker instanceof HTMLInputElement) {
if (invoker.type === 'date' && invoker.valueAsDate) {
initialDate = invoker.valueAsDate;
}
else if (invoker.value) {
initialDate = new Date(invoker.value);
}
if (invoker.min) min = new Date(invoker.min);
if (invoker.max) max = new Date(invoker.max);
}
else {
const parsed = new Date(invoker.value || invoker.textContent);
if (isValidDate(parsed)) {
initialDate = parsed;
}
}
if (!isValidDate(initialDate)) initialDate = new Date();
initialDate = toLocalMidnight(initialDate);
const state: DatePickerState = {
invoker,
selected: initialDate,
viewDate: new Date(initialDate),
min,
max
};
stateMap.set(dialog, state);
initPeriodPickers(dialog, min, max);
toggleView(dialog, 'calendars');
renderCalendar(dialog, state);
dialog.querySelector('.micl-datepicker__input input')?.addEventListener('blur', e =>
{
const element = e.target as HTMLInputElement;
selectDate(dialog, element.value, true);
}, { once: true });
});
dialog.addEventListener('close', (): void =>
{
const state = stateMap.get(dialog);
if (!state?.invoker || dialog.returnValue === '') {
return;
}
state.invoker.value = formatToInputDateValue(state.selected);
if (state.invoker instanceof HTMLInputElement) {
state.invoker.dispatchEvent(new Event('change', { bubbles: true }));
state.invoker.dispatchEvent(new Event('input', { bubbles: true }));
}
else {
state.invoker.textContent = state.selected.toLocaleDateString();
}
});
}
};
})();