Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion demo/js/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
15 changes: 14 additions & 1 deletion demo/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
3 changes: 2 additions & 1 deletion plugins/search/src/Search.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,10 @@ export function Search ({ appConfig, iconRegistry, pluginState, pluginConfig, ap
const isFormVisible = defaultExpanded || isExpanded

return (
<div
<div // NOSONAR - not interactive itself: only catches Tab from its focusable children to continue tab order from the search button
className={`im-c-search${isFormVisible ? '' : ' im-c-search--collapsed'}`}
ref={searchContainerRef}
onKeyDown={(e) => events.handleTabOut(e, appState.buttonRefs)}
>
<Form
id={id}
Expand Down
11 changes: 10 additions & 1 deletion plugins/search/src/Search.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ jest.mock('./datasets.js', () => ({
jest.mock('./events/index.js', () => ({
attachEvents: jest.fn(() => ({
handleCloseClick: jest.fn(),
handleOutside: jest.fn()
handleOutside: jest.fn(),
handleTabOut: jest.fn()
}))
}))

Expand Down Expand Up @@ -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(<Search {...props} />)
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(<Search {...props} />)
const events = attachEvents.mock.results[0].value
Expand Down
34 changes: 34 additions & 0 deletions plugins/search/src/events/formHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 } })
Expand Down
87 changes: 87 additions & 0 deletions plugins/search/src/events/formHandlers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
})
16 changes: 10 additions & 6 deletions plugins/search/src/search.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading