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

View File

@ -3,13 +3,10 @@
import "@spectrum-css/inputgroup/dist/index-vars.css"
import "@spectrum-css/textfield/dist/index-vars.css"
import Popover from "../../../Popover/Popover.svelte"
import dayjs from "dayjs"
import { createEventDispatcher, onMount } from "svelte"
import TimePicker from "./TimePicker.svelte"
import Calendar from "./Calendar.svelte"
import { onMount } from "svelte"
import DateInput from "./DateInput.svelte"
import ActionButton from "../../../ActionButton/ActionButton.svelte"
import { parseDate } from "../../../helpers"
import DatePickerPopoverContents from "./DatePickerPopoverContents.svelte"
export let id = null
export let disabled = false
@ -25,74 +22,18 @@
export let api = null
export let align = "left"
const dispatch = createEventDispatcher()
let isOpen = false
let anchor
let popover
let calendar
$: parsedValue = parseDate(value, { timeOnly, dateOnly: !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()
}
}
$: parsedValue = parseDate(value, { timeOnly, enableTime })
const onOpen = () => {
isOpen = true
if (useKeyboardShortcuts) {
document.addEventListener("keyup", clearDateOnBackspace)
}
}
const onClose = () => {
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(() => {
@ -130,54 +71,13 @@
{align}
>
{#if isOpen}
<div class="date-time-popover">
{#if showCalendar}
<Calendar
value={parsedValue}
onChange={handleChange}
bind:this={calendar}
/>
{/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>
<DatePickerPopoverContents
{useKeyboardShortcuts}
{ignoreTimezones}
{enableTime}
{timeOnly}
value={parsedValue}
on:change
/>
{/if}
</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-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
box-sizing: content-box;
box-sizing: content-box !important;
}
input:focus,
input:hover {

View File

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

View File

@ -9,6 +9,7 @@ export { default as CoreCombobox } from "./Combobox.svelte"
export { default as CoreSwitch } from "./Switch.svelte"
export { default as CoreSearch } from "./Search.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 CoreDropzone } from "./Dropzone.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 (!value) {
return null
@ -131,7 +133,7 @@ export const parseDate = (value, { dateOnly } = {}) => {
}
// 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]
}
}
@ -148,7 +150,42 @@ export const parseDate = (value, { dateOnly } = {}) => {
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()) {
return ""
}

View File

@ -1,6 +1,13 @@
<script>
import { CoreDatePicker, Icon, Helpers } from "@budibase/bbui"
import {
CoreDatePickerPopoverContents,
Icon,
Helpers,
clickOutside,
} from "@budibase/bbui"
import { onMount } from "svelte"
import dayjs from "dayjs"
import { debounce } from "../../../utils/utils"
export let value
export let schema
@ -9,44 +16,89 @@
export let readonly = false
export let api
let datePickerAPI
let isOpen
$: timeOnly = schema?.timeOnly
$: dateOnly = schema?.dateOnly
$: enableTime = !schema?.dateOnly
$: ignoreTimezones = schema?.ignoreTimezones
$: 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 parsedDate = Helpers.parseDate(value, { dateOnly })
return Helpers.getDateDisplayValue(parsedDate, {
enableTime: !dateOnly,
const getDisplayValue = (value, timeOnly, enableTime) => {
return Helpers.getDateDisplayValue(value, {
enableTime,
timeOnly,
})
}
// Ensure we close flatpickr when unselected
$: {
if (!focused) {
datePickerAPI?.close()
}
const open = () => {
isOpen = true
}
const onKeyDown = () => {
return false
const close = () => {
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(() => {
api = {
onKeyDown,
focus: () => datePickerAPI?.open(),
blur: () => datePickerAPI?.close(),
focus: open,
blur: close,
isActive: () => isOpen,
}
})
</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">
{displayValue}
</div>
@ -55,17 +107,14 @@
{/if}
</div>
{#if editable}
<div class="picker">
<CoreDatePicker
{value}
{#if isOpen}
<div class="picker" use:clickOutside={close}>
<CoreDatePickerPopoverContents
value={parsedValue}
on:change={e => onChange(e.detail)}
enableTime={!dateOnly}
{enableTime}
{timeOnly}
ignoreTimezones={schema.ignoreTimezones}
bind:api={datePickerAPI}
on:open={() => (isOpen = true)}
on:close={() => (isOpen = false)}
{ignoreTimezones}
useKeyboardShortcuts={false}
/>
</div>
@ -80,6 +129,10 @@
align-items: center;
flex: 1 1 auto;
gap: var(--cell-spacing);
user-select: none;
}
.container.editable:hover {
cursor: pointer;
}
.value {
flex: 1 1 auto;
@ -92,9 +145,10 @@
}
.picker {
position: absolute;
opacity: 0;
}
.picker :global(.spectrum-Textfield-input) {
width: 100%;
top: 100%;
left: -1px;
background: var(--grid-background-alt);
border: var(--cell-border);
border-radius: 2px;
}
</style>

View File

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

View File

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

View File

@ -35,7 +35,7 @@
$: lookupMap = buildLookupMap(value, isOpen)
$: debouncedSearch(searchString)
$: {
if (!focused) {
if (!focused && isOpen) {
close()
}
}
@ -451,7 +451,7 @@
left: 0;
width: 100%;
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);
border: var(--cell-border);

View File

@ -22,8 +22,7 @@
import NewRow from "./NewRow.svelte"
import { createGridWebsocket } from "../lib/websocket"
import {
MaxCellRenderHeight,
MaxCellRenderWidthOverflow,
MaxCellRenderOverflow,
GutterWidth,
DefaultRowHeight,
} from "../lib/constants"
@ -78,6 +77,7 @@
contentLines,
gridFocused,
error,
focusedCellId,
} = context
// Keep config store up to date with props
@ -129,7 +129,7 @@
class:quiet
on:mouseenter={() => gridFocused.set(true)}
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}
<div class="controls">

View File

@ -1,5 +1,4 @@
export const Padding = 246
export const MaxCellRenderHeight = 222
export const ScrollBarSize = 8
export const GutterWidth = 72
export const DefaultColumnWidth = 200
@ -12,4 +11,4 @@ export const NewRowID = "new"
export const BlankRowID = "blank"
export const RowPageSize = 100
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 {
MaxCellRenderHeight,
MaxCellRenderWidthOverflow,
MaxCellRenderOverflow,
MinColumnWidth,
ScrollBarSize,
} from "../lib/constants"
@ -95,11 +94,11 @@ export const deriveStores = context => {
// Compute the last row index with space to render popovers below it
const minBottom =
$height - ScrollBarSize * 3 - MaxCellRenderHeight + offset
$height - ScrollBarSize * 3 - MaxCellRenderOverflow + offset
const lastIdx = Math.floor(minBottom / $rowHeight)
// 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)
// 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
for (let i = $visibleColumns.length - 1; i >= 0; i--, inversionIdx--) {
const rightEdge = $visibleColumns[i].left + $visibleColumns[i].width
if (rightEdge + MaxCellRenderWidthOverflow <= cutoff) {
if (rightEdge + MaxCellRenderOverflow <= cutoff) {
break
}
}