date input: full keyboard nav, use internationalized/CalendarDate

This commit is contained in:
Elijah Duffy
2025-07-10 19:27:51 -07:00
parent b675f01c95
commit 23c0236596
4 changed files with 215 additions and 81 deletions

View File

@@ -1,8 +1,9 @@
<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, KeyboardEventHandler } from 'svelte/elements';
import type { ClassValue, FormEventHandler, KeyboardEventHandler } from 'svelte/elements';
import { generateIdentifier } from './util';
import Label from './Label.svelte';
@@ -15,9 +16,9 @@
interface Props {
name?: string;
value?: Date;
min?: Date;
max?: Date;
value?: CalendarDate;
min?: CalendarDate;
max?: CalendarDate;
label?: string;
required?: boolean;
invalidMessage?: string;
@@ -27,11 +28,11 @@
let {
name,
value = $bindable<Date | undefined>(),
value = $bindable<CalendarDate | undefined>(),
/** min specifies lower bounds for the date input (WARNING: NOT IMPLEMENTED) */
min = new Date(1900, 0, 1),
min = new CalendarDate(1900, 0, 1),
/** max specifies upper bounds for the date input (WARNING: NOT IMPLEMENTED) */
max = new Date(2100, 11, 31),
max = new CalendarDate(2100, 11, 31),
label,
required = false,
invalidMessage = 'Valid date is required',
@@ -41,41 +42,34 @@
const id = $derived(generateIdentifier('dateinput', name));
const inputSnippets = $derived.by(() => {
const found: Partial<Record<FormatString, boolean>> = {};
const arr: Snippet[] = [];
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;
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}`);
}
if (!controls[f]) throw new Error(`Unsupported 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 value.year.toString();
}
return blankState['year'];
});
@@ -85,7 +79,7 @@
return previousMonthValue;
}
if (value) {
return String(value.getMonth() + 1).padStart(2, '0');
return String(value.month).padStart(2, '0');
}
return blankState['month'];
});
@@ -95,7 +89,7 @@
return previousDayValue;
}
if (value) {
return String(value.getDate()).padStart(2, '0');
return String(value.day).padStart(2, '0');
}
return blankState['day'];
});
@@ -108,8 +102,8 @@
};
let year: number | undefined = undefined;
if (yearElement) {
year = parseInt(yearElement.textContent || '', 10);
if (controls.year.ref) {
year = parseInt(controls.year.ref.textContent || '', 10);
if (isNaN(year) || year < 0 || year > 9999) {
if (value) {
setPrevious();
@@ -121,8 +115,8 @@
}
let month: number | undefined = undefined;
if (monthElement) {
month = parseInt(monthElement.textContent || '', 10);
if (controls.month.ref) {
month = parseInt(controls.month.ref.textContent || '', 10);
if (isNaN(month) || month < 1 || month > 12) {
if (value) {
setPrevious();
@@ -134,8 +128,8 @@
}
let day: number | undefined = undefined;
if (dayElement) {
day = parseInt(dayElement.textContent || '', 10);
if (controls.day.ref) {
day = parseInt(controls.day.ref.textContent || '', 10);
if (isNaN(day) || day < 1 || day > 31) {
if (value) {
setPrevious();
@@ -146,21 +140,126 @@
}
}
const newDate = new Date(
year ?? (value ? value.getFullYear() : min.getFullYear()),
month ? month - 1 : value ? value.getMonth() : min.getMonth(),
day ?? (value ? value.getDate() : min.getDate())
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;
};
const handleKeydown: KeyboardEventHandler<HTMLDivElement> = (e) => {
const target = e.target as HTMLDivElement;
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;
if (target.isContentEditable && e.key === 'Enter') {
e.preventDefault();
target.blur(); // Remove focus to trigger blur event
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) => {
@@ -168,11 +267,13 @@
// 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);
setTimeout(() => {
const range = document.createRange();
const selection = window.getSelection();
range.selectNodeContents(target);
selection?.removeAllRanges();
selection?.addRange(range);
}, 1);
}
};
@@ -216,18 +317,12 @@
!valid && 'border-red-500!'
]}
>
<input
type="hidden"
{name}
{id}
value={value?.toISOString() ?? ''}
use:validate={{ required }}
/>
<input type="hidden" {name} {id} value={value?.toString() ?? ''} use:validate={{ required }} />
{#each inputSnippets as snippet, i}
{@render snippet()}
{#each format as key, i (key)}
{@render controls[key].snippet(i)}
{#if i < inputSnippets.length - 1}
{#if i < format.length - 1}
<span class="text-sui-text/60 dark:text-sui-background/60">-</span>
{/if}
{/each}
@@ -241,9 +336,9 @@
</div>
</div>
{#snippet year()}
{#snippet year(i: number)}
<div
bind:this={yearElement}
bind:this={controls.year.ref}
bind:textContent={yearValue}
role="textbox"
tabindex="0"
@@ -252,7 +347,9 @@
spellcheck="false"
onfocus={handleFocus}
onblur={createHandleBlur(blankState.year)}
onkeydown={handleKeydown}
onkeydown={createHandleKeydown(i)}
onkeyup={createHandleKeyup(i)}
oninput={createHandleInput(i, 4)}
use:validate={{
baseval: blankState.year,
pattern: /^\d{1,4}$/
@@ -264,9 +361,9 @@
></div>
{/snippet}
{#snippet month()}
{#snippet month(i: number)}
<div
bind:this={monthElement}
bind:this={controls.month.ref}
bind:textContent={monthValue}
role="textbox"
tabindex="0"
@@ -275,10 +372,12 @@
spellcheck="false"
onfocus={handleFocus}
onblur={createHandleBlur(blankState.month)}
onkeydown={handleKeydown}
onkeydown={createHandleKeydown(i)}
onkeyup={createHandleKeyup(i)}
oninput={createHandleInput(i, 2)}
use:validate={{
baseval: blankState.month,
pattern: /^(0[1-9]|1[0-2])$/
pattern: /^([0-9]|0[1-9]|1[0-2])$/
}}
use:liveValidator={{ constrain: true }}
onvalidate={(e) => {
@@ -287,9 +386,9 @@
></div>
{/snippet}
{#snippet day()}
{#snippet day(i: number)}
<div
bind:this={dayElement}
bind:this={controls.day.ref}
bind:textContent={dayValue}
role="textbox"
tabindex="0"
@@ -298,10 +397,12 @@
spellcheck="false"
onfocus={handleFocus}
onblur={createHandleBlur(blankState.day)}
onkeydown={handleKeydown}
onkeydown={createHandleKeydown(i)}
onkeyup={createHandleKeyup(i)}
oninput={createHandleInput(i, 2)}
use:validate={{
baseval: blankState.day,
pattern: /^(0[1-9]|[12][0-9]|3[01])$/
pattern: /^([0-9]|0[1-9]|[12][0-9]|3[01])$/
}}
use:liveValidator={{ constrain: true }}
onvalidate={(e) => {

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { CalendarDate, today, getLocalTimeZone } from '@internationalized/date';
import Button from '$lib/Button.svelte';
import ActionSelect from '$lib/ActionSelect.svelte';
import Checkbox, { type CheckboxState } from '$lib/Checkbox.svelte';
@@ -27,10 +28,16 @@
TextStrikethrough,
TextUnderline
} from 'phosphor-svelte';
import type { Option } from '$lib';
let dateInputValue = $state<Date | undefined>(undefined);
let dateInputValue = $state<CalendarDate | undefined>(undefined);
let checkboxValue = $state<CheckboxState>('indeterminate');
let dialogOpen = $state(false);
let toggleOptions: Option[] = $state([
'item one',
'item two',
{ value: 'complex', label: 'Complex item' }
]);
const toolbar = new Toolbar();
const fontGroup = toolbar.group();
@@ -141,7 +148,7 @@
<Button
icon={{ component: Plus }}
onclick={() => {
dateInputValue = new Date();
dateInputValue = today(getLocalTimeZone());
console.log('Dateinput value set to:', dateInputValue);
}}
>
@@ -208,10 +215,13 @@
<ToggleSelect name="example-toggle-select" class="mb-4">Toggle Me!</ToggleSelect>
<ToggleGroup
label="Toggle Group"
options={['item one', 'item two', { value: 'complex', label: 'Complex item' }]}
/>
<ToggleGroup label="Toggle Group" options={toggleOptions} />
<Button
onclick={() => {
toggleOptions.push({ value: 'new', label: 'New Option' });
}}>Add Option</Button
>
</div>
<div class="component">