add DatePicker component with type='date' support

This commit is contained in:
Justin Edmund 2025-12-02 01:24:36 -08:00
parent 9ace2eb1e2
commit da26645df0
5 changed files with 456 additions and 8 deletions

View file

@ -67,6 +67,7 @@
"packageManager": "pnpm@10.15.1+sha512.34e538c329b5553014ca8e8f4535997f96180a1d0f614339357449935350d924e22f8614682191264ec33d1462ac21561aff97f6bb18065351c162c7e8f6de67",
"dependencies": {
"@friendofsvelte/tipex": "^0.0.9",
"@internationalized/date": "^3.10.0",
"@tanstack/svelte-query": "^6.0.9",
"@tiptap/core": "^3.5.1",
"@tiptap/extension-highlight": "^3.5.1",

View file

@ -11,6 +11,9 @@ importers:
'@friendofsvelte/tipex':
specifier: ^0.0.9
version: 0.0.9(highlight.js@11.8.0)(svelte@5.38.7)
'@internationalized/date':
specifier: ^3.10.0
version: 3.10.0
'@tanstack/svelte-query':
specifier: ^6.0.9
version: 6.0.9(svelte@5.38.7)
@ -31,7 +34,7 @@ importers:
version: 3.5.1
bits-ui:
specifier: ^2.9.6
version: 2.9.6(@internationalized/date@3.9.0)(svelte@5.38.7)
version: 2.9.6(@internationalized/date@3.10.0)(svelte@5.38.7)
fluid-dnd:
specifier: ^2.6.2
version: 2.6.2
@ -430,8 +433,8 @@ packages:
resolution: {integrity: sha512-cvz/C1rF5WBxzHbEoiBoI6Sz6q6M+TdxfWkEGBYTD77opY8i8WN01prUWXEM87GPF4SZcyIySez9U0Ccm12oFQ==}
engines: {node: '>=18.0.0'}
'@internationalized/date@3.9.0':
resolution: {integrity: sha512-yaN3brAnHRD+4KyyOsJyk49XUvj2wtbNACSqg0bz3u8t2VuzhC8Q5dfRnrSxjnnbDb+ienBnkn1TzQfE154vyg==}
'@internationalized/date@3.10.0':
resolution: {integrity: sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@ -3094,7 +3097,7 @@ snapshots:
transitivePeerDependencies:
- babel-plugin-macros
'@internationalized/date@3.9.0':
'@internationalized/date@3.10.0':
dependencies:
'@swc/helpers': 0.5.17
@ -4145,11 +4148,11 @@ snapshots:
balanced-match@1.0.2: {}
bits-ui@2.9.6(@internationalized/date@3.9.0)(svelte@5.38.7):
bits-ui@2.9.6(@internationalized/date@3.10.0)(svelte@5.38.7):
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/dom': 1.7.4
'@internationalized/date': 3.9.0
'@internationalized/date': 3.10.0
esm-env: 1.2.2
runed: 0.29.2(svelte@5.38.7)
svelte: 5.38.7

View file

@ -0,0 +1,438 @@
<svelte:options runes={true} />
<script lang="ts">
import { DatePicker as BitsDatePicker, Label } from 'bits-ui'
import { parseDate, type DateValue, CalendarDate } from '@internationalized/date'
import Icon from '../Icon.svelte'
interface Props {
value?: string | null
placeholder?: string
disabled?: boolean
readonly?: boolean
contained?: boolean
locale?: string
label?: string
minValue?: string
maxValue?: string
}
let {
value = $bindable(),
placeholder = 'Select date',
disabled = false,
readonly = false,
contained = false,
locale = 'en',
label,
minValue,
maxValue
}: Props = $props()
// Convert ISO string to DateValue
function stringToDate(iso: string | null | undefined): DateValue | undefined {
if (!iso) return undefined
try {
return parseDate(iso)
} catch {
return undefined
}
}
// Convert DateValue to ISO string
function dateToString(date: DateValue | undefined): string | null {
if (!date) return null
return date.toString()
}
// Internal state using DateValue
let internalValue = $state<DateValue | undefined>(stringToDate(value))
// Sync external value changes to internal state
$effect(() => {
const parsed = stringToDate(value)
if (parsed?.toString() !== internalValue?.toString()) {
internalValue = parsed
}
})
// Handle internal value changes
function handleValueChange(newValue: DateValue | undefined) {
internalValue = newValue
value = dateToString(newValue)
}
// Convert min/max values
const minDateValue = $derived(stringToDate(minValue))
const maxDateValue = $derived(stringToDate(maxValue))
// Generate a placeholder date for the calendar to show when no value is selected
const placeholderDate = $derived(() => {
if (internalValue) return internalValue
const now = new Date()
return new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate())
})
</script>
{#if label}
<fieldset class="fieldset">
<Label.Root class="label">{label}</Label.Root>
<BitsDatePicker.Root
value={internalValue}
onValueChange={handleValueChange}
{locale}
{disabled}
{readonly}
minValue={minDateValue}
maxValue={maxDateValue}
placeholder={placeholderDate()}
>
<div class="date-picker-field" class:contained>
<BitsDatePicker.Input>
{#snippet children({ segments })}
{#each segments as { part, value: segValue }}
<BitsDatePicker.Segment {part} class="segment">
{segValue}
</BitsDatePicker.Segment>
{/each}
{/snippet}
</BitsDatePicker.Input>
<BitsDatePicker.Trigger class="trigger">
<Icon name="calendar" size={16} />
</BitsDatePicker.Trigger>
</div>
<BitsDatePicker.Content class="content" sideOffset={8}>
<BitsDatePicker.Calendar class="calendar">
{#snippet children({ months, weekdays })}
<BitsDatePicker.Header class="calendar-header">
<BitsDatePicker.PrevButton class="nav-button">
<Icon name="chevron-left" size={16} />
</BitsDatePicker.PrevButton>
<BitsDatePicker.Heading class="heading" />
<BitsDatePicker.NextButton class="nav-button">
<Icon name="chevron-right" size={16} />
</BitsDatePicker.NextButton>
</BitsDatePicker.Header>
{#each months as month}
<BitsDatePicker.Grid class="grid">
<BitsDatePicker.GridHead>
<BitsDatePicker.GridRow class="grid-row">
{#each weekdays as day}
<BitsDatePicker.HeadCell class="head-cell">
{day}
</BitsDatePicker.HeadCell>
{/each}
</BitsDatePicker.GridRow>
</BitsDatePicker.GridHead>
<BitsDatePicker.GridBody>
{#each month.weeks as weekDates}
<BitsDatePicker.GridRow class="grid-row">
{#each weekDates as date}
<BitsDatePicker.Cell {date} month={month.value} class="cell">
<BitsDatePicker.Day class="day">
{date.day}
</BitsDatePicker.Day>
</BitsDatePicker.Cell>
{/each}
</BitsDatePicker.GridRow>
{/each}
</BitsDatePicker.GridBody>
</BitsDatePicker.Grid>
{/each}
{/snippet}
</BitsDatePicker.Calendar>
</BitsDatePicker.Content>
</BitsDatePicker.Root>
</fieldset>
{:else}
<BitsDatePicker.Root
value={internalValue}
onValueChange={handleValueChange}
{locale}
{disabled}
{readonly}
minValue={minDateValue}
maxValue={maxDateValue}
placeholder={placeholderDate()}
>
<div class="date-picker-field" class:contained>
<BitsDatePicker.Input>
{#snippet children({ segments })}
{#each segments as { part, value: segValue }}
<BitsDatePicker.Segment {part} class="segment">
{segValue}
</BitsDatePicker.Segment>
{/each}
{/snippet}
</BitsDatePicker.Input>
<BitsDatePicker.Trigger class="trigger">
<Icon name="calendar" size={16} />
</BitsDatePicker.Trigger>
</div>
<BitsDatePicker.Content class="content" sideOffset={8}>
<BitsDatePicker.Calendar class="calendar">
{#snippet children({ months, weekdays })}
<BitsDatePicker.Header class="calendar-header">
<BitsDatePicker.PrevButton class="nav-button">
<Icon name="chevron-left" size={16} />
</BitsDatePicker.PrevButton>
<BitsDatePicker.Heading class="heading" />
<BitsDatePicker.NextButton class="nav-button">
<Icon name="chevron-right" size={16} />
</BitsDatePicker.NextButton>
</BitsDatePicker.Header>
{#each months as month}
<BitsDatePicker.Grid class="grid">
<BitsDatePicker.GridHead>
<BitsDatePicker.GridRow class="grid-row">
{#each weekdays as day}
<BitsDatePicker.HeadCell class="head-cell">
{day}
</BitsDatePicker.HeadCell>
{/each}
</BitsDatePicker.GridRow>
</BitsDatePicker.GridHead>
<BitsDatePicker.GridBody>
{#each month.weeks as weekDates}
<BitsDatePicker.GridRow class="grid-row">
{#each weekDates as date}
<BitsDatePicker.Cell {date} month={month.value} class="cell">
<BitsDatePicker.Day class="day">
{date.day}
</BitsDatePicker.Day>
</BitsDatePicker.Cell>
{/each}
</BitsDatePicker.GridRow>
{/each}
</BitsDatePicker.GridBody>
</BitsDatePicker.Grid>
{/each}
{/snippet}
</BitsDatePicker.Calendar>
</BitsDatePicker.Content>
</BitsDatePicker.Root>
{/if}
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/mixins' as *;
@use '$src/themes/effects' as *;
.fieldset {
display: flex;
flex-direction: column;
gap: $unit-half;
border: none;
padding: 0;
margin: 0;
:global(.label) {
color: var(--text-primary);
font-size: $font-small;
font-weight: $medium;
margin-bottom: $unit-half;
}
}
.date-picker-field {
display: flex;
align-items: center;
gap: $unit;
background-color: var(--input-bg);
border-radius: $input-corner;
padding: calc($unit * 1.25) $unit-2x;
@include smooth-transition($duration-quick, background-color);
&:hover:not(:has(:disabled)) {
background-color: var(--input-bg-hover);
}
&.contained {
background-color: var(--input-bound-bg);
&:hover:not(:has(:disabled)) {
background-color: var(--input-bound-bg-hover);
}
}
:global(.segment) {
color: var(--text-primary);
font-size: $font-regular;
font-family: inherit;
padding: $unit-fourth $unit-half;
border-radius: $unit-fourth;
outline: none;
&:focus {
background-color: $water-text-20;
color: white;
}
&[data-placeholder] {
color: var(--text-tertiary);
}
&[data-segment='literal'] {
color: var(--text-secondary);
padding: 0;
}
}
:global(.trigger) {
display: flex;
align-items: center;
justify-content: center;
padding: $unit-half;
margin-left: auto;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: $unit-half;
@include smooth-transition($duration-quick, background-color, color);
&:hover {
background-color: $grey-80;
color: var(--text-primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
:global(.content) {
z-index: 50;
background-color: $grey-85;
border-radius: $card-corner;
padding: $unit-2x;
box-shadow: $dialog-elevation;
border: 1px solid $grey-80;
}
:global(.calendar) {
display: flex;
flex-direction: column;
gap: $unit;
}
:global(.calendar-header) {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: $unit;
}
:global(.heading) {
font-size: $font-regular;
font-weight: $medium;
color: var(--text-primary);
}
:global(.nav-button) {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: $unit-half;
@include smooth-transition($duration-quick, background-color, color);
&:hover:not(:disabled) {
background-color: $grey-80;
color: var(--text-primary);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
:global(.grid) {
border-collapse: collapse;
}
:global(.grid-row) {
display: flex;
gap: $unit-fourth;
}
:global(.head-cell) {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-small;
font-weight: $medium;
color: var(--text-tertiary);
}
:global(.cell) {
width: 32px;
height: 32px;
padding: 0;
}
:global(.day) {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-small;
color: var(--text-primary);
border: none;
background: transparent;
cursor: pointer;
border-radius: $unit-half;
@include smooth-transition($duration-quick, background-color, color);
&:hover:not(:disabled):not([data-selected]) {
background-color: $grey-80;
}
&[data-today] {
font-weight: $medium;
color: $water-text-20;
}
&[data-selected] {
background-color: $water-text-20;
color: white;
font-weight: $medium;
}
&[data-outside-month] {
color: var(--text-tertiary);
opacity: 0.5;
}
&[data-disabled] {
opacity: 0.3;
cursor: not-allowed;
}
&[data-unavailable] {
text-decoration: line-through;
opacity: 0.3;
}
}
</style>

View file

@ -5,6 +5,7 @@
import Input from './Input.svelte'
import Select from './Select.svelte'
import Checkbox from './checkbox/Checkbox.svelte'
import DatePicker from './DatePicker.svelte'
interface SelectOption {
value: string | number
@ -31,7 +32,7 @@
value?: string | number | boolean | null | undefined
children?: Snippet
editable?: boolean
type?: 'text' | 'number' | 'select' | 'checkbox'
type?: 'text' | 'number' | 'select' | 'checkbox' | 'date'
options?: SelectOption[]
placeholder?: string
element?: 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
@ -85,6 +86,8 @@
{placeholder}
alignRight={true}
/>
{:else if type === 'date'}
<DatePicker bind:value={value as string | null} contained={true} {placeholder} />
{:else}
<Input bind:value type="text" contained={true} {placeholder} alignRight={false} />
{/if}

View file

@ -5,6 +5,7 @@
import Input from './Input.svelte'
import Select from './Select.svelte'
import Checkbox from './checkbox/Checkbox.svelte'
import DatePicker from './DatePicker.svelte'
import SuggestionBadge from './SuggestionBadge.svelte'
interface SelectOption {
@ -38,7 +39,7 @@
value?: string | number | boolean | null | undefined
children?: Snippet
editable?: boolean
type?: 'text' | 'number' | 'select' | 'checkbox'
type?: 'text' | 'number' | 'select' | 'checkbox' | 'date'
options?: SelectOption[]
placeholder?: string
element?: 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
@ -133,6 +134,8 @@
{placeholder}
alignRight={true}
/>
{:else if type === 'date'}
<DatePicker bind:value={value as string | null} contained={true} {placeholder} />
{:else}
<Input bind:value type="text" contained={true} {placeholder} alignRight={false} />
{/if}