diff --git a/src/components/Datepicker.vue b/src/components/Datepicker.vue index ffa70427..fb7b1765 100644 --- a/src/components/Datepicker.vue +++ b/src/components/Datepicker.vue @@ -343,6 +343,12 @@ export default { this.setInitialView() } }, + isActive(hasJustBecomeActive) { + if (hasJustBecomeActive && this.inline) { + this.setNavElementsFocusedIndex() + this.tabToCorrectInlineCell() + } + }, openDate() { this.setPageDate() }, diff --git a/src/mixins/navMixin.vue b/src/mixins/navMixin.vue index c77b2d5a..486ca5b7 100644 --- a/src/mixins/navMixin.vue +++ b/src/mixins/navMixin.vue @@ -7,6 +7,7 @@ export default { delay: 0, refs: [], }, + inlineTabbableCell: null, isActive: false, isRevertingToOpenDate: false, navElements: [], @@ -47,6 +48,17 @@ export default { }, }, methods: { + /** + * 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` */ @@ -63,6 +75,22 @@ export default { } } }, + /** + * Ensures the most recently focused tabbable cell is focused when tabbing backwards to an inline calendar + * If no element has previously been focused, the tabbable cell is reset and focused + */ + focusInlineTabbableCell() { + if (this.inlineTabbableCell) { + this.inlineTabbableCell.focus() + + return + } + + this.resetTabbableCell = true + this.setTabbableCell() + this.tabbableCell.focus() + this.resetTabbableCell = false + }, /** * Returns the currently focused cell element, if there is one... */ @@ -181,7 +209,7 @@ export default { document.datepickerId = this.datepickerId this.isActive = true - + this.setInlineTabbableCell() this.setAllElements() this.setNavElements() }, @@ -198,6 +226,57 @@ export default { hasArrowedToNewPage() { return this.focus.refs && this.focus.refs[0] === 'arrow-to-cell' }, + /** + * 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 firstNavElement = this.navElements[0] + + return document.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 lastNavElement = this.navElements[this.navElements.length - 1] + + return document.activeElement === lastNavElement + }, /** * Resets the focus to the open date */ @@ -239,6 +318,17 @@ export default { 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 + }, /** * Sets the direction of the slide transition and whether or not to delay application of the focus * @param {Date|Number} startDate The date from which to measure @@ -339,6 +429,26 @@ export default { this.transitionName = isInTheFuture ? 'slide-right' : 'slide-left' } }, + /** + * Focuses the first focusable element in the datepicker, so that the previous element on the page will be tabbed to + */ + tabAwayFromFirstElement() { + const firstElement = this.allElements[0] + + firstElement.focus() + + this.tabbableCell = this.inlineTabbableCell + }, + /** + * Focuses the last focusable element in the datepicker, so that the next element on the page will be tabbed to + */ + tabAwayFromLastElement() { + const lastElement = this.allElements[this.allElements.length - 1] + + lastElement.focus() + + this.tabbableCell = this.inlineTabbableCell + }, /** * Tab backwards through the focus-trapped elements */ @@ -368,10 +478,10 @@ export default { * @param event */ tabThroughNavigation(event) { - // Allow normal tabbing when closed - if (!this.isOpen) { + if (this.allowNormalTabbing(event)) { return } + event.preventDefault() if (event.shiftKey) { @@ -380,6 +490,30 @@ export default { this.tabForwards() } }, + /** + * Special cases for when tabbing to an inline datepicker + */ + tabToCorrectInlineCell() { + const lastElement = this.allElements[this.allElements.length - 1] + const isACell = this.hasClass(lastElement, 'cell') + const isLastElementFocused = document.activeElement === lastElement + + // If there are no focusable elements in the footer slots and the inline datepicker has been tabbed to (backwards) + if (isACell && isLastElementFocused) { + this.focusInlineTabbableCell() + return + } + + // If `show-header` is false and the inline datepicker has been tabbed to (forwards) + this.$nextTick(() => { + const isFirstCell = + document.activeElement.getAttribute('data-id') === '0' + + if (isFirstCell) { + this.focusInlineTabbableCell() + } + }) + }, /** * Update which cell in the picker should be focus-trapped */ diff --git a/test/FEATURES.md b/test/FEATURES.md index fbae51c6..278dbba7 100644 --- a/test/FEATURES.md +++ b/test/FEATURES.md @@ -1,5 +1,5 @@ | Focus state | Calendar state | Typeable | Input date | Show on button click | Show on focus | Other state | Action | Calendar state | Focus state | Other state changes | Feature file | Test id | -| --------------- | -------------- | -------- | ---------- | -------------------- | ------------- | ------------------------------------------------------- | -------------------------- | -------------- | ------------------------------- | ------------------------- | ------------------------- |---------| +|-----------------|----------------|----------|------------|----------------------|---------------|---------------------------------------------------------|----------------------------|----------------|---------------------------------|---------------------------|---------------------------|---------| | ANY | closed | | | | | initialView=day | Open calendar | open | focusable-cell (day) | | InitialFocus | 1#1 | | | closed | | | | | initialView=month | Open calendar | open | focusable-cell (month) | | InitialFocus | 1#2 | | | closed | | | | | initialView=year | Open calendar | open | focusable-cell (year) | | InitialFocus | 1#3 | @@ -147,5 +147,7 @@ | | open | | | | | | Keydown right | open | cell right on next page | Focusable cell, page up | CellNavigation | 2#4 | | | open | | | | | | Keydown tab | open | prev | | FocusTrap | 7 | | | open | | | | | | Keydown shift + tab | open | next | | FocusTrap | 8 | +| | open | | | | | isInline, last element is focused | Keydown tab | open | Previous element on page | | FocusTrap | 9 | +| | open | | | | | isInline, first element is focused | Keydown shift + tab | open | Next element on page | | FocusTrap | 10 | | --------------- | -------------- | -------- | ---------- | -------------------- | ------------- | --------------------------------- | -------------------------- | -------------- | -------------------------- | ------------------------- | ------------------------- | ------- | | Focus state | Calendar state | Typeable | Input date | Show on button click | Show on focus | Other state | Action | Calendar state | Focus state | Other state changes | Feature file | Test id | diff --git a/test/e2e/specs/FocusTrap.feature b/test/e2e/specs/FocusTrap.feature index 361e64c5..7eba6660 100644 --- a/test/e2e/specs/FocusTrap.feature +++ b/test/e2e/specs/FocusTrap.feature @@ -13,47 +13,59 @@ Feature: Focus Trap # # # @id-1 -# Scenario: Press tab when the previous button is focused -# When the user focuses the previous button and presses tab +# Scenario: Tab forwards when the previous button is focused +# When the user focuses the previous button and tabs forwards # Then the up button has focus # # @id-2 -# Scenario: Press shift + tab when the previous button is focused -# When the user focuses the previous button and presses shift + tab +# Scenario: Tab backwards when the previous button is focused +# When the user focuses the previous button and tabs backwards # Then the tabbable cell has focus # # # @id-3 -# Scenario: Press tab when the up button is focused -# When the user focuses the up button and presses tab +# Scenario: Tab forwards when the up button is focused +# When the user focuses the up button and tabs forwards # Then the next button has focus # # # @id-4 -# Scenario: Press shift + tab when the up button is focused -# When the user focuses the up button and presses shift + tab +# Scenario: Tab backwards when the up button is focused +# When the user focuses the up button and tabs backwards # Then the previous button has focus # # # @id-5 -# Scenario: Press tab when the next button is focused -# When the user focuses the next button and presses tab +# Scenario: Tab forwards when the next button is focused +# When the user focuses the next button and tabs forwards # Then the tabbable cell has focus # # # @id-6 -# Scenario: Press shift + tab when the next button is focused -# When the user focuses the next button and presses shift + tab +# Scenario: Tab backwards when the next button is focused +# When the user focuses the next button and tabs backwards # Then the up button has focus # # # @id-7 -# Scenario: Press tab when today's cell is focused -# When the user focuses the tabbable cell and presses tab +# Scenario: Tab forwards when today's cell is focused +# When the user focuses the tabbable cell and tabs forwards # Then the previous button has focus # # # @id-8 -# Scenario: Press shift + tab when today's cell is focused -# When the user focuses the tabbable cell and presses shift + tab +# Scenario: Tab backwards when today's cell is focused +# When the user focuses the tabbable cell and tabs backwards # Then the next button has focus +# +# +# @id-9 +# Scenario: Inline calendar: Tab forwards when last element is focused +# When the user focuses the last element and tabs forwards +# Then the next element on the page has focus +# +# +# @id-10 +# Scenario: Inline calendar: Tab backwards when first element is focused +# When the user focuses the first element and tabs backwards +# Then the previous element on the page has focus diff --git a/test/e2e/specs/FocusTrap/index.js b/test/e2e/specs/FocusTrap/index.js index dd88008d..56c9a99a 100644 --- a/test/e2e/specs/FocusTrap/index.js +++ b/test/e2e/specs/FocusTrap/index.js @@ -12,8 +12,8 @@ describe('Focus Trap', () => { the('calendar').should('be.visible') }) - describe('@id-1: Press tab when the previous button is focused', () => { - When('the user focuses the previous button and presses tab', () => { + describe('@id-1: Tab forwards when the previous button is focused', () => { + When('the user focuses the previous button and tabs forwards', () => { focusThe('previous-button').tab() }) @@ -22,8 +22,8 @@ describe('Focus Trap', () => { }) }) - describe('@id-2: Press shift + tab when the previous button is focused', () => { - When('the user focuses the previous button and presses shift + tab', () => { + describe('@id-2: Tab backwards when the previous button is focused', () => { + When('the user focuses the previous button and tabs backwards', () => { focusThe('previous-button').tab({ shift: true }) }) @@ -32,16 +32,16 @@ describe('Focus Trap', () => { }) }) - describe('@id-3: Press tab when the up button is focused', () => { - When('the user focuses the up button and presses tab', () => { + describe('@id-3: Tab forwards when the up button is focused', () => { + When('the user focuses the up button and tabs forwards', () => { focusThe('up-button').tab() }) Then('the next button has focus') }) - describe('@id-4: Press shift + tab when the up button is focused', () => { - Given('the user focuses the up button and presses shift + tab', () => { + describe('@id-4: Tab backwards when the up button is focused', () => { + Given('the user focuses the up button and tabs backwards', () => { focusThe('up-button').tab({ shift: true }) }) @@ -50,8 +50,8 @@ describe('Focus Trap', () => { }) }) - describe('@id-5: Press tab when the next button is focused', () => { - When('the user focuses the next button and presses tab', () => { + describe('@id-5: Tab forwards when the next button is focused', () => { + When('the user focuses the next button and tabs forwards', () => { focusThe('next-button').tab() }) @@ -60,16 +60,16 @@ describe('Focus Trap', () => { }) }) - describe('@id-6: Press shift + tab when the next button is focused', () => { - Given('the user focuses the next button and presses shift + tab', () => { + describe('@id-6: Tab backwards when the next button is focused', () => { + Given('the user focuses the next button and tabs backwards', () => { focusThe('next-button').tab({ shift: true }) }) Then('the up button has focus') }) - describe("@id-7: Press tab when today's cell is focused", () => { - When('the user focuses the tabbable cell and presses tab', () => { + describe("@id-7: Tab forwards when today's cell is focused", () => { + When('the user focuses the tabbable cell and tabs forwards', () => { focusThe('tabbable-cell').tab() }) @@ -78,8 +78,8 @@ describe('Focus Trap', () => { }) }) - describe("@id-8: Press shift + tab when today's cell is focused", () => { - When('the user focuses the tabbable cell and presses shift + tab', () => { + describe("@id-8: Tab backwards when today's cell is focused", () => { + When('the user focuses the tabbable cell and tabs backwards', () => { focusThe('tabbable-cell').tab({ shift: true }) }) @@ -87,4 +87,24 @@ describe('Focus Trap', () => { the('next-button').should('be.focused') }) }) + + describe('@id-9: Inline calendar: Tab forwards when last element is focused', () => { + When('the user focuses the last element and tabs forwards', () => { + focusThe('tabbable-cell').tab() + }) + + Then('the next element on the page has focus', () => { + the('next-element').should('be.focused') + }) + }) + + describe('@id-10: Inline calendar: Tab backwards when first element is focused', () => { + When('the user focuses the first element and tabs backwards', () => { + focusThe('previous-button').tab({ shift: true }) + }) + + Then('the previous element on the page has focus', () => { + the('previous-element').should('be.focused') + }) + }) }) diff --git a/test/unit/specs/Datepicker/Datepicker.spec.js b/test/unit/specs/Datepicker/Datepicker.spec.js index e65d6a4a..e0da4a23 100755 --- a/test/unit/specs/Datepicker/Datepicker.spec.js +++ b/test/unit/specs/Datepicker/Datepicker.spec.js @@ -324,6 +324,14 @@ describe('Datepicker mounted to body', () => { expect(document.activeElement).toBe(document.body) }) + + it('tabs away from a closed calendar', async () => { + const input = wrapper.find('input') + + await input.trigger('keydown.tab') + + expect(document.activeElement).toBe(document.body) + }) }) describe('Datepicker mounted to body with openDate', () => {