From a8bc7235cc0f108ccba32e788d8cabadc25f12a6 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Mon, 28 Sep 2026 14:09:24 +0100 Subject: [PATCH 1/2] Let search work in any slot: keep the form aligned and continue tab order from its button While search is open, every other item in the top row (buttons, button groups and controls) is taken out of flow as well as hidden, so nothing offsets the form within its column when it isn't in top-left. They stay focusable. A slot renders its controls before its buttons, so native Tab order left the open form for whatever followed it in the DOM, not the search button. handleTabOut now continues from the button: Tab past the form's last element goes to whatever follows the button, and Shift+Tab past its first returns to the button. The index and draw demos put search in the top-right on tablet/desktop. --- demo/js/draw.js | 15 +++- demo/js/index.js | 15 +++- plugins/search/src/Search.jsx | 1 + plugins/search/src/Search.test.jsx | 11 ++- plugins/search/src/events/formHandlers.js | 30 +++++++ .../search/src/events/formHandlers.test.js | 87 +++++++++++++++++++ plugins/search/src/search.scss | 16 ++-- 7 files changed, 166 insertions(+), 9 deletions(-) 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..31a0f36a2 100755 --- a/plugins/search/src/Search.jsx +++ b/plugins/search/src/Search.jsx @@ -87,6 +87,7 @@ export function Search ({ appConfig, iconRegistry, pluginState, pluginConfig, ap
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..bd04d2ca7 100755 --- a/plugins/search/src/events/formHandlers.js +++ b/plugins/search/src/events/formHandlers.js @@ -31,10 +31,17 @@ 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) + export const createFormHandlers = ({ dispatch, services, viewportRef, + searchContainerRef, mapProvider, markers, datasets, @@ -45,6 +52,29 @@ export const createFormHandlers = ({ let lastFetchedValue = '' return { + // 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. + handleTabOut (e, buttonRefs) { + 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() + }, + 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, From 208b550aa38262dd2bd52af7e9eb639889b3104f Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Tue, 29 Sep 2026 13:39:03 +0100 Subject: [PATCH 2/2] Sonar cloud fixes --- plugins/search/src/Search.jsx | 2 +- plugins/search/src/events/formHandlers.js | 44 ++++++++++++----------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/plugins/search/src/Search.jsx b/plugins/search/src/Search.jsx index 31a0f36a2..427a3160c 100755 --- a/plugins/search/src/Search.jsx +++ b/plugins/search/src/Search.jsx @@ -84,7 +84,7 @@ export function Search ({ appConfig, iconRegistry, pluginState, pluginConfig, ap const isFormVisible = defaultExpanded || isExpanded return ( -
events.handleTabOut(e, appState.buttonRefs)} diff --git a/plugins/search/src/events/formHandlers.js b/plugins/search/src/events/formHandlers.js index bd04d2ca7..44eaa0d39 100755 --- a/plugins/search/src/events/formHandlers.js +++ b/plugins/search/src/events/formHandlers.js @@ -37,6 +37,29 @@ 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, @@ -52,27 +75,8 @@ export const createFormHandlers = ({ let lastFetchedValue = '' return { - // 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. handleTabOut (e, buttonRefs) { - 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() + tabOutFromTrigger(e, buttonRefs, searchContainerRef) }, handleCloseClick (_e, appState) {