From 580d0346be7b0973ac21e0cd0332d3c0b9ab815e Mon Sep 17 00:00:00 2001 From: serafim-san Date: Fri, 6 Dec 2024 16:43:52 +0400 Subject: [PATCH] feat(input-calendar): added input calendar --- src/lib/ui/core/Calendar/DatePicker.svelte | 27 +- .../core/InputCalendar/InputCalendar.svelte | 292 +++--------------- src/lib/ui/core/InputCalendar/utils.svelte.ts | 253 +++++++++++++++ src/lib/ui/core/Popover/Popover.svelte | 24 +- .../Calendar/index.svelte | 2 +- 5 files changed, 326 insertions(+), 272 deletions(-) create mode 100644 src/lib/ui/core/InputCalendar/utils.svelte.ts diff --git a/src/lib/ui/core/Calendar/DatePicker.svelte b/src/lib/ui/core/Calendar/DatePicker.svelte index acd64741..08f10840 100644 --- a/src/lib/ui/core/Calendar/DatePicker.svelte +++ b/src/lib/ui/core/Calendar/DatePicker.svelte @@ -21,6 +21,7 @@ timeZone?: string children?: Snippet popoverRootProps?: ComponentProps['rootProps'] + popoverIsOpened?: boolean } type TSingleProps = { @@ -37,13 +38,14 @@ type TProps = TCommonProps & (TSingleProps | TRangeProps) - const { + let { as, class: className, maxDate, children: _children, minDate = new Date(2009, 0, 1), timeZone = BROWSER ? getLocalTimeZone() : 'utc', + popoverIsOpened = $bindable(false), ...rest }: TProps = $props() @@ -76,18 +78,21 @@ {@render label()} {:else} - + {#snippet children({ ref })} - + {:else} {@render label()} - + {/if} {/snippet} {#snippet content()} diff --git a/src/lib/ui/core/InputCalendar/InputCalendar.svelte b/src/lib/ui/core/InputCalendar/InputCalendar.svelte index c49588e7..0fa4fbf5 100644 --- a/src/lib/ui/core/InputCalendar/InputCalendar.svelte +++ b/src/lib/ui/core/InputCalendar/InputCalendar.svelte @@ -1,263 +1,51 @@ - - - +
onBlur(e, (newIsOpened) => (isOpened = newIsOpened))}> + + + +
diff --git a/src/lib/ui/core/InputCalendar/utils.svelte.ts b/src/lib/ui/core/InputCalendar/utils.svelte.ts new file mode 100644 index 00000000..fc9bd65a --- /dev/null +++ b/src/lib/ui/core/InputCalendar/utils.svelte.ts @@ -0,0 +1,253 @@ +import { ss } from 'svelte-runes' + +import { MONTH_NAMES, setDayStart, setDayEnd, getDateFormats } from '$lib/utils/dates/index.js' + +const MAX_DATE = setDayEnd(new Date()) + +export const getDaysInMonth = (year: any, month: any) => new Date(20 + year, month, 0).getDate() + +export function formatDate(date: Date) { + const { DD, MM, YY } = getDateFormats(date) + return `${DD}/${MM}/${YY}` +} + +export function formatValue(dates: [Date, Date] | [Date]) { + const formattedStart = formatDate(dates[0]) + return dates[1] ? formattedStart + ' - ' + formatDate(dates[1]) : formattedStart +} + +export function parseInputData(input: string) { + const parsedInput = input.split(' - ').map((item) => item.split('/')) + + return [ + parsedInput, + parsedInput.map(([day, month, year]) => { + const fullYear = `20${year || 23}` + const fullMonth = Math.min(+month, 12) || 1 + const fullDays = Math.min(+day || 1, getDaysInMonth(year, fullMonth)) + + return new Date(`${fullMonth}/${fullDays}/${fullYear}`) + }), + ] as [[[string, string, string], [string, string, string]], [Date, Date]] +} + +export function useInputCalendar(date: [Date, Date], onDateSelect: (date: [Date, Date]) => void) { + const labelElement = ss(null) + const inputNode = ss() + // const calendar = $state() + + $effect(() => { + if (inputNode) { + setInputValue(date) + } + }) + + function setInputValue(dates: [Date, Date]) { + inputNode.$.value = formatValue(dates) + } + + function changeCalendar(wasInputBlurred = false) { + const dates = validateInput(inputNode.$.value) + + if (dates) { + setInputValue(dates) + + // NOTE: Needed since calendar is unmounted on blur [@vanguard | 27 Jul, 2023] + if (wasInputBlurred) { + onDateSelect(dates) + } // else { + // calendar?.selectDate(dates) + // } + } + } + + function validateInput(input: string) { + const [parsedInput, dates] = parseInputData(input) + + const [from] = dates + let to = dates[1] + + if (+from === +to) { + setDayStart(from) + } else if (!to) { + to = new Date(from) + } + + dates[1] = to = setDayEnd(to) + if (dates[1] > MAX_DATE) dates[1] = MAX_DATE + + let msg = getValidityMsg(parsedInput[0]) || getValidityMsg(parsedInput[1]) + if (from > to) { + msg = 'Left date should be before right date' + } + + inputNode.$.setCustomValidity(msg) + inputNode.$.reportValidity() + + return msg ? null : dates + } + + function fixInputValue() { + let caret = inputNode.$.selectionStart as number + const [parsed, dates] = parseInputData(inputNode.$.value) + + setInputValue(dates) + + let offset = null as null | number + parsed.some((data, i) => { + const changed = data.findIndex((date) => date.toString().length < 2) + offset = changed > -1 ? changed + i * 3 : null + return offset !== null + }) + + if (offset !== null && caret > GROUP_INDICES[offset]) { + caret += 1 + } + + inputNode.$.selectionStart = caret + inputNode.$.selectionEnd = caret + + return caret + } + + function onBlur(e: FocusEvent, callback?: (newState: boolean) => void) { + const relatedTarget = e.relatedTarget as Node + + if (labelElement && labelElement.$?.contains(relatedTarget)) return callback?.(true) + + if (formatValue(date) !== inputNode.$.value) { + fixInputValue() + changeCalendar(true) + } + + return callback?.(false) + } + + function onClick() { + const caret = inputNode.$.selectionStart as number + + if ((inputNode.$.selectionEnd as number) - caret !== 2) { + selectNextGroup(inputNode.$, false, fixInputValue()) + } else { + inputNode.$.selectionStart = caret + inputNode.$.selectionEnd = caret // NOTE: Needed to preserve active selection [@vanguard | Jun 1, 2020] + inputNode.$.selectionEnd = caret + 2 + } + } + + function onKeyDown(e: KeyboardEvent) { + const { key, currentTarget } = e + const inputNode = currentTarget as HTMLInputElement & { + selectionStart: number + selectionEnd: number + } + + if (inputNode.selectionEnd - inputNode.selectionStart > 2) { + selectNextGroup(inputNode) + } + + const beforeCaretIndex = inputNode.selectionStart - 1 + const charBeforeCaret = inputNode.value[beforeCaretIndex] + const charAfterCaret = inputNode.value[inputNode.selectionStart] + + if (key === 'Enter') { + changeCalendar() + } else if (key === 'Backspace') { + if (checkIsValidNumber(charBeforeCaret)) return + } else if (NavigationChar[key]) { + selectNextGroup(inputNode, key === 'ArrowRight', fixInputValue()) + } else if ( + +checkIsValidNumber(key) ^ + (BlockingNeighbourChar[charBeforeCaret] && BlockingNeighbourChar[charAfterCaret]) + ) { + return + } + + return e.preventDefault() + } + + function onInput() { + const start = inputNode.$.selectionStart + + if (start) { + const nextGroupIndex = nextModifyableGroupIndex(start) + + if ( + start + 1 === nextGroupIndex || + start + 3 === nextGroupIndex || + nextGroupIndex + 2 === start + ) { + selectNextGroup(inputNode.$, true) + validateInput(inputNode.$.value) + } + } + } + + function selectNextGroup( + node: HTMLInputElement, + isRightDir = false, + caret = node.selectionStart, + ) { + const left = (isRightDir ? nextModifyableGroupIndex : prevModifyableGroupIndex)(caret as number) + node.selectionStart = left + node.selectionEnd = left + 2 + + // const isFirstDateFocused = node.selectionStart < 11 + + // calendar?.setViewDate(date[isFirstDateFocused ? 0 : 1]) + } + + const NavigationChar: any = { ArrowLeft: true, ArrowRight: true } + const BlockingNeighbourChar: any = { ' ': true, '-': true } + + const checkIsValidNumber = (char: string | number) => + Number.isFinite(+char) && char.toString().trim() !== '' + + const GROUP_INDICES = [0, 3, 6, 11, 14, 17] + function prevModifyableGroupIndex(caret: number) { + for (let i = GROUP_INDICES.length - 1; i > -1; i--) { + if (GROUP_INDICES[i] < caret) { + return GROUP_INDICES[i] + } + } + return GROUP_INDICES[0] + } + + function nextModifyableGroupIndex(caret: number) { + for (let i = 0; i < GROUP_INDICES.length; i++) { + if (GROUP_INDICES[i] > caret) { + return GROUP_INDICES[i] + } + } + return GROUP_INDICES[GROUP_INDICES.length - 1] + } + + function getValidityMsg(dateSettings?: [string, string, string]) { + if (!dateSettings) return '' + + const [day, fullMonth, year] = dateSettings + + const month = +fullMonth - 1 + if (month < 0 || month > 11) { + return 'Month value should be between "1" and "12"' + } + + const daysInMonth = getDaysInMonth(year, fullMonth) + if (+day > daysInMonth) { + return `${MONTH_NAMES[month]} has "${daysInMonth}" days, but tried to set "${day}"` + } + + return '' + } + + return { + labelElement, + inputNode, + // calendar, + formatValue, + onKeyDown, + onInput, + onClick, + onBlur, + } +} diff --git a/src/lib/ui/core/Popover/Popover.svelte b/src/lib/ui/core/Popover/Popover.svelte index f3428bdd..dcde70c0 100644 --- a/src/lib/ui/core/Popover/Popover.svelte +++ b/src/lib/ui/core/Popover/Popover.svelte @@ -1,5 +1,6 @@ +{#if rootProps?.outerControl} + {@render children({})} +{/if} - + {#if rootProps?.outerControl} + + {:else} + + {/if}
InputCalendar: - +
With Presets: