Skip to content

Commit 26d29ee

Browse files
committed
feat(pickercells): Handle arrow keys
1 parent 0fa129a commit 26d29ee

File tree

9 files changed

+602
-0
lines changed

9 files changed

+602
-0
lines changed

src/components/Datepicker.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
:show-edge-dates="showEdgeDates"
8989
:show-full-month-name="fullMonthName"
9090
:show-header="showHeader"
91+
:slide-duration="slideDuration"
9192
:tabbable-cell-id="tabbableCellId"
9293
:transition-name="transitionName"
9394
:translation="translation"

src/components/PickerCells.vue

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
:disabled="cell.isDisabled"
1212
type="button"
1313
@click="$emit('select', cell)"
14+
@keydown.up.prevent="handleArrow(id, -columns)"
15+
@keydown.down.prevent="handleArrow(id, columns)"
16+
@keydown.left.prevent="handleArrow(id, isRtl ? 1 : -1)"
17+
@keydown.right.prevent="handleArrow(id, isRtl ? -1 : 1)"
1418
>
1519
<slot :cell="cell" />
1620
</button>
@@ -29,6 +33,10 @@ export default {
2933
type: Array,
3034
required: true,
3135
},
36+
isRtl: {
37+
type: Boolean,
38+
default: false,
39+
},
3240
showEdgeDates: {
3341
type: Boolean,
3442
default: true,
@@ -43,6 +51,15 @@ export default {
4351
required: true,
4452
},
4553
},
54+
computed: {
55+
/**
56+
* The number of columns in the picker
57+
* @return {Number}
58+
*/
59+
columns() {
60+
return this.view === 'day' ? 7 : 3
61+
},
62+
},
4663
methods: {
4764
/**
4865
* Set the classes for a specific cell
@@ -73,6 +90,12 @@ export default {
7390
},
7491
]
7592
},
93+
/**
94+
* Emits an `arrow` event
95+
*/
96+
handleArrow(cellId, delta) {
97+
this.$emit('arrow', { cellId, delta })
98+
},
7699
},
77100
}
78101
</script>

src/components/PickerDay.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,11 @@
4343
v-slot="{ cell }"
4444
:bootstrap-styling="bootstrapStyling"
4545
:cells="cells"
46+
:is-rtl="isRtl"
4647
:show-edge-dates="showEdgeDates"
4748
:tabbable-cell-id="tabbableCellId"
4849
view="day"
50+
@arrow="handleArrow($event)"
4951
@select="select($event)"
5052
>
5153
<slot name="dayCellContent" :cell="cell">

src/components/PickerMonth.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@
3636
v-slot="{ cell }"
3737
:bootstrap-styling="bootstrapStyling"
3838
:cells="cells"
39+
:is-rtl="isRtl"
3940
:tabbable-cell-id="tabbableCellId"
4041
view="month"
42+
@arrow="handleArrow($event)"
4143
@select="select($event)"
4244
>
4345
{{ cell.month }}

src/components/PickerYear.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@
3333
v-slot="{ cell }"
3434
:bootstrap-styling="bootstrapStyling"
3535
:cells="cells"
36+
:is-rtl="isRtl"
3637
:tabbable-cell-id="tabbableCellId"
3738
view="year"
39+
@arrow="handleArrow($event)"
3840
@select="select($event)"
3941
>
4042
{{ cell.year }}

