Commit 75cb299868c5bee3978ea424da3c5145acefae82

Authored by Sergio Crisostomo
1 parent 2bf3e047

Add keyboard navigation to date|time picker

src/components/date-picker/base/confirm.vue
1 1 <template>
2   - <div :class="[prefixCls + '-confirm']">
3   - <span :class="timeClasses" v-if="showTime" @click="handleToggleTime">
4   - <template v-if="isTime">{{ t('i.datepicker.selectDate') }}</template>
5   - <template v-else>{{ t('i.datepicker.selectTime') }}</template>
6   - </span>
7   - <i-button size="small" type="text" @click.native="handleClear">{{ t('i.datepicker.clear') }}</i-button>
8   - <i-button size="small" type="primary" @click.native="handleSuccess">{{ t('i.datepicker.ok') }}</i-button>
  2 + <div :class="[prefixCls + '-confirm']" @keydown.tab.capture="handleTab">
  3 + <i-button :class="timeClasses" size="small" type="text" :disabled="timeDisabled" v-if="showTime" @click="handleToggleTime">
  4 + {{labels.time}}
  5 + </i-button>
  6 + <i-button size="small" type="ghost" @click.native="handleClear" @keydown.enter.native="handleClear">
  7 + {{labels.clear}}
  8 + </i-button>
  9 + <i-button size="small" type="primary" @click.native="handleSuccess" @keydown.enter.native="handleSuccess">
  10 + {{labels.ok}}
  11 + </i-button>
9 12 </div>
10 13 </template>
11 14 <script>
12 15 import iButton from '../../button/button.vue';
13 16 import Locale from '../../../mixins/locale';
  17 + import Emitter from '../../../mixins/emitter';
14 18  
15 19 const prefixCls = 'ivu-picker';
16 20  
17 21 export default {
18   - mixins: [ Locale ],
19   - components: { iButton },
  22 + mixins: [Locale, Emitter],
  23 + components: {iButton},
20 24 props: {
21 25 showTime: false,
22 26 isTime: false,
23 27 timeDisabled: false
24 28 },
25   - data () {
  29 + data() {
26 30 return {
27 31 prefixCls: prefixCls
28 32 };
29 33 },
30 34 computed: {
31 35 timeClasses () {
32   - return {
33   - [`${prefixCls}-confirm-time-disabled`]: this.timeDisabled
34   - };
  36 + return `${prefixCls}-confirm-time`;
  37 + },
  38 + labels(){
  39 + const labels = ['time', 'clear', 'ok'];
  40 + const values = [(this.isTime ? 'selectDate' : 'selectTime'), 'clear', 'ok'];
  41 + return labels.reduce((obj, key, i) => {
  42 + obj[key] = this.t('i.datepicker.' + values[i]);
  43 + return obj;
  44 + }, {});
35 45 }
36 46 },
37 47 methods: {
... ... @@ -44,6 +54,17 @@
44 54 handleToggleTime () {
45 55 if (this.timeDisabled) return;
46 56 this.$emit('on-pick-toggle-time');
  57 + this.dispatch('CalendarPicker', 'focus-input');
  58 + },
  59 + handleTab(e) {
  60 + const tabbables = [...this.$el.children];
  61 + const expectedFocus = tabbables[e.shiftKey ? 'shift' : 'pop']();
  62 +
  63 + if (document.activeElement === expectedFocus) {
  64 + e.preventDefault();
  65 + e.stopPropagation();
  66 + this.dispatch('CalendarPicker', 'focus-input');
  67 + }
47 68 }
48 69 }
49 70 };
... ...
src/components/date-picker/base/date-table.vue
... ... @@ -9,7 +9,7 @@
9 9 :class="getCellCls(cell)"
10 10 v-for="(cell, i) in readCells"
11 11 :key="String(cell.date) + i"
12   - @click="handleClick(cell)"
  12 + @click="handleClick(cell, $event)"
13 13 @mouseenter="handleMouseMove(cell)"
14 14 >
15 15 <em>{{ cell.desc }}</em>
... ... @@ -99,7 +99,9 @@
99 99 [`${prefixCls}-cell-prev-month`]: cell.type === 'prevMonth',
100 100 [`${prefixCls}-cell-next-month`]: cell.type === 'nextMonth',
101 101 [`${prefixCls}-cell-week-label`]: cell.type === 'weekLabel',
102   - [`${prefixCls}-cell-range`]: cell.range && !cell.start && !cell.end
  102 + [`${prefixCls}-cell-range`]: cell.range && !cell.start && !cell.end,
  103 + [`${prefixCls}-focused`]: clearHours(cell.date) === clearHours(this.focusedDate)
  104 +
103 105 }
104 106 ];
105 107 },
... ...
src/components/date-picker/base/mixin.js
... ... @@ -26,7 +26,10 @@ export default {
26 26 selecting: false
27 27 })
28 28 },
29   -
  29 + focusedDate: {
  30 + type: Date,
  31 + required: true,
  32 + }
