Swap datepicker implementations

This commit is contained in:
Jon Staab 2026-07-03 14:43:00 -07:00
parent 213605ecdf
commit a6d58ba79f
8 changed files with 259 additions and 97 deletions

View file

@ -64,6 +64,7 @@
"@noble/curves": "^1.9.7", "@noble/curves": "^1.9.7",
"@pomade/core": "^0.3.1", "@pomade/core": "^0.3.1",
"@poppanator/sveltekit-svg": "^7.0.0", "@poppanator/sveltekit-svg": "^7.0.0",
"@svelte-plugins/datepicker": "^1.0.11",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.27.2", "@tiptap/core": "^2.27.2",
"@tiptap/pm": "^2.27.2", "@tiptap/pm": "^2.27.2",
@ -83,7 +84,6 @@
"@welshman/util": "^0.8.16", "@welshman/util": "^0.8.16",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"compressorjs-next": "^1.1.2", "compressorjs-next": "^1.1.2",
"date-picker-svelte": "^2.17.0",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"emoji-picker-element": "^1.28.1", "emoji-picker-element": "^1.28.1",
"emoji-picker-element-data": "^1.8.0", "emoji-picker-element-data": "^1.8.0",

Binary file not shown.

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {randomId, HOUR} from "@welshman/lib" import {randomId} from "@welshman/lib"
import {makeEvent, EVENT_TIME} from "@welshman/util" import {makeEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk, waitForThunkError} from "@welshman/app" import {publishThunk, waitForThunkError} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
@ -16,7 +16,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import Modal from "@lib/components/Modal.svelte" import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte" import DateTimeRangeInput from "@lib/components/DateTimeRangeInput.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED, publishRoomQuote} from "@app/groups" import {PROTECTED, publishRoomQuote} from "@app/groups"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
@ -134,7 +134,6 @@
let location = $state(initialValues?.location ?? "") let location = $state(initialValues?.location ?? "")
let start: number | undefined = $state(initialValues?.start) let start: number | undefined = $state(initialValues?.start)
let end: number | undefined = $state(initialValues?.end) let end: number | undefined = $state(initialValues?.end)
let endDirty = $state(Boolean(initialValues?.end))
let content = $state(initialValues?.content ?? "") let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => { const onChange = (json: object) => {
@ -146,14 +145,6 @@
$effect(() => { $effect(() => {
draftKey.set({d, title, location, start, end, content}) draftKey.set({d, title, location, start, end, content})
}) })
$effect(() => {
if (!endDirty && start) {
end = start + HOUR
} else if (end) {
endDirty = true
}
})
</script> </script>
<Modal tag="form" novalidate onsubmit={preventDefault(submit)}> <Modal tag="form" novalidate onsubmit={preventDefault(submit)}>
@ -194,18 +185,10 @@
</Field> </Field>
<Field> <Field>
{#snippet label()} {#snippet label()}
Start* <p>Date and time*</p>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<DateTimeInput bind:value={start} /> <DateTimeRangeInput bind:start bind:end />
{/snippet}
</Field>
<Field>
{#snippet label()}
End*
{/snippet}
{#snippet input()}
<DateTimeInput bind:value={end} />
{/snippet} {/snippet}
</Field> </Field>
<Field> <Field>

View file

@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {DateInput} from "date-picker-svelte" import {DatePicker} from "@svelte-plugins/datepicker"
import {secondsToDate, dateToSeconds} from "@welshman/lib"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl" import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl" import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
import {anchorDatepicker} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@ -13,95 +13,66 @@
let {value = $bindable()}: Props = $props() let {value = $bindable()}: Props = $props()
const pad = (n: number) => ("00" + String(n)).slice(-2) // The picker works in millisecond timestamps that include the time of day
// selected via its time input; we expose unix seconds
let element: HTMLElement
let isOpen = $state(false)
let startDate: number | null = $state(value ? value * 1000 : null)
let startDateTime = $state("")
const getTime = (d: Date, m: string) => `${pad(d.getHours())}:${m}` // When no date is provided the library initializes its time input from the
// epoch (a locale-dependent afternoon); reset it to something friendlier
onMount(() => {
if (!startDate) startDateTime = "12:00"
})
const setTime = (d: Date, time: string) => { const toggle = () => {
const [hours, minutes] = time.split(":").map(x => parseInt(x)) isOpen = !isOpen
const newDate = new Date(d)
newDate.setHours(hours, minutes, 0, 0)
return newDate
} }
const onTimeChange = () => {
if (time) {
minutes = time.slice(-2)
date = setTime(date || new Date(), time)
}
}
const focusDate = () => element.querySelector("input")?.focus()
const clear = () => { const clear = () => {
date = undefined startDate = null
time = undefined isOpen = false
} }
const initialDate = value ? secondsToDate(value) : undefined const fmt = (ts: number) =>
const initialTime = initialDate ? getTime(initialDate, pad(initialDate.getMinutes())) : undefined new Date(ts).toLocaleString([], {dateStyle: "medium", timeStyle: "short"})
const initialMinutes = initialTime ? initialTime.slice(-2) : "00"
let date: Date | undefined = $state(initialDate) const display = $derived(startDate ? fmt(startDate) : "")
let time: string | undefined = $state(initialTime)
let minutes: string = $state(initialMinutes)
let element: HTMLElement
// When mounted during a reactive flush (e.g. inside a modal), DateInput's legacy $effect(() => {
// `$:` chain runs against a stale inner store and writes null back through the if (isOpen && element) {
// binding, wiping the initial date. It can't happen legitimately this early, so return anchorDatepicker(element)
// restore it after mount.
onMount(() => {
if (initialDate && !date) {
date = initialDate
} }
}) })
// Sync date to time and value
$effect(() => { $effect(() => {
if (date) { value = startDate ? Math.round(startDate / 1000) : undefined
time = getTime(date, minutes)
value = dateToSeconds(date)
} else {
value = undefined
}
})
// Sync updates to value to date/time
$effect(() => {
const derivedDate = value ? secondsToDate(value) : undefined
const derivedTime = derivedDate ? getTime(derivedDate, minutes) : undefined
date = derivedDate
time = derivedTime
}) })
</script> </script>
<div class="relative focus-within:z-modal grid grid-cols-2 gap-2" bind:this={element}> <div class="relative focus-within:z-modal" bind:this={element}>
<div class="relative group z-popover"> <DatePicker
<DateInput format="yyyy-MM-dd" placeholder="" bind:value={date} /> bind:isOpen
<div bind:startDate
class="absolute right-2 top-0 flex h-12 cursor-pointer items-center gap-2 opacity-100 group-focus-within:opacity-0 group-focus-within:pointer-events-none transition-opacity pointer-events-none"> bind:startDateTime
{#if date} showTimePicker
<Button onclick={clear} class="h-5 pointer-events-auto"> enableFutureDates
includeFont={false}>
<label class="input input-group cursor-pointer">
<Icon icon={CalendarMinimalistic} />
<input
type="text"
readonly
class="grow cursor-pointer"
placeholder="Select date"
value={display}
onclick={toggle} />
{#if startDate}
<Button onclick={clear} class="h-5">
<Icon icon={CloseCircle} /> <Icon icon={CloseCircle} />
</Button> </Button>
{:else}
<Button onclick={focusDate} class="h-5 pointer-events-auto">
<Icon icon={CalendarMinimalistic} />
</Button>
{/if} {/if}
</div> </label>
</div> </DatePicker>
<label class="input input-group flex items-center relative">
<input
list="time-options"
class="grow"
type="time"
step="300"
bind:value={time}
onchange={onTimeChange} />
</label>
</div> </div>

View file

@ -0,0 +1,79 @@
<script lang="ts">
import {onMount} from "svelte"
import {DatePicker} from "@svelte-plugins/datepicker"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
import {anchorDatepicker} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
interface Props {
start?: number | undefined
end?: number | undefined
}
let {start = $bindable(), end = $bindable()}: Props = $props()
// The picker works in millisecond timestamps that include the time of day
// selected via its time inputs; we expose unix seconds
let element: HTMLElement
let isOpen = $state(false)
let startDate: number | null = $state(start ? start * 1000 : null)
let endDate: number | null = $state(end ? end * 1000 : null)
let startDateTime = $state("")
let endDateTime = $state("")
// When no date is provided the library initializes its time inputs from the
// epoch (a locale-dependent afternoon); reset them for the create flow
onMount(() => {
if (!startDate) startDateTime = "12:00"
if (!endDate) endDateTime = "13:00"
})
const toggle = () => {
isOpen = !isOpen
}
const fmt = (ts: number) =>
new Date(ts).toLocaleString([], {dateStyle: "medium", timeStyle: "short"})
const display = $derived(
startDate ? (endDate ? `${fmt(startDate)} – ${fmt(endDate)}` : fmt(startDate)) : "",
)
$effect(() => {
if (isOpen && element) {
return anchorDatepicker(element)
}
})
$effect(() => {
start = startDate ? Math.round(startDate / 1000) : undefined
})
$effect(() => {
end = endDate ? Math.round(endDate / 1000) : undefined
})
</script>
<div class="relative focus-within:z-modal" bind:this={element}>
<DatePicker
bind:isOpen
bind:startDate
bind:endDate
bind:startDateTime
bind:endDateTime
isRange
showTimePicker
enableFutureDates
includeFont={false}>
<label class="input input-group cursor-pointer">
<Icon icon={CalendarMinimalistic} />
<input
type="text"
readonly
class="grow cursor-pointer"
placeholder="Select dates"
value={display}
onclick={toggle} />
</label>
</DatePicker>
</div>

View file

@ -264,6 +264,91 @@ body.keyboard-open .room__compose {
padding: 0.65rem; padding: 0.65rem;
} }
/* @svelte-plugins/datepicker — the library declares its variables on :root,
where chained var() references resolve immediately, and our theme tokens
only exist under .fl; so each color is overridden individually here. The
tripled selector out-specifies the component's scoped .datepicker rule. */
.datepicker.datepicker.datepicker {
--datepicker-font-family: var(--font-body);
--datepicker-color: var(--content);
--datepicker-border-color: var(--line);
--datepicker-state-active: var(--primary);
--datepicker-state-hover: var(--surface-more);
--datepicker-container-background: var(--surface);
--datepicker-container-border: var(--border-thin) solid var(--line);
--datepicker-calendar-header-color: var(--content);
--datepicker-calendar-header-text-color: var(--content);
--datepicker-calendar-header-month-nav-color: var(--content);
--datepicker-calendar-header-month-nav-background-hover: var(--surface-more);
--datepicker-calendar-header-year-nav-color: var(--content);
--datepicker-calendar-dow-color: var(--content-muted);
--datepicker-calendar-day-color: var(--content);
--datepicker-calendar-day-color-hover: var(--content);
--datepicker-calendar-day-background-hover: var(--surface-more);
--datepicker-calendar-day-color-disabled: var(--content-subtle);
--datepicker-calendar-day-other-color: var(--content-subtle);
--datepicker-calendar-today-border: var(--border-thin) solid var(--line-more);
/* pale wash for in-range days and the square connectors that join the
endpoint circles; endpoints themselves get full primary + primary-content */
--fl-dp-wash: color-mix(in srgb, var(--primary) 14%, var(--surface));
--datepicker-calendar-range-color: var(--content);
--datepicker-calendar-range-background: var(--fl-dp-wash);
--datepicker-calendar-range-background-disabled: var(--fl-dp-wash);
--datepicker-calendar-range-start-box-shadow: inset -20px 0 0 var(--fl-dp-wash);
--datepicker-calendar-range-end-box-shadow: inset 20px 0 0 var(--fl-dp-wash);
--datepicker-calendar-range-start-box-shadow-selected: inset -20px 0 0 var(--fl-dp-wash);
--datepicker-calendar-range-end-box-shadow-selected: inset 20px 0 0 var(--fl-dp-wash);
--datepicker-calendar-range-start-end-background: var(--primary);
--datepicker-calendar-range-start-end-color: var(--primary-content);
--datepicker-calendar-range-selected-background: var(--primary);
--datepicker-calendar-range-selected-color: var(--primary-content);
--datepicker-calendar-range-included-background: var(--fl-dp-wash);
--datepicker-calendar-range-included-box-shadow: inset 20px 0 0 var(--fl-dp-wash);
--datepicker-calendar-range-included-color: var(--content);
--datepicker-timepicker-input-border: var(--border-thin) solid var(--line);
}
/* While only the start of a range is picked, the cell doesn't yet have the
.range class, so the library's generic day-hover rule (square, gray) paints
over the selected circle. Keep the selected look on hover instead. The :not()
chain exists to out-specify that hover rule, which carries five of its own. */
.datepicker.datepicker.datepicker
.calendar
.date.start:not(.range):not(.other):not(.past):not(.future):not(.disabled)
span:hover,
.datepicker.datepicker.datepicker
.calendar
.date.end:not(.range):not(.other):not(.past):not(.future):not(.disabled)
span:hover {
background-color: var(--primary);
color: var(--primary-content);
border-radius: var(--datepicker-calendar-range-selected-border-radius);
}
/* Hovering the picked start makes the cell both ends of a zero-width preview
range; the library's !important sibling rules still paint a square connector
shadow behind the circle, so remove it (their !important, out-specified) */
.datepicker.datepicker.datepicker .calendars-container .calendar .date.rangehover.start.end {
box-shadow: none !important;
}
/* A freshly picked start with no end and no hover preview carries the same
classes as the start of a real range, so the library paints its half-cell
connector shadow toward a range that doesn't exist yet. The only reliable
distinguisher is whether the next day is in-range. */
.datepicker.datepicker.datepicker
.calendars-container
.calendar
.date.range.start:not(:has(+ .range, + .rangehover)) {
box-shadow: none;
}
/* link-content (tiptap pill) */ /* link-content (tiptap pill) */
.link-content { .link-content {
@apply bg-surface text-content max-w-full overflow-hidden px-1 text-ellipsis whitespace-nowrap; @apply bg-surface text-content max-w-full overflow-hidden px-1 text-ellipsis whitespace-nowrap;

View file

@ -68,7 +68,7 @@
} }
} }
.range { input.range {
@apply bg-surface-more w-full cursor-pointer appearance-none rounded-full; @apply bg-surface-more w-full cursor-pointer appearance-none rounded-full;
height: 0.5rem; height: 0.5rem;

View file

@ -1,6 +1,50 @@
import {sleep, randomId} from "@welshman/lib" import {sleep, randomId} from "@welshman/lib"
export {preventDefault, stopPropagation} from "svelte/legacy" export {preventDefault, stopPropagation} from "svelte/legacy"
// Anchors an @svelte-plugins/datepicker popup with fixed positioning so it
// escapes scroll-container clipping (e.g. inside modals). Call when the picker
// opens; returns a cleanup function that removes the listeners.
export const anchorDatepicker = (wrapper: HTMLElement) => {
const reposition = () => {
const anchor = wrapper.querySelector("label")
const container = wrapper.querySelector(".calendars-container") as HTMLElement | null
if (!anchor || !container) return
const margin = 8
const rect = anchor.getBoundingClientRect()
const height = container.offsetHeight
const width = container.offsetWidth
let top = rect.bottom + 4
// flip above the input when there's not enough room below, else pin to the bottom edge
if (height && top + height > innerHeight - margin) {
top =
rect.top - height - 4 >= margin
? rect.top - height - 4
: Math.max(margin, innerHeight - height - margin)
}
const left = width
? Math.max(margin, Math.min(rect.left, innerWidth - width - margin))
: rect.left
wrapper.style.setProperty("--datepicker-container-position", "fixed")
wrapper.style.setProperty("--datepicker-container-top", `${top}px`)
wrapper.style.setProperty("--datepicker-container-left", `${left}px`)
}
reposition()
addEventListener("scroll", reposition, true)
addEventListener("resize", reposition)
return () => {
removeEventListener("scroll", reposition, true)
removeEventListener("resize", reposition)
}
}
export const copyToClipboard = (text: string) => { export const copyToClipboard = (text: string) => {
const {activeElement} = document const {activeElement} = document
const input = document.createElement("textarea") const input = document.createElement("textarea")