src/mixins/navMixin.vue

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,21 @@ export default {
147147
this.setTabbableCell()
148148
}
149149
},
150+
/**
151+
* Returns true if the user has arrowed to a new page
152+
* @return {Boolean}
153+
*/
154+
hasArrowedToNewPage() {
155+
return this.focus.refs && this.focus.refs[0] === 'arrow-to-cell'
156+
},
150157
/**
151158
* Sets the correct focus on next tick
152159
*/
153160
reviewFocus() {
161+
if (this.hasArrowedToNewPage()) {
162+
return
163+
}
164+
154165
this.$nextTick(() => {
155166
this.setTabbableCell()
156167
this.setNavElements()

src/mixins/pickerMixin.vue

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ export default {
5454
type: Boolean,
5555
default: true,
5656
},
57+
slideDuration: {
58+
type: Number,
59+
default: 250,
60+
},
5761
tabbableCellId: {
5862
type: Number,
5963
default: null,
@@ -99,6 +103,16 @@ export default {
99103
},
100104
},
101105
methods: {
106+
/**
107+
* Used when an arrow key press would cause the focus to land on a disabled date
108+
* @param {Object} options
109+
*/
110+
addMoreSteps(options) {
111+
if (options.stepsRemaining <= 0 && Math.abs(options.delta) > 1) {
112+
return Math.abs(options.delta)
113+
}
114+
return options.stepsRemaining
115+
},
102116
/**
103117
* Changes the page up or down
104118
* @param {Number} incrementBy
@@ -119,6 +133,29 @@ export default {
119133
120134
this.$emit('page-change', { focusRefs, pageDate })
121135
},
136+
/**
137+
* Changes the page and focuses the cell that is being 'arrowed' to
138+
* @param {Object} options
139+
*/
140+
changePageAndSetFocus(options) {
141+
const { delta } = options
142+
const isPageDisabled =
143+
(delta > 0 && this.isNextDisabled) ||
144+
(delta < 0 && this.isPreviousDisabled)
145+
146+
if (isPageDisabled) {
147+
return
148+
}
149+
150+
this.changePage({
151+
incrementBy: Math.sign(delta),
152+
focusRefs: ['arrow-to-cell'],
153+
})
154+
155+
this.$nextTick(() => {
156+
this.setFocusOnNewPage(options)
157+
})
158+
},
122159
/**
123160
* Focuses the input field, if typeable
124161
*/
@@ -127,6 +164,75 @@ export default {
127164
this.$emit('set-focus', ['input'])
128165
}
129166
},
167+
/**
168+
* Returns the element that should be focused when navigating via an arrow key
169+
* @param {HTMLElement} currentElement The element currently being iterated on
170+
* @param {Number} delta The number of cells that the focus should move
171+
* @param {Number} stepsRemaining The number of steps remaining in the iteration
172+
* @return {HTMLElement}
173+
*/
174+
getElement({ currentElement, delta, stepsRemaining }) {
175+
const element = this.getElementSibling(currentElement, delta)
176+
const options = {
177+
currentElement: element,
178+
delta,
179+
stepsRemaining: stepsRemaining - 1,
180+
}
181+
182+
if (!element) {
183+
return this.changePageAndSetFocus(options)
184+
}
185+
186+
if (this.isMutedOrDisabled(element)) {
187+
options.stepsRemaining = this.addMoreSteps(options)
188+
189+
return this.getElement(options)
190+
}
191+
192+
if (stepsRemaining > 1 && options.currentElement) {
193+
return this.getElement(options)
194+
}
195+
196+
return element
197+
},
198+
/**
199+
* Returns the element directly next to the currentElement
200+
* @param {HTMLElement} currentElement The element currently being iterated on
201+
* @param {Number} delta The number of cells that the focus should move
202+
* @return {HTMLElement}
203+
*/
204+
getElementSibling(currentElement, delta) {
205+
const isNext = delta > 0
206+
207+
return isNext
208+
? currentElement.nextElementSibling
209+
: currentElement.previousElementSibling
210+
},
211+
/**
212+
* Returns the first or last cell, depending on the direction of the search
213+
* @param {Number} delta The number of cells that the focus should move
214+
* @return {HTMLElement}
215+
*/
216+
getFirstOrLastElement(delta) {
217+
const isNext = delta > 0
218+
const elements = this.$refs.cells.$el.children
219+
220+
return isNext ? elements[0] : elements[elements.length - 1]
221+
},
222+
/**
223+
* Moves the focused cell up/down/left/right
224+
* @param {Object}
225+
*/
226+
handleArrow({ delta }) {
227+
const stepsRemaining = Math.abs(delta)
228+
const options = {
229+
currentElement: document.activeElement,
230+
delta,
231+
stepsRemaining,
232+
}
233+
234+
this.setFocusToAvailableCell(options)
235+
},
130236
/**
131237
* Determines which transition to use (for edge dates) and emits a 'select' event
132238
* @param {Object} cell
@@ -142,6 +248,62 @@ export default {
142248
143249
this.$emit('select', cell)
144250
},
251+
/**
252+
* Returns true if the given element cannot be focused
253+
* @param {HTMLElement} element The element in question
254+
* @return {Boolean}
255+
*/
256+
isMutedOrDisabled(element) {
257+
const isMuted = element.classList.value.split(' ').includes('muted')
258+
const isDisabled = element.disabled
259+
260+
return isMuted || isDisabled
261+
},
262+
/**
263+
* Sets the focus on the correct cell following a page change
264+
* @param {Object} options
265+
*/
266+
setFocusOnNewPage({ delta, stepsRemaining }) {
267+
const currentElement = this.getFirstOrLastElement(delta)
268+
const options = {
269+
currentElement,
270+
delta,
271+
stepsRemaining,
272+
}
273+
274+
if (stepsRemaining <= 0) {
275+
if (this.isMutedOrDisabled(currentElement)) {
276+
options.stepsRemaining = Math.abs(options.delta)
277+
278+
setTimeout(() => {
279+
this.setFocusToAvailableCell(options)
280+
}, this.slideDuration)
281+
282+
return
283+
}
284+
285+
setTimeout(() => {
286+
currentElement.focus()
287+
}, this.slideDuration)
288+
289+
return
290+
}
291+
292+
setTimeout(() => {
293+
this.setFocusToAvailableCell(options)
294+
}, this.slideDuration)
295+
},
296+
/**
297+
* Sets the focus on the next focusable cell when an arrow key is pressed
298+
* @param {Object} options
299+
*/
300+
setFocusToAvailableCell(options) {
301+
const element = this.getElement(options)
302+
303+
if (element) {
304+
element.focus()
305+
}
306+
},
145307
},
146308
}
147309
</script>

0 commit comments

Comments
 (0)