Swap datepicker implementations
This commit is contained in:
parent
213605ecdf
commit
a6d58ba79f
8 changed files with 259 additions and 97 deletions
|
|
@ -64,6 +64,7 @@
|
|||
"@noble/curves": "^1.9.7",
|
||||
"@pomade/core": "^0.3.1",
|
||||
"@poppanator/sveltekit-svg": "^7.0.0",
|
||||
"@svelte-plugins/datepicker": "^1.0.11",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@tiptap/core": "^2.27.2",
|
||||
"@tiptap/pm": "^2.27.2",
|
||||
|
|
@ -83,7 +84,6 @@
|
|||
"@welshman/util": "^0.8.16",
|
||||
"cheerio": "^1.2.0",
|
||||
"compressorjs-next": "^1.1.2",
|
||||
"date-picker-svelte": "^2.17.0",
|
||||
"dotenv": "^16.6.1",
|
||||
"emoji-picker-element": "^1.28.1",
|
||||
"emoji-picker-element-data": "^1.8.0",
|
||||
|
|
|
|||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import {randomId, HOUR} from "@welshman/lib"
|
||||
import {randomId} from "@welshman/lib"
|
||||
import {makeEvent, EVENT_TIME} from "@welshman/util"
|
||||
import {publishThunk, waitForThunkError} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import Modal from "@lib/components/Modal.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 {PROTECTED, publishRoomQuote} from "@app/groups"
|
||||
import {makeEditor} from "@app/editor"
|
||||
|
|
@ -134,7 +134,6 @@
|
|||
let location = $state(initialValues?.location ?? "")
|
||||
let start: number | undefined = $state(initialValues?.start)
|
||||
let end: number | undefined = $state(initialValues?.end)
|
||||
let endDirty = $state(Boolean(initialValues?.end))
|
||||
let content = $state(initialValues?.content ?? "")
|
||||
|
||||
const onChange = (json: object) => {
|
||||
|
|
@ -146,14 +145,6 @@
|
|||
$effect(() => {
|
||||
draftKey.set({d, title, location, start, end, content})
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!endDirty && start) {
|
||||
end = start + HOUR
|
||||
} else if (end) {
|
||||
endDirty = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Modal tag="form" novalidate onsubmit={preventDefault(submit)}>
|
||||
|
|
@ -194,18 +185,10 @@
|
|||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
Start*
|
||||
<p>Date and time*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<DateTimeInput bind:value={start} />
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
End*
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<DateTimeInput bind:value={end} />
|
||||
<DateTimeRangeInput bind:start bind:end />
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {DateInput} from "date-picker-svelte"
|
||||
import {secondsToDate, dateToSeconds} from "@welshman/lib"
|
||||
import {DatePicker} from "@svelte-plugins/datepicker"
|
||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
||||
import {anchorDatepicker} from "@lib/html"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
|
||||
|
|
@ -13,95 +13,66 @@
|
|||
|
||||
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 [hours, minutes] = time.split(":").map(x => parseInt(x))
|
||||
const newDate = new Date(d)
|
||||
|
||||
newDate.setHours(hours, minutes, 0, 0)
|
||||
|
||||
return newDate
|
||||
const toggle = () => {
|
||||
isOpen = !isOpen
|
||||
}
|
||||
|
||||
const onTimeChange = () => {
|
||||
if (time) {
|
||||
minutes = time.slice(-2)
|
||||
date = setTime(date || new Date(), time)
|
||||
}
|
||||
}
|
||||
|
||||
const focusDate = () => element.querySelector("input")?.focus()
|
||||
|
||||
const clear = () => {
|
||||
date = undefined
|
||||
time = undefined
|
||||
startDate = null
|
||||
isOpen = false
|
||||
}
|
||||
|
||||
const initialDate = value ? secondsToDate(value) : undefined
|
||||
const initialTime = initialDate ? getTime(initialDate, pad(initialDate.getMinutes())) : undefined
|
||||
const initialMinutes = initialTime ? initialTime.slice(-2) : "00"
|
||||
const fmt = (ts: number) =>
|
||||
new Date(ts).toLocaleString([], {dateStyle: "medium", timeStyle: "short"})
|
||||
|
||||
let date: Date | undefined = $state(initialDate)
|
||||
let time: string | undefined = $state(initialTime)
|
||||
let minutes: string = $state(initialMinutes)
|
||||
let element: HTMLElement
|
||||
const display = $derived(startDate ? fmt(startDate) : "")
|
||||
|
||||
// When mounted during a reactive flush (e.g. inside a modal), DateInput's legacy
|
||||
// `$:` chain runs against a stale inner store and writes null back through the
|
||||
// binding, wiping the initial date. It can't happen legitimately this early, so
|
||||
// restore it after mount.
|
||||
onMount(() => {
|
||||
if (initialDate && !date) {
|
||||
date = initialDate
|
||||
$effect(() => {
|
||||
if (isOpen && element) {
|
||||
return anchorDatepicker(element)
|
||||
}
|
||||
})
|
||||
|
||||
// Sync date to time and value
|
||||
$effect(() => {
|
||||
if (date) {
|
||||
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
|
||||
value = startDate ? Math.round(startDate / 1000) : undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="relative focus-within:z-modal grid grid-cols-2 gap-2" bind:this={element}>
|
||||
<div class="relative group z-popover">
|
||||
<DateInput format="yyyy-MM-dd" placeholder="" bind:value={date} />
|
||||
<div
|
||||
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">
|
||||
{#if date}
|
||||
<Button onclick={clear} class="h-5 pointer-events-auto">
|
||||
<div class="relative focus-within:z-modal" bind:this={element}>
|
||||
<DatePicker
|
||||
bind:isOpen
|
||||
bind:startDate
|
||||
bind:startDateTime
|
||||
showTimePicker
|
||||
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} />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button onclick={focusDate} class="h-5 pointer-events-auto">
|
||||
<Icon icon={CalendarMinimalistic} />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</label>
|
||||
</DatePicker>
|
||||
</div>
|
||||
|
|
|
|||
79
src/lib/components/DateTimeRangeInput.svelte
Normal file
79
src/lib/components/DateTimeRangeInput.svelte
Normal 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>
|
||||
|
|
@ -264,6 +264,91 @@ body.keyboard-open .room__compose {
|
|||
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 {
|
||||
@apply bg-surface text-content max-w-full overflow-hidden px-1 text-ellipsis whitespace-nowrap;
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.range {
|
||||
input.range {
|
||||
@apply bg-surface-more w-full cursor-pointer appearance-none rounded-full;
|
||||
height: 0.5rem;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,50 @@
|
|||
import {sleep, randomId} from "@welshman/lib"
|
||||
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) => {
|
||||
const {activeElement} = document
|
||||
const input = document.createElement("textarea")
|
||||
|
|
|
|||
Loading…
Reference in a new issue