@wslyhbb/vuejs-datepicker
Version:
A simple Vue.js datepicker component. Supports disabling of dates, inline mode, translations. Fork of initial vuejs-datepicker which is abandoned.
457 lines (404 loc) • 13.6 kB
JavaScript
export default {
data () {
return {
focus: {
delay: 0,
refs: []
},
inlineTabbableCell: null,
navElements: [],
navElementsFocusedIndex: 0,
resetTabbableCell: false,
tabbableCell: null
}
},
computed: {
fallbackElementsToFocus () {
const elements = ['tabbableCell', 'prev', 'next']
if (this.typeable) {
elements.unshift('input')
}
return elements
},
tabbableCellId () {
return (
this.tabbableCell && Number(this.tabbableCell.getAttribute('data-id'))
)
}
},
methods: {
/**
* Converts a date to first in month for `month` view or first in year for `year` view
* @param {Date} date The date to convert
* @returns {Date}
*/
getCellDate (date) {
switch (this.view) {
case 'month':
return new Date(this.utils.setDate(date, 1))
case 'year':
return new Date(
this.utils.setMonth(new Date(this.utils.setDate(date, 1)), 0)
)
default:
return date
}
},
/**
* Returns true, unless tabbing should be focus-trapped
* @return {Boolean}
*/
allowNormalTabbing (event) {
if (!this.isOpen) {
return true
}
return this.isTabbingAwayFromInlineDatepicker(event)
},
/**
* Focuses the first non-disabled element found in the `focus.refs` array and sets `navElementsFocusedIndex`
*/
applyFocus () {
const focusRefs = [...this.focus.refs, ...this.fallbackElementsToFocus]
for (let i = 0; i < focusRefs.length; i++) {
const element = this.getElementByRef(focusRefs[i])
if (element && element.getAttribute('disabled') === null) {
element.focus()
this.setNavElementsFocusedIndex()
break
}
}
},
/**
* Returns the currently focused cell element, if there is one...
*/
getActiveCell () {
const activeElement = this.getActiveElement()
const isActiveElementACell = this.hasClass(activeElement, 'cell')
const isOnSameView = this.hasClass(activeElement, this.view)
if (isActiveElementACell && isOnSameView && !this.resetTabbableCell) {
return activeElement
}
return null
},
/**
* Returns the currently focused element, using shadowRoot for web-components...
*/
getActiveElement () {
return document.activeElement.shadowRoot
? document.activeElement.shadowRoot.activeElement
: document.activeElement
},
/**
* Returns the `cellId` for a given a date
* @param {Date} date The date for which we need the cellId
* @returns {Number|null}
*/
getCellId (date) {
if (!date || !this.$refs.picker.$refs.cells) {
return null
}
const cellDate = this.getCellDate(date)
const { cells } = this.$refs.picker.$refs.cells
for (let i = 0; i < cells.length; i++) {
if (cells[i].timestamp === cellDate.valueOf()) {
return i
}
}
return null
},
/**
* Finds an element by its `ref` attribute
* @param {string} ref The `ref` name of the wanted element
* @returns {HTMLElement|Vue} A Vue element
*/
getElementByRef (ref) {
if (ref === 'tabbableCell') {
return this.tabbableCell
}
if (ref === 'input') {
return this.$refs.dateInput && this.$refs.dateInput.$refs[this.refName]
}
if (ref === 'calendarButton') {
return this.$refs.dateInput && this.$refs.dateInput.$refs.calendarButton
}
return (
this.$refs.picker &&
this.$refs.picker.$refs.pickerHeader &&
this.$refs.picker.$refs.pickerHeader.$refs[ref]
)
},
/**
* Returns an array of all HTML elements which should be focus-trapped in the specified slot
* @returns {Array} An array of HTML elements
*/
getElementsFromSlot (slotName) {
if (!this.hasSlot(slotName)) {
return []
}
if (slotName === 'beforeCalendarHeader') {
return this.getFocusableElements(this.$refs.view.children[0])
}
const isBeforeHeader = slotName.indexOf('beforeCalendarHeader') > -1
const picker = this.$refs.picker.$el
const index = isBeforeHeader ? 0 : picker.children.length - 1
return this.getFocusableElements(picker.children[index])
},
/**
* Returns an array of all HTMLButtonElements which should be focus-trapped in the header
* @returns {Array} An array of HTMLButtonElements
*/
getElementsFromHeader () {
if (!this.$refs.picker.$refs.pickerHeader) {
return []
}
const header = this.$refs.picker.$refs.pickerHeader.$el
const navNodeList = header.querySelectorAll('button:enabled')
return [...Array.prototype.slice.call(navNodeList)]
},
/**
* Returns an array of focusable elements in a given HTML fragment
* @param {Element} fragment The HTML fragment to search
* @returns {Array}
*/
getFocusableElements (fragment) {
if (!fragment) {
return []
}
const navNodeList = fragment.querySelectorAll(
'button:enabled:not([tabindex="-1"]), [href]:not([tabindex="-1"]), input:not([tabindex="-1"]):not([type=hidden]), select:enabled:not([tabindex="-1"]), textarea:enabled:not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])'
)
return [...Array.prototype.slice.call(navNodeList)]
},
/**
* Returns the first focusable element of an inline datepicker
* @returns {HTMLElement}
*/
getFirstInlineFocusableElement () {
const pickerElements = this.getFocusableElements(this.$refs.picker.$el)
return pickerElements[0]
},
/**
* Returns the last focusable element of an inline datepicker
* @returns {HTMLElement}
*/
getLastInlineFocusableElement () {
const pickerElements = this.getFocusableElements(this.$refs.picker.$el)
return pickerElements[pickerElements.length - 1]
},
/**
* Returns the input element (when typeable)
* @returns {Element}
*/
getInputField () {
if (!this.typeable || this.inline) {
return null
}
return this.$refs.dateInput.$refs[this.refName]
},
/**
* Used for a typeable datepicker: returns the cell element that corresponds to latestValidTypedDate...
*/
getTypedCell () {
if (!this.typeable) {
return null
}
const cellId = this.getCellId(this.latestValidTypedDate)
return cellId ? this.$refs.picker.$refs.cells.$el.children[cellId] : null
},
/**
* Returns true if the calendar has been passed the given slot
* @param {String} slotName The name of the slot
* @return {Boolean}
*/
hasSlot (slotName) {
return !!this.$slots[slotName]
},
/**
* Returns true if the user is tabbing away from an inline datepicker
* @return {Boolean}
*/
isTabbingAwayFromInlineDatepicker (event) {
if (!this.inline) {
return false
}
if (this.isTabbingAwayFromFirstNavElement(event)) {
this.tabAwayFromFirstElement()
return true
}
if (this.isTabbingAwayFromLastNavElement(event)) {
this.tabAwayFromLastElement()
return true
}
return false
},
/**
* Used for inline calendars; returns true if the user tabs backwards from the first focusable element
* @param {object} event Used to determine whether we are tabbing forwards or backwards
* @return {Boolean}
*/
isTabbingAwayFromFirstNavElement (event) {
if (!event.shiftKey) {
return false
}
const activeElement = this.getActiveElement()
const firstNavElement = this.navElements[0]
return activeElement === firstNavElement
},
/**
* Used for inline calendars; returns true if the user tabs forwards from the last focusable element
* @param {object} event Used to determine whether we are tabbing forwards or backwards
* @return {Boolean}
*/
isTabbingAwayFromLastNavElement (event) {
if (event.shiftKey) {
return false
}
const activeElement = this.getActiveElement()
const lastNavElement = this.navElements[this.navElements.length - 1]
return activeElement === lastNavElement
},
/**
* Sets the correct focus on next tick
*/
reviewFocus () {
this.tabbableCell = null
this.resetTabbableCell = true
this.$nextTick(() => {
this.setNavElements()
setTimeout(() => {
this.applyFocus()
}, this.focus.delay)
this.resetTabbableCell = false
})
},
/**
* Stores the current tabbableCell of an inline datepicker
* N.B. This is used when tabbing back (shift + tab) to an inline calendar from further down the page
*/
setInlineTabbableCell () {
if (!this.inline) {
return
}
this.inlineTabbableCell = this.tabbableCell
},
/**
* Set the focus
* @param {Array} refs An array of `refs` to focus (in order of preference)
*/
setFocus (refs) {
this.focus.refs = refs
this.applyFocus()
},
/**
* Determines which elements in datepicker should be focus-trapped
*/
setNavElements () {
if (!this.view) return
this.updateTabbableCell()
const view = this.ucFirst(this.view)
this.navElements = [
this.getInputField(),
this.getElementsFromSlot('beforeCalendarHeader'),
this.getElementsFromSlot(`beforeCalendarHeader${view}`),
this.getElementsFromHeader(),
this.tabbableCell
]
.filter((item) => !!item)
.reduce((acc, val) => acc.concat(val), [])
},
/**
* Keeps track of the currently focused index in the navElements array
*/
setNavElementsFocusedIndex () {
const activeElement = this.getActiveElement()
for (let i = 0; i < this.navElements.length; i++) {
if (activeElement === this.navElements[i]) {
this.navElementsFocusedIndex = i
return
}
}
this.navElementsFocusedIndex = 0
},
/**
* Sets the focus-trapped cell in the picker
*/
setTabbableCell () {
if (!this.$refs.picker || !this.$refs.picker.$refs.cells) {
return
}
const pickerCells = this.$refs.picker.$refs.cells.$el
this.tabbableCell =
this.getActiveCell() ||
this.getTypedCell() ||
pickerCells.querySelector('button.selected:not(.muted):enabled') ||
pickerCells.querySelector('button.today:not(.muted):enabled') ||
pickerCells.querySelector('button.cell:not(.muted):enabled')
},
/**
* Focuses the first focusable element of an inline datepicker, so that the previous element on the page will be tabbed to
*/
tabAwayFromFirstElement () {
const firstElement = this.getFirstInlineFocusableElement()
firstElement.focus()
// Reset the tabbableCell as we don't want it to be the `firstElement` if the latter is
// an edge date from the previous month
this.tabbableCell = this.inlineTabbableCell
},
/**
* Focuses the last focusable element of an inline datepicker, so that the next element on the page will be tabbed to
*/
tabAwayFromLastElement () {
const lastElement = this.getLastInlineFocusableElement()
lastElement.focus()
// Reset the tabbableCell as we don't want it to be the `lastElement` if the latter is
// an edge date from the next month
this.tabbableCell = this.inlineTabbableCell
},
/**
* Tab backwards through the focus-trapped elements
*/
tabBackwards () {
this.navElementsFocusedIndex -= 1
if (this.navElementsFocusedIndex < 0) {
this.navElementsFocusedIndex = this.navElements.length - 1
}
this.navElements[this.navElementsFocusedIndex].focus()
},
/**
* Tab forwards through the focus-trapped elements
*/
tabForwards () {
this.navElementsFocusedIndex += 1
if (this.navElementsFocusedIndex >= this.navElements.length) {
this.navElementsFocusedIndex = 0
}
this.navElements[this.navElementsFocusedIndex].focus()
},
/**
* Tab through the focus-trapped elements
* @param event
*/
tabThroughNavigation (event) {
if (this.allowNormalTabbing(event)) {
return
}
event.preventDefault()
if (event.shiftKey) {
this.tabBackwards()
} else {
this.tabForwards()
}
},
/**
* Update which cell in the picker should be focus-trapped
*/
updateTabbableCell () {
const activeElement = this.getActiveElement()
const isActiveElementACell = this.hasClass(activeElement, 'cell')
const needToUpdate = !this.tabbableCell || isActiveElementACell
if (needToUpdate) {
this.setTabbableCell()
}
}
}
}