430 lines
12 KiB
Svelte
430 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { CalendarDate } from '@internationalized/date';
|
|
import { InputValidatorEvent, liveValidator, validate } from '@svelte-toolkit/validate';
|
|
import { CalendarBlank } from 'phosphor-svelte';
|
|
import { type Snippet } from 'svelte';
|
|
import type { ClassValue, FormEventHandler, KeyboardEventHandler } from 'svelte/elements';
|
|
import { generateIdentifier } from './util';
|
|
import Label from './Label.svelte';
|
|
|
|
type FormatString = 'year' | 'month' | 'day';
|
|
const blankState = {
|
|
year: 'yyyy',
|
|
month: 'mm',
|
|
day: 'dd'
|
|
};
|
|
|
|
interface Props {
|
|
name?: string;
|
|
value?: CalendarDate | null;
|
|
min?: CalendarDate;
|
|
// max?: CalendarDate; // TODO: Implement validation.
|
|
label?: string;
|
|
required?: boolean;
|
|
invalidMessage?: string;
|
|
class?: ClassValue | undefined | null;
|
|
format?: FormatString[];
|
|
onchange?: (date: CalendarDate | null) => void;
|
|
}
|
|
|
|
let {
|
|
name,
|
|
value = $bindable<CalendarDate | null>(null),
|
|
/** min specifies lower bounds for the date input (WARNING: NOT IMPLEMENTED) */
|
|
min = new CalendarDate(1900, 0, 1),
|
|
/** max specifies upper bounds for the date input (WARNING: NOT IMPLEMENTED) */
|
|
// max = new CalendarDate(2100, 11, 31),
|
|
label,
|
|
required = false,
|
|
invalidMessage = 'Valid date is required',
|
|
class: classValue,
|
|
format = ['year', 'month', 'day'],
|
|
onchange
|
|
}: Props = $props();
|
|
|
|
const id = $derived(generateIdentifier('dateinput', name));
|
|
|
|
const controls: Record<
|
|
FormatString,
|
|
{ snippet: Snippet<[i: number]>; ref: HTMLDivElement | null; prevCaretPos?: caretPos }
|
|
> = $state({
|
|
year: { snippet: year, ref: null, beforeInput: null },
|
|
month: { snippet: month, ref: null, beforeInput: null },
|
|
day: { snippet: day, ref: null, beforeInput: null }
|
|
});
|
|
|
|
$effect(() => {
|
|
// Throw an error if format includes duplicate or unsupported controls
|
|
const found: Partial<Record<FormatString, boolean>> = {};
|
|
format.forEach((f) => {
|
|
if (found[f]) throw new Error(`Duplicate format string: ${f}`);
|
|
found[f] = true;
|
|
if (!controls[f]) throw new Error(`Unsupported format string: ${f}`);
|
|
});
|
|
});
|
|
|
|
let valid = $state(true);
|
|
let containerElement: HTMLDivElement;
|
|
let previousYearValue = $state<string | undefined>(undefined);
|
|
let yearValue = $derived.by(() => {
|
|
if (!value && previousYearValue) {
|
|
return previousYearValue;
|
|
}
|
|
if (value) {
|
|
return value.year.toString();
|
|
}
|
|
return blankState['year'];
|
|
});
|
|
let previousMonthValue = $state<string | undefined>(undefined);
|
|
let monthValue = $derived.by(() => {
|
|
if (!value && previousMonthValue) {
|
|
return previousMonthValue;
|
|
}
|
|
if (value) {
|
|
return String(value.month).padStart(2, '0');
|
|
}
|
|
return blankState['month'];
|
|
});
|
|
let previousDayValue = $state<string | undefined>(undefined);
|
|
let dayValue = $derived.by(() => {
|
|
if (!value && previousDayValue) {
|
|
return previousDayValue;
|
|
}
|
|
if (value) {
|
|
return String(value.day).padStart(2, '0');
|
|
}
|
|
return blankState['day'];
|
|
});
|
|
|
|
const tryUpdateValue = () => {
|
|
const setPrevious = () => {
|
|
previousYearValue = yearValue;
|
|
previousMonthValue = monthValue;
|
|
previousDayValue = dayValue;
|
|
};
|
|
|
|
let year: number | undefined = undefined;
|
|
if (controls.year.ref) {
|
|
year = parseInt(controls.year.ref.textContent || '', 10);
|
|
if (isNaN(year) || year < 0 || year > 9999) {
|
|
if (value) {
|
|
setPrevious();
|
|
previousYearValue = undefined;
|
|
value = null;
|
|
onchange?.(value);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
let month: number | undefined = undefined;
|
|
if (controls.month.ref) {
|
|
month = parseInt(controls.month.ref.textContent || '', 10);
|
|
if (isNaN(month) || month < 1 || month > 12) {
|
|
if (value) {
|
|
setPrevious();
|
|
previousMonthValue = undefined;
|
|
value = null;
|
|
onchange?.(value);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
let day: number | undefined = undefined;
|
|
if (controls.day.ref) {
|
|
day = parseInt(controls.day.ref.textContent || '', 10);
|
|
if (isNaN(day) || day < 1 || day > 31) {
|
|
if (value) {
|
|
setPrevious();
|
|
previousDayValue = undefined;
|
|
value = null;
|
|
onchange?.(value);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
const newDate = new CalendarDate(
|
|
year ?? (value ? value.year : min.year),
|
|
month ? month : value ? value.month : min.month,
|
|
day ?? (value ? value.day : min.day)
|
|
);
|
|
value = newDate;
|
|
onchange?.(value);
|
|
};
|
|
|
|
type caretPos = { start: number; end: number } | null;
|
|
/** getCaretPosition returns the start and end of the text selection in a particular div */
|
|
const getCaretPosition = (div: HTMLDivElement): caretPos => {
|
|
const selection = window.getSelection();
|
|
if (!selection || selection.rangeCount === 0) return null;
|
|
|
|
const range = selection.getRangeAt(0);
|
|
|
|
// Ensure the selection is within the target div
|
|
if (!div.contains(range.startContainer) || !div.contains(range.endContainer)) {
|
|
return null;
|
|
}
|
|
|
|
// Create a range from the start of the div to the selection start
|
|
const preRange = document.createRange();
|
|
preRange.selectNodeContents(div);
|
|
preRange.setEnd(range.startContainer, range.startOffset);
|
|
const start = preRange.toString().length;
|
|
|
|
// Create a range from the start of the div to the selection end
|
|
const postRange = document.createRange();
|
|
postRange.selectNodeContents(div);
|
|
postRange.setEnd(range.endContainer, range.endOffset);
|
|
const end = postRange.toString().length;
|
|
|
|
return { start, end };
|
|
};
|
|
|
|
/** focusNext focuses the next input */
|
|
const focusNext = (i: number) => {
|
|
if (i < format.length - 1) {
|
|
const next = controls[format[i + 1]].ref;
|
|
next?.focus();
|
|
}
|
|
};
|
|
/** focusPrevious focuses the previous input */
|
|
const focusPrevious = (i: number) => {
|
|
if (i > 0) {
|
|
const prev = controls[format[i - 1]].ref;
|
|
prev?.focus();
|
|
}
|
|
};
|
|
|
|
const createHandleKeydown = (i: number): KeyboardEventHandler<HTMLDivElement> => {
|
|
return (e) => {
|
|
if (!e.target) return;
|
|
const target = e.target as HTMLDivElement;
|
|
|
|
const pos = getCaretPosition(target);
|
|
controls[format[i]].prevCaretPos = pos;
|
|
|
|
if (
|
|
(e.key === 'Backspace' || e.key === 'Delete') &&
|
|
target.textContent === blankState[format[i]]
|
|
) {
|
|
target.textContent = '';
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
};
|
|
};
|
|
|
|
const createHandleKeyup = (i: number): KeyboardEventHandler<HTMLDivElement> => {
|
|
return (e) => {
|
|
if (!e.target) return;
|
|
const target = e.target as HTMLDivElement;
|
|
const prevPos = controls[format[i]].prevCaretPos;
|
|
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
target.blur(); // Remove focus to trigger blur event
|
|
// Deselect any selected text at window-level
|
|
const selection = window.getSelection();
|
|
selection?.removeAllRanges();
|
|
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
|
// If the select has reached the end of this input, go on to the next
|
|
// note: add `prevPos?.start === prevPos?.end` to disable moving with an active selection.
|
|
if (prevPos?.end === target.textContent?.length) {
|
|
focusNext(i);
|
|
}
|
|
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
|
// If the select has reached the start of this input, go back to the previous
|
|
// note: add `prevPos?.start === prevPos?.end` to disable moving with an active selection.
|
|
if (prevPos?.start === 0) {
|
|
focusPrevious(i);
|
|
}
|
|
} else if (e.key === 'Backspace') {
|
|
// If previous caret end position is at the start of the input, go back to the previous
|
|
if (prevPos?.end === 0 && prevPos?.start === 0) {
|
|
focusPrevious(i);
|
|
}
|
|
} else if (e.key === 'Delete') {
|
|
// If previous caret start position is at the end of the input, go to the next
|
|
if (
|
|
prevPos?.start === target.textContent?.length &&
|
|
prevPos?.end === target.textContent?.length
|
|
) {
|
|
focusNext(i);
|
|
}
|
|
}
|
|
};
|
|
};
|
|
|
|
const createHandleInput = (i: number, maxLength: number): FormEventHandler<HTMLDivElement> => {
|
|
return (e) => {
|
|
const target = e.target as HTMLDivElement;
|
|
|
|
// If we've typed to the end of the input, go on to the next input
|
|
if (target.textContent?.length === maxLength) {
|
|
focusNext(i);
|
|
}
|
|
};
|
|
};
|
|
|
|
const handleFocus = (e: FocusEvent) => {
|
|
const target = e.target as HTMLDivElement;
|
|
|
|
// Select all text in the input when focused
|
|
if (target.isContentEditable) {
|
|
setTimeout(() => {
|
|
const range = document.createRange();
|
|
const selection = window.getSelection();
|
|
range.selectNodeContents(target);
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
}, 1);
|
|
}
|
|
};
|
|
|
|
const handleComponentValidate = (e: InputValidatorEvent) => {
|
|
if (!e.detail.valid) {
|
|
const target = e.target as HTMLDivElement;
|
|
target.classList.add('invalid');
|
|
setTimeout(() => {
|
|
target.classList.remove('invalid');
|
|
}, 500);
|
|
}
|
|
};
|
|
|
|
const createHandleBlur = (defaultText: string) => {
|
|
return (e: FocusEvent) => {
|
|
const target = e.target as HTMLDivElement;
|
|
if (!target.textContent || target.textContent.trim() === '') {
|
|
target.textContent = defaultText;
|
|
}
|
|
|
|
if (e.relatedTarget === null || !containerElement.contains(e.relatedTarget as Node)) {
|
|
tryUpdateValue();
|
|
}
|
|
};
|
|
};
|
|
</script>
|
|
|
|
<div class={classValue}>
|
|
<!-- Date input Label -->
|
|
{#if label}
|
|
<Label for={id}>{label}</Label>
|
|
{/if}
|
|
|
|
<div
|
|
bind:this={containerElement}
|
|
class={[
|
|
'inline-flex w-54 items-center justify-start gap-1',
|
|
'border-sui-accent rounded-sm border bg-white px-[1.125rem] py-3.5 font-normal transition-colors',
|
|
'text-sui-text dark:border-sui-accent/50 dark:bg-sui-text-800 dark:text-sui-background dark:sm:bg-slate-800',
|
|
'ring-sui-accent focus-within:ring-1',
|
|
!valid && 'border-red-500!'
|
|
]}
|
|
>
|
|
<input type="hidden" {name} {id} value={value?.toString() ?? ''} use:validate={{ required }} />
|
|
|
|
{#each format as key, i (key)}
|
|
{@render controls[key].snippet(i)}
|
|
|
|
{#if i < format.length - 1}
|
|
<span class="text-sui-text/60 dark:text-sui-background/60">-</span>
|
|
{/if}
|
|
{/each}
|
|
|
|
<CalendarBlank size="1.5em" class="ml-auto" />
|
|
</div>
|
|
|
|
<!-- Error message if invalid -->
|
|
<div class={['opacity-0 transition-opacity', !valid && 'opacity-100']}>
|
|
<Label for={id} error>{invalidMessage}</Label>
|
|
</div>
|
|
</div>
|
|
|
|
{#snippet year(i: number)}
|
|
<div
|
|
bind:this={controls.year.ref}
|
|
bind:textContent={yearValue}
|
|
role="textbox"
|
|
tabindex="0"
|
|
class="date-input"
|
|
contenteditable
|
|
spellcheck="false"
|
|
onfocus={handleFocus}
|
|
onblur={createHandleBlur(blankState.year)}
|
|
onkeydown={createHandleKeydown(i)}
|
|
onkeyup={createHandleKeyup(i)}
|
|
oninput={createHandleInput(i, 4)}
|
|
use:validate={{
|
|
baseval: blankState.year,
|
|
pattern: /^\d{1,4}$/
|
|
}}
|
|
use:liveValidator={{ constrain: true }}
|
|
onvalidate={(e) => {
|
|
handleComponentValidate(e);
|
|
}}
|
|
></div>
|
|
{/snippet}
|
|
|
|
{#snippet month(i: number)}
|
|
<div
|
|
bind:this={controls.month.ref}
|
|
bind:textContent={monthValue}
|
|
role="textbox"
|
|
tabindex="0"
|
|
class="date-input"
|
|
contenteditable
|
|
spellcheck="false"
|
|
onfocus={handleFocus}
|
|
onblur={createHandleBlur(blankState.month)}
|
|
onkeydown={createHandleKeydown(i)}
|
|
onkeyup={createHandleKeyup(i)}
|
|
oninput={createHandleInput(i, 2)}
|
|
use:validate={{
|
|
baseval: blankState.month,
|
|
pattern: /^([0-9]|0[1-9]|1[0-2])$/
|
|
}}
|
|
use:liveValidator={{ constrain: true }}
|
|
onvalidate={(e) => {
|
|
handleComponentValidate(e);
|
|
}}
|
|
></div>
|
|
{/snippet}
|
|
|
|
{#snippet day(i: number)}
|
|
<div
|
|
bind:this={controls.day.ref}
|
|
bind:textContent={dayValue}
|
|
role="textbox"
|
|
tabindex="0"
|
|
class="date-input"
|
|
contenteditable
|
|
spellcheck="false"
|
|
onfocus={handleFocus}
|
|
onblur={createHandleBlur(blankState.day)}
|
|
onkeydown={createHandleKeydown(i)}
|
|
onkeyup={createHandleKeyup(i)}
|
|
oninput={createHandleInput(i, 2)}
|
|
use:validate={{
|
|
baseval: blankState.day,
|
|
pattern: /^([0-9]|0[1-9]|[12][0-9]|3[01])$/
|
|
}}
|
|
use:liveValidator={{ constrain: true }}
|
|
onvalidate={(e) => {
|
|
handleComponentValidate(e);
|
|
}}
|
|
></div>
|
|
{/snippet}
|
|
|
|
<style lang="postcss">
|
|
@reference "./styles/reference.css";
|
|
|
|
.date-input {
|
|
@apply ring-sui-accent dark:ring-sui-accent/50 h-6 min-w-[1ch] outline-0 transition-all focus:ring-2;
|
|
}
|
|
.date-input.invalid {
|
|
@apply ring-red-500!;
|
|
}
|
|
</style>
|