Add keyboard navigation to date|time picker

This commit is contained in:
Sergio Crisostomo 2018-05-18 13:06:43 +02:00
parent 2bf3e04753
commit 75cb299868
16 changed files with 467 additions and 67 deletions

View file

@ -1,5 +1,9 @@
<template>
<div :class="[prefixCls]" v-clickoutside="handleClose">
<div
:class="wrapperClasses"
v-click-outside:mousedown.capture="handleClose"
v-click-outside.capture="handleClose"
>
<div ref="reference" :class="[prefixCls + '-rel']">
<slot>
<i-input
@ -12,10 +16,14 @@
:placeholder="placeholder"
:value="visualValue"
:name="name"
ref="input"
@on-input-change="handleInputChange"
@on-focus="handleFocus"
@on-blur="handleBlur"
@on-click="handleIconClick"
@click.native="handleFocus"
@keydown.native="handleKeydown"
@mouseenter.native="handleInputMouseenter"
@mouseleave.native="handleInputMouseleave"
@ -48,6 +56,7 @@
:show-week-numbers="showWeekNumbers"
:picker-type="type"
:multiple="multiple"
:focused-date="focusedDate"
:time-picker-options="timePickerOptions"
@ -69,21 +78,49 @@
import iInput from '../../components/input/input.vue';
import Drop from '../../components/select/dropdown.vue';
import clickoutside from '../../directives/clickoutside';
import vClickOutside from 'v-click-outside-x/index';
import TransferDom from '../../directives/transfer-dom';
import { oneOf } from '../../utils/assist';
import { DEFAULT_FORMATS, RANGE_SEPARATOR, TYPE_VALUE_RESOLVER_MAP } from './util';
import { DEFAULT_FORMATS, RANGE_SEPARATOR, TYPE_VALUE_RESOLVER_MAP, getDayCountOfMonth } from './util';
import {findComponentsDownward} from '../../utils/assist';
import Emitter from '../../mixins/emitter';
const prefixCls = 'ivu-date-picker';
const pickerPrefixCls = 'ivu-picker';
const isEmptyArray = val => val.reduce((isEmpty, str) => isEmpty && !str || (typeof str === 'string' && str.trim() === ''), true);
const keyValueMapper = {
40: 'up',
39: 'right',
38: 'down',
37: 'left',
};
const mapPossibleValues = (key, horizontal, vertical) => {
if (key === 'left') return horizontal * -1;
if (key === 'right') return horizontal * 1;
if (key === 'up') return vertical * 1;
if (key === 'down') return vertical * -1;
};
const pulseElement = (el) => {
const pulseClass = 'ivu-date-picker-btn-pulse';
el.classList.add(pulseClass);
setTimeout(() => el.classList.remove(pulseClass), 200);
};
const extractTime = date => {
if (!date) return [0, 0, 0];
return [
date.getHours(), date.getMinutes(), date.getSeconds()
];
};
export default {
name: 'CalendarPicker',
mixins: [ Emitter ],
components: { iInput, Drop },
directives: { clickoutside, TransferDom },
directives: { clickOutside: vClickOutside.directive, TransferDom },
props: {
format: {
type: String
@ -172,6 +209,7 @@
const isRange = this.type.includes('range');
const emptyArray = isRange ? [null, null] : [null];
const initialValue = isEmptyArray((isRange ? this.value : [this.value]) || []) ? emptyArray : this.parseDate(this.value);
const focusedTime = initialValue.map(extractTime);
return {
prefixCls: prefixCls,
@ -181,10 +219,24 @@
disableClickOutSide: false, // fixed when click a date,trigger clickoutside to close picker
disableCloseUnderTransfer: false, // transfer Drop,
selectionMode: this.onSelectionModeChange(this.type),
forceInputRerender: 1
forceInputRerender: 1,
isFocused: false,
focusedDate: initialValue[0] || new Date(),
focusedTime: {
column: 0, // which column inside the picker
picker: 0, // which picker
time: focusedTime, // the values array into [hh, mm, ss],
active: false
},
internalFocus: false,
};
},
computed: {
wrapperClasses(){
return [prefixCls, {
[prefixCls + '-focused']: this.isFocused
}];
},
publicVModelValue(){
if (this.multiple){
return this.internalValue.slice();
@ -232,32 +284,246 @@
handleTransferClick () {
if (this.transfer) this.disableCloseUnderTransfer = true;
},
handleClose () {
handleClose (e) {
if (this.disableCloseUnderTransfer) {
this.disableCloseUnderTransfer = false;
return false;
}
if (this.open !== null) return;
this.visible = false;
if (e && e.type === 'mousedown' && this.visible) {
e.preventDefault();
e.stopPropagation();
return;
}
if (this.visible) {
const pickerPanel = this.$refs.pickerPanel && this.$refs.pickerPanel.$el;
if (e && pickerPanel && pickerPanel.contains(e.target)) return; // its a click inside own component, lets ignore it.
this.visible = false;
e && e.preventDefault();
e && e.stopPropagation();
return;
}
this.isFocused = false;
this.disableClickOutSide = false;
},
handleFocus () {
handleFocus (e) {
if (this.readonly) return;
this.isFocused = true;
if (e && e.type === 'focus') return; // just focus, don't open yet
this.visible = true;
this.$refs.pickerPanel.onToggleVisibility(true);
},
handleBlur () {
this.visible = false;
handleBlur (e) {
if (this.internalFocus){
this.internalFocus = false;
return;
}
if (this.visible) {
e.preventDefault();
return;
}
this.isFocused = false;
this.onSelectionModeChange(this.type);
this.internalValue = this.internalValue.slice(); // trigger panel watchers to reset views
this.reset();
this.$refs.pickerPanel.onToggleVisibility(false);
},
handleKeydown(e){
const keyCode = e.keyCode;
// handle "tab" key
if (keyCode === 9){
if (this.visible){
e.stopPropagation();
e.preventDefault();
if (this.isConfirm){
const selector = `.${pickerPrefixCls}-confirm > *`;
const tabbable = this.$refs.drop.$el.querySelectorAll(selector);
this.internalFocus = true;
const element = [...tabbable][e.shiftKey ? 'pop' : 'shift']();
element.focus();
} else {
this.handleClose();
}
} else {
this.focused = false;
}
}
// open the panel
const arrows = [37, 38, 39, 40];
if (!this.visible && arrows.includes(keyCode)){
this.visible = true;
return;
}
// close on "esc" key
if (keyCode === 27){
if (this.visible) {
e.stopPropagation();
this.handleClose();
}
}
// select date, "Enter" key
if (keyCode === 13){
const timePickers = findComponentsDownward(this, 'TimeSpinner');
if (timePickers.length > 0){
const columnsPerPicker = timePickers[0].showSeconds ? 3 : 2;
const pickerIndex = Math.floor(this.focusedTime.column / columnsPerPicker);
const value = this.focusedTime.time[pickerIndex];
timePickers[pickerIndex].chooseValue(value);
return;
}
if (this.type.match(/range/)){
this.$refs.pickerPanel.handleRangePick(this.focusedDate, 'date');
} else {
this.onPick(this.focusedDate, false, 'date');
}
}
if (!arrows.includes(keyCode)) return; // ignore rest of keys
// navigate times and dates
if (this.focusedTime.active) e.preventDefault(); // to prevent cursor from moving
this.navigateDatePanel(keyValueMapper[keyCode], e.shiftKey);
},
reset(){
this.$refs.pickerPanel.reset && this.$refs.pickerPanel.reset();
},
navigateTimePanel(direction){
this.focusedTime.active = true;
const horizontal = direction.match(/left|right/);
const vertical = direction.match(/up|down/);
const timePickers = findComponentsDownward(this, 'TimeSpinner');
const maxNrOfColumns = (timePickers[0].showSeconds ? 3 : 2) * timePickers.length;
const column = (currentColumn => {
const incremented = currentColumn + (horizontal ? (direction === 'left' ? -1 : 1) : 0);
return (incremented + maxNrOfColumns) % maxNrOfColumns;
})(this.focusedTime.column);
const columnsPerPicker = maxNrOfColumns / timePickers.length;
const pickerIndex = Math.floor(column / columnsPerPicker);
const col = column % columnsPerPicker;
if (horizontal){
const time = this.internalValue.map(extractTime);
this.focusedTime = {
...this.focusedTime,
column: column,
time: time
};
timePickers.forEach((instance, i) => {
if (i === pickerIndex) instance.updateFocusedTime(col, time[pickerIndex]);
else instance.updateFocusedTime(-1, instance.focusedTime);
});
}
if (vertical){
const increment = direction === 'up' ? 1 : -1;
const timeParts = ['hours', 'minutes', 'seconds'];
const pickerPossibleValues = timePickers[pickerIndex][`${timeParts[col]}List`];
const nextIndex = pickerPossibleValues.findIndex(({text}) => this.focusedTime.time[pickerIndex][col] === text) + increment;
const nextValue = pickerPossibleValues[nextIndex % pickerPossibleValues.length].text;
const times = this.focusedTime.time.map((time, i) => {
if (i !== pickerIndex) return time;
time[col] = nextValue;
return time;
});
this.focusedTime = {
...this.focusedTime,
time: times
};
timePickers.forEach((instance, i) => {
if (i === pickerIndex) instance.updateFocusedTime(col, times[i]);
else instance.updateFocusedTime(-1, instance.focusedTime);
});
}
},
navigateDatePanel(direction, shift){
const timePickers = findComponentsDownward(this, 'TimeSpinner');
if (timePickers.length > 0) {
// we are in TimePicker mode
this.navigateTimePanel(direction, shift, timePickers);
return;
}
if (shift){
if (this.type === 'year'){
this.focusedDate = new Date(
this.focusedDate.getFullYear() + mapPossibleValues(direction, 0, 10),
this.focusedDate.getMonth(),
this.focusedDate.getDate()
);
} else {
this.focusedDate = new Date(
this.focusedDate.getFullYear() + mapPossibleValues(direction, 0, 1),
this.focusedDate.getMonth() + mapPossibleValues(direction, 1, 0),
this.focusedDate.getDate()
);
}
const position = direction.match(/left|down/) ? 'prev' : 'next';
const double = direction.match(/up|down/) ? '-double' : '';
// pulse button
const button = this.$refs.drop.$el.querySelector(`.ivu-date-picker-${position}-btn-arrow${double}`);
if (button) pulseElement(button);
return;
}
const initialDate = this.focusedDate || (this.internalValue && this.internalValue[0]) || new Date();
const focusedDate = new Date(initialDate);
if (this.type.match(/^date/)){
const lastOfMonth = getDayCountOfMonth(initialDate.getFullYear(), initialDate.getMonth());
const startDay = initialDate.getDate();
const nextDay = focusedDate.getDate() + mapPossibleValues(direction, 1, 7);
if (nextDay < 1) {
if (direction.match(/left|right/)) {
focusedDate.setMonth(focusedDate.getMonth() + 1);
focusedDate.setDate(nextDay);
} else {
focusedDate.setDate(startDay + Math.floor((lastOfMonth - startDay) / 7) * 7);
}
} else if (nextDay > lastOfMonth){
if (direction.match(/left|right/)) {
focusedDate.setMonth(focusedDate.getMonth() - 1);
focusedDate.setDate(nextDay);
} else {
focusedDate.setDate(startDay % 7);
}
} else {
focusedDate.setDate(nextDay);
}
}
if (this.type.match(/^month/)) {
focusedDate.setMonth(focusedDate.getMonth() + mapPossibleValues(direction, 1, 3));
}
if (this.type.match(/^year/)) {
focusedDate.setFullYear(focusedDate.getFullYear() + mapPossibleValues(direction, 1, 3));
}
this.focusedDate = focusedDate;
},
handleInputChange (event) {
const isArrayValue = this.type.includes('range') || this.multiple;
const oldValue = this.visualValue;
@ -377,6 +643,12 @@
this.internalValue = Array.isArray(dates) ? dates : [dates];
}
this.focusedDate = this.internalValue[0];
this.focusedTime = {
...this.focusedTime,
time: this.internalValue.map(extractTime)
};
if (!this.isConfirm) this.onSelectionModeChange(this.type); // reset the selectionMode
if (!this.isConfirm) this.visible = visible;
this.emitChange(type);
@ -384,22 +656,23 @@
onPickSuccess(){
this.visible = false;
this.$emit('on-ok');
this.focus();
this.reset();
},
focus() {
this.$refs.input.focus();
}
},
watch: {
visible (state) {
if (state === false){
this.$refs.drop.destroy();
const input = this.$el.querySelector('input');
if (input) input.blur();
}
this.$refs.drop.update();
this.$emit('on-open-change', state);
},
value(val) {
this.internalValue = this.parseDate(val);
},
open (val) {
this.visible = val === true;
@ -421,6 +694,9 @@
this.$emit('input', this.publicVModelValue); // to update v-model
}
if (this.open !== null) this.visible = this.open;
// to handle focus from confirm buttons
this.$on('focus-input', () => this.focus());
}
};
</script>