30 33 },
31 34 computed: {
32 35 dates(){
... ...
src/components/date-picker/base/month-table.vue
... ... @@ -38,14 +38,16 @@
38 38  
39 39 const tableYear = this.tableDate.getFullYear();
40 40 const selectedDays = this.dates.filter(Boolean).map(date => clearHours(new Date(date.getFullYear(), date.getMonth(), 1)));
  41 + const focusedDate = clearHours(new Date(this.focusedDate.getFullYear(), this.focusedDate.getMonth(), 1));
41 42  
42 43 for (let i = 0; i < 12; i++) {
43 44 const cell = deepCopy(cell_tmpl);
44 45 cell.date = new Date(tableYear, i, 1);
45 46 cell.text = this.tCell(i + 1);
46   - const time = clearHours(cell.date);
  47 + const day = clearHours(cell.date);
47 48 cell.disabled = typeof this.disabledDate === 'function' && this.disabledDate(cell.date) && this.selectionMode === 'month';
48   - cell.selected = selectedDays.includes(time);
  49 + cell.selected = selectedDays.includes(day);
  50 + cell.focused = day === focusedDate;
49 51 cells.push(cell);
50 52 }
51 53  
... ... @@ -59,6 +61,7 @@
59 61 {
60 62 [`${prefixCls}-cell-selected`]: cell.selected,
61 63 [`${prefixCls}-cell-disabled`]: cell.disabled,
  64 + [`${prefixCls}-cell-focused`]: cell.focused,
62 65 [`${prefixCls}-cell-range`]: cell.range && !cell.start && !cell.end
63 66 }
64 67 ];
... ...
src/components/date-picker/base/time-spinner.vue
... ... @@ -22,8 +22,10 @@
22 22 import { deepCopy, scrollTop, firstUpperCase } from '../../../utils/assist';
23 23  
24 24 const prefixCls = 'ivu-time-picker-cells';
  25 + const timeParts = ['hours', 'minutes', 'seconds'];
25 26  
26 27 export default {
  28 + name: 'TimeSpinner',
27 29 mixins: [Options],
28 30 props: {
29 31 hours: {
... ... @@ -51,7 +53,9 @@
51 53 return {
52 54 spinerSteps: [1, 1, 1].map((one, i) => Math.abs(this.steps[i]) || one),
53 55 prefixCls: prefixCls,
54   - compiled: false
  56 + compiled: false,
  57 + focusedColumn: -1, // which column inside the picker
  58 + focusedTime: [0, 0, 0] // the values array into [hh, mm, ss]
55 59 };
56 60 },
57 61 computed: {
... ... @@ -66,6 +70,7 @@
66 70 hoursList () {
67 71 let hours = [];
68 72 const step = this.spinerSteps[0];
  73 + const focusedHour = this.focusedColumn === 0 && this.focusedTime[0];
69 74 const hour_tmpl = {
70 75 text: 0,
71 76 selected: false,
... ... @@ -76,6 +81,7 @@
76 81 for (let i = 0; i < 24; i += step) {
77 82 const hour = deepCopy(hour_tmpl);
78 83 hour.text = i;
  84 + hour.focused = i === focusedHour;
79 85  
80 86 if (this.disabledHours.length && this.disabledHours.indexOf(i) > -1) {
81 87 hour.disabled = true;
... ... @@ -90,6 +96,7 @@
90 96 minutesList () {
91 97 let minutes = [];
92 98 const step = this.spinerSteps[1];
  99 + const focusedMinute = this.focusedColumn === 1 && this.focusedTime[1];
93 100 const minute_tmpl = {
94 101 text: 0,
95 102 selected: false,
... ... @@ -100,6 +107,7 @@
100 107 for (let i = 0; i < 60; i += step) {
101 108 const minute = deepCopy(minute_tmpl);
102 109 minute.text = i;
  110 + minute.focused = i === focusedMinute;
103 111  
104 112 if (this.disabledMinutes.length && this.disabledMinutes.indexOf(i) > -1) {
105 113 minute.disabled = true;
... ... @@ -113,6 +121,7 @@
113 121 secondsList () {
114 122 let seconds = [];
115 123 const step = this.spinerSteps[2];
  124 + const focusedMinute = this.focusedColumn === 2 && this.focusedTime[2];
116 125 const second_tmpl = {
117 126 text: 0,
118 127 selected: false,
... ... @@ -123,6 +132,7 @@
123 132 for (let i = 0; i < 60; i += step) {
124 133 const second = deepCopy(second_tmpl);
125 134 second.text = i;
  135 + second.focused = i === focusedMinute;
126 136  
127 137 if (this.disabledSeconds.length && this.disabledSeconds.indexOf(i) > -1) {
128 138 second.disabled = true;
... ... @@ -141,15 +151,32 @@
141 151 `${prefixCls}-cell`,
142 152 {
143 153 [`${prefixCls}-cell-selected`]: cell.selected,
  154 + [`${prefixCls}-cell-focused`]: cell.focused,
144 155 [`${prefixCls}-cell-disabled`]: cell.disabled
  156 +
145 157 }
146 158 ];
147 159 },
  160 + chooseValue(values){
  161 + const changes = timeParts.reduce((obj, part, i) => {
  162 + const value = values[i];
  163 + if (this[part] === value) return obj;
  164 + return {
  165 + ...obj,
  166 + [part]: value
  167 + };
  168 + }, {});
  169 + if (Object.keys(changes).length > 0) {
  170 + this.emitChange(changes);
  171 + }
  172 + },
148 173 handleClick (type, cell) {
149 174 if (cell.disabled) return;
150   - const data = {};
151   - data[type] = cell.text;
152   - this.$emit('on-change', data);
  175 + const data = {[type]: cell.text};
  176 + this.emitChange(data);
  177 + },
  178 + emitChange(changes){
  179 + this.$emit('on-change', changes);
153 180 this.$emit('on-pick-click');
154 181 },
155 182 scroll (type, index) {
... ... @@ -168,15 +195,19 @@
168 195 return index;
169 196 },
170 197 updateScroll () {
171   - const times = ['hours', 'minutes', 'seconds'];
172 198 this.$nextTick(() => {
173   - times.forEach(type => {
  199 + timeParts.forEach(type => {
174 200 this.$refs[type].scrollTop = 24 * this[`${type}List`].findIndex(obj => obj.text == this[type]);
175 201 });
176 202 });
177 203 },
178 204 formatTime (text) {
179 205 return text < 10 ? '0' + text : text;
  206 + },
  207 + updateFocusedTime(col, time) {
  208 + this.focusedColumn = col;
  209 + this.focusedTime = time.slice();
  210 +
180 211 }
181 212 },
182 213 watch: {
... ... @@ -191,6 +222,13 @@
191 222 seconds (val) {
192 223 if (!this.compiled) return;
193 224 this.scroll('seconds', this.secondsList.findIndex(obj => obj.text == val));
  225 + },
  226 + focusedTime(updated, old){
  227 + timeParts.forEach((part, i) => {
  228 + if (updated[i] === old[i] || typeof updated[i] === 'undefined') return;
  229 + const valueIndex = this[`${part}List`].findIndex(obj => obj.text === updated[i]);
  230 + this.scroll(part, valueIndex);
  231 + });
194 232 }
195 233 },
196 234 mounted () {
... ...
src/components/date-picker/base/year-table.vue
... ... @@ -39,13 +39,15 @@
39 39 };
40 40  
41 41 const selectedDays = this.dates.filter(Boolean).map(date => clearHours(new Date(date.getFullYear(), 0, 1)));
  42 + const focusedDate = clearHours(new Date(this.focusedDate.getFullYear(), 0, 1));
42 43  
43 44 for (let i = 0; i < 10; i++) {
44 45 const cell = deepCopy(cell_tmpl);
45 46 cell.date = new Date(this.startYear + i, 0, 1);
46 47 cell.disabled = typeof this.disabledDate === 'function' && this.disabledDate(cell.date) && this.selectionMode === 'year';
47   - const time = clearHours(cell.date);
48   - cell.selected = selectedDays.includes(time);
  48 + const day = clearHours(cell.date);
  49 + cell.selected = selectedDays.includes(day);
  50 + cell.focused = day === focusedDate;
49 51 cells.push(cell);
50 52 }
51 53  
... ... @@ -59,6 +61,7 @@
59 61 {
60 62 [`${prefixCls}-cell-selected`]: cell.selected,
61 63 [`${prefixCls}-cell-disabled`]: cell.disabled,
  64 + [`${prefixCls}-cell-focused`]: cell.focused,
62 65 [`${prefixCls}-cell-range`]: cell.range && !cell.start && !cell.end
63 66 }
64 67 ];
... ...
src/components/date-picker/panel/Date/date-panel-mixin.js
... ... @@ -46,6 +46,10 @@ export default {
46 46 pickerType: {
47 47 type: String,
48 48 require: true
  49 + },
  50 + focusedDate: {
  51 + type: Date,
  52 + required: true,
49 53 }
50 54 },
51 55 computed: {
... ...
src/components/date-picker/panel/Date/date-range.vue
... ... @@ -41,6 +41,8 @@
41 41 :range-state="rangeState"
42 42 :show-week-numbers="showWeekNumbers"
43 43 :value="preSelecting.left ? [dates[0]] : dates"
  44 + :focused-date="focusedDate"
  45 +
44 46 @on-change-range="handleChangeRange"
45 47 @on-pick="panelPickerHandlers.left"
46 48 @on-pick-click="handlePickClick"
... ... @@ -80,6 +82,8 @@
80 82 :disabled-date="disabledDate"
81 83 :show-week-numbers="showWeekNumbers"
82 84 :value="preSelecting.right ? [dates[dates.length - 1]] : dates"
  85 + :focused-date="focusedDate"
  86 +
83 87 @on-change-range="handleChangeRange"
84 88 @on-pick="panelPickerHandlers.right"
85 89 @on-pick-click="handlePickClick"></component>
... ... @@ -178,7 +182,7 @@
178 182 [prefixCls + '-body-time']: this.showTime,
179 183 [prefixCls + '-body-date']: !this.showTime,
180 184 }
181   - ]
  185 + ];
182 186 },
183 187 leftDatePanelLabel(){
184 188 return this.panelLabelConfig('left');
... ... @@ -224,10 +228,7 @@
224 228  
225 229  
226 230 // set panels positioning
227   - const leftPanelDate = this.startDate || this.dates[0] || new Date();
228   - this.leftPanelDate = leftPanelDate;
229   - const rightPanelDate = new Date(leftPanelDate.getFullYear(), leftPanelDate.getMonth() + 1, leftPanelDate.getDate());
230   - this.rightPanelDate = this.splitPanels ? new Date(Math.max(this.dates[1], rightPanelDate)) : rightPanelDate;
  231 + this.setPanelDates(this.startDate || this.dates[0] || new Date());
231 232 },
232 233 currentView(currentView){
233 234 const leftMonth = this.leftPanelDate.getMonth();
... ... @@ -246,6 +247,9 @@
246 247 },
247 248 selectionMode(type){
248 249 this.currentView = type || 'range';
  250 + },
  251 + focusedDate(date){
  252 + this.setPanelDates(date || new Date());
249 253 }
250 254 },
251 255 methods: {
... ... @@ -254,6 +258,11 @@
254 258 this.leftPickerTable = `${this.currentView}-table`;
255 259 this.rightPickerTable = `${this.currentView}-table`;
256 260 },
  261 + setPanelDates(leftPanelDate){
  262 + this.leftPanelDate = leftPanelDate;
  263 + const rightPanelDate = new Date(leftPanelDate.getFullYear(), leftPanelDate.getMonth() + 1, leftPanelDate.getDate());
  264 + this.rightPanelDate = this.splitPanels ? new Date(Math.max(this.dates[1], rightPanelDate)) : rightPanelDate;
  265 + },
257 266 panelLabelConfig (direction) {
258 267 const locale = this.t('i.locale');
259 268 const datePanelLabel = this.t('i.datepicker.datePanelLabel');
... ...
src/components/date-picker/panel/Date/date.vue
... ... @@ -39,6 +39,8 @@
39 39 :value="dates"
40 40 :selection-mode="selectionMode"
41 41 :disabled-date="disabledDate"
  42 + :focused-date="focusedDate"
  43 +
42 44 @on-pick="panelPickerHandlers"
43 45 @on-pick-click="handlePickClick"
44 46 ></component>
... ... @@ -51,6 +53,8 @@
51 53 :format="format"
52 54 :time-disabled="timeDisabled"
53 55 :disabled-date="disabledDate"
  56 + :focused-date="focusedDate"
  57 +
54 58 v-bind="timePickerOptions"
55 59 @on-pick="handlePick"
56 60 @on-pick-click="handlePickClick"
... ... @@ -150,7 +154,6 @@
150 154 },
151 155 currentView (currentView) {
152 156 this.$emit('on-selection-mode-change', currentView);
153   - this.pickertable = this.getTableType(currentView);
154 157  
155 158 if (this.currentView === 'time') {
156 159 this.$nextTick(() => {
... ... @@ -162,6 +165,13 @@
162 165 selectionMode(type){
163 166 this.currentView = type;
164 167 this.pickerTable = this.getTableType(type);
  168 + },
  169 + focusedDate(date){
  170 + const isDifferentYear = date.getFullYear() !== this.panelDate.getFullYear();
  171 + const isDifferentMonth = isDifferentYear || date.getMonth() !== this.panelDate.getMonth();
  172 + if (isDifferentYear || isDifferentMonth){
  173 + this.panelDate = date;
  174 + }
165 175 }
166 176 },
167 177 methods: {
... ...
src/components/date-picker/picker.vue
1 1 <template>
2   - <div :class="[prefixCls]" v-clickoutside="handleClose">
  2 + <div
  3 + :class="wrapperClasses"
  4 + v-click-outside:mousedown.capture="handleClose"
  5 + v-click-outside.capture="handleClose"
  6 + >
3 7 <div ref="reference" :class="[prefixCls + '-rel']">
4 8 <slot>
5 9 <i-input
... ... @@ -12,10 +16,14 @@
12 16 :placeholder="placeholder"
13 17 :value="visualValue"
14 18 :name="name"
  19 + ref="input"
  20 +
15 21 @on-input-change="handleInputChange"
16 22 @on-focus="handleFocus"
17 23 @on-blur="handleBlur"
18 24 @on-click="handleIconClick"
  25 + @click.native="handleFocus"
  26 + @keydown.native="handleKeydown"
19 27 @mouseenter.native="handleInputMouseenter"
20 28 @mouseleave.native="handleInputMouseleave"
21 29  
... ... @@ -48,6 +56,7 @@
48 56 :show-week-numbers="showWeekNumbers"
49 57 :picker-type="type"
50 58 :multiple="multiple"
  59 + :focused-date="focusedDate"
51 60  
52 61 :time-picker-options="timePickerOptions"
53 62  
... ... @@ -69,21 +78,49 @@
69 78  
70 79 import iInput from '../../components/input/input.vue';
71 80 import Drop from '../../components/select/dropdown.vue';
72   - import clickoutside from '../../directives/clickoutside';
  81 + import vClickOutside from 'v-click-outside-x/index';
73 82 import TransferDom from '../../directives/transfer-dom';
74 83 import { oneOf } from '../../utils/assist';
75   - import { DEFAULT_FORMATS, RANGE_SEPARATOR, TYPE_VALUE_RESOLVER_MAP } from './util';
  84 + import { DEFAULT_FORMATS, RANGE_SEPARATOR, TYPE_VALUE_RESOLVER_MAP, getDayCountOfMonth } from './util';
  85 + import {findComponentsDownward} from '../../utils/assist';
76 86 import Emitter from '../../mixins/emitter';
77 87  
78 88 const prefixCls = 'ivu-date-picker';
  89 + const pickerPrefixCls = 'ivu-picker';
79 90  
80 91 const isEmptyArray = val => val.reduce((isEmpty, str) => isEmpty && !str || (typeof str === 'string' && str.trim() === ''), true);
  92 + const keyValueMapper = {
  93 + 40: 'up',
  94 + 39: 'right',
  95 + 38: 'down',
  96 + 37: 'left',
  97 + };
  98 +
  99 + const mapPossibleValues = (key, horizontal, vertical) => {
  100 + if (key === 'left') return horizontal * -1;
  101 + if (key === 'right') return horizontal * 1;
  102 + if (key === 'up') return vertical * 1;
  103 + if (key === 'down') return vertical * -1;
  104 + };
  105 +
  106 + const pulseElement = (el) => {
  107 + const pulseClass = 'ivu-date-picker-btn-pulse';
  108 + el.classList.add(pulseClass);
  109 + setTimeout(() => el.classList.remove(pulseClass), 200);
  110 + };
  111 +
  112 + const extractTime = date => {
  113 + if (!date) return [0, 0, 0];
  114 + return [
  115 + date.getHours(), date.getMinutes(), date.getSeconds()
  116 + ];
  117 + };
  118 +
81 119  
82 120 export default {
83   - name: 'CalendarPicker',
84 121 mixins: [ Emitter ],
85 122 components: { iInput, Drop },
86   - directives: { clickoutside, TransferDom },
  123 + directives: { clickOutside: vClickOutside.directive, TransferDom },
87 124 props: {
88 125 format: {
89 126 type: String
... ... @@ -172,6 +209,7 @@
172 209 const isRange = this.type.includes('range');
173 210 const emptyArray = isRange ? [null, null] : [null];
174 211 const initialValue = isEmptyArray((isRange ? this.value : [this.value]) || []) ? emptyArray : this.parseDate(this.value);
  212 + const focusedTime = initialValue.map(extractTime);
175 213  
176 214 return {
177 215 prefixCls: prefixCls,
... ... @@ -181,10 +219,24 @@
181 219 disableClickOutSide: false, // fixed when click a date,trigger clickoutside to close picker
182 220 disableCloseUnderTransfer: false, // transfer ๆจกๅผไธ‹๏ผŒ็‚นๅ‡ปDropไนŸไผš่งฆๅ‘ๅ…ณ้—ญ,
183 221 selectionMode: this.onSelectionModeChange(this.type),
184   - forceInputRerender: 1
  222 + forceInputRerender: 1,
  223 + isFocused: false,
  224 + focusedDate: initialValue[0] || new Date(),
  225 + focusedTime: {
  226 + column: 0, // which column inside the picker
  227 + picker: 0, // which picker
  228 + time: focusedTime, // the values array into [hh, mm, ss],
  229 + active: false
  230 + },
  231 + internalFocus: false,
185 232 };
186 233 },
187 234 computed: {
  235 + wrapperClasses(){
  236 + return [prefixCls, {
  237 + [prefixCls + '-focused']: this.isFocused
  238 + }];
  239 + },
188 240 publicVModelValue(){
189 241 if (this.multiple){
190 242 return this.internalValue.slice();
... ... @@ -232,32 +284,246 @@
232 284 handleTransferClick () {
233 285 if (this.transfer) this.disableCloseUnderTransfer = true;
234 286 },
235   - handleClose () {
  287 + handleClose (e) {
236 288 if (this.disableCloseUnderTransfer) {
237 289 this.disableCloseUnderTransfer = false;
238 290 return false;
239 291 }
240   - if (this.open !== null) return;
241 292  
242   - this.visible = false;
  293 + if (e && e.type === 'mousedown' && this.visible) {
  294 + e.preventDefault();
  295 + e.stopPropagation();
  296 + return;
  297 + }
  298 +
  299 + if (this.visible) {
  300 + const pickerPanel = this.$refs.pickerPanel && this.$refs.pickerPanel.$el;
  301 + if (e && pickerPanel && pickerPanel.contains(e.target)) return; // its a click inside own component, lets ignore it.
  302 +
  303 + this.visible = false;
  304 + e && e.preventDefault();
  305 + e && e.stopPropagation();
  306 + return;
  307 + }
  308 +
  309 + this.isFocused = false;
243 310 this.disableClickOutSide = false;
244 311 },
245   - handleFocus () {
  312 + handleFocus (e) {
246 313 if (this.readonly) return;
  314 + this.isFocused = true;
  315 + if (e && e.type === 'focus') return; // just focus, don't open yet
247 316 this.visible = true;
248   - this.$refs.pickerPanel.onToggleVisibility(true);
249 317 },
250   - handleBlur () {
251   - this.visible = false;
  318 + handleBlur (e) {
  319 + if (this.internalFocus){
  320 + this.internalFocus = false;
  321 + return;
  322 + }
  323 + if (this.visible) {
  324 + e.preventDefault();
  325 + return;
  326 + }
  327 +
  328 + this.isFocused = false;
252 329 this.onSelectionModeChange(this.type);
253 330 this.internalValue = this.internalValue.slice(); // trigger panel watchers to reset views
254 331 this.reset();
255 332 this.$refs.pickerPanel.onToggleVisibility(false);
256 333  
257 334 },
  335 + handleKeydown(e){
  336 + const keyCode = e.keyCode;
  337 +
  338 + // handle "tab" key
  339 + if (keyCode === 9){
  340 + if (this.visible){
  341 + e.stopPropagation();
  342 + e.preventDefault();
  343 +
  344 + if (this.isConfirm){
  345 + const selector = `.${pickerPrefixCls}-confirm > *`;
  346 + const tabbable = this.$refs.drop.$el.querySelectorAll(selector);
  347 + this.internalFocus = true;
  348 + const element = [...tabbable][e.shiftKey ? 'pop' : 'shift']();
  349 + element.focus();
  350 + } else {
  351 + this.handleClose();
  352 + }
  353 + } else {
  354 + this.focused = false;
  355 + }
  356 + }
  357 +
  358 + // open the panel
  359 + const arrows = [37, 38, 39, 40];
  360 + if (!this.visible && arrows.includes(keyCode)){
  361 + this.visible = true;
  362 + return;
  363 + }
  364 +
  365 + // close on "esc" key
  366 + if (keyCode === 27){
  367 + if (this.visible) {
  368 + e.stopPropagation();
  369 + this.handleClose();
  370 + }
  371 + }
  372 +
  373 + // select date, "Enter" key
  374 + if (keyCode === 13){
  375 + const timePickers = findComponentsDownward(this, 'TimeSpinner');
  376 + if (timePickers.length > 0){
  377 + const columnsPerPicker = timePickers[0].showSeconds ? 3 : 2;
  378 + const pickerIndex = Math.floor(this.focusedTime.column / columnsPerPicker);
  379 + const value = this.focusedTime.time[pickerIndex];
  380 +
  381 + timePickers[pickerIndex].chooseValue(value);
  382 + return;
  383 + }
  384 +
  385 + if (this.type.match(/range/)){
  386 + this.$refs.pickerPanel.handleRangePick(this.focusedDate, 'date');
  387 + } else {
  388 + this.onPick(this.focusedDate, false, 'date');
  389 + }
  390 + }
  391 +
  392 + if (!arrows.includes(keyCode)) return; // ignore rest of keys
  393 +
  394 + // navigate times and dates
  395 + if (this.focusedTime.active) e.preventDefault(); // to prevent cursor from moving
  396 + this.navigateDatePanel(keyValueMapper[keyCode], e.shiftKey);
  397 + },
258 398 reset(){
259 399 this.$refs.pickerPanel.reset && this.$refs.pickerPanel.reset();
260 400 },
  401 + navigateTimePanel(direction){
  402 +
  403 + this.focusedTime.active = true;
  404 + const horizontal = direction.match(/left|right/);
  405 + const vertical = direction.match(/up|down/);
  406 + const timePickers = findComponentsDownward(this, 'TimeSpinner');
  407 +
  408 + const maxNrOfColumns = (timePickers[0].showSeconds ? 3 : 2) * timePickers.length;
  409 + const column = (currentColumn => {
  410 + const incremented = currentColumn + (horizontal ? (direction === 'left' ? -1 : 1) : 0);
  411 + return (incremented + maxNrOfColumns) % maxNrOfColumns;
  412 + })(this.focusedTime.column);
  413 +
  414 + const columnsPerPicker = maxNrOfColumns / timePickers.length;
  415 + const pickerIndex = Math.floor(column / columnsPerPicker);
  416 + const col = column % columnsPerPicker;
  417 +
  418 +
  419 + if (horizontal){
  420 + const time = this.internalValue.map(extractTime);
  421 +
  422 + this.focusedTime = {
  423 + ...this.focusedTime,
  424 + column: column,
  425 + time: time
  426 + };
  427 + timePickers.forEach((instance, i) => {
  428 + if (i === pickerIndex) instance.updateFocusedTime(col, time[pickerIndex]);
  429 + else instance.updateFocusedTime(-1, instance.focusedTime);
  430 + });
  431 + }
  432 +
  433 + if (vertical){
  434 + const increment = direction === 'up' ? 1 : -1;
  435 + const timeParts = ['hours', 'minutes', 'seconds'];
  436 +
  437 +
  438 + const pickerPossibleValues = timePickers[pickerIndex][`${timeParts[col]}List`];
  439 + const nextIndex = pickerPossibleValues.findIndex(({text}) => this.focusedTime.time[pickerIndex][col] === text) + increment;
  440 + const nextValue = pickerPossibleValues[nextIndex % pickerPossibleValues.length].text;
  441 + const times = this.focusedTime.time.map((time, i) => {
  442 + if (i !== pickerIndex) return time;
  443 + time[col] = nextValue;
  444 + return time;
  445 + });
  446 + this.focusedTime = {
  447 + ...this.focusedTime,
  448 + time: times
  449 + };
  450 +
  451 + timePickers.forEach((instance, i) => {
  452 + if (i === pickerIndex) instance.updateFocusedTime(col, times[i]);
  453 + else instance.updateFocusedTime(-1, instance.focusedTime);
  454 + });
  455 + }
  456 + },
  457 + navigateDatePanel(direction, shift){
  458 +
  459 + const timePickers = findComponentsDownward(this, 'TimeSpinner');
  460 + if (timePickers.length > 0) {
  461 + // we are in TimePicker mode
  462 + this.navigateTimePanel(direction, shift, timePickers);
  463 + return;
  464 + }
  465 +
  466 + if (shift){
  467 + if (this.type === 'year'){
  468 + this.focusedDate = new Date(
  469 + this.focusedDate.getFullYear() + mapPossibleValues(direction, 0, 10),
  470 + this.focusedDate.getMonth(),
  471 + this.focusedDate.getDate()
  472 + );
  473 + } else {
  474 + this.focusedDate = new Date(
  475 + this.focusedDate.getFullYear() + mapPossibleValues(direction, 0, 1),
  476 + this.focusedDate.getMonth() + mapPossibleValues(direction, 1, 0),
  477 + this.focusedDate.getDate()
  478 + );
  479 + }
  480 +
  481 + const position = direction.match(/left|down/) ? 'prev' : 'next';
  482 + const double = direction.match(/up|down/) ? '-double' : '';
  483 +
  484 + // pulse button
  485 + const button = this.$refs.drop.$el.querySelector(`.ivu-date-picker-${position}-btn-arrow${double}`);
  486 + if (button) pulseElement(button);
  487 + return;
  488 + }
  489 +
  490 + const initialDate = this.focusedDate || (this.internalValue && this.internalValue[0]) || new Date();
  491 + const focusedDate = new Date(initialDate);
  492 +
  493 + if (this.type.match(/^date/)){
  494 + const lastOfMonth = getDayCountOfMonth(initialDate.getFullYear(), initialDate.getMonth());
  495 + const startDay = initialDate.getDate();
  496 + const nextDay = focusedDate.getDate() + mapPossibleValues(direction, 1, 7);
  497 +
  498 + if (nextDay < 1) {
  499 + if (direction.match(/left|right/)) {
  500 + focusedDate.setMonth(focusedDate.getMonth() + 1);
  501 + focusedDate.setDate(nextDay);
  502 + } else {
  503 + focusedDate.setDate(startDay + Math.floor((lastOfMonth - startDay) / 7) * 7);
  504 + }
  505 + } else if (nextDay > lastOfMonth){
  506 + if (direction.match(/left|right/)) {
  507 + focusedDate.setMonth(focusedDate.getMonth() - 1);
  508 + focusedDate.setDate(nextDay);
  509 + } else {
  510 + focusedDate.setDate(startDay % 7);
  511 + }
  512 + } else {
  513 + focusedDate.setDate(nextDay);
  514 + }
  515 + }
  516 +
  517 + if (this.type.match(/^month/)) {
  518 + focusedDate.setMonth(focusedDate.getMonth() + mapPossibleValues(direction, 1, 3));
  519 + }
  520 +
  521 + if (this.type.match(/^year/)) {
  522 + focusedDate.setFullYear(focusedDate.getFullYear() + mapPossibleValues(direction, 1, 3));
  523 + }
  524 +
  525 + this.focusedDate = focusedDate;
  526 + },
261 527 handleInputChange (event) {
262 528 const isArrayValue = this.type.includes('range') || this.multiple;
263 529 const oldValue = this.visualValue;
... ... @@ -377,6 +643,12 @@
377 643 this.internalValue = Array.isArray(dates) ? dates : [dates];
378 644 }
379 645  
  646 + this.focusedDate = this.internalValue[0];
  647 + this.focusedTime = {
  648 + ...this.focusedTime,
  649 + time: this.internalValue.map(extractTime)
  650 + };
  651 +
380 652 if (!this.isConfirm) this.onSelectionModeChange(this.type); // reset the selectionMode
381 653 if (!this.isConfirm) this.visible = visible;
382 654 this.emitChange(type);
... ... @@ -384,22 +656,23 @@
384 656 onPickSuccess(){
385 657 this.visible = false;
386 658 this.$emit('on-ok');
  659 + this.focus();
387 660 this.reset();
388 661 },
  662 + focus() {
  663 + this.$refs.input.focus();
  664 + }
389 665 },
390 666 watch: {
391 667 visible (state) {
392 668 if (state === false){
393 669 this.$refs.drop.destroy();
394   - const input = this.$el.querySelector('input');
395   - if (input) input.blur();
396 670 }
397 671 this.$refs.drop.update();
398 672 this.$emit('on-open-change', state);
399 673 },
400 674 value(val) {
401 675 this.internalValue = this.parseDate(val);
402   -
403 676 },
404 677 open (val) {
405 678 this.visible = val === true;
... ... @@ -421,6 +694,9 @@
421 694 this.$emit('input', this.publicVModelValue); // to update v-model
422 695 }
423 696 if (this.open !== null) this.visible = this.open;
  697 +
  698 + // to handle focus from confirm buttons
  699 + this.$on('focus-input', () => this.focus());
424 700 }
425 701 };
426 702 </script>
... ...
src/components/date-picker/picker/date-picker.js
... ... @@ -5,6 +5,7 @@ import RangeDatePickerPanel from &#39;../panel/Date/date-range.vue&#39;;
5 5 import { oneOf } from '../../../utils/assist';
6 6  
7 7 export default {
  8 + name: 'CalendarPicker',
8 9 mixins: [Picker],
9 10 props: {
10 11 type: {
... ...
src/components/date-picker/picker/time-picker.js
... ... @@ -3,7 +3,7 @@ import TimePickerPanel from &#39;../panel/Time/time.vue&#39;;
3 3 import RangeTimePickerPanel from '../panel/Time/time-range.vue';
4 4 import Options from '../time-mixins';
5 5  
6   -import { oneOf } from '../../../utils/assist';
  6 +import { findComponentsDownward, oneOf } from '../../../utils/assist';
7 7  
8 8 export default {
9 9 mixins: [Picker, Options],
... ... @@ -30,4 +30,14 @@ export default {
30 30 };
31 31 }
32 32 },
  33 + watch: {
  34 + visible(visible){
  35 + if (visible) {
  36 + this.$nextTick(() => {
  37 + const spinners = findComponentsDownward(this, 'TimeSpinner');
  38 + spinners.forEach(instance => instance.updateScroll());
  39 + });
  40 + }
  41 + }
  42 + }
33 43 };
... ...
src/styles/components/date-picker.less
... ... @@ -44,17 +44,18 @@
44 44 margin: 2px;
45 45 color: @btn-disable-color;
46 46 }
  47 + &-cell:hover, &-focused{
  48 + em{
  49 + background: @date-picker-cell-hover-bg;
  50 + }
  51 + }
  52 +
47 53 &-cell{
48 54 span&{
49 55 width: 28px;
50 56 height: 28px;
51 57 cursor: pointer;
52 58 }
53   - &:hover{
54   - em{
55   - background: @date-picker-cell-hover-bg;
56   - }
57   - }
58 59 &-prev-month,&-next-month{
59 60 em{
60 61 color: @btn-disable-color;
... ... @@ -154,6 +155,11 @@
154 155 margin: 0;
155 156 }
156 157 }
  158 +
  159 + .@{date-picker-prefix-cls}-cells-cell-focused{
  160 + background-color: tint(@primary-color, 80%);
  161 + }
  162 +
157 163 }
158 164  
159 165 &-header{
... ... @@ -169,6 +175,11 @@
169 175 }
170 176 }
171 177 }
  178 + &-btn-pulse{
  179 + background-color: tint(@primary-color, 80%) !important;
  180 + border-radius: @border-radius-small;
  181 + transition: background-color @transition-time @ease-in-out;
  182 + }
172 183 &-prev-btn{
173 184 float: left;
174 185 &-arrow-double{
... ... @@ -216,6 +227,10 @@
216 227 max-height: none;
217 228 width: auto;
218 229 }
  230 +
  231 + &-focused input{
  232 + .active();
  233 + }
219 234 }
220 235  
221 236 .@{picker-prefix-cls} {
... ... @@ -289,9 +304,9 @@
289 304 color: @link-active-color;
290 305 }
291 306 }
292   - & > span&-time-disabled{
293   - color: @btn-disable-color;
294   - cursor: @cursor-disabled;
  307 +
  308 + &-time{
  309 + float: left;
295 310 }
296 311 }
297 312 }
... ...
src/styles/components/time-picker.less
... ... @@ -70,6 +70,9 @@
70 70 color: @primary-color;
71 71 background: @background-color-select-hover;
72 72 }
  73 + &-focused{
  74 + background-color: tint(@primary-color, 80%);
  75 + }
73 76 }
74 77 }
75 78  
... ... @@ -165,4 +168,4 @@
165 168 }
166 169 }
167 170 }
168   -}
169 171 \ No newline at end of file
  172 +}
... ...
test/unit/specs/date-picker.spec.js
... ... @@ -116,7 +116,7 @@ describe(&#39;DatePicker.vue&#39;, () =&gt; {
116 116 `);
117 117  
118 118 const picker = vm.$children[0];
119   - picker.handleIconClick();
  119 + picker.handleFocus({type: 'focus'});
120 120 vm.$nextTick(() => {
121 121 const displayField = vm.$el.querySelector('.ivu-input');
122 122 const clickableCells = vm.$el.querySelectorAll('.ivu-date-picker-cells-cell');
... ... @@ -169,7 +169,7 @@ describe(&#39;DatePicker.vue&#39;, () =&gt; {
169 169 });
170 170  
171 171 const picker = vm.$children[0];
172   - picker.handleIconClick();
  172 + picker.handleFocus({type: 'focus'});
173 173 vm.$nextTick(() => {
174 174 const panel = vm.$el.querySelector('.ivu-picker-panel-content');
175 175 const dayPanel = panel.querySelector('[class="ivu-date-picker-cells"]');
... ... @@ -243,7 +243,7 @@ describe(&#39;DatePicker.vue&#39;, () =&gt; {
243 243 `);
244 244  
245 245 const picker = vm.$children[0];
246   - picker.handleIconClick();
  246 + picker.handleFocus({type: 'focus'});
247 247 vm.$nextTick(() => {
248 248 const displayField = vm.$el.querySelector('.ivu-input');
249 249 const clickableCells = vm.$el.querySelectorAll('.ivu-date-picker-cells-cell');
... ... @@ -266,9 +266,11 @@ describe(&#39;DatePicker.vue&#39;, () =&gt; {
266 266 // it should be closed by now
267 267 expect(picker.visible).to.equal(false);
268 268 // open picker again
269   - picker.handleIconClick();
  269 + picker.handleFocus({type: 'focus'});
  270 + picker.visible = true;
270 271  
271   - vm.$nextTick(() => {
  272 +
  273 + vm.$nextTick(() => {
272 274 expect(picker.visible).to.equal(true);
273 275 expect(JSON.stringify(picker.internalValue)).to.equal('[null,null]');
274 276 expect(displayField.value).to.equal('');
... ... @@ -355,7 +357,7 @@ describe(&#39;DatePicker.vue&#39;, () =&gt; {
355 357 `);
356 358  
357 359 const picker = vm.$children[0];
358   - picker.handleIconClick();
  360 + picker.handleFocus({type: 'focus'});
359 361 vm.$nextTick(() => {
360 362 const now = new Date();
361 363 const labels = vm.$el.querySelectorAll('.ivu-picker-panel-body .ivu-date-picker-header-label');
... ...
test/unit/specs/time-spinner.spec.js
... ... @@ -11,7 +11,7 @@ describe(&#39;TimePicker.vue&#39;, () =&gt; {
11 11 <Time-Picker></Time-Picker>
12 12 `);
13 13 const picker = vm.$children[0];
14   - picker.handleIconClick(); // open the picker panels
  14 + picker.handleFocus({type: 'focus'}); // open the picker panels
15 15  
16 16 vm.$nextTick(() => {
17 17 const spiners = picker.$el.querySelectorAll('.ivu-time-picker-cells-list');
... ... @@ -28,7 +28,7 @@ describe(&#39;TimePicker.vue&#39;, () =&gt; {
28 28 <Time-Picker format="HH:mm"></Time-Picker>
29 29 `);
30 30 const picker = vm.$children[0];
31   - picker.handleIconClick(); // open the picker panels
  31 + picker.handleFocus({type: 'focus'}); // open the picker panels
32 32  
33 33 vm.$nextTick(() => {
34 34 const spiners = picker.$el.querySelectorAll('.ivu-time-picker-cells-list');
... ... @@ -44,7 +44,7 @@ describe(&#39;TimePicker.vue&#39;, () =&gt; {
44 44 <Time-Picker :steps="[1, 15]"></Time-Picker>
45 45 `);
46 46 const picker = vm.$children[0];
47   - picker.handleIconClick(); // open the picker panels
  47 + picker.handleFocus({type: 'focus'}); // open the picker panels
48 48  
49 49 vm.$nextTick(() => {
50 50 const spiners = picker.$el.querySelectorAll('.ivu-time-picker-cells-list');
... ...