pickadate
Version:
The composable date & time picker.
653 lines (564 loc) • 18.2 kB
JavaScript
// @flow
import type { DatePickerCoreApi } from 'pickadate/types'
import type { ClassNameDynamic, RenderOptions } from 'pickadate/apis/dom/types'
import type { EventName } from 'pickadate/renderers/dom/constants'
import * as jsUtil from 'pickadate/utils/jsUtil'
import * as calendarUtil from 'pickadate/utils/calendarUtil'
import * as classNameUtil from 'pickadate/utils/classNameUtil'
import * as gridCellSelector from 'pickadate/selectors/gridCellSelector'
import createOnKeyDownCalendar from 'pickadate/renderers/dom/createOnKeyDownCalendar'
import getValueFromMouseEvent from 'pickadate/renderers/dom/getValueFromMouseEvent'
import { EVENT_NAME } from 'pickadate/renderers/dom/constants'
import { KEY_CODE } from 'pickadate/constants'
import chevronRight from 'pickadate/renderers/dom/icons/chevron-right.svg'
import chevronLeft from 'pickadate/renderers/dom/icons/chevron-left.svg'
import chevronUp from 'pickadate/renderers/dom/icons/chevron-up.svg'
import chevronDown from 'pickadate/renderers/dom/icons/chevron-down.svg'
import bullsEye from 'pickadate/renderers/dom/icons/bulls-eye.svg'
/////////////
// LAYOUTS //
/////////////
type CreateElement = (
picker: DatePickerCoreApi,
rootElement: HTMLElement
) => HTMLElement
type CreateLayout = CreateElement => Initialize => (...Array<Layout>) => Layout
type Initialize = (
element: HTMLElement,
picker: DatePickerCoreApi,
options: RenderOptions,
rootElement: HTMLElement
) => void
type Layout = (
picker: DatePickerCoreApi,
options: RenderOptions,
rootElement: HTMLElement
) => HTMLElement
const createLayout: CreateLayout = createElement => initialize => (
...childLayouts
) => (picker, options, rootElement) => {
const element = createElement(picker, rootElement)
initialize(element, picker, options, rootElement)
const children = childLayouts.map(layout =>
layout(picker, options, rootElement)
)
appendChildren(element, children)
return element
}
const createBoxLayout = createLayout(() =>
createDocumentElement({ tagName: 'div' })
)
const createButtonLayout = createLayout((picker, rootElement) => {
const buttonElement: HTMLButtonElement = (createDocumentElement({
tagName: 'button',
}): any)
rootElement.addEventListener(EVENT_NAME.INPUT_OPEN, () => {
buttonElement.disabled = false
})
rootElement.addEventListener(EVENT_NAME.INPUT_CLOSE, () => {
buttonElement.disabled = true
})
return buttonElement
})
////////////////
// COMPONENTS //
////////////////
export const createInputRootBox = createBoxLayout(
(element, picker, options, rootElement) => {
if (!(rootElement instanceof HTMLInputElement)) {
throw new Error(
'Cannot initialize an input root without the input element'
)
}
initializeInput(rootElement, picker)
element.className = options.className.inputRoot
element.addEventListener('focusin', (event: UIEvent) =>
event.stopPropagation()
)
element.addEventListener('click', (event: MouseEvent) =>
event.stopPropagation()
)
element.addEventListener('mousedown', (event: MouseEvent) => {
const { target } = event
if (
target instanceof HTMLInputElement ||
target instanceof HTMLButtonElement
) {
return
}
event.preventDefault()
})
}
)
export const createRootBox = createBoxLayout(
(element, picker, options, rootElement) => {
addAttributes(element, { className: options.className.root })
rootElement.addEventListener(EVENT_NAME.INPUT_OPEN, () => {
element.classList.add(options.className.root__active)
})
rootElement.addEventListener(EVENT_NAME.INPUT_CLOSE, () => {
element.classList.remove(options.className.root__active)
})
picker.subscribeToSelection(() =>
dispatchEvent(rootElement, EVENT_NAME.CHANGE)
)
}
)
export const createHeaderBox = createBoxLayout((element, picker, options) => {
addAttributes(element, { className: options.className.header })
})
export const createBodyBox = createBoxLayout((element, picker, options) => {
addAttributes(element, { className: options.className.body })
})
export const createFooterBox = createBoxLayout((element, picker, options) => {
addAttributes(element, { className: options.className.footer })
})
export const createTimeBox = createBoxLayout((element, picker, options) => {
// $FlowFixMe
const state = picker.store.getState()
addAttributes(element, {
className: options.className.time,
hidden: !state.selected,
})
picker.store.subscribe(() => {
const state = picker.store.getState()
element.hidden = !state.selected
})
})
export const createMonthAndYearBox = createBoxLayout(
(element, picker, options) => {
addAttributes(element, {
className: options.className.monthAndYear,
})
const render = () => {
const state = picker.store.getState()
element.innerHTML = ''
appendChildren(element, [
createDocumentElement({
children: state.templateHookWords.MMMM[state.view.getMonth()],
className: options.className.monthAndYear_month,
}),
createDocumentElement({
children: state.view.getFullYear(),
className: options.className.monthAndYear_year,
}),
])
}
render()
picker.store.subscribe(render)
}
)
export const createPreviousButton = createButtonLayout(
(element, picker, options) => {
const onMouseDown = (event: MouseEvent) => event.preventDefault()
const onClick = () => picker.viewPrevious()
addAttributes(element, {
className: options.className.button_previous,
onMouseDown,
onClick,
})
element.innerHTML = chevronLeft
}
)
export const createTodayButton = createButtonLayout(
(element, picker, options) => {
const onMouseDown = (event: MouseEvent) => event.preventDefault()
const onClick = () => picker.viewToday()
addAttributes(element, {
className: options.className.button_today,
onMouseDown,
onClick,
})
element.innerHTML = bullsEye
}
)
export const createNextButton = createButtonLayout(
(element, picker, options) => {
const onMouseDown = (event: MouseEvent) => event.preventDefault()
const onClick = () => picker.viewNext()
addAttributes(element, {
className: options.className.button_next,
onMouseDown,
onClick,
})
element.innerHTML = chevronRight
}
)
export const createClearButton = createButtonLayout(
(element, picker, options) => {
const onMouseDown = (event: MouseEvent) => event.preventDefault()
const onClick = () => picker.clear()
addAttributes(element, {
className: options.className.button_clear,
onMouseDown,
onClick,
})
appendChildren(element, 'Clear')
}
)
export const createGridButton = createButtonLayout(
(element, picker, options, rootElement) => {
rootElement.addEventListener(EVENT_NAME.INPUT_ACTIVE, () => {
element.classList.add(options.className.grid__focused)
})
rootElement.addEventListener(EVENT_NAME.INPUT_INACTIVE, () => {
element.classList.remove(options.className.grid__focused)
})
const onClick = (event: MouseEvent) => {
const value = getValueFromMouseEvent(event)
if (value) {
picker.setSelected({ value })
}
}
addAttributes(element, {
className: options.className.grid,
onClick,
onKeyDown: createOnKeyDownCalendar(picker),
})
const render = () => {
element.innerHTML = ''
appendChildren(
element,
createGridCellElements(options, picker.store.getState())
)
}
render()
picker.store.subscribe(render)
}
)
export const createMeridiemButton = createButtonLayout(
(element, picker, options) => {
const onMouseDown = (event: Event) => event.preventDefault()
const onClick = () => picker.cycleMeridiem()
addAttributes(element, {
className: options.className.button_meridiem,
onMouseDown,
onClick,
})
const updateButton = () => {
const { selected } = picker.store.getState()
element.innerHTML = ''
appendChildren(
element,
selected ? (selected.getHours() < 12 ? 'AM' : 'PM') : 'AM'
)
element.tabIndex = selected ? 0 : -1
}
updateButton()
picker.store.subscribe(updateButton)
}
)
export const createHourInput = createBoxLayout((element, picker, options) => {
addAttributes(element, { className: options.className.time_hours })
const cycleUp = () => picker.cycleHourUp()
const cycleDown = () => picker.cycleHourDown()
const hourInput = createTimeInputElement(options, {
className: options.className.time_input__hours,
onKeyCodeUp: cycleUp,
onKeyCodeDown: cycleDown,
})
const updateHourInput = () => {
const state = picker.store.getState()
const hours = state.selected
? jsUtil.padZero(state.selected.getHours() % 12 || 12, 2)
: ''
hourInput.value = hours
hourInput.tabIndex = state.selected ? 0 : -1
}
updateHourInput()
picker.store.subscribe(updateHourInput)
appendChildren(element, [
hourInput,
...createTimeCounterElements(options, {
onClickUp: cycleUp,
onClickDown: cycleDown,
}),
])
})
export const createMinuteInput = createBoxLayout((element, picker, options) => {
addAttributes(element, {
className: options.className.time_minutes,
})
const cycleUp = () => picker.cycleMinuteUp()
const cycleDown = () => picker.cycleMinuteDown()
const minuteInput = createTimeInputElement(options, {
className: options.className.time_input__minutes,
onKeyCodeUp: cycleUp,
onKeyCodeDown: cycleDown,
})
const updateMinuteInput = () => {
const state = picker.store.getState()
const minutes = state.selected
? jsUtil.padZero(state.selected.getMinutes(), 2)
: ''
minuteInput.value = minutes
minuteInput.tabIndex = state.selected ? 0 : -1
}
updateMinuteInput()
picker.store.subscribe(updateMinuteInput)
appendChildren(element, [
minuteInput,
...createTimeCounterElements(options, {
onClickUp: cycleUp,
onClickDown: cycleDown,
}),
])
})
export const createTimeSeparator = createBoxLayout(
(element, picker, options) => {
addAttributes(element, {
className: options.className.time_separator,
})
element.innerHTML = ':'
}
)
//////////////
// ELEMENTS //
//////////////
const createGridCellElements = (options, state) => {
const datesInWeeks = calendarUtil.getDatesInWeeks(
state.view,
state.firstDayOfWeek
)
const elements = datesInWeeks.map(datesInWeek =>
createDocumentElement({
className: options.className.grid_row,
children: datesInWeek.map(dateObject =>
createGridCellElement(options, state, dateObject)
),
})
)
elements.unshift(
createDocumentElement({
className: classNameUtil.flatten([
options.className.grid_row,
options.className.grid_row__label,
]),
children: calendarUtil
.getWeekdayLabels(state.templateHookWords.DDD, state.firstDayOfWeek)
.map(weekday =>
createDocumentElement({
className: options.className.grid_label,
children: weekday,
})
),
})
)
return elements
}
const createGridCellElement = (options, state, dateObject) => {
const cellInfo = gridCellSelector.computeInfo(state, dateObject)
const parent = createDocumentElement({
className: classNameUtil.flatten([
options.className.grid_cell,
{
[options.className.grid_cell__today]: cellInfo.isToday,
[options.className.grid_cell__highlighted]: cellInfo.isHighlighted,
[options.className.grid_cell__selected]: cellInfo.isSelected,
[options.className.grid_cell__outOfView]: !cellInfo.isSameMonth,
[options.className.grid_cell__disabled]: cellInfo.isDisabled,
},
]),
attributes: {
dataset: cellInfo.isDisabled
? undefined
: { value: dateObject.getTime() },
},
})
const children = createDocumentElement({ children: dateObject.getDate() })
const content = options.renderCell({ dateObject, children }) || children
appendChildren(parent, content)
return parent
}
const createTimeInputElement = (
options,
{ className, onKeyCodeUp, onKeyCodeDown }
): HTMLInputElement => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.keyCode === KEY_CODE.UP) {
event.preventDefault()
onKeyCodeUp()
} else if (event.keyCode === KEY_CODE.DOWN) {
event.preventDefault()
onKeyCodeDown()
}
}
const onFocus = () => inputElement.setSelectionRange(0, 0)
const inputElement: HTMLInputElement = (createDocumentElement({
tagName: 'input',
className: [options.className.time_input, className],
attributes: { onKeyDown, onFocus, readOnly: true },
}): any)
return inputElement
}
const createTimeCounterElements = (options, { onClickUp, onClickDown }) => {
const onMouseDown = (event: MouseEvent) => event.preventDefault()
const upElement = createDocumentElement({
tagName: 'button',
className: [
options.className.time_counter,
options.className.time_counter__up,
],
attributes: { tabIndex: -1, onMouseDown, onClick: onClickUp },
})
upElement.innerHTML = chevronUp
const downElement = createDocumentElement({
tagName: 'button',
className: [
options.className.time_counter,
options.className.time_counter__down,
],
attributes: { tabIndex: -1, onMouseDown, onClick: onClickDown },
})
downElement.innerHTML = chevronDown
return [upElement, downElement]
}
//////////////////
// INITIALIZERS //
//////////////////
const initializeInput = (inputElement, picker) => {
inputElement.readOnly = true
inputElement.value = picker.getValue()
inputElement.addEventListener('focus', () => {
dispatchEvent(inputElement, EVENT_NAME.INPUT_OPEN)
dispatchEvent(inputElement, EVENT_NAME.INPUT_ACTIVE)
})
inputElement.addEventListener('blur', () => {
dispatchEvent(inputElement, EVENT_NAME.INPUT_INACTIVE)
})
inputElement.addEventListener('keydown', createOnKeyDownCalendar(picker))
const onDocumentEvent = (event: UIEvent) => {
if (
event.target !== inputElement &&
// $FlowFixMe: In Chrome 62+, password auto-fill simulates focusin
!event.isSimulated &&
// In Firefox, a click on an `option` element bubbles up directly
// to the document.
event.target !== document &&
// In Firefox stopPropagation() doesn’t prevent right-click events
// from bubbling. So make sure the event wasn’t a right-click.
// $FlowFixMe
event.which !== 3
) {
dispatchEvent(inputElement, EVENT_NAME.INPUT_CLOSE)
}
}
document.addEventListener('focusin', onDocumentEvent)
document.addEventListener('click', onDocumentEvent)
inputElement.addEventListener(EVENT_NAME.UNMOUNT, () => {
document.removeEventListener('focusin', onDocumentEvent)
document.removeEventListener('click', onDocumentEvent)
})
dispatchEvent(inputElement, EVENT_NAME.INPUT_CLOSE)
picker.subscribeToSelection(({ value }) => {
if (value === inputElement.value) {
return
}
inputElement.value = value
const event = new CustomEvent('change')
inputElement.dispatchEvent(event)
})
}
/////////////
// HELPERS //
/////////////
type CreateDocumentElementArguments = {
tagName?: string,
className?: ClassNameDynamic,
attributes?: Object,
children?: any,
}
const createDocumentElement = ({
tagName = 'div',
className,
attributes,
children,
}: CreateDocumentElementArguments) => {
const element = document.createElement(tagName)
addAttributes(element, {
...attributes,
className: classNameUtil.flatten(['pickadate--element', className]),
})
appendChildren(element, children)
return element
}
export const appendChildren = (element: HTMLElement, children: any) => {
if (children == null) {
return
}
children = Array.isArray(children) ? children : [children]
children.forEach(child => {
if (!(child instanceof Node)) {
child = typeof child !== 'string' ? child.toString() : child
child = document.createTextNode(child)
}
element.appendChild(child)
})
}
const addAttributes = (element, attributes) => {
if (!attributes) {
return
}
Object.keys(attributes).forEach(attributeName => {
// $FlowFixMe
const attributeValue = attributes[attributeName]
if (attributeValue == null) {
return
}
if (typeof attributeValue === 'function') {
element.addEventListener(
attributeName.replace(/^on/, '').toLowerCase(),
(attributeValue: any)
)
return
}
if (attributeName === 'className') {
// $FlowFixMe
addClassName(element, attributeValue)
return
}
if (attributeName === 'dataset') {
// $FlowFixMe
Object.keys(attributeValue).forEach(datasetName => {
const fullDatasetName = `data-${jsUtil.caseDash(datasetName)}`
// $FlowFixMe
element.setAttribute(fullDatasetName, attributeValue[datasetName])
})
return
}
// $FlowFixMe
element.setAttribute(attributeName, attributeValue)
})
}
const addClassName = (element: Element, className: ClassNameDynamic | void) => {
if (!className) {
return
}
const list = Array.isArray(className) ? className : [className]
list.forEach(item => {
if (!item) {
return
}
if (typeof item === 'string') {
item = { [item]: true }
}
if (Array.isArray(item)) {
item.forEach(className => {
if (className && typeof className === 'string') {
element.classList.add(className)
}
})
return
}
Object.keys(item).forEach(className => {
// $FlowFixMe
if (className && typeof className === 'string' && item[className]) {
element.classList.add(className)
}
})
})
}
export const dispatchEvent = (element: HTMLElement, eventName: EventName) => {
const customEvent = new CustomEvent(eventName)
element.dispatchEvent(customEvent)
}