Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/migrate input calendar to svelte 5 #213

Open
wants to merge 3 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions src/lib/ui/core/Calendar/DatePicker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
maxDate?: Date
timeZone?: string
children?: Snippet
popoverRootProps?: ComponentProps<typeof Popover>['rootProps']
popoverIsOpened?: boolean
}

type TSingleProps = {
Expand All @@ -36,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()

Expand Down Expand Up @@ -75,17 +78,21 @@
{@render label()}
</Button>
{:else}
<Popover noStyles class="z-10">
<Popover noStyles class="z-10" rootProps={rest.popoverRootProps} bind:isOpened={popoverIsOpened}>
{#snippet children({ ref })}
<Button
{as}
{ref}
variant="border"
icon="calendar"
class={cn('whitespace-nowrap', className)}
>
{#if ref}
<Button
{as}
{ref}
variant="border"
icon="calendar"
class={cn('whitespace-nowrap', className)}
>
{@render label()}
</Button>
{:else}
{@render label()}
</Button>
{/if}
{/snippet}

{#snippet content()}
Expand Down
51 changes: 51 additions & 0 deletions src/lib/ui/core/InputCalendar/InputCalendar.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<script lang="ts">
import DatePicker from '$ui/core/Calendar/index.js'
import Button from '$ui/core/Button/index.js'

import { formatValue, useInputCalendar } from './utils.svelte.js'

type TProps = {
date: [Date, Date]
onChange: (date: [Date, Date]) => void
}

const { date, onChange }: TProps = $props()

let isOpened = $state(false)

let { labelElement, inputNode, onKeyDown, onInput, onClick, onBlur } = useInputCalendar(
date,
onChange,
)
</script>

<div class="relative" onfocusout={(e) => onBlur(e, (newIsOpened) => (isOpened = newIsOpened))}>
<DatePicker
{date}
{onChange}
class="relative"
popoverRootProps={{
closeOnOutsideClick: false,
disableFocusTrap: true,
closeFocus: null,
openFocus: null,
portal: labelElement.$,
outerControl: true,
}}
bind:popoverIsOpened={isOpened}
withPresets
>
<Button as="label" ref={labelElement} variant="border" icon="calendar">
<input
class="cursor-pointer select-none bg-transparent outline-none"
bind:this={inputNode.$}
type="text"
value={formatValue(date)}
onclick={onClick}
onfocus={() => (isOpened = true)}
onkeydown={onKeyDown}
oninput={onInput}
/>
</Button>
</DatePicker>
</div>
1 change: 1 addition & 0 deletions src/lib/ui/core/InputCalendar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './InputCalendar.svelte'
245 changes: 245 additions & 0 deletions src/lib/ui/core/InputCalendar/utils.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
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 | HTMLElement>(null)
const inputNode = ss<HTMLInputElement>()

$effect(() => {
if (inputNode) {
setInputValue(date)
}
})

function setInputValue(dates: [Date, Date]) {
inputNode.$.value = formatValue(dates)
}

function changeCalendar() {
const dates = validateInput(inputNode.$.value)

if (dates) {
setInputValue(dates)
onDateSelect(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()
}

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,
formatValue,
onKeyDown,
onInput,
onClick,
onBlur,
}
}
Loading