diff --git a/demo/js/draw.js b/demo/js/draw.js index 30cf992d6..096b472fa 100755 --- a/demo/js/draw.js +++ b/demo/js/draw.js @@ -159,7 +159,20 @@ const interactiveMap = new InteractiveMap('map', { osNamesURL: process.env.OS_NAMES_URL, customDatasets: [parcelSearch, gridRefSearchETRS89], width: '300px', - showMarker: false + showMarker: false, + // Search trigger and form in the top-right on tablet/desktop (mobile keeps its defaults) + manifest: { + buttons: [{ + id: 'search', + tablet: { slot: 'top-right', showLabel: false }, + desktop: { slot: 'top-right', showLabel: false } + }], + controls: [{ + id: 'search', + tablet: { slot: 'top-right' }, + desktop: { slot: 'top-right' } + }] + } }), datasetsPlugin, createMapKeyPlugin(), diff --git a/demo/js/index.js b/demo/js/index.js index 12f806df1..8cb6bca42 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -309,7 +309,20 @@ const interactiveMap = new InteractiveMap('map', { customDatasets: [parcelSearch, gridRefSearchETRS89], width: '300px', showMarker: true, - showLabel: true + showLabel: true, + // Search trigger and form in the top-right on tablet/desktop (mobile keeps its defaults) + manifest: { + buttons: [{ + id: 'search', + tablet: { slot: 'top-right', showLabel: false }, + desktop: { slot: 'top-right', showLabel: false } + }], + controls: [{ + id: 'search', + tablet: { slot: 'top-right' }, + desktop: { slot: 'top-right' } + }] + } }), datasetsPlugin, createMapKeyPlugin(), diff --git a/plugins/search/src/Search.jsx b/plugins/search/src/Search.jsx index 3a76de1ca..427a3160c 100755 --- a/plugins/search/src/Search.jsx +++ b/plugins/search/src/Search.jsx @@ -84,9 +84,10 @@ export function Search ({ appConfig, iconRegistry, pluginState, pluginConfig, ap const isFormVisible = defaultExpanded || isExpanded return ( -
events.handleTabOut(e, appState.buttonRefs)} >
({ jest.mock('./events/index.js', () => ({ attachEvents: jest.fn(() => ({ handleCloseClick: jest.fn(), - handleOutside: jest.fn() + handleOutside: jest.fn(), + handleTabOut: jest.fn() })) })) @@ -141,6 +142,14 @@ describe('Search component', () => { expect(screen.getByTestId('submit-button')).toBeInTheDocument() }) + it('passes key presses in the search container to handleTabOut with the button refs', () => { + props.appState.buttonRefs = { current: {} } + const { container } = render() + fireEvent.keyDown(container.querySelector('.im-c-search'), { key: 'Tab' }) + const { handleTabOut } = attachEvents.mock.results[0].value + expect(handleTabOut).toHaveBeenCalledWith(expect.objectContaining({ key: 'Tab' }), props.appState.buttonRefs) + }) + it('CloseButton click triggers handleCloseClick', () => { render() const events = attachEvents.mock.results[0].value diff --git a/plugins/search/src/events/formHandlers.js b/plugins/search/src/events/formHandlers.js index a47be5cda..44eaa0d39 100755 --- a/plugins/search/src/events/formHandlers.js +++ b/plugins/search/src/events/formHandlers.js @@ -31,10 +31,40 @@ const restoreTriggerFocus = (buttonRefs) => { requestAnimationFrame(focusWhenReady) } +// Every focusable element on the page in tab order — including [tabindex] elements (e.g. the features +// list) and visually hidden but still-rendered ones (e.g. buttons search hides with opacity while open). +const TAB_STOP_SELECTOR = 'input, button, select, textarea, a[href], [tabindex]' +const getTabStops = () => Array.from(document.querySelectorAll(TAB_STOP_SELECTOR)) + .filter(el => el.tabIndex >= 0 && !el.disabled && el.getClientRects().length > 0) + +// The form renders apart from its trigger (a slot's controls come before its buttons), so native +// Tab order would leave the form for whatever happens to follow it in the DOM. Continue from the +// trigger's position instead: Tab past the last element goes to whatever follows the trigger, +// Shift+Tab past the first returns to the trigger. handleOutside then closes search as usual. +const tabOutFromTrigger = (e, buttonRefs, searchContainerRef) => { + const trigger = getTriggerButton(buttonRefs) + if (e.key !== 'Tab' || !trigger) { + return + } + const stops = getTabStops() + const adjacent = stops[stops.indexOf(e.target) + (e.shiftKey ? -1 : 1)] + if (searchContainerRef.current?.contains(adjacent)) { + return + } + // Nothing after the trigger means it's the page's last tab stop: let focus leave the page natively. + const target = e.shiftKey ? trigger : stops[stops.indexOf(trigger) + 1] + if (!target) { + return + } + e.preventDefault() + target.focus() +} + export const createFormHandlers = ({ dispatch, services, viewportRef, + searchContainerRef, mapProvider, markers, datasets, @@ -45,6 +75,10 @@ export const createFormHandlers = ({ let lastFetchedValue = '' return { + handleTabOut (e, buttonRefs) { + tabOutFromTrigger(e, buttonRefs, searchContainerRef) + }, + handleCloseClick (_e, appState) { dispatch({ type: 'TOGGLE_EXPANDED', payload: false }) dispatch({ type: 'UPDATE_SUGGESTIONS', payload: { results: [], hasError: false } }) diff --git a/plugins/search/src/events/formHandlers.test.js b/plugins/search/src/events/formHandlers.test.js index 63ef68743..c154898e3 100644 --- a/plugins/search/src/events/formHandlers.test.js +++ b/plugins/search/src/events/formHandlers.test.js @@ -262,4 +262,91 @@ describe('createFormHandlers', () => { expect(fetchSuggestions).toHaveBeenCalledTimes(1) }) + + describe('handleTabOut', () => { + let container, input, closeButton, trigger, afterTrigger, before, tabHandlers + + const keyDown = (target, shiftKey = false) => ({ key: 'Tab', shiftKey, target, preventDefault: jest.fn() }) + + beforeEach(() => { + container = document.createElement('div') + input = document.createElement('input') + closeButton = document.createElement('button') + container.append(input, closeButton) + before = document.createElement('button') + trigger = document.createElement('button') + afterTrigger = document.createElement('a') + afterTrigger.href = '#' + document.body.append(before, container, trigger, afterTrigger) + // jsdom has no layout, so give each tab stop a client rect (i.e. rendered). Page tab order: + // before, input, closeButton, trigger, afterTrigger + const stops = [before, input, closeButton, trigger, afterTrigger] + stops.forEach(el => { el.getClientRects = () => [{}] }) + tabHandlers = createFormHandlers({ dispatch, services, viewportRef, searchContainerRef: { current: container }, markers }) + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + const buttonRefs = () => ({ current: { search: trigger } }) + + test('Tab past the last element moves focus to whatever follows the trigger', () => { + const e = keyDown(closeButton) + tabHandlers.handleTabOut(e, buttonRefs()) + expect(e.preventDefault).toHaveBeenCalled() + expect(document.activeElement).toBe(afterTrigger) + }) + + test('Shift+Tab past the first element returns focus to the trigger', () => { + const e = keyDown(input, true) + tabHandlers.handleTabOut(e, buttonRefs()) + expect(e.preventDefault).toHaveBeenCalled() + expect(document.activeElement).toBe(trigger) + }) + + test('leaves Tab alone while focus is moving within the form', () => { + const e = keyDown(input) + tabHandlers.handleTabOut(e, buttonRefs()) + expect(e.preventDefault).not.toHaveBeenCalled() + }) + + test('ignores keys other than Tab', () => { + const e = { ...keyDown(closeButton), key: 'Enter' } + tabHandlers.handleTabOut(e, buttonRefs()) + expect(e.preventDefault).not.toHaveBeenCalled() + }) + + test('Shift+Tab from the page\'s first tab stop still returns to the trigger', () => { + before.remove() + const e = keyDown(input, true) + tabHandlers.handleTabOut(e, buttonRefs()) + expect(document.activeElement).toBe(trigger) + }) + + test('counts [tabindex] elements and skips unrendered ones when finding what follows the trigger', () => { + const hidden = document.createElement('button') + hidden.getClientRects = () => [] + const listItem = document.createElement('li') + listItem.tabIndex = 0 + listItem.getClientRects = () => [{}] + trigger.after(hidden, listItem) + tabHandlers.handleTabOut(keyDown(closeButton), buttonRefs()) + expect(document.activeElement).toBe(listItem) + }) + + test('lets focus leave the page natively when the trigger is the last tab stop', () => { + afterTrigger.remove() + const e = keyDown(closeButton) + tabHandlers.handleTabOut(e, buttonRefs()) + expect(e.preventDefault).not.toHaveBeenCalled() + }) + + test('keeps native tab order when there is no trigger (default-expanded)', () => { + trigger.remove() + const e = keyDown(closeButton) + tabHandlers.handleTabOut(e, { current: {} }) + expect(e.preventDefault).not.toHaveBeenCalled() + }) + }) }) diff --git a/plugins/search/src/search.scss b/plugins/search/src/search.scss index da54c811b..fbfd26124 100755 --- a/plugins/search/src/search.scss +++ b/plugins/search/src/search.scss @@ -113,16 +113,20 @@ // 3. Modifiers .im-o-app--exclusive-control-search { - .im-o-app__top .im-c-button-wrapper, - .im-o-app__right .im-c-button-wrapper { + // Everything in the top row except the form itself: buttons (the trigger included), button + // groups and other controls, whatever slot search is in. Taken out of flow so nothing offsets the + // form within its column, but kept focusable: Tab from the form still reaches the next item, and + // focus can return to the trigger on close. + .im-o-app__top-col > :not(.im-c-search) { + position: absolute; opacity: 0; z-index: -1; + pointer-events: none; } - // Takes the trigger out of flow (already hidden above) but keeps it focusable so focus can return to it on close. - .im-c-button-wrapper--search { - position: absolute; - pointer-events: none; + .im-o-app__right .im-c-button-wrapper { + opacity: 0; + z-index: -1; } .im-o-app__left-top,