Refactor new datepicker so that we can use a custom grid cell, and fix issues with timezone offsets

This commit is contained in:
Andrew Kingston 2024-04-23 17:00:15 +01:00
parent 0aeddfa029
commit 4d24b2ba1c
14 changed files with 269 additions and 168 deletions

View File

@ -3,10 +3,11 @@
import Select from "../../Select.svelte" import Select from "../../Select.svelte"
import dayjs from "dayjs" import dayjs from "dayjs"
import NumberInput from "./NumberInput.svelte" import NumberInput from "./NumberInput.svelte"
import { createEventDispatcher } from "svelte"
export let value export let value
export let onChange
const dispatch = createEventDispatcher()
const DaysOfWeek = [ const DaysOfWeek = [
"Monday", "Monday",
"Tuesday", "Tuesday",
@ -58,7 +59,10 @@
const handleDateChange = date => { const handleDateChange = date => {
const base = value || now const base = value || now
onChange(base.year(date.year()).month(date.month()).date(date.date())) dispatch(
"change",
base.year(date.year()).month(date.month()).date(date.date())
)
} }
export const setDate = date => { export const setDate = date => {
@ -224,6 +228,9 @@
.spectrum-Calendar-date.is-selected { .spectrum-Calendar-date.is-selected {
color: white; color: white;
} }
.spectrum-Calendar-dayOfWeek {
color: var(--spectrum-global-color-gray-600);
}
/* Style select */ /* Style select */
.month-selector :global(.spectrum-Picker) { .month-selector :global(.spectrum-Picker) {

View File

@ -3,13 +3,10 @@
import "@spectrum-css/inputgroup/dist/index-vars.css" import "@spectrum-css/inputgroup/dist/index-vars.css"
import "@spectrum-css/textfield/dist/index-vars.css" import "@spectrum-css/textfield/dist/index-vars.css"
import Popover from "../../../Popover/Popover.svelte" import Popover from "../../../Popover/Popover.svelte"
import dayjs from "dayjs" import { onMount } from "svelte"
import { createEventDispatcher, onMount } from "svelte"
import TimePicker from "./TimePicker.svelte"
import Calendar from "./Calendar.svelte"
import DateInput from "./DateInput.svelte" import DateInput from "./DateInput.svelte"
import ActionButton from "../../../ActionButton/ActionButton.svelte"
import { parseDate } from "../../../helpers" import { parseDate } from "../../../helpers"
import DatePickerPopoverContents from "./DatePickerPopoverContents.svelte"
export let id = null export let id = null
export let disabled = false export let disabled = false
@ -25,74 +22,18 @@
export let api = null export let api = null
export let align = "left" export let align = "left"
const dispatch = createEventDispatcher()
let isOpen = false let isOpen = false
let anchor let anchor
let popover let popover
let calendar
$: parsedValue = parseDate(value, { timeOnly, dateOnly: !enableTime }) $: parsedValue = parseDate(value, { timeOnly, enableTime })
$: showCalendar = !timeOnly
$: showTime = enableTime || timeOnly
const clearDateOnBackspace = event => {
// Ignore if we're typing a value
if (document.activeElement?.tagName.toLowerCase() === "input") {
return
}
if (["Backspace", "Clear", "Delete"].includes(event.key)) {
handleChange(null)
popover?.hide()
}
}
const onOpen = () => { const onOpen = () => {
isOpen = true isOpen = true
if (useKeyboardShortcuts) {
document.addEventListener("keyup", clearDateOnBackspace)
}
} }
const onClose = () => { const onClose = () => {
isOpen = false isOpen = false
if (useKeyboardShortcuts) {
document.removeEventListener("keyup", clearDateOnBackspace)
}
}
const handleChange = date => {
if (!date) {
dispatch("change", null)
return
}
let newValue = date.toISOString()
// Time only fields always ignore timezones, otherwise they make no sense.
// For non-timezone-aware fields, create an ISO 8601 timestamp of the exact
// time picked, without timezone
const offsetForTimezone = (enableTime && ignoreTimezones) || timeOnly
if (offsetForTimezone) {
const offset = new Date().getTimezoneOffset() * 60000
newValue = new Date(date.valueOf() - offset).toISOString().slice(0, -1)
}
// For date-only fields, construct a manual timestamp string without a time
// or time zone
else if (!enableTime) {
const year = date.year()
const month = `${date.month() + 1}`.padStart(2, "0")
const day = `${date.date()}`.padStart(2, "0")
newValue = `${year}-${month}-${day}T00:00:00.000`
}
dispatch("change", newValue)
}
const setToNow = () => {
const now = dayjs()
calendar?.setDate(now)
handleChange(now)
} }
onMount(() => { onMount(() => {
@ -130,54 +71,13 @@
{align} {align}
> >
{#if isOpen} {#if isOpen}
<div class="date-time-popover"> <DatePickerPopoverContents
{#if showCalendar} {useKeyboardShortcuts}
<Calendar {ignoreTimezones}
value={parsedValue} {enableTime}
onChange={handleChange} {timeOnly}
bind:this={calendar} value={parsedValue}
/> on:change
{/if} />
<div class="footer" class:spaced={showCalendar}>
{#if showTime}
<TimePicker value={parsedValue} onChange={handleChange} />
{/if}
<div class="actions">
<ActionButton
disabled={!value}
size="S"
on:click={() => handleChange(null)}
>
Clear
</ActionButton>
<ActionButton size="S" on:click={setToNow}>
{showTime ? "Now" : "Today"}
</ActionButton>
</div>
</div>
</div>
{/if} {/if}
</Popover> </Popover>
<style>
.date-time-popover {
padding: 8px;
overflow: hidden;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 60px;
}
.footer.spaced {
padding-top: 14px;
}
.actions {
padding: 4px 0;
flex: 1 1 auto;
display: flex;
justify-content: flex-end;
gap: 6px;
}
</style>

View File

@ -0,0 +1,102 @@
<script>
import dayjs from "dayjs"
import TimePicker from "./TimePicker.svelte"
import Calendar from "./Calendar.svelte"
import ActionButton from "../../../ActionButton/ActionButton.svelte"
import { createEventDispatcher, onMount } from "svelte"
import { stringifyDate } from "../../../helpers"
export let useKeyboardShortcuts = true
export let ignoreTimezones
export let enableTime
export let timeOnly
export let value
const dispatch = createEventDispatcher()
let calendar
$: showCalendar = !timeOnly
$: showTime = enableTime || timeOnly
const setToNow = () => {
const now = dayjs()
calendar?.setDate(now)
handleChange(now)
}
const handleChange = date => {
dispatch(
"change",
stringifyDate(date, { enableTime, timeOnly, ignoreTimezones })
)
}
const clearDateOnBackspace = event => {
// Ignore if we're typing a value
if (document.activeElement?.tagName.toLowerCase() === "input") {
return
}
if (["Backspace", "Clear", "Delete"].includes(event.key)) {
dispatch("change", null)
}
}
onMount(() => {
if (useKeyboardShortcuts) {
document.addEventListener("keyup", clearDateOnBackspace)
}
return () => {
document.removeEventListener("keyup", clearDateOnBackspace)
}
})
</script>
<div class="date-time-popover">
{#if showCalendar}
<Calendar
{value}
on:change={e => handleChange(e.detail)}
bind:this={calendar}
/>
{/if}
<div class="footer" class:spaced={showCalendar}>
{#if showTime}
<TimePicker {value} on:change={e => handleChange(e.detail)} />
{/if}
<div class="actions">
<ActionButton
disabled={!value}
size="S"
on:click={() => dispatch("change", null)}
>
Clear
</ActionButton>
<ActionButton size="S" on:click={setToNow}>
{showTime ? "Now" : "Today"}
</ActionButton>
</div>
</div>
</div>
<style>
.date-time-popover {
padding: 8px;
overflow: hidden;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 60px;
}
.footer.spaced {
padding-top: 14px;
}
.actions {
padding: 4px 0;
flex: 1 1 auto;
display: flex;
justify-content: flex-end;
gap: 6px;
}
</style>

View File

@ -33,7 +33,7 @@
font-weight: bold; font-weight: bold;
font-family: var(--font-sans); font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
box-sizing: content-box; box-sizing: content-box !important;
} }
input:focus, input:focus,
input:hover { input:hover {

View File

@ -2,18 +2,20 @@
import { cleanInput } from "./utils" import { cleanInput } from "./utils"
import dayjs from "dayjs" import dayjs from "dayjs"
import NumberInput from "./NumberInput.svelte" import NumberInput from "./NumberInput.svelte"
import { createEventDispatcher } from "svelte"
export let value export let value
export let onChange
const dispatch = createEventDispatcher()
$: displayValue = value || dayjs() $: displayValue = value || dayjs()
const handleHourChange = e => { const handleHourChange = e => {
onChange(displayValue.hour(parseInt(e.target.value))) dispatch("change", displayValue.hour(parseInt(e.target.value)))
} }
const handleMinuteChange = e => { const handleMinuteChange = e => {
onChange(displayValue.minute(parseInt(e.target.value))) dispatch("change", displayValue.minute(parseInt(e.target.value)))
} }
const cleanHour = cleanInput({ max: 23, pad: 2, fallback: "00" }) const cleanHour = cleanInput({ max: 23, pad: 2, fallback: "00" })
@ -51,7 +53,7 @@
.time-picker span { .time-picker span {
font-weight: bold; font-weight: bold;
font-size: 18px; font-size: 18px;
z-index: -1; z-index: 0;
margin-bottom: 1px; margin-bottom: 1px;
} }
</style> </style>

View File

@ -9,6 +9,7 @@ export { default as CoreCombobox } from "./Combobox.svelte"
export { default as CoreSwitch } from "./Switch.svelte" export { default as CoreSwitch } from "./Switch.svelte"
export { default as CoreSearch } from "./Search.svelte" export { default as CoreSearch } from "./Search.svelte"
export { default as CoreDatePicker } from "./DatePicker/DatePicker.svelte" export { default as CoreDatePicker } from "./DatePicker/DatePicker.svelte"
export { default as CoreDatePickerPopoverContents } from "./DatePicker/DatePickerPopoverContents.svelte"
export { default as CoreDateRangePicker } from "./DateRangePicker.svelte" export { default as CoreDateRangePicker } from "./DateRangePicker.svelte"
export { default as CoreDropzone } from "./Dropzone.svelte" export { default as CoreDropzone } from "./Dropzone.svelte"
export { default as CoreStepper } from "./Stepper.svelte" export { default as CoreStepper } from "./Stepper.svelte"

View File

@ -117,7 +117,9 @@ export const copyToClipboard = value => {
}) })
} }
export const parseDate = (value, { dateOnly } = {}) => { // Parsed a date value. This is usually an ISO string, but can be a
// bunch of different formats and shapes depending on schema flags.
export const parseDate = (value, { enableTime = true }) => {
// If empty then invalid // If empty then invalid
if (!value) { if (!value) {
return null return null
@ -131,7 +133,7 @@ export const parseDate = (value, { dateOnly } = {}) => {
} }
// If date only, check for cases where we received a UTC string // If date only, check for cases where we received a UTC string
else if (dateOnly && value.endsWith("Z")) { else if (!enableTime && value.endsWith("Z")) {
value = value.split("Z")[0] value = value.split("Z")[0]
} }
} }
@ -148,7 +150,42 @@ export const parseDate = (value, { dateOnly } = {}) => {
return dayjs(Math.floor(parsedDate.valueOf() / 1000) * 1000) return dayjs(Math.floor(parsedDate.valueOf() / 1000) * 1000)
} }
export const getDateDisplayValue = (value, { enableTime, timeOnly }) => { // Stringifies a dayjs object to create an ISO string that respects the various
// schema flags
export const stringifyDate = (
value,
{ enableTime = true, timeOnly = false, ignoreTimezones = false }
) => {
if (!value) {
return null
}
// Time only fields always ignore timezones, otherwise they make no sense.
// For non-timezone-aware fields, create an ISO 8601 timestamp of the exact
// time picked, without timezone
const offsetForTimezone = (enableTime && ignoreTimezones) || timeOnly
if (offsetForTimezone) {
// Ensure we use the correct offset for the date
const referenceDate = timeOnly ? new Date() : value.toDate()
const offset = referenceDate.getTimezoneOffset() * 60000
return new Date(value.valueOf() - offset).toISOString().slice(0, -1)
}
// For date-only fields, construct a manual timestamp string without a time
// or time zone
else if (!enableTime) {
const year = value.year()
const month = `${value.month() + 1}`.padStart(2, "0")
const day = `${value.date()}`.padStart(2, "0")
return `${year}-${month}-${day}T00:00:00.000`
}
}
// Formats a dayjs date according to schema flags
export const getDateDisplayValue = (
value,
{ enableTime = true, timeOnly = false }
) => {
if (!value?.isValid()) { if (!value?.isValid()) {
return "" return ""
} }

View File

@ -1,6 +1,13 @@
<script> <script>
import { CoreDatePicker, Icon, Helpers } from "@budibase/bbui" import {
CoreDatePickerPopoverContents,
Icon,
Helpers,
clickOutside,
} from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import dayjs from "dayjs"
import { debounce } from "../../../utils/utils"
export let value export let value
export let schema export let schema
@ -9,44 +16,89 @@
export let readonly = false export let readonly = false
export let api export let api
let datePickerAPI
let isOpen let isOpen
$: timeOnly = schema?.timeOnly $: timeOnly = schema?.timeOnly
$: dateOnly = schema?.dateOnly $: enableTime = !schema?.dateOnly
$: ignoreTimezones = schema?.ignoreTimezones
$: editable = focused && !readonly $: editable = focused && !readonly
$: displayValue = getDisplayValue(value, timeOnly, dateOnly) $: parsedValue = Helpers.parseDate(value, {
timeOnly,
enableTime,
ignoreTimezones,
})
$: displayValue = getDisplayValue(parsedValue, timeOnly, enableTime)
// Ensure open state matches desired state
$: {
if (!focused && isOpen) {
close()
}
}
const getDisplayValue = (value, timeOnly, dateOnly) => { const getDisplayValue = (value, timeOnly, enableTime) => {
const parsedDate = Helpers.parseDate(value, { dateOnly }) return Helpers.getDateDisplayValue(value, {
return Helpers.getDateDisplayValue(parsedDate, { enableTime,
enableTime: !dateOnly,
timeOnly, timeOnly,
}) })
} }
// Ensure we close flatpickr when unselected const open = () => {
$: { isOpen = true
if (!focused) {
datePickerAPI?.close()
}
} }
const onKeyDown = () => { const close = () => {
return false isOpen = false
} }
const onKeyDown = e => {
if (!isOpen) {
return false
}
e.preventDefault()
if (e.key === "ArrowUp") {
changeDate(-1, "week")
} else if (e.key === "ArrowDown") {
changeDate(1, "week")
} else if (e.key === "ArrowLeft") {
changeDate(-1, "day")
} else if (e.key === "ArrowRight") {
changeDate(1, "day")
} else if (e.key === "Enter") {
close()
}
return true
}
const changeDate = (quantity, unit) => {
if (!value) {
value = dayjs()
} else {
value = dayjs(value).add(quantity, unit)
}
debouncedOnChange(
Helpers.stringifyDate(value, {
enableTime,
timeOnly,
ignoreTimezones,
})
)
}
const debouncedOnChange = debounce(onChange, 250)
onMount(() => { onMount(() => {
api = { api = {
onKeyDown, onKeyDown,
focus: () => datePickerAPI?.open(), focus: open,
blur: () => datePickerAPI?.close(), blur: close,
isActive: () => isOpen, isActive: () => isOpen,
} }
}) })
</script> </script>
<div class="container"> <!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="container" class:editable on:click={editable ? open : null}>
<div class="value"> <div class="value">
{displayValue} {displayValue}
</div> </div>
@ -55,17 +107,14 @@
{/if} {/if}
</div> </div>
{#if editable} {#if isOpen}
<div class="picker"> <div class="picker" use:clickOutside={close}>
<CoreDatePicker <CoreDatePickerPopoverContents
{value} value={parsedValue}
on:change={e => onChange(e.detail)} on:change={e => onChange(e.detail)}
enableTime={!dateOnly} {enableTime}
{timeOnly} {timeOnly}
ignoreTimezones={schema.ignoreTimezones} {ignoreTimezones}
bind:api={datePickerAPI}
on:open={() => (isOpen = true)}
on:close={() => (isOpen = false)}
useKeyboardShortcuts={false} useKeyboardShortcuts={false}
/> />
</div> </div>
@ -80,6 +129,10 @@
align-items: center; align-items: center;
flex: 1 1 auto; flex: 1 1 auto;
gap: var(--cell-spacing); gap: var(--cell-spacing);
user-select: none;
}
.container.editable:hover {
cursor: pointer;
} }
.value { .value {
flex: 1 1 auto; flex: 1 1 auto;
@ -92,9 +145,10 @@
} }
.picker { .picker {
position: absolute; position: absolute;
opacity: 0; top: 100%;
} left: -1px;
.picker :global(.spectrum-Textfield-input) { background: var(--grid-background-alt);
width: 100%; border: var(--cell-border);
border-radius: 2px;
} }
</style> </style>

View File

@ -103,8 +103,8 @@
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: calc(100% + var(--max-cell-render-width-overflow)); width: calc(100% + var(--max-cell-render-verflow));
height: calc(var(--row-height) + var(--max-cell-render-height)); height: calc(var(--row-height) + var(--max-cell-render-overflow));
z-index: 1; z-index: 1;
border-radius: 2px; border-radius: 2px;
resize: none; resize: none;

View File

@ -23,7 +23,7 @@
$: values = Array.isArray(value) ? value : [value].filter(x => x != null) $: values = Array.isArray(value) ? value : [value].filter(x => x != null)
$: { $: {
// Close when deselected // Close when deselected
if (!focused) { if (!focused && isOpen) {
close() close()
} }
} }
@ -219,7 +219,7 @@
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
max-height: var(--max-cell-render-height); max-height: var(--max-cell-render-overflow);
overflow-y: auto; overflow-y: auto;
border: var(--cell-border); border: var(--cell-border);
box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15); box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15);

View File

@ -35,7 +35,7 @@
$: lookupMap = buildLookupMap(value, isOpen) $: lookupMap = buildLookupMap(value, isOpen)
$: debouncedSearch(searchString) $: debouncedSearch(searchString)
$: { $: {
if (!focused) { if (!focused && isOpen) {
close() close()
} }
} }
@ -451,7 +451,7 @@
left: 0; left: 0;
width: 100%; width: 100%;
max-height: calc( max-height: calc(
var(--max-cell-render-height) + var(--row-height) - var(--values-height) var(--max-cell-render-overflow) + var(--row-height) - var(--values-height)
); );
background: var(--grid-background-alt); background: var(--grid-background-alt);
border: var(--cell-border); border: var(--cell-border);

View File

@ -22,8 +22,7 @@
import NewRow from "./NewRow.svelte" import NewRow from "./NewRow.svelte"
import { createGridWebsocket } from "../lib/websocket" import { createGridWebsocket } from "../lib/websocket"
import { import {
MaxCellRenderHeight, MaxCellRenderOverflow,
MaxCellRenderWidthOverflow,
GutterWidth, GutterWidth,
DefaultRowHeight, DefaultRowHeight,
} from "../lib/constants" } from "../lib/constants"
@ -78,6 +77,7 @@
contentLines, contentLines,
gridFocused, gridFocused,
error, error,
focusedCellId,
} = context } = context
// Keep config store up to date with props // Keep config store up to date with props
@ -129,7 +129,7 @@
class:quiet class:quiet
on:mouseenter={() => gridFocused.set(true)} on:mouseenter={() => gridFocused.set(true)}
on:mouseleave={() => gridFocused.set(false)} on:mouseleave={() => gridFocused.set(false)}
style="--row-height:{$rowHeight}px; --default-row-height:{DefaultRowHeight}px; --gutter-width:{GutterWidth}px; --max-cell-render-height:{MaxCellRenderHeight}px; --max-cell-render-width-overflow:{MaxCellRenderWidthOverflow}px; --content-lines:{$contentLines};" style="--row-height:{$rowHeight}px; --default-row-height:{DefaultRowHeight}px; --gutter-width:{GutterWidth}px; --max-cell-render-overflow:{MaxCellRenderOverflow}px; --content-lines:{$contentLines};"
> >
{#if showControls} {#if showControls}
<div class="controls"> <div class="controls">

View File

@ -1,5 +1,4 @@
export const Padding = 246 export const Padding = 246
export const MaxCellRenderHeight = 222
export const ScrollBarSize = 8 export const ScrollBarSize = 8
export const GutterWidth = 72 export const GutterWidth = 72
export const DefaultColumnWidth = 200 export const DefaultColumnWidth = 200
@ -12,4 +11,4 @@ export const NewRowID = "new"
export const BlankRowID = "blank" export const BlankRowID = "blank"
export const RowPageSize = 100 export const RowPageSize = 100
export const FocusedCellMinOffset = 48 export const FocusedCellMinOffset = 48
export const MaxCellRenderWidthOverflow = Padding - 3 * ScrollBarSize export const MaxCellRenderOverflow = Padding - 3 * ScrollBarSize

View File

@ -1,7 +1,6 @@
import { derived } from "svelte/store" import { derived } from "svelte/store"
import { import {
MaxCellRenderHeight, MaxCellRenderOverflow,
MaxCellRenderWidthOverflow,
MinColumnWidth, MinColumnWidth,
ScrollBarSize, ScrollBarSize,
} from "../lib/constants" } from "../lib/constants"
@ -95,11 +94,11 @@ export const deriveStores = context => {
// Compute the last row index with space to render popovers below it // Compute the last row index with space to render popovers below it
const minBottom = const minBottom =
$height - ScrollBarSize * 3 - MaxCellRenderHeight + offset $height - ScrollBarSize * 3 - MaxCellRenderOverflow + offset
const lastIdx = Math.floor(minBottom / $rowHeight) const lastIdx = Math.floor(minBottom / $rowHeight)
// Compute the first row index with space to render popovers above it // Compute the first row index with space to render popovers above it
const minTop = MaxCellRenderHeight + offset const minTop = MaxCellRenderOverflow + offset
const firstIdx = Math.ceil(minTop / $rowHeight) const firstIdx = Math.ceil(minTop / $rowHeight)
// Use the greater of the two indices so that we prefer content below, // Use the greater of the two indices so that we prefer content below,
@ -117,7 +116,7 @@ export const deriveStores = context => {
let inversionIdx = $visibleColumns.length let inversionIdx = $visibleColumns.length
for (let i = $visibleColumns.length - 1; i >= 0; i--, inversionIdx--) { for (let i = $visibleColumns.length - 1; i >= 0; i--, inversionIdx--) {
const rightEdge = $visibleColumns[i].left + $visibleColumns[i].width const rightEdge = $visibleColumns[i].left + $visibleColumns[i].width
if (rightEdge + MaxCellRenderWidthOverflow <= cutoff) { if (rightEdge + MaxCellRenderOverflow <= cutoff) {
break break
} }
} }