rename Dateinput -> DateInput

This commit is contained in:
Elijah Duffy
2025-07-03 19:54:30 -07:00
parent 79552d21f8
commit 269373dbb5
3 changed files with 3 additions and 2 deletions

322
src/lib/DateInput.svelte Normal file
View File

@@ -0,0 +1,322 @@
<script lang="ts">
import { InputValidatorEvent, liveValidator, validate } from '@svelte-toolkit/validate';
import { CalendarBlank } from 'phosphor-svelte';
import { type Snippet } from 'svelte';
import type { ClassValue, 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?: Date;
min?: Date;
max?: Date;
label?: string;
required?: boolean;
invalidMessage?: string;
class?: ClassValue | undefined | null;
format?: FormatString[];
}
let {
name,
value = $bindable<Date | undefined>(),
/** min specifies lower bounds for the date input (WARNING: NOT IMPLEMENTED) */
min = new Date(1900, 0, 1),
/** max specifies upper bounds for the date input (WARNING: NOT IMPLEMENTED) */
max = new Date(2100, 11, 31),
label,
required = false,
invalidMessage = 'Valid date is required',
class: classValue,
format = ['year', 'month', 'day']
}: Props = $props();
const id = $derived(generateIdentifier('dateinput', name));
const inputSnippets = $derived.by(() => {
const found: Partial<Record<FormatString, boolean>> = {};
const arr: Snippet[] = [];
format.forEach((f) => {
if (found[f]) throw new Error(`Duplicate format string: ${f}`);
found[f] = true;
switch (f) {
case 'year':
return arr.push(year);
case 'month':
return arr.push(month);
case 'day':
return arr.push(day);
default:
throw new Error(`Unknown format string: ${f}`);
}
});
return arr;
});
let valid = $state(true);
let containerElement: HTMLDivElement;
let yearElement = $state<HTMLDivElement | null>(null);
let monthElement = $state<HTMLDivElement | null>(null);
let dayElement = $state<HTMLDivElement | null>(null);
let previousYearValue = $state<string | undefined>(undefined);
let yearValue = $derived.by(() => {
if (!value && previousYearValue) {
return previousYearValue;
}
if (value) {
return value.getFullYear().toString();
}
return blankState['year'];
});
let previousMonthValue = $state<string | undefined>(undefined);
let monthValue = $derived.by(() => {
if (!value && previousMonthValue) {
return previousMonthValue;
}
if (value) {
return String(value.getMonth() + 1).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.getDate()).padStart(2, '0');
}
return blankState['day'];
});
const tryUpdateValue = () => {
const setPrevious = () => {
previousYearValue = yearValue;
previousMonthValue = monthValue;
previousDayValue = dayValue;
};
let year: number | undefined = undefined;
if (yearElement) {
year = parseInt(yearElement.textContent || '', 10);
if (isNaN(year) || year < 0 || year > 9999) {
if (value) {
setPrevious();
previousYearValue = undefined;
value = undefined;
}
return;
}
}
let month: number | undefined = undefined;
if (monthElement) {
month = parseInt(monthElement.textContent || '', 10);
if (isNaN(month) || month < 1 || month > 12) {
if (value) {
setPrevious();
previousMonthValue = undefined;
value = undefined;
}
return;
}
}
let day: number | undefined = undefined;
if (dayElement) {
day = parseInt(dayElement.textContent || '', 10);
if (isNaN(day) || day < 1 || day > 31) {
if (value) {
setPrevious();
previousDayValue = undefined;
value = undefined;
}
return;
}
}
const newDate = new Date(
year ?? (value ? value.getFullYear() : min.getFullYear()),
month ? month - 1 : value ? value.getMonth() : min.getMonth(),
day ?? (value ? value.getDate() : min.getDate())
);
value = newDate;
};
const handleKeydown: KeyboardEventHandler<HTMLDivElement> = (e) => {
const target = e.target as HTMLDivElement;
if (target.isContentEditable && e.key === 'Enter') {
e.preventDefault();
target.blur(); // Remove focus to trigger blur event
}
};
const handleFocus = (e: FocusEvent) => {
const target = e.target as HTMLDivElement;
// Select all text in the input when focused
if (target.isContentEditable) {
const range = document.createRange();
const selection = window.getSelection();
range.selectNodeContents(target);
selection?.removeAllRanges();
selection?.addRange(range);
}
};
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-accent rounded-sm border bg-white px-[1.125rem] py-3.5 font-normal transition-colors',
'text-text dark:border-accent/50 dark:bg-text-800 dark:text-background dark:sm:bg-slate-800',
'ring-accent focus-within:ring-1',
!valid && 'border-red-500!'
]}
>
<input
type="hidden"
{name}
{id}
value={value?.toISOString() ?? ''}
use:validate={{ required }}
/>
{#each inputSnippets as snippet, i}
{@render snippet()}
{#if i < inputSnippets.length - 1}
<span class="text-text/60 dark:text-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()}
<div
bind:this={yearElement}
bind:textContent={yearValue}
role="textbox"
tabindex="0"
class="date-input"
contenteditable
spellcheck="false"
onfocus={handleFocus}
onblur={createHandleBlur(blankState.year)}
onkeydown={handleKeydown}
use:validate={{
baseval: blankState.year,
pattern: /^\d{1,4}$/
}}
use:liveValidator={{ constrain: true }}
onvalidate={(e) => {
handleComponentValidate(e);
}}
></div>
{/snippet}
{#snippet month()}
<div
bind:this={monthElement}
bind:textContent={monthValue}
role="textbox"
tabindex="0"
class="date-input"
contenteditable
spellcheck="false"
onfocus={handleFocus}
onblur={createHandleBlur(blankState.month)}
onkeydown={handleKeydown}
use:validate={{
baseval: blankState.month,
pattern: /^(0[1-9]|1[0-2])$/
}}
use:liveValidator={{ constrain: true }}
onvalidate={(e) => {
handleComponentValidate(e);
}}
></div>
{/snippet}
{#snippet day()}
<div
bind:this={dayElement}
bind:textContent={dayValue}
role="textbox"
tabindex="0"
class="date-input"
contenteditable
spellcheck="false"
onfocus={handleFocus}
onblur={createHandleBlur(blankState.day)}
onkeydown={handleKeydown}
use:validate={{
baseval: blankState.day,
pattern: /^(0[1-9]|[12][0-9]|3[01])$/
}}
use:liveValidator={{ constrain: true }}
onvalidate={(e) => {
handleComponentValidate(e);
}}
></div>
{/snippet}
<style lang="postcss">
@reference "./styles/theme.css";
.date-input {
@apply ring-accent dark:ring-accent/50 h-6 min-w-[1ch] outline-0 transition-all focus:ring-2;
}
.date-input.invalid {
@apply ring-red-500!;
}
</style>