add date picker component
This commit is contained in:
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -4,6 +4,9 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
'@svelte-toolkit/validate': link:../validate
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -22,8 +25,8 @@ importers:
|
|||||||
specifier: ^0.2.1
|
specifier: ^0.2.1
|
||||||
version: 0.2.1(svelte@5.34.9)
|
version: 0.2.1(svelte@5.34.9)
|
||||||
'@svelte-toolkit/validate':
|
'@svelte-toolkit/validate':
|
||||||
specifier: ^0.0.0
|
specifier: link:../validate
|
||||||
version: 0.0.0(svelte@5.34.9)
|
version: link:../validate
|
||||||
'@sveltejs/adapter-auto':
|
'@sveltejs/adapter-auto':
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.0.0(@sveltejs/kit@2.22.2(@sveltejs/vite-plugin-svelte@5.1.0(svelte@5.34.9)(vite@6.3.5(@types/node@24.0.9)(jiti@2.4.2)(lightningcss@1.30.1)))(svelte@5.34.9)(vite@6.3.5(@types/node@24.0.9)(jiti@2.4.2)(lightningcss@1.30.1)))
|
version: 4.0.0(@sveltejs/kit@2.22.2(@sveltejs/vite-plugin-svelte@5.1.0(svelte@5.34.9)(vite@6.3.5(@types/node@24.0.9)(jiti@2.4.2)(lightningcss@1.30.1)))(svelte@5.34.9)(vite@6.3.5(@types/node@24.0.9)(jiti@2.4.2)(lightningcss@1.30.1)))
|
||||||
@@ -497,11 +500,6 @@ packages:
|
|||||||
'@sinclair/typebox@0.27.8':
|
'@sinclair/typebox@0.27.8':
|
||||||
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
|
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
|
||||||
|
|
||||||
'@svelte-toolkit/validate@0.0.0':
|
|
||||||
resolution: {integrity: sha512-A8qdXyYh7SyauqA83pJSUZ8tBTYjWTxsV29Zv4R6GPw9lfp9l9b+y98gEFDvZn6bgPEzOXBrf76TJKAUXvsACA==, tarball: https://gitea.auvem.com/api/packages/svelte-toolkit/npm/%40svelte-toolkit%2Fvalidate/-/0.0.0/validate-0.0.0.tgz}
|
|
||||||
peerDependencies:
|
|
||||||
svelte: ^5.0.0
|
|
||||||
|
|
||||||
'@sveltejs/acorn-typescript@1.0.5':
|
'@sveltejs/acorn-typescript@1.0.5':
|
||||||
resolution: {integrity: sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==}
|
resolution: {integrity: sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1895,10 +1893,6 @@ snapshots:
|
|||||||
|
|
||||||
'@sinclair/typebox@0.27.8': {}
|
'@sinclair/typebox@0.27.8': {}
|
||||||
|
|
||||||
'@svelte-toolkit/validate@0.0.0(svelte@5.34.9)':
|
|
||||||
dependencies:
|
|
||||||
svelte: 5.34.9
|
|
||||||
|
|
||||||
'@sveltejs/acorn-typescript@1.0.5(acorn@8.15.0)':
|
'@sveltejs/acorn-typescript@1.0.5(acorn@8.15.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.15.0
|
acorn: 8.15.0
|
||||||
|
|||||||
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
overrides:
|
||||||
|
'@svelte-toolkit/validate': link:../validate
|
||||||
301
src/lib/Dateinput.svelte
Normal file
301
src/lib/Dateinput.svelte
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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';
|
||||||
|
|
||||||
|
type FormatString = 'year' | 'month' | 'day';
|
||||||
|
const blankState = {
|
||||||
|
year: 'yyyy',
|
||||||
|
month: 'mm',
|
||||||
|
day: 'dd'
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name?: string;
|
||||||
|
value?: Date;
|
||||||
|
min?: Date;
|
||||||
|
max?: Date;
|
||||||
|
required?: boolean;
|
||||||
|
class?: ClassValue | undefined | null;
|
||||||
|
format?: FormatString[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
value = $bindable<Date | undefined>(),
|
||||||
|
min = new Date(1900, 0, 1),
|
||||||
|
max = new Date(2100, 11, 31),
|
||||||
|
required = false,
|
||||||
|
class: classList,
|
||||||
|
format = ['year', 'month', 'day']
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
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 yearValid = $state(true);
|
||||||
|
let yearElement = $state<HTMLDivElement | null>(null);
|
||||||
|
let monthValid = $state(true);
|
||||||
|
let monthElement = $state<HTMLDivElement | null>(null);
|
||||||
|
let dayValid = $state(true);
|
||||||
|
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
|
||||||
|
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!',
|
||||||
|
classList
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<input type="hidden" {name} value={value?.toISOString() ?? ''} />
|
||||||
|
|
||||||
|
{#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>
|
||||||
|
|
||||||
|
{#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) => {
|
||||||
|
yearValid = e.detail.valid;
|
||||||
|
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) => {
|
||||||
|
monthValid = e.detail.valid;
|
||||||
|
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) => {
|
||||||
|
dayValid = e.detail.valid;
|
||||||
|
handleComponentValidate(e);
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
@reference "$lib/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>
|
||||||
@@ -10,7 +10,17 @@
|
|||||||
--font-*: initial;
|
--font-*: initial;
|
||||||
--font-sans: var(--ui-font-sans, Work Sans, sans-serif);
|
--font-sans: var(--ui-font-sans, Work Sans, sans-serif);
|
||||||
--font-serif: var(--ui-font-serif, Merriweather, serif);
|
--font-serif: var(--ui-font-serif, Merriweather, serif);
|
||||||
--font-mono: var(--ui-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
|
--font-mono: var(
|
||||||
|
--ui-font-mono,
|
||||||
|
ui-monospace,
|
||||||
|
SFMono-Regular,
|
||||||
|
Menlo,
|
||||||
|
Monaco,
|
||||||
|
Consolas,
|
||||||
|
'Liberation Mono',
|
||||||
|
'Courier New',
|
||||||
|
monospace
|
||||||
|
);
|
||||||
|
|
||||||
/* Primary Colors */
|
/* Primary Colors */
|
||||||
--color-primary-50: var(--ui-primary-50, #f0f8fe);
|
--color-primary-50: var(--ui-primary-50, #f0f8fe);
|
||||||
@@ -83,7 +93,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|
||||||
*,
|
*,
|
||||||
::after,
|
::after,
|
||||||
::before,
|
::before,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import Button from '$lib/Button.svelte';
|
import Button from '$lib/Button.svelte';
|
||||||
import Checkbox from '$lib/Checkbox.svelte';
|
import Checkbox from '$lib/Checkbox.svelte';
|
||||||
import Combobox from '$lib/Combobox.svelte';
|
import Combobox from '$lib/Combobox.svelte';
|
||||||
|
import Dateinput from '$lib/Dateinput.svelte';
|
||||||
|
|
||||||
|
let dateInputValue = $state<Date | undefined>(undefined);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1 class="mb-4 text-3xl font-bold">sui — Opinionated Svelte 5 UI toolkit</h1>
|
<h1 class="mb-4 text-3xl font-bold">sui — Opinionated Svelte 5 UI toolkit</h1>
|
||||||
@@ -52,6 +55,26 @@
|
|||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="component">
|
||||||
|
<p class="title">Dateinput</p>
|
||||||
|
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<Dateinput bind:value={dateInputValue} />
|
||||||
|
<div class="shrink-0">
|
||||||
|
<Button
|
||||||
|
icon="add"
|
||||||
|
onclick={() => {
|
||||||
|
dateInputValue = new Date();
|
||||||
|
console.log('Dateinput value set to:', dateInputValue);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Set Current Date
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<span>Selected date is {dateInputValue}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
@reference '$lib/styles/theme.css';
|
@reference '$lib/styles/theme.css';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user