From 4d24b2ba1c7c1ec6fac84c3eee43bdad607a47dc Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Tue, 23 Apr 2024 17:00:15 +0100 Subject: [PATCH] Refactor new datepicker so that we can use a custom grid cell, and fix issues with timezone offsets --- .../src/Form/Core/DatePicker/Calendar.svelte | 11 +- .../Form/Core/DatePicker/DatePicker.svelte | 122 ++---------------- .../DatePickerPopoverContents.svelte | 102 +++++++++++++++ .../Form/Core/DatePicker/NumberInput.svelte | 2 +- .../Form/Core/DatePicker/TimePicker.svelte | 10 +- packages/bbui/src/Form/Core/index.js | 1 + packages/bbui/src/helpers.js | 43 +++++- .../src/components/grid/cells/DateCell.svelte | 116 ++++++++++++----- .../components/grid/cells/LongFormCell.svelte | 4 +- .../components/grid/cells/OptionsCell.svelte | 4 +- .../grid/cells/RelationshipCell.svelte | 4 +- .../src/components/grid/layout/Grid.svelte | 6 +- .../src/components/grid/lib/constants.js | 3 +- .../src/components/grid/stores/viewport.js | 9 +- 14 files changed, 269 insertions(+), 168 deletions(-) create mode 100644 packages/bbui/src/Form/Core/DatePicker/DatePickerPopoverContents.svelte diff --git a/packages/bbui/src/Form/Core/DatePicker/Calendar.svelte b/packages/bbui/src/Form/Core/DatePicker/Calendar.svelte index 83692ff898..bc06530044 100644 --- a/packages/bbui/src/Form/Core/DatePicker/Calendar.svelte +++ b/packages/bbui/src/Form/Core/DatePicker/Calendar.svelte @@ -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) { diff --git a/packages/bbui/src/Form/Core/DatePicker/DatePicker.svelte b/packages/bbui/src/Form/Core/DatePicker/DatePicker.svelte index a29746ee75..4587195865 100644 --- a/packages/bbui/src/Form/Core/DatePicker/DatePicker.svelte +++ b/packages/bbui/src/Form/Core/DatePicker/DatePicker.svelte @@ -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} -
- {#if showCalendar} - - {/if} - -
+ {/if} - - diff --git a/packages/bbui/src/Form/Core/DatePicker/DatePickerPopoverContents.svelte b/packages/bbui/src/Form/Core/DatePicker/DatePickerPopoverContents.svelte new file mode 100644 index 0000000000..f1bb809b29 --- /dev/null +++ b/packages/bbui/src/Form/Core/DatePicker/DatePickerPopoverContents.svelte @@ -0,0 +1,102 @@ + + +
+ {#if showCalendar} + handleChange(e.detail)} + bind:this={calendar} + /> + {/if} + +
+ + diff --git a/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte b/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte index 91dd95ff5f..dc4886d28d 100644 --- a/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte +++ b/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte @@ -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 { diff --git a/packages/bbui/src/Form/Core/DatePicker/TimePicker.svelte b/packages/bbui/src/Form/Core/DatePicker/TimePicker.svelte index adf2a5e87a..047e5a4f08 100644 --- a/packages/bbui/src/Form/Core/DatePicker/TimePicker.svelte +++ b/packages/bbui/src/Form/Core/DatePicker/TimePicker.svelte @@ -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; } diff --git a/packages/bbui/src/Form/Core/index.js b/packages/bbui/src/Form/Core/index.js index 533a1956c5..7117b90081 100644 --- a/packages/bbui/src/Form/Core/index.js +++ b/packages/bbui/src/Form/Core/index.js @@ -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" diff --git a/packages/bbui/src/helpers.js b/packages/bbui/src/helpers.js index c98ebad2c7..4448527fea 100644 --- a/packages/bbui/src/helpers.js +++ b/packages/bbui/src/helpers.js @@ -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 "" } diff --git a/packages/frontend-core/src/components/grid/cells/DateCell.svelte b/packages/frontend-core/src/components/grid/cells/DateCell.svelte index 019a0db9c2..394b6e1f43 100644 --- a/packages/frontend-core/src/components/grid/cells/DateCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DateCell.svelte @@ -1,6 +1,13 @@ -
+ + +
{displayValue}
@@ -55,17 +107,14 @@ {/if}
-{#if editable} -
- + onChange(e.detail)} - enableTime={!dateOnly} + {enableTime} {timeOnly} - ignoreTimezones={schema.ignoreTimezones} - bind:api={datePickerAPI} - on:open={() => (isOpen = true)} - on:close={() => (isOpen = false)} + {ignoreTimezones} useKeyboardShortcuts={false} />
@@ -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; } diff --git a/packages/frontend-core/src/components/grid/cells/LongFormCell.svelte b/packages/frontend-core/src/components/grid/cells/LongFormCell.svelte index 0299e66e2f..b3eab24fa1 100644 --- a/packages/frontend-core/src/components/grid/cells/LongFormCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/LongFormCell.svelte @@ -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; diff --git a/packages/frontend-core/src/components/grid/cells/OptionsCell.svelte b/packages/frontend-core/src/components/grid/cells/OptionsCell.svelte index ede9bd1cff..158451c930 100644 --- a/packages/frontend-core/src/components/grid/cells/OptionsCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/OptionsCell.svelte @@ -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); diff --git a/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte b/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte index bf1fe92ef0..a52dd9d53c 100644 --- a/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte @@ -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); diff --git a/packages/frontend-core/src/components/grid/layout/Grid.svelte b/packages/frontend-core/src/components/grid/layout/Grid.svelte index b6c686fd62..468f49bdec 100644 --- a/packages/frontend-core/src/components/grid/layout/Grid.svelte +++ b/packages/frontend-core/src/components/grid/layout/Grid.svelte @@ -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}
diff --git a/packages/frontend-core/src/components/grid/lib/constants.js b/packages/frontend-core/src/components/grid/lib/constants.js index a6e6723463..37d829873d 100644 --- a/packages/frontend-core/src/components/grid/lib/constants.js +++ b/packages/frontend-core/src/components/grid/lib/constants.js @@ -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 diff --git a/packages/frontend-core/src/components/grid/stores/viewport.js b/packages/frontend-core/src/components/grid/stores/viewport.js index 8df8acd0f4..96a5a954ee 100644 --- a/packages/frontend-core/src/components/grid/stores/viewport.js +++ b/packages/frontend-core/src/components/grid/stores/viewport.js @@ -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 } }