285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
|
|
import flatpickr from "flatpickr"
|
||
|
|
|
||
|
|
import {InputWidget, InputWidgetView} from "@bokehjs/models/widgets/input_widget"
|
||
|
|
import type {StyleSheetLike} from "@bokehjs/core/dom"
|
||
|
|
import {bounding_box, input} from "@bokehjs/core/dom"
|
||
|
|
import {CalendarPosition} from "@bokehjs/core/enums"
|
||
|
|
import type * as p from "@bokehjs/core/properties"
|
||
|
|
import {isString} from "@bokehjs/core/util/types"
|
||
|
|
|
||
|
|
import * as inputs from "@bokehjs/styles/widgets/inputs.css"
|
||
|
|
import flatpickr_css from "@bokehjs/styles/widgets/flatpickr.css"
|
||
|
|
|
||
|
|
type DateStr = string
|
||
|
|
type DatesList = (DateStr | [DateStr, DateStr])[]
|
||
|
|
|
||
|
|
function _convert_date_list(value: DatesList): flatpickr.Options.DateLimit[] {
|
||
|
|
const result: flatpickr.Options.DateLimit[] = []
|
||
|
|
for (const item of value) {
|
||
|
|
if (isString(item)) {
|
||
|
|
result.push(item)
|
||
|
|
} else {
|
||
|
|
const [from, to] = item
|
||
|
|
result.push({from, to})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
export class DatetimePickerView extends InputWidgetView {
|
||
|
|
declare model: DatetimePicker
|
||
|
|
|
||
|
|
private _picker?: flatpickr.Instance
|
||
|
|
|
||
|
|
override connect_signals(): void {
|
||
|
|
super.connect_signals()
|
||
|
|
|
||
|
|
const {
|
||
|
|
value, min_date, max_date, disabled_dates, enabled_dates, inline,
|
||
|
|
enable_time, enable_seconds, military_time, date_format, mode,
|
||
|
|
} = this.model.properties
|
||
|
|
|
||
|
|
this.on_change(value, () => this.model.value ? this._picker?.setDate(this.model.value) : this._clear())
|
||
|
|
this.on_change(min_date, () => this._picker?.set("minDate", this.model.min_date))
|
||
|
|
this.on_change(max_date, () => this._picker?.set("maxDate", this.model.max_date))
|
||
|
|
this.on_change(disabled_dates, () => {
|
||
|
|
const {disabled_dates} = this.model
|
||
|
|
this._picker?.set("disable", disabled_dates != null ? _convert_date_list(disabled_dates) : [])
|
||
|
|
})
|
||
|
|
this.on_change(enabled_dates, () => {
|
||
|
|
const {enabled_dates} = this.model
|
||
|
|
if (enabled_dates != null) {
|
||
|
|
this._picker?.set("enable", _convert_date_list(enabled_dates))
|
||
|
|
} else {
|
||
|
|
// this reimplements `set()` for the `undefined` case
|
||
|
|
if (this._picker) {
|
||
|
|
this._picker.config._enable = undefined
|
||
|
|
this._picker.redraw()
|
||
|
|
this._picker.updateValue(true)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
this.on_change(inline, () => this._picker?.set("inline", this.model.inline))
|
||
|
|
this.on_change(enable_time, () => this._picker?.set("enableTime", this.model.enable_time))
|
||
|
|
this.on_change(enable_seconds, () => this._picker?.set("enableSeconds", this.model.enable_seconds))
|
||
|
|
this.on_change(military_time, () => this._picker?.set("time_24hr", this.model.military_time))
|
||
|
|
this.on_change(mode, () => this._picker?.set("mode", this.model.mode))
|
||
|
|
this.on_change(date_format, () => this._picker?.set("dateFormat", this.model.date_format))
|
||
|
|
}
|
||
|
|
|
||
|
|
override remove(): void {
|
||
|
|
this._picker?.destroy()
|
||
|
|
super.remove()
|
||
|
|
}
|
||
|
|
|
||
|
|
override stylesheets(): StyleSheetLike[] {
|
||
|
|
return [...super.stylesheets(), flatpickr_css]
|
||
|
|
}
|
||
|
|
|
||
|
|
_render_input(): HTMLElement {
|
||
|
|
return this.input_el = input({type: "text", class: inputs.input, disabled: this.model.disabled})
|
||
|
|
}
|
||
|
|
|
||
|
|
override render(): void {
|
||
|
|
if (this._picker != null) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
super.render()
|
||
|
|
|
||
|
|
const options: flatpickr.Options.Options = {
|
||
|
|
appendTo: this.group_el,
|
||
|
|
positionElement: this.input_el,
|
||
|
|
defaultDate: this.model.value!,
|
||
|
|
inline: this.model.inline,
|
||
|
|
position: this._position.bind(this),
|
||
|
|
enableTime: this.model.enable_time,
|
||
|
|
enableSeconds: this.model.enable_seconds,
|
||
|
|
time_24hr: this.model.military_time,
|
||
|
|
dateFormat: this.model.date_format,
|
||
|
|
mode: this.model.mode,
|
||
|
|
onClose: (selected_dates, date_string, instance) => this._on_close(selected_dates, date_string, instance),
|
||
|
|
}
|
||
|
|
|
||
|
|
const {min_date, max_date, disabled_dates, enabled_dates} = this.model
|
||
|
|
if (min_date != null) {
|
||
|
|
options.minDate = min_date
|
||
|
|
}
|
||
|
|
if (max_date != null) {
|
||
|
|
options.maxDate = max_date
|
||
|
|
}
|
||
|
|
if (disabled_dates != null) {
|
||
|
|
options.disable = _convert_date_list(disabled_dates)
|
||
|
|
}
|
||
|
|
if (enabled_dates != null) {
|
||
|
|
options.enable = _convert_date_list(enabled_dates)
|
||
|
|
}
|
||
|
|
|
||
|
|
this._picker = flatpickr(this.input_el, options)
|
||
|
|
this._picker.maxDateHasTime = true
|
||
|
|
this._picker.minDateHasTime = true
|
||
|
|
}
|
||
|
|
|
||
|
|
protected _clear() {
|
||
|
|
this._picker?.clear()
|
||
|
|
this.model.value = null
|
||
|
|
}
|
||
|
|
|
||
|
|
protected _on_close(_selected_dates: Date[], date_string: string, _instance: flatpickr.Instance): void {
|
||
|
|
if (this.model.mode == "range" && !date_string.includes("to")) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
this.model.value = date_string
|
||
|
|
this.change_input()
|
||
|
|
}
|
||
|
|
|
||
|
|
protected _position(self: flatpickr.Instance, custom_el: HTMLElement | undefined): void {
|
||
|
|
// This function is copied directly from bokehs date_picker
|
||
|
|
const positionElement = custom_el ?? self._positionElement
|
||
|
|
|
||
|
|
const calendarHeight = [...self.calendarContainer.children].reduce((acc, child) => acc + bounding_box(child).height, 0)
|
||
|
|
const calendarWidth = self.calendarContainer.offsetWidth
|
||
|
|
const configPos = this.model.position.split(" ")
|
||
|
|
const configPosVertical = configPos[0]
|
||
|
|
const configPosHorizontal = configPos.length > 1 ? configPos[1] : null
|
||
|
|
// const inputBounds = positionElement.getBoundingClientRect()
|
||
|
|
const inputBounds = {
|
||
|
|
top: positionElement.offsetTop,
|
||
|
|
bottom: positionElement.offsetTop + positionElement.offsetHeight,
|
||
|
|
left: positionElement.offsetLeft,
|
||
|
|
right: positionElement.offsetLeft + positionElement.offsetWidth,
|
||
|
|
width: positionElement.offsetWidth,
|
||
|
|
}
|
||
|
|
const distanceFromBottom = window.innerHeight - inputBounds.bottom
|
||
|
|
const showOnTop =
|
||
|
|
configPosVertical === "above" ||
|
||
|
|
(configPosVertical !== "below" &&
|
||
|
|
distanceFromBottom < calendarHeight &&
|
||
|
|
inputBounds.top > calendarHeight)
|
||
|
|
|
||
|
|
// const top =
|
||
|
|
// window.scrollY +
|
||
|
|
// inputBounds.top +
|
||
|
|
// (!showOnTop ? positionElement.offsetHeight + 2 : -calendarHeight - 2)
|
||
|
|
const top = self.config.appendTo
|
||
|
|
? inputBounds.top +
|
||
|
|
(!showOnTop ? positionElement.offsetHeight + 2 : -calendarHeight - 2)
|
||
|
|
: window.scrollY +
|
||
|
|
inputBounds.top +
|
||
|
|
(!showOnTop ? positionElement.offsetHeight + 2 : -calendarHeight - 2)
|
||
|
|
|
||
|
|
self.calendarContainer.classList.toggle("arrowTop", !showOnTop)
|
||
|
|
self.calendarContainer.classList.toggle("arrowBottom", showOnTop)
|
||
|
|
|
||
|
|
if (self.config.inline) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
let left = window.scrollX + inputBounds.left
|
||
|
|
let isCenter = false
|
||
|
|
let isRight = false
|
||
|
|
|
||
|
|
if (configPosHorizontal === "center") {
|
||
|
|
left -= (calendarWidth - inputBounds.width) / 2
|
||
|
|
isCenter = true
|
||
|
|
} else if (configPosHorizontal === "right") {
|
||
|
|
left -= calendarWidth - inputBounds.width
|
||
|
|
isRight = true
|
||
|
|
}
|
||
|
|
|
||
|
|
self.calendarContainer.classList.toggle("arrowLeft", !isCenter && !isRight)
|
||
|
|
self.calendarContainer.classList.toggle("arrowCenter", isCenter)
|
||
|
|
self.calendarContainer.classList.toggle("arrowRight", isRight)
|
||
|
|
|
||
|
|
const right =
|
||
|
|
window.document.body.offsetWidth -
|
||
|
|
(window.scrollX + inputBounds.right)
|
||
|
|
const rightMost = left + calendarWidth > window.document.body.offsetWidth
|
||
|
|
const centerMost = right + calendarWidth > window.document.body.offsetWidth
|
||
|
|
|
||
|
|
self.calendarContainer.classList.toggle("rightMost", rightMost)
|
||
|
|
|
||
|
|
if (self.config.static) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
self.calendarContainer.style.top = `${top}px`
|
||
|
|
|
||
|
|
if (!rightMost) {
|
||
|
|
self.calendarContainer.style.left = `${left}px`
|
||
|
|
self.calendarContainer.style.right = "auto"
|
||
|
|
} else if (!centerMost) {
|
||
|
|
self.calendarContainer.style.left = "auto"
|
||
|
|
self.calendarContainer.style.right = `${right}px`
|
||
|
|
} else {
|
||
|
|
const css = this.shadow_el.styleSheets[0]
|
||
|
|
const bodyWidth = window.document.body.offsetWidth
|
||
|
|
const centerLeft = Math.max(0, bodyWidth / 2 - calendarWidth / 2)
|
||
|
|
const centerBefore = ".flatpickr-calendar.centerMost:before"
|
||
|
|
const centerAfter = ".flatpickr-calendar.centerMost:after"
|
||
|
|
const centerIndex = css.cssRules.length
|
||
|
|
const centerStyle = `{left:${inputBounds.left}px;right:auto;}`
|
||
|
|
self.calendarContainer.classList.toggle("rightMost", false)
|
||
|
|
self.calendarContainer.classList.toggle("centerMost", true)
|
||
|
|
css.insertRule(`${centerBefore},${centerAfter}${centerStyle}`, centerIndex)
|
||
|
|
self.calendarContainer.style.left = `${centerLeft}px`
|
||
|
|
self.calendarContainer.style.right = "auto"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export namespace DatetimePicker {
|
||
|
|
export type Attrs = p.AttrsOf<Props>
|
||
|
|
|
||
|
|
export type Props = InputWidget.Props & {
|
||
|
|
value: p.Property<string | null>
|
||
|
|
min_date: p.Property<string | null>
|
||
|
|
max_date: p.Property<string | null>
|
||
|
|
disabled_dates: p.Property<DatesList | null>
|
||
|
|
enabled_dates: p.Property<DatesList | null>
|
||
|
|
position: p.Property<CalendarPosition>
|
||
|
|
inline: p.Property<boolean>
|
||
|
|
enable_time: p.Property<boolean>
|
||
|
|
enable_seconds: p.Property<boolean>
|
||
|
|
military_time: p.Property<boolean>
|
||
|
|
date_format: p.Property<string>
|
||
|
|
mode: p.Property<any>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface DatetimePicker extends DatetimePicker.Attrs {}
|
||
|
|
|
||
|
|
export class DatetimePicker extends InputWidget {
|
||
|
|
declare properties: DatetimePicker.Props
|
||
|
|
declare __view_type__: DatetimePickerView
|
||
|
|
|
||
|
|
constructor(attrs?: Partial<DatetimePicker.Attrs>) {
|
||
|
|
super(attrs)
|
||
|
|
}
|
||
|
|
|
||
|
|
static override __module__ = "panel.models.datetime_picker"
|
||
|
|
|
||
|
|
static {
|
||
|
|
this.prototype.default_view = DatetimePickerView
|
||
|
|
|
||
|
|
this.define<DatetimePicker.Props>(({Bool, Str, List, Tuple, Or, Nullable}) => {
|
||
|
|
const DateStr = Str
|
||
|
|
const DatesList = List(Or(DateStr, Tuple(DateStr, DateStr)))
|
||
|
|
return {
|
||
|
|
value: [ Nullable(Str), null ],
|
||
|
|
min_date: [ Nullable(Str), null ],
|
||
|
|
max_date: [ Nullable(Str), null ],
|
||
|
|
disabled_dates: [ Nullable(DatesList), null ],
|
||
|
|
enabled_dates: [ Nullable(DatesList), null ],
|
||
|
|
position: [ CalendarPosition, "auto" ],
|
||
|
|
inline: [ Bool, false ],
|
||
|
|
enable_time: [ Bool, true ],
|
||
|
|
enable_seconds: [ Bool, true ],
|
||
|
|
military_time: [ Bool, true ],
|
||
|
|
date_format: [ Str, "Y-m-d H:i:S" ],
|
||
|
|
mode: [ Str, "single" ],
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|