UNPKG

@melt-ui/svelte

Version:
354 lines (353 loc) 11.3 kB
import { getPlaceholder, toDate, isZonedDateTime, hasTime, getSegments, } from '../../../internal/helpers/date/index.js'; import { ALL_SEGMENT_PARTS, DATE_SEGMENT_PARTS, EDITABLE_SEGMENT_PARTS, TIME_SEGMENT_PARTS, } from './parts.js'; import { isBrowser, isNull, generateId, kbd, isNumberString, styleToString, } from '../../../internal/helpers/index.js'; import { get } from 'svelte/store'; export function initializeSegmentValues(granularity) { const calendarDateTimeGranularities = ['hour', 'minute', 'second']; const initialParts = EDITABLE_SEGMENT_PARTS.map((part) => { if (part === 'dayPeriod') { return [part, 'AM']; } return [part, null]; }).filter(([key]) => { if (key === 'literal' || key === null) return false; if (granularity === 'day') { return !calendarDateTimeGranularities.includes(key); } else { return true; } }); return Object.fromEntries(initialParts); } function createContentObj(props) { const { segmentValues, formatter, locale, dateRef } = props; const content = Object.keys(segmentValues).reduce((obj, part) => { if (!isSegmentPart(part)) return obj; if ('hour' in segmentValues && part === 'dayPeriod') { const value = segmentValues[part]; if (!isNull(value)) { obj[part] = value; } else { obj[part] = getPlaceholder(part, 'AM', locale); } } else { obj[part] = getPartContent(part); } return obj; }, {}); function getPartContent(part) { if ('hour' in segmentValues) { const value = segmentValues[part]; if (!isNull(value)) { return formatter.part(dateRef.set({ [part]: value }), part, { hourCycle: props.hourCycle === 24 ? 'h24' : undefined, }); } else { return getPlaceholder(part, '', locale); } } else { if (isDateSegmentPart(part)) { const value = segmentValues[part]; if (!isNull(value)) { return formatter.part(dateRef.set({ [part]: value }), part); } else { return getPlaceholder(part, '', locale); } } return ''; } } return content; } function createContentArr(props) { const { granularity, dateRef, formatter, contentObj, hideTimeZone, hourCycle } = props; const parts = formatter.toParts(dateRef, getOptsByGranularity(granularity, hourCycle)); const segmentContentArr = parts .map((part) => { const defaultParts = ['literal', 'dayPeriod', 'timeZoneName', null]; if (defaultParts.includes(part.type) || !isSegmentPart(part.type)) { return { part: part.type, value: part.value, }; } return { part: part.type, value: contentObj[part.type], }; }) .filter((segment) => { if (isNull(segment.part) || isNull(segment.value)) return false; if (segment.part === 'timeZoneName' && (!isZonedDateTime(dateRef) || hideTimeZone)) { return false; } return true; }); return segmentContentArr; } export function createContent(props) { const contentObj = createContentObj(props); const contentArr = createContentArr({ contentObj, ...props, }); return { obj: contentObj, arr: contentArr, }; } function getOptsByGranularity(granularity, hourCycle) { const opts = { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', timeZoneName: 'short', hourCycle: hourCycle === 24 ? 'h24' : undefined, hour12: hourCycle === 24 ? false : undefined, }; if (granularity === 'day') { delete opts.second; delete opts.hour; delete opts.minute; delete opts.timeZoneName; } if (granularity === 'hour') { delete opts.minute; } if (granularity === 'minute') { delete opts.second; } return opts; } export function initSegmentStates() { return EDITABLE_SEGMENT_PARTS.reduce((acc, key) => { acc[key] = { lastKeyZero: false, hasLeftFocus: true, hasTouched: false, }; return acc; }, {}); } export function initSegmentIds() { return Object.fromEntries(ALL_SEGMENT_PARTS.map((part) => { return [part, generateId()]; }).filter(([key]) => key !== 'literal')); } export function isDateSegmentPart(part) { return DATE_SEGMENT_PARTS.includes(part); } export function isSegmentPart(part) { return EDITABLE_SEGMENT_PARTS.includes(part); } export function isAnySegmentPart(part) { return ALL_SEGMENT_PARTS.includes(part); } /** * Get the segments being used/ are rendered in the DOM. * We're using this to determine when to set the value of * the date picker, which is when all the segments have * been filled. */ function getUsedSegments(id) { if (!isBrowser) return []; const usedSegments = getSegments(id) .map((el) => el.dataset.segment) .filter((part) => { return EDITABLE_SEGMENT_PARTS.includes(part); }); return usedSegments; } export function getValueFromSegments(props) { const { segmentObj, id, dateRef } = props; const usedSegments = getUsedSegments(id); let date = dateRef; usedSegments.forEach((part) => { if ('hour' in segmentObj) { const value = segmentObj[part]; if (isNull(value)) return; date = date.set({ [part]: segmentObj[part] }); return; } else if (isDateSegmentPart(part)) { const value = segmentObj[part]; if (isNull(value)) return; date = date.set({ [part]: segmentObj[part] }); return; } }); return date; } /** * Check if all the segments being used have been filled. * We use this to determine when we should set the value * store of the date field(s). * * @param segmentValues - The current `SegmentValueObj` * @param id - The id of the date field */ export function areAllSegmentsFilled(segmentValues, id) { const usedSegments = getUsedSegments(id); return usedSegments.every((part) => { if ('hour' in segmentValues) { return segmentValues[part] !== null; } else if (isDateSegmentPart(part)) { return segmentValues[part] !== null; } }); } /** * Extracts the segment part from the provided node, * if it exists, otherwise returns null. */ export function getPartFromNode(node) { const part = node.dataset.segment; if (!isAnySegmentPart(part)) return null; return part; } /** * Determines if the provided object is a valid `DateAndTimeSegmentObj` * by checking if it has the correct keys and values for each key. */ export function isDateAndTimeSegmentObj(obj) { if (typeof obj !== 'object' || obj === null) { return false; } return Object.entries(obj).every(([key, value]) => { const validKey = TIME_SEGMENT_PARTS.includes(key) || DATE_SEGMENT_PARTS.includes(key); const validValue = key === 'dayPeriod' ? value === 'AM' || value === 'PM' || value === null : typeof value === 'number' || value === null; return validKey && validValue; }); } /** * Infer the granularity to use based on the * value and granularity props. */ export function inferGranularity(value, granularity) { if (granularity) { return granularity; } if (hasTime(value)) { return 'minute'; } return 'day'; } export function isAcceptableSegmentKey(key) { const acceptableSegmentKeys = [ kbd.ENTER, kbd.ARROW_UP, kbd.ARROW_DOWN, kbd.ARROW_LEFT, kbd.ARROW_RIGHT, kbd.BACKSPACE, kbd.SPACE, ]; if (acceptableSegmentKeys.includes(key)) return true; if (isNumberString(key)) return true; return false; } /** * Sets the individual segment values based on the current * value of the date picker. This is used to initialize the * segment values if a default value is provided, and to * keep it in sync as the value changes outside the builder. */ export function syncSegmentValues(props) { const { value, updatingDayPeriod, segmentValues, formatter } = props; const dateValues = DATE_SEGMENT_PARTS.map((part) => { return [part, value[part]]; }); if ('hour' in value) { const timeValues = TIME_SEGMENT_PARTS.map((part) => { if (part === 'dayPeriod') { const $updatingDayPeriod = get(updatingDayPeriod); if ($updatingDayPeriod) { return [part, $updatingDayPeriod]; } else { return [part, formatter.dayPeriod(toDate(value))]; } } return [part, value[part]]; }); const mergedSegmentValues = [...dateValues, ...timeValues]; segmentValues.set(Object.fromEntries(mergedSegmentValues)); updatingDayPeriod.set(null); return; } segmentValues.set(Object.fromEntries(dateValues)); } /** * Determines if the element with the provided id is the first focusable * segment in the date field with the provided fieldId. * * @param id - The id of the element to check if it's the first segment * @param fieldId - The id of the date field associated with the segment */ export function isFirstSegment(id, fieldId) { if (!isBrowser) return false; const segments = getSegments(fieldId); return segments.length ? segments[0].id === id : false; } /** * Creates or updates a description element for a date field * which enables screen readers to read the date field's value. * * This element is hidden from view, and is portalled to the body * so it can be associated via `aria-describedby` and read by * screen readers as the user interacts with the date field. */ export function setDescription(id, formatter, value) { if (!isBrowser) return; const valueString = formatter.selectedDate(value); const el = document.getElementById(id); if (!el) { const div = document.createElement('div'); div.style.cssText = styleToString({ display: 'none', }); div.id = id; div.innerText = `Selected Date: ${valueString}`; document.body.appendChild(div); } else { el.innerText = `Selected Date: ${valueString}`; } } /** * Removes the description element for the date field with * the provided ID. This function should be called when the * date field is unmounted. */ export function removeDescriptionElement(id) { if (!isBrowser) return; const el = document.getElementById(id); if (!el) return; document.body.removeChild(el); }