diff --git a/src/components/accessibility/skip_link/skip_link.spec.tsx b/src/components/accessibility/skip_link/skip_link.spec.tsx new file mode 100644 index 000000000000..b49fb87c7f56 --- /dev/null +++ b/src/components/accessibility/skip_link/skip_link.spec.tsx @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/// + +import React from 'react'; +import { EuiSkipLink } from './'; + +describe('EuiSkipLink', () => { + describe('overrideLinkBehavior', () => { + describe('focus/tab behavior', () => { + it('manually focuses the target element', () => { + cy.realMount( +
+ +
Hello world
+
+ ); + + cy.realPress('Tab'); + cy.focused().should('have.class', 'euiSkipLink'); + + cy.realPress('Enter'); + cy.focused().should('have.id', 'start-of-content'); + }); + + it('removes the manual tabindex on the target element after the user moves away from it', () => { + cy.realMount( +
+ +
+ Hello world + +
+
+ ); + cy.realPress('Tab'); + cy.realPress('Enter'); + + cy.focused() + .should('have.id', 'start-of-content') + .should('have.attr', 'tabindex', '-1'); + + cy.realPress('Tab'); + + cy.focused().should('have.id', 'button'); + cy.get('#start-of-content').should('not.have.attr', 'tabindex'); + }); + + it('does not add tabindex -1 to target elements that are already tabbable', () => { + cy.realMount( +
+ + + + +
+ tabIndex -1 +
+
+ ); + + cy.realPress('Tab'); + cy.realPress('Enter'); + cy.focused() + .should('have.id', 'button') + .should('not.have.attr', 'tabindex', '-1'); + + cy.realPress('Tab'); + cy.realPress('Enter'); + cy.focused() + .should('have.id', 'negativeOne') + .should('have.attr', 'tabindex', '0'); + }); + }); + + describe('scroll behavior', () => { + it('should scroll down to the target element if it is out of the viewport', () => { + cy.realMount( +
+ +
Hello world
+
+ ); + cy.window().its('scrollY').should('equal', 0); + + cy.realPress('Tab'); + cy.realPress('Enter'); + + cy.window().its('scrollY').should('not.equal', 0); + }); + + it('should scroll back up to the top of the target element if the user scrolled down past it', () => { + cy.realMount( +
+ +
Hello world
+
+ ); + cy.scrollTo('bottom'); + cy.window().its('scrollY').should('not.equal', 0); + + cy.realPress('Tab'); + cy.realPress('Enter'); + + cy.window().its('scrollY').should('equal', 1); // Not sure why this isn't 0 - might be a Chrome thing + }); + + it('should not scroll if the element is already visible in the viewport', () => { + cy.realMount( +
+ +
Hello world
+
+ ); + cy.window().its('scrollY').should('equal', 0); + + cy.realPress('Tab'); + cy.realPress('Enter'); + + cy.window().its('scrollY').should('equal', 0); + }); + }); + }); +}); diff --git a/src/components/accessibility/skip_link/skip_link.test.tsx b/src/components/accessibility/skip_link/skip_link.test.tsx index 186bdf265eeb..c8dd6c7d0267 100644 --- a/src/components/accessibility/skip_link/skip_link.test.tsx +++ b/src/components/accessibility/skip_link/skip_link.test.tsx @@ -24,24 +24,39 @@ describe('EuiSkipLink', () => { }); describe('props', () => { - test('overrideLinkBehavior prevents default link behavior and manually scrolls and focuses the destination', () => { - const scrollSpy = jest.fn(); - const focusSpy = jest.fn(); - jest.spyOn(document, 'getElementById').mockReturnValue({ - scrollIntoView: scrollSpy, - focus: focusSpy, - } as any); - - const component = mount( - - ); + describe('overrideLinkBehavior', () => { + const mockElement = document.createElement('main'); + jest.spyOn(document, 'getElementById').mockReturnValue(mockElement); + + it('prevents default link behavior and manually focuses the destination', () => { + const focusSpy = jest.fn(); + mockElement.focus = focusSpy; + + const component = mount( + + ); + + const preventDefault = jest.fn(); + component.find('EuiButton').simulate('click', { preventDefault }); - const preventDefault = jest.fn(); - component.find('EuiButton').simulate('click', { preventDefault }); + expect(preventDefault).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it('only scrolls to the destination if out of view', () => { + const scrollSpy = jest.fn(); + mockElement.scrollIntoView = scrollSpy; - expect(preventDefault).toHaveBeenCalled(); - expect(scrollSpy).toHaveBeenCalled(); - expect(focusSpy).toHaveBeenCalled(); + const component = mount( + + ); + component.find('EuiButton').simulate('click'); + expect(scrollSpy).not.toHaveBeenCalled(); + + mockElement.getBoundingClientRect = () => ({ top: 1000 } as any); + component.find('EuiButton').simulate('click'); + expect(scrollSpy).toHaveBeenCalled(); + }); }); test('tabIndex is rendered', () => { diff --git a/src/components/accessibility/skip_link/skip_link.tsx b/src/components/accessibility/skip_link/skip_link.tsx index d88e76be080c..a11591f2a82f 100644 --- a/src/components/accessibility/skip_link/skip_link.tsx +++ b/src/components/accessibility/skip_link/skip_link.tsx @@ -8,6 +8,7 @@ import React, { FunctionComponent, Ref } from 'react'; import classNames from 'classnames'; +import { isTabbable } from 'tabbable'; import { useEuiTheme } from '../../../services'; import { EuiButton, EuiButtonProps } from '../../button/button'; import { PropsForAnchor, PropsForButton, ExclusiveUnion } from '../../common'; @@ -91,9 +92,27 @@ export const EuiSkipLink: FunctionComponent = ({ const destinationEl = document.getElementById(destinationId); if (!destinationEl) return; - destinationEl.scrollIntoView(); - destinationEl.tabIndex = -1; // Ensure the destination content is focusable - destinationEl.focus({ preventScroll: true }); // Scrolling is already handled above, and focus's autoscroll behaves oddly around fixed headers + // Scroll to the top of the destination content only if it's ~mostly out of view + const destinationY = destinationEl.getBoundingClientRect().top; + const halfOfViewportHeight = window.innerHeight / 2; + if ( + destinationY >= halfOfViewportHeight || + window.scrollY >= destinationY + halfOfViewportHeight + ) { + destinationEl.scrollIntoView(); + } + + // Ensure the destination content is focusable + if (!isTabbable(destinationEl)) { + destinationEl.tabIndex = -1; + destinationEl.addEventListener( + 'blur', + () => destinationEl.removeAttribute('tabindex'), + { once: true } + ); + } + + destinationEl.focus({ preventScroll: true }); // Scrolling is already handled above, and focus autoscroll behaves oddly on Chrome around fixed headers }, }; } diff --git a/upcoming_changelogs/5996.md b/upcoming_changelogs/5996.md new file mode 100644 index 000000000000..8b3dd8b7d0f9 --- /dev/null +++ b/upcoming_changelogs/5996.md @@ -0,0 +1 @@ +- Enhanced `EuiSkipLink`'s `overrideLinkBehavior` scroll and focus UX