From 7af81c95bd3afe28e36f7a3107be76522582efbc Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Mon, 1 Dec 2025 19:11:10 +0200 Subject: [PATCH 01/39] Routes.js: if window scope has __focusedElementId__ set, move focus on that element shortly after navigation event. --- src/routing/Routes.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/routing/Routes.js b/src/routing/Routes.js index c31bcce18..b6950b28c 100644 --- a/src/routing/Routes.js +++ b/src/routing/Routes.js @@ -92,6 +92,19 @@ const handleLocationChanged = (dispatch, location, routeConfiguration, delayed) dispatch(locationChanged({ location, canonicalPath: path })); }; +const handleFocusedElement = delayed => { + if (window.__focusedElementId__) { + delayed = window.setTimeout(() => { + const focusedElement = document.getElementById(window.__focusedElementId__); + if (focusedElement) { + focusedElement.focus(); + } else { + window.__focusedElementId__ = null; + } + }, 300); + } +}; + /** * RouteComponentRenderer handles loadData calls on client-side. * It also checks authentication and redirects unauthenticated users @@ -123,6 +136,7 @@ class RouteComponentRenderer extends Component { // Calling loadData on initial rendering (on client side). callLoadData(this.props); handleLocationChanged(dispatch, location, routeConfiguration, this.delayed); + handleFocusedElement(this.focusedElementDelay); } componentDidUpdate(prevProps) { @@ -136,12 +150,16 @@ class RouteComponentRenderer extends Component { callLoadData(this.props); handleLocationChanged(dispatch, location, routeConfiguration, this.delayed); } + handleFocusedElement(this.focusedElementDelay); } componentWillUnmount() { if (this.delayed) { window.clearTimeout(this.resetTimeoutId); } + if (this.focusedElementDelay) { + window.clearTimeout(this.focusedElementDelay); + } } render() { From dd15ecdaf2728e7ddafceeab9583802a75aa4249 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 2 Dec 2025 14:34:47 +0200 Subject: [PATCH 02/39] Menu: add focus handling when menu-item is selected --- src/components/Menu/Menu.js | 12 ++++++++++++ src/components/MenuLabel/MenuLabel.js | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/Menu/Menu.js b/src/components/Menu/Menu.js index 9b4ac4e37..58164100e 100644 --- a/src/components/Menu/Menu.js +++ b/src/components/Menu/Menu.js @@ -60,6 +60,7 @@ class Menu extends Component { } this.onBlur = this.onBlur.bind(this); + this.onFocus = this.onFocus.bind(this); this.onKeyDown = this.onKeyDown.bind(this); this.toggleOpen = this.toggleOpen.bind(this); this.prepareChildren = this.prepareChildren.bind(this); @@ -80,6 +81,10 @@ class Menu extends Component { if (!this.menu.contains(event.relatedTarget)) { const { isOpen = null, onToggleActive = null } = this.props; + if (event.relatedTarget !== null) { + window.__focusedElementId__ = null; + } + if (isControlledMenu(isOpen, onToggleActive)) { onToggleActive(false); } else { @@ -88,6 +93,12 @@ class Menu extends Component { } } + onFocus(event) { + if (event.currentTarget.contains(event.target)) { + window.__focusedElementId__ = event.currentTarget.firstChild?.id; + } + } + onKeyDown(e) { // Gather all escape presses to close menu if (e.keyCode === KEY_CODE_ESCAPE) { @@ -208,6 +219,7 @@ class Menu extends Component { id={id} className={classes} onBlur={this.onBlur} + onFocus={this.onFocus} onKeyDown={this.onKeyDown} ref={c => { this.menu = c; diff --git a/src/components/MenuLabel/MenuLabel.js b/src/components/MenuLabel/MenuLabel.js index 5e0d6fc87..24e94adfc 100644 --- a/src/components/MenuLabel/MenuLabel.js +++ b/src/components/MenuLabel/MenuLabel.js @@ -20,7 +20,7 @@ import css from './MenuLabel.module.css'; */ const MenuLabel = props => { const [clicked, setClicked] = useState(false); - const { children, className, rootClassName, isOpen, isOpenClassName, onToggleActive } = props; + const { children, id, className, rootClassName, isOpen, isOpenClassName, onToggleActive } = props; const onClick = e => { e.stopPropagation(); @@ -48,7 +48,7 @@ const MenuLabel = props => { }); return ( - ); From d686c2e0e0cb082f4416ed26ae48737c2812045e Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 2 Dec 2025 14:44:32 +0200 Subject: [PATCH 03/39] Menu: take focus handling into use in different menus --- src/containers/InboxPage/InboxSearchForm/InboxSortBy.js | 2 +- src/containers/SearchPage/SearchPageWithGrid.js | 1 + src/containers/SearchPage/SearchPageWithMap.js | 1 + src/containers/SearchPage/SortBy/SortByPopup.js | 3 ++- .../Topbar/TopbarDesktop/CustomLinksMenu/LinksMenu.js | 6 +++++- .../TopbarContainer/Topbar/TopbarDesktop/TopbarDesktop.js | 6 +++++- 6 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/containers/InboxPage/InboxSearchForm/InboxSortBy.js b/src/containers/InboxPage/InboxSearchForm/InboxSortBy.js index 7471eb1d7..f2426019b 100644 --- a/src/containers/InboxPage/InboxSearchForm/InboxSortBy.js +++ b/src/containers/InboxPage/InboxSearchForm/InboxSortBy.js @@ -63,7 +63,7 @@ const InboxSortBy = props => { isOpen={isOpen} preferScreenWidthOnMobile > - + {menuLabel} diff --git a/src/containers/SearchPage/SearchPageWithGrid.js b/src/containers/SearchPage/SearchPageWithGrid.js index c348c2302..7fe5fee2b 100644 --- a/src/containers/SearchPage/SearchPageWithGrid.js +++ b/src/containers/SearchPage/SearchPageWithGrid.js @@ -342,6 +342,7 @@ export class SearchPageComponent extends Component { onSelect={this.handleSortBy} showAsPopup mode={mode} + labelId={`${mode}-search-page-sort-by`} contentPlacementOffset={FILTER_DROPDOWN_OFFSET} /> ) : null; diff --git a/src/containers/SearchPage/SearchPageWithMap.js b/src/containers/SearchPage/SearchPageWithMap.js index 010f3a757..9e2608bcc 100644 --- a/src/containers/SearchPage/SearchPageWithMap.js +++ b/src/containers/SearchPage/SearchPageWithMap.js @@ -487,6 +487,7 @@ export class SearchPageComponent extends Component { onSelect={this.handleSortBy} showAsPopup mode={mode} + labelId={`${mode}-search-page-sort-by`} contentPlacementOffset={FILTER_DROPDOWN_OFFSET} /> ) : null; diff --git a/src/containers/SearchPage/SortBy/SortByPopup.js b/src/containers/SearchPage/SortBy/SortByPopup.js index 1db460c28..dbf1cd0c5 100644 --- a/src/containers/SearchPage/SortBy/SortByPopup.js +++ b/src/containers/SearchPage/SortBy/SortByPopup.js @@ -33,6 +33,7 @@ const SortByPopup = props => { menuLabelRootClassName, urlParam, label, + labelId, options, initialValue, contentPlacementOffset = 0, @@ -65,7 +66,7 @@ const SortByPopup = props => { isOpen={isOpen} preferScreenWidthOnMobile > - + {menuLabel} diff --git a/src/containers/TopbarContainer/Topbar/TopbarDesktop/CustomLinksMenu/LinksMenu.js b/src/containers/TopbarContainer/Topbar/TopbarDesktop/CustomLinksMenu/LinksMenu.js index a6d4b06ca..b578297db 100644 --- a/src/containers/TopbarContainer/Topbar/TopbarDesktop/CustomLinksMenu/LinksMenu.js +++ b/src/containers/TopbarContainer/Topbar/TopbarDesktop/CustomLinksMenu/LinksMenu.js @@ -147,7 +147,11 @@ const LinksMenu = props => { isOpen={isOpen} onToggleActive={setIsOpen} > - + diff --git a/src/containers/TopbarContainer/Topbar/TopbarDesktop/TopbarDesktop.js b/src/containers/TopbarContainer/Topbar/TopbarDesktop/TopbarDesktop.js index affcbcf7b..0664bf5be 100644 --- a/src/containers/TopbarContainer/Topbar/TopbarDesktop/TopbarDesktop.js +++ b/src/containers/TopbarContainer/Topbar/TopbarDesktop/TopbarDesktop.js @@ -60,7 +60,11 @@ const ProfileMenu = ({ currentPage, currentUser, onLogout, showManageListingsLin return ( - + From 5c55e1a624425a04837e1a86ce947632e526e02f Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 2 Dec 2025 14:45:34 +0200 Subject: [PATCH 04/39] NamedLink: add focus handling with id and holdFocus props --- src/components/NamedLink/NamedLink.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/components/NamedLink/NamedLink.js b/src/components/NamedLink/NamedLink.js index aa45ab895..aea7cef6c 100644 --- a/src/components/NamedLink/NamedLink.js +++ b/src/components/NamedLink/NamedLink.js @@ -24,6 +24,17 @@ import { useRouteConfiguration } from '../../context/routeConfigurationContext'; import { pathByRouteName, findRouteByRouteName } from '../../util/routes'; +const handleFocus = event => { + if (event.currentTarget.contains(event.target)) { + window.__focusedElementId__ = event.currentTarget?.id; + } +}; +const handleBlur = event => { + if (event.relatedTarget !== null) { + window.__focusedElementId__ = null; + } +}; + /** * This component wraps React-Router's Link by providing name-based routing. * @@ -46,6 +57,8 @@ import { pathByRouteName, findRouteByRouteName } from '../../util/routes'; export const NamedLink = withRouter(props => { const routeConfiguration = useRouteConfiguration(); const { + id, + holdFocus, name, params = {}, // pathParams title, @@ -71,11 +84,15 @@ export const NamedLink = withRouter(props => { const pathname = pathByRouteName(name, routeConfiguration, params); const active = match.url && match.url === pathname; + const focusHandlers = id && holdFocus ? { onFocus: handleFocus, onBlur: handleBlur } : {}; + // element props const aElemProps = { + id, className: classNames(className, { [activeClassName]: active }), style, title, + ...focusHandlers, }; return ( From 03c74489b19f0acc000b8c4f21d2eb9609a54b1f Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 2 Dec 2025 15:05:10 +0200 Subject: [PATCH 05/39] NamedLink: add some id to named links on Topbar This was originally taking holdFocus prop into use, but it does not make sense with skip to content feature. --- src/components/Logo/LinkedLogo.js | 3 ++- src/containers/TopbarContainer/Topbar/Topbar.js | 1 + .../TopbarDesktop/CustomLinksMenu/PriorityLinks.js | 3 ++- .../Topbar/TopbarDesktop/TopbarDesktop.js | 12 +++++++++--- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/components/Logo/LinkedLogo.js b/src/components/Logo/LinkedLogo.js index 3b3390196..7392d58b8 100644 --- a/src/components/Logo/LinkedLogo.js +++ b/src/components/Logo/LinkedLogo.js @@ -22,6 +22,7 @@ import css from './LinkedLogo.module.css'; */ const LinkedLogo = props => { const { + id, className, rootClassName, logoClassName, @@ -43,7 +44,7 @@ const LinkedLogo = props => { /> ) : ( - + { {notificationDot} { const PriorityLink = ({ linkConfig }) => { const { text, type, href, route, highlight } = linkConfig; const classes = classNames(css.priorityLink, { [css.highlight]: highlight }); + const id = `priority-link-${text.toLowerCase().replace(/ /g, '-')}`; // Note: if the config contains 'route' keyword, // then in-app linking config has been resolved already. @@ -42,7 +43,7 @@ const PriorityLink = ({ linkConfig }) => { // Internal link const { name, params, to } = route || {}; return ( - + {text} ); diff --git a/src/containers/TopbarContainer/Topbar/TopbarDesktop/TopbarDesktop.js b/src/containers/TopbarContainer/Topbar/TopbarDesktop/TopbarDesktop.js index 0664bf5be..587ffce63 100644 --- a/src/containers/TopbarContainer/Topbar/TopbarDesktop/TopbarDesktop.js +++ b/src/containers/TopbarContainer/Topbar/TopbarDesktop/TopbarDesktop.js @@ -21,7 +21,7 @@ import css from './TopbarDesktop.module.css'; const SignupLink = () => { return ( - + @@ -31,7 +31,7 @@ const SignupLink = () => { const LoginLink = () => { return ( - + @@ -42,7 +42,12 @@ const LoginLink = () => { const InboxLink = ({ notificationCount, inboxTab }) => { const notificationDot = notificationCount > 0 ?
: null; return ( - + {notificationDot} @@ -199,6 +204,7 @@ const TopbarDesktop = props => { aria-label={intl.formatMessage({ id: 'TopbarDesktop.screenreader.topbarNavigation' })} > Date: Tue, 2 Dec 2025 18:33:54 +0200 Subject: [PATCH 06/39] EditListingAvailabilityPanel/WeeklyCalendar: add aria-label to buttons on week navigation --- .../WeeklyCalendar/WeeklyCalendar.js | 8 ++++++++ src/translations/en.json | 1 + 2 files changed, 9 insertions(+) diff --git a/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/WeeklyCalendar/WeeklyCalendar.js b/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/WeeklyCalendar/WeeklyCalendar.js index a24520c77..7b44799ef 100644 --- a/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/WeeklyCalendar/WeeklyCalendar.js +++ b/src/containers/EditListingPage/EditListingWizard/EditListingAvailabilityPanel/WeeklyCalendar/WeeklyCalendar.js @@ -496,6 +496,10 @@ const WeeklyCalendar = props => { showUntilDate={thisWeek} startOfPrevRange={getStartOfPrevWeek(currentWeek, timeZone, firstDayOfWeek)} size="big" + aria-label={intl.formatMessage( + { id: 'EditListingAvailabilityPanel.WeeklyCalendar.screenreader.weekNavigation' }, + { direction: 'previous' } + )} /> { showUntilDate={endOfAvailabilityExceptionRange(timeZone, TODAY)} startOfNextRange={getStartOfNextWeek(currentWeek, timeZone, firstDayOfWeek)} size="big" + aria-label={intl.formatMessage( + { id: 'EditListingAvailabilityPanel.WeeklyCalendar.screenreader.weekNavigation' }, + { direction: 'next' } + )} />
diff --git a/src/translations/en.json b/src/translations/en.json index d202eddee..5770ac5fe 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -204,6 +204,7 @@ "EditListingAvailabilityPanel.WeeklyCalendar.fetchExceptionsError": "Fetching availability exception data failed", "EditListingAvailabilityPanel.WeeklyCalendar.notAvailable": "Not available", "EditListingAvailabilityPanel.WeeklyCalendar.scheduleTitle": "Schedule for", + "EditListingAvailabilityPanel.WeeklyCalendar.screenreader.weekNavigation": "{direction, select, next {Next week} other {Previous week}}", "EditListingAvailabilityPanel.WeeklyCalendar.seats": "{seats, plural, one {{seats} seat} other {{seats} seats}}", "EditListingAvailabilityPanel.addException": "Add an availability exception", "EditListingAvailabilityPanel.availabilityPlanInfo": "When is this listing available for booking? Start by setting a default weekly schedule. After that you can adjust the availability for specific dates.", From 25f5df4890250ee1304a819b42484aef02961e52 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 2 Dec 2025 18:41:34 +0200 Subject: [PATCH 07/39] EditListingPage/ListingImage: add aria-label to remove image button --- .../EditListingPhotosPanel/ListingImage.js | 8 +++++++- src/translations/en.json | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/containers/EditListingPage/EditListingWizard/EditListingPhotosPanel/ListingImage.js b/src/containers/EditListingPage/EditListingWizard/EditListingPhotosPanel/ListingImage.js index 758d6f50a..b09ba4231 100644 --- a/src/containers/EditListingPage/EditListingWizard/EditListingPhotosPanel/ListingImage.js +++ b/src/containers/EditListingPage/EditListingWizard/EditListingPhotosPanel/ListingImage.js @@ -1,5 +1,6 @@ import React from 'react'; import classNames from 'classnames'; +import { useIntl } from 'react-intl'; // Import shared components import { @@ -15,9 +16,14 @@ import css from './ListingImage.module.css'; // Cross shaped button on the top-right corner of the image thumbnail const RemoveImageButton = props => { const { className, rootClassName, onClick } = props; + const intl = useIntl(); const classes = classNames(rootClassName || css.removeImage, className); return ( - )} > {children} - ); From a579bface00017247c6971b35f460123f8e93020 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Thu, 4 Dec 2025 16:40:26 +0200 Subject: [PATCH 13/39] PaginationLinks: describe pagination page and next/prev buttons better using aria-label --- src/components/NamedLink/NamedLink.js | 3 +++ src/components/PaginationLinks/PaginationLinks.js | 3 +++ .../__snapshots__/PaginationLinks.test.js.snap | 14 ++++++++++++++ 3 files changed, 20 insertions(+) diff --git a/src/components/NamedLink/NamedLink.js b/src/components/NamedLink/NamedLink.js index aea7cef6c..cf677b86a 100644 --- a/src/components/NamedLink/NamedLink.js +++ b/src/components/NamedLink/NamedLink.js @@ -62,6 +62,7 @@ export const NamedLink = withRouter(props => { name, params = {}, // pathParams title, + ariaLabel, // Link props to = {}, children, @@ -85,6 +86,7 @@ export const NamedLink = withRouter(props => { const active = match.url && match.url === pathname; const focusHandlers = id && holdFocus ? { onFocus: handleFocus, onBlur: handleBlur } : {}; + const ariaLabelMaybe = ariaLabel ? { ['aria-label']: ariaLabel } : {}; //
element props const aElemProps = { @@ -92,6 +94,7 @@ export const NamedLink = withRouter(props => { className: classNames(className, { [activeClassName]: active }), style, title, + ...ariaLabelMaybe, ...focusHandlers, }; diff --git a/src/components/PaginationLinks/PaginationLinks.js b/src/components/PaginationLinks/PaginationLinks.js index 9e626761e..724b137f0 100644 --- a/src/components/PaginationLinks/PaginationLinks.js +++ b/src/components/PaginationLinks/PaginationLinks.js @@ -88,6 +88,7 @@ export const PaginationLinks = props => { params={pagePathParams} to={{ search: stringify(prevSearchParams) }} title={intl.formatMessage({ id: 'PaginationLinks.previous' })} + ariaLabel={intl.formatMessage({ id: 'PaginationLinks.previous' })} > @@ -111,6 +112,7 @@ export const PaginationLinks = props => { params={pagePathParams} to={{ search: stringify(nextSearchParams) }} title={intl.formatMessage({ id: 'PaginationLinks.next' })} + ariaLabel={intl.formatMessage({ id: 'PaginationLinks.next' })} > @@ -139,6 +141,7 @@ export const PaginationLinks = props => { params={pagePathParams} to={{ search: stringify({ ...pageSearchParams, page: v }) }} title={intl.formatMessage({ id: 'PaginationLinks.toPage' }, { page: v })} + ariaLabel={intl.formatMessage({ id: 'PaginationLinks.toPage' }, { page: v })} > {v} diff --git a/src/components/PaginationLinks/__snapshots__/PaginationLinks.test.js.snap b/src/components/PaginationLinks/__snapshots__/PaginationLinks.test.js.snap index 9651ea746..cf54e8d7b 100644 --- a/src/components/PaginationLinks/__snapshots__/PaginationLinks.test.js.snap +++ b/src/components/PaginationLinks/__snapshots__/PaginationLinks.test.js.snap @@ -25,6 +25,7 @@ exports[`PaginationLinks should match snapshot with both links disabled 1`] = ` class="pageNumberList pageNumberList1Items" > Date: Mon, 8 Dec 2025 15:09:43 +0200 Subject: [PATCH 14/39] Menu: add keyboard actions for ArrowDown, ArrowUp, Home and End --- src/components/Menu/Menu.js | 114 +++++++++++++++++++++++++++++++++--- 1 file changed, 106 insertions(+), 8 deletions(-) diff --git a/src/components/Menu/Menu.js b/src/components/Menu/Menu.js index 58164100e..4e93978a0 100644 --- a/src/components/Menu/Menu.js +++ b/src/components/Menu/Menu.js @@ -4,16 +4,58 @@ import classNames from 'classnames'; import { MenuContent, MenuLabel } from '../../components'; import css from './Menu.module.css'; -const KEY_CODE_ESCAPE = 27; const CONTENT_PLACEMENT_OFFSET = 0; const CONTENT_TO_LEFT = 'left'; const CONTENT_TO_RIGHT = 'right'; const MAX_MOBILE_SCREEN_WIDTH = 767; +const FOCUSABLE_ELEMENTS = 'a, button, [tabindex], input, select, textarea'; const isControlledMenu = (isOpenProp, onToggleActiveProp) => { return isOpenProp !== null && onToggleActiveProp !== null; }; +const moveFocusToFirstFocusableElement = menuContent => { + const focusableElements = menuContent.querySelectorAll(FOCUSABLE_ELEMENTS); + if (focusableElements.length > 0) { + const firstFocusableElement = Array.from(focusableElements).find( + element => !(element.tabIndex < 0 || element.disabled) + ); + firstFocusableElement?.focus(); + } +}; + +const moveFocusToLastFocusableElement = menuContent => { + const focusableElements = menuContent.querySelectorAll(FOCUSABLE_ELEMENTS); + if (focusableElements.length > 0) { + const lastFocusableElement = Array.from(focusableElements) + .reverse() + .find(element => !(element.tabIndex < 0 || element.disabled)); + lastFocusableElement?.focus(); + } +}; + +const moveFocusToNextFocusableElement = (menuContent, direction = 'next') => { + const focusableElements = Array.from( + menuContent.querySelectorAll('a, button, [tabindex], input, select, textarea') + ).filter(element => !(element.tabIndex < 0 || element.disabled)); + + if (focusableElements.length === 0) { + return; + } + + const currentIndex = focusableElements.findIndex(element => element === document.activeElement); + let targetIndex; + + if (direction === 'next') { + targetIndex = currentIndex < focusableElements.length - 1 ? currentIndex + 1 : 0; + } else { + // direction === 'previous' + targetIndex = currentIndex > 0 ? currentIndex - 1 : focusableElements.length - 1; + } + + focusableElements[targetIndex]?.focus(); +}; + /** * Menu is component that shows extra content when it is clicked. * Clicking it toggles visibility of MenuContent. @@ -101,23 +143,79 @@ class Menu extends Component { onKeyDown(e) { // Gather all escape presses to close menu - if (e.keyCode === KEY_CODE_ESCAPE) { - this.toggleOpen(false); + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + this.menu?.firstChild?.focus(); + this.toggleOpen({ enforcedState: false }); + } else if (e.key === 'ArrowDown') { + if (e.target === this.menu?.firstChild) { + e.preventDefault(); + e.stopPropagation(); + this.toggleOpen({ enforcedState: true }); + } else if (this.menuContent?.contains(e.target)) { + e.preventDefault(); + e.stopPropagation(); + moveFocusToNextFocusableElement(this.menuContent); + } + } else if (e.key === 'ArrowUp') { + if (e.target === this.menu?.firstChild) { + e.preventDefault(); + e.stopPropagation(); + this.toggleOpen({ enforcedState: false }); + } else if (this.menuContent?.contains(e.target)) { + e.preventDefault(); + e.stopPropagation(); + moveFocusToNextFocusableElement(this.menuContent, 'previous'); + } + } else if (e.key === 'Home') { + if (e.target === this.menu?.firstChild) { + e.preventDefault(); + e.stopPropagation(); + this.toggleOpen({ enforcedState: true }); + } else if (this.menuContent?.contains(e.target)) { + e.preventDefault(); + e.stopPropagation(); + moveFocusToFirstFocusableElement(this.menuContent); + } + } else if (e.key === 'End') { + if (e.target === this.menu?.firstChild) { + e.preventDefault(); + e.stopPropagation(); + this.toggleOpen({ enforcedState: true, moveFocusFn: moveFocusToLastFocusableElement }); + } else if (this.menuContent?.contains(e.target)) { + e.preventDefault(); + e.stopPropagation(); + moveFocusToLastFocusableElement(this.menuContent); + } } } - toggleOpen(enforcedState) { + toggleOpen(options = {}) { + const { enforcedState, moveFocusFn } = options || {}; + const moveFocusTo = moveFocusFn || moveFocusToFirstFocusableElement; + const delayedMoveFocus = isMenuOpen => { + if (isMenuOpen) { + setTimeout(() => { + moveFocusTo(this.menuContent); + }, 100); + } + }; // If state is handled outside of Menu component, we call a passed in onToggleActive func const { isOpen = null, onToggleActive = null } = this.props; if (isControlledMenu(isOpen, onToggleActive)) { const isMenuOpen = enforcedState != null ? enforcedState : !isOpen; onToggleActive(isMenuOpen); + delayedMoveFocus(isMenuOpen); } else { // If state is handled inside of Menu component, set state - this.setState(prevState => { - const isMenuOpen = enforcedState != null ? enforcedState : !prevState.isOpen; - return { isOpen: isMenuOpen }; - }); + this.setState( + prevState => { + const isMenuOpen = enforcedState != null ? enforcedState : !prevState.isOpen; + return { isOpen: isMenuOpen }; + }, + () => delayedMoveFocus(this.state.isOpen) + ); } } From 272c3e8108184b93238bb40cbb1f92c7f9d5aaf5 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Mon, 8 Dec 2025 15:20:27 +0200 Subject: [PATCH 15/39] BookingDateRangeFilter/FilterPopupForSidebar: keep focus context with keyboard navigation and events like submit and cancel --- .../DatePicker/DatePickers/DatePicker.js | 1 + .../FilterPopupForSidebar.js | 40 +++++++++++++++---- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/components/DatePicker/DatePickers/DatePicker.js b/src/components/DatePicker/DatePickers/DatePicker.js index 93fd149c2..bd61619c8 100644 --- a/src/components/DatePicker/DatePickers/DatePicker.js +++ b/src/components/DatePicker/DatePickers/DatePicker.js @@ -242,6 +242,7 @@ const CalendarMonth = props => { aria-label={ariaLabel} className={classes} data-date={isoDateString} + data-current={isCurrent && !isDisabled ? 'true' : undefined} key={cellKey} onClick={onClick} onMouseEnter={onMouseEnter} diff --git a/src/containers/SearchPage/BookingDateRangeFilter/FilterPopupForSidebar.js b/src/containers/SearchPage/BookingDateRangeFilter/FilterPopupForSidebar.js index f812a27ed..cc34cded7 100644 --- a/src/containers/SearchPage/BookingDateRangeFilter/FilterPopupForSidebar.js +++ b/src/containers/SearchPage/BookingDateRangeFilter/FilterPopupForSidebar.js @@ -10,8 +10,6 @@ import IconPlus from '../IconPlus/IconPlus'; import css from './FilterPopupForSidebar.module.css'; -const KEY_CODE_ESCAPE = 27; - /** * FilterPopupForSidebar component * @@ -51,6 +49,11 @@ class FilterPopupForSidebar extends Component { handleSubmit(values) { const { onSubmit } = this.props; this.setState({ isOpen: false }); + const button = document.getElementById(`${this.props.id}.toggle`); + if (button) { + button.focus(); + } + onSubmit(values); } @@ -58,6 +61,11 @@ class FilterPopupForSidebar extends Component { const { onSubmit, onClear } = this.props; this.setState({ isOpen: false }); + const button = document.getElementById(`${this.props.id}.toggle`); + if (button) { + button.focus(); + } + if (onClear) { onClear(); } @@ -69,6 +77,11 @@ class FilterPopupForSidebar extends Component { const { onSubmit, onCancel, initialValues } = this.props; this.setState({ isOpen: false }); + const button = document.getElementById(`${this.props.id}.toggle`); + if (button) { + button.focus(); + } + if (onCancel) { onCancel(); } @@ -82,16 +95,28 @@ class FilterPopupForSidebar extends Component { handleKeyDown(e) { // Gather all escape presses to close menu - if (e.keyCode === KEY_CODE_ESCAPE) { + if (e.key === 'Escape') { + const button = document.getElementById(`${this.props.id}.toggle`); + if (button) { + button.focus(); + } + this.toggleOpen(false); } } toggleOpen(enforcedState) { + const callback = () => { + const form = document.getElementById(`${this.props.id}.form`); + const currentDate = form?.querySelector('[data-current="true"]'); + if (this.state.isOpen) { + window.setTimeout(() => currentDate?.focus(), 100); + } + }; if (enforcedState) { - this.setState({ isOpen: enforcedState }); + this.setState({ isOpen: enforcedState }, callback); } else { - this.setState(prevState => ({ isOpen: !prevState.isOpen })); + this.setState(prevState => ({ isOpen: !prevState.isOpen }), callback); } } @@ -137,7 +162,7 @@ class FilterPopupForSidebar extends Component { const popupClasses = classNames(css.popup, { [css.isOpen]: this.state.isOpen }); const popupSizeClasses = popupClassName || css.popupSize; const contentStyle = this.positionStyleForContent(); - + const formId = `${id}.form`; return (
)} + clearButton={ + + } > {children}
- + ); } } From 415e93812e374f45f9bfbb8f5efc8ffe582aaf76 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 9 Dec 2025 14:11:21 +0200 Subject: [PATCH 19/39] Add proactively containerId to filters, so that their context can be identified on event handlers --- src/containers/SearchPage/FilterPlain/FilterPlain.js | 2 +- src/containers/SearchPage/FilterPopup/FilterPopup.js | 2 +- src/containers/SearchPage/SearchPageWithGrid.js | 2 ++ src/containers/SearchPage/SearchPageWithMap.js | 3 +++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/containers/SearchPage/FilterPlain/FilterPlain.js b/src/containers/SearchPage/FilterPlain/FilterPlain.js index 157ca603a..e92e13c8e 100644 --- a/src/containers/SearchPage/FilterPlain/FilterPlain.js +++ b/src/containers/SearchPage/FilterPlain/FilterPlain.js @@ -127,7 +127,7 @@ class FilterPlainComponent extends Component { initialValues, keepDirtyOnReinitialize = false, ariaLabel, - containerId, //TODO + containerId, // Note: this could be used to identify different filter containers } = this.props; const formId = `${id}.form`; const classes = classNames(rootClassName || css.root, className); diff --git a/src/containers/SearchPage/FilterPopup/FilterPopup.js b/src/containers/SearchPage/FilterPopup/FilterPopup.js index 723c32cb9..f7c1f91fd 100644 --- a/src/containers/SearchPage/FilterPopup/FilterPopup.js +++ b/src/containers/SearchPage/FilterPopup/FilterPopup.js @@ -202,7 +202,7 @@ class FilterPopup extends Component { keepDirtyOnReinitialize = false, contentPlacementOffset = 0, ariaLabel, - containerId, // TODO + containerId, // Note: this could be used to identify different filter containers } = this.props; const classes = classNames(rootClassName || css.root, className); diff --git a/src/containers/SearchPage/SearchPageWithGrid.js b/src/containers/SearchPage/SearchPageWithGrid.js index 7fe5fee2b..389e73119 100644 --- a/src/containers/SearchPage/SearchPageWithGrid.js +++ b/src/containers/SearchPage/SearchPageWithGrid.js @@ -401,6 +401,7 @@ export class SearchPageComponent extends Component { id={filterId} className={css.filter} config={filterConfig} + containerId="SearchPageWithGrid_DesktopFilters" listingCategories={listingCategories} marketplaceCurrency={marketplaceCurrency} urlQueryParams={validQueryParams} @@ -450,6 +451,7 @@ export class SearchPageComponent extends Component { key={key} id={filterId} config={filterConfig} + containerId="SearchPage_MobileFilters" listingCategories={listingCategories} marketplaceCurrency={marketplaceCurrency} urlQueryParams={validQueryParams} diff --git a/src/containers/SearchPage/SearchPageWithMap.js b/src/containers/SearchPage/SearchPageWithMap.js index 9e2608bcc..af06d363b 100644 --- a/src/containers/SearchPage/SearchPageWithMap.js +++ b/src/containers/SearchPage/SearchPageWithMap.js @@ -564,6 +564,7 @@ export class SearchPageComponent extends Component { key={key} id={filterId} config={filterConfig} + containerId="SearchPage_MobileFilters" listingCategories={listingCategories} marketplaceCurrency={marketplaceCurrency} urlQueryParams={validQueryParams} @@ -597,6 +598,7 @@ export class SearchPageComponent extends Component { key={key} id={filterId} config={filterConfig} + containerId="SearchPageWithMap_PrimaryFilters" listingCategories={listingCategories} marketplaceCurrency={marketplaceCurrency} urlQueryParams={validQueryParams} @@ -630,6 +632,7 @@ export class SearchPageComponent extends Component { key={key} id={filterId} config={filterConfig} + containerId="SearchPageWithMap_SecondaryFilters" listingCategories={listingCategories} marketplaceCurrency={marketplaceCurrency} urlQueryParams={validQueryParams} From 2a236ad9444d260a1c264b7d215c3f76bcc35dda Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Mon, 8 Dec 2025 16:31:45 +0200 Subject: [PATCH 20/39] Move KeyboardListener to components directory --- .../KeyboardListener/KeyboardListener.js | 0 src/components/index.js | 1 + src/containers/SearchPage/FilterPlain/FilterPlain.js | 3 ++- src/containers/SearchPage/FilterPopup/FilterPopup.js | 3 +-- 4 files changed, 4 insertions(+), 3 deletions(-) rename src/{containers/SearchPage => components}/KeyboardListener/KeyboardListener.js (100%) diff --git a/src/containers/SearchPage/KeyboardListener/KeyboardListener.js b/src/components/KeyboardListener/KeyboardListener.js similarity index 100% rename from src/containers/SearchPage/KeyboardListener/KeyboardListener.js rename to src/components/KeyboardListener/KeyboardListener.js diff --git a/src/components/index.js b/src/components/index.js index b42fda143..a0809da8a 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -47,6 +47,7 @@ export { default as AspectRatioWrapper } from './AspectRatioWrapper/AspectRatioW export { default as ExternalLink } from './ExternalLink/ExternalLink'; export { default as ExpandingTextarea } from './ExpandingTextarea/ExpandingTextarea'; export { default as Form } from './Form/Form'; +export { default as KeyboardListener } from './KeyboardListener/KeyboardListener'; export { default as LimitedAccessBanner } from './LimitedAccessBanner/LimitedAccessBanner'; export { default as Logo } from './Logo/Logo'; export { default as NamedLink } from './NamedLink/NamedLink'; diff --git a/src/containers/SearchPage/FilterPlain/FilterPlain.js b/src/containers/SearchPage/FilterPlain/FilterPlain.js index e92e13c8e..1d24c87fb 100644 --- a/src/containers/SearchPage/FilterPlain/FilterPlain.js +++ b/src/containers/SearchPage/FilterPlain/FilterPlain.js @@ -3,9 +3,10 @@ import classNames from 'classnames'; import { FormattedMessage, injectIntl, intlShape } from '../../../util/reactIntl'; +import { KeyboardListener } from '../../../components'; + import IconPlus from '../IconPlus/IconPlus'; import FilterForm from '../FilterForm/FilterForm'; -import KeyboardListener from '../KeyboardListener/KeyboardListener'; import css from './FilterPlain.module.css'; diff --git a/src/containers/SearchPage/FilterPopup/FilterPopup.js b/src/containers/SearchPage/FilterPopup/FilterPopup.js index f7c1f91fd..aa4c00e00 100644 --- a/src/containers/SearchPage/FilterPopup/FilterPopup.js +++ b/src/containers/SearchPage/FilterPopup/FilterPopup.js @@ -3,9 +3,8 @@ import classNames from 'classnames'; import { injectIntl, intlShape } from '../../../util/reactIntl'; -import { OutsideClickHandler } from '../../../components'; +import { KeyboardListener, OutsideClickHandler } from '../../../components'; -import KeyboardListener from '../KeyboardListener/KeyboardListener'; import PopupOpenerButton from '../PopupOpenerButton/PopupOpenerButton'; import FilterForm from '../FilterForm/FilterForm'; From 0840cc6bfc0edccfb47ec67af08dae5874661f83 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 9 Dec 2025 14:08:27 +0200 Subject: [PATCH 21/39] FilterPopup: close open filters, when focus moves away --- .../SearchPage/FilterPopup/FilterPopup.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/containers/SearchPage/FilterPopup/FilterPopup.js b/src/containers/SearchPage/FilterPopup/FilterPopup.js index aa4c00e00..a7e803fb8 100644 --- a/src/containers/SearchPage/FilterPopup/FilterPopup.js +++ b/src/containers/SearchPage/FilterPopup/FilterPopup.js @@ -92,6 +92,7 @@ class FilterPopup extends Component { this.handleClear = this.handleClear.bind(this); this.handleCancel = this.handleCancel.bind(this); this.handleBlur = this.handleBlur.bind(this); + this.handleOutsideClick = this.handleOutsideClick.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.toggleIsOpen = this.toggleIsOpen.bind(this); this.positionStyleForContent = this.positionStyleForContent.bind(this); @@ -140,7 +141,14 @@ class FilterPopup extends Component { onSubmit(initialValues); } - handleBlur() { + handleBlur(event) { + const hasBothTargets = event.target && event.relatedTarget; + if (this.state.isOpen && hasBothTargets && !this.filter.contains(event.relatedTarget)) { + this.setState({ isOpen: false }); + } + } + + handleOutsideClick() { this.setState({ isOpen: false }); } @@ -218,7 +226,7 @@ class FilterPopup extends Component { }; return ( - + { this.filter = node; }} + onBlur={this.handleBlur} > Date: Mon, 8 Dec 2025 15:39:34 +0200 Subject: [PATCH 22/39] CustomLinksMenu: use NewListingPage route instead of manually resolving draft listing URL --- .../Topbar/TopbarDesktop/CustomLinksMenu/CustomLinksMenu.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/containers/TopbarContainer/Topbar/TopbarDesktop/CustomLinksMenu/CustomLinksMenu.js b/src/containers/TopbarContainer/Topbar/TopbarDesktop/CustomLinksMenu/CustomLinksMenu.js index 80c9c7c60..882b7cdcb 100644 --- a/src/containers/TopbarContainer/Topbar/TopbarDesktop/CustomLinksMenu/CustomLinksMenu.js +++ b/src/containers/TopbarContainer/Topbar/TopbarDesktop/CustomLinksMenu/CustomLinksMenu.js @@ -5,7 +5,6 @@ import LinksMenu from './LinksMenu'; import css from './CustomLinksMenu.module.css'; -const draftId = '00000000-0000-0000-0000-000000000000'; const createListingLinkConfigMaybe = (intl, showLink) => showLink ? [ @@ -14,8 +13,7 @@ const createListingLinkConfigMaybe = (intl, showLink) => text: intl.formatMessage({ id: 'TopbarDesktop.createListing' }), type: 'internal', route: { - name: 'EditListingPage', - params: { slug: 'draft', id: draftId, type: 'new', tab: 'details' }, + name: 'NewListingPage', }, highlight: true, }, From 98252a10103de43df8781781c2dac6c2e715b47a Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Mon, 8 Dec 2025 15:42:01 +0200 Subject: [PATCH 23/39] TopbarMobileMenu: convert links into proper link lists --- .../TopbarMobileMenu/TopbarMobileMenu.js | 73 +++++++++---------- 1 file changed, 36 insertions(+), 37 deletions(-) diff --git a/src/containers/TopbarContainer/Topbar/TopbarMobileMenu/TopbarMobileMenu.js b/src/containers/TopbarContainer/Topbar/TopbarMobileMenu/TopbarMobileMenu.js index e2560ab18..971a32525 100644 --- a/src/containers/TopbarContainer/Topbar/TopbarMobileMenu/TopbarMobileMenu.js +++ b/src/containers/TopbarContainer/Topbar/TopbarMobileMenu/TopbarMobileMenu.js @@ -39,17 +39,21 @@ const CustomLinkComponent = ({ linkConfig, currentPage }) => { const { name, params, to } = route || {}; const className = classNames(css.navigationLink, getCurrentPageClass(name)); return ( - - - {text} - +
  • + + + {text} + +
  • ); } return ( - - - {text} - +
  • + + + {text} + +
  • ); }; @@ -151,12 +155,11 @@ const TopbarMobileMenu = props => { }; const manageListingsLinkMaybe = showCreateListingsLink ? ( - - - +
  • + + + +
  • ) : null; return ( @@ -170,30 +173,26 @@ const TopbarMobileMenu = props => { -
    - - - {notificationCountBadge} - +
      +
    • + + + {notificationCountBadge} + +
    • {manageListingsLinkMaybe} - - - - - - -
    -
    {extraLinks}
    +
  • + + + +
  • +
  • + + + +
  • + +
      {extraLinks}
    {createListingsLinkMaybe}
    From 5ae4962268c9fb27a791f1e37c26ca35356f8065 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Mon, 8 Dec 2025 21:17:01 +0200 Subject: [PATCH 24/39] DatePicker: don't allow event propagation from 'handled' keys --- src/components/DatePicker/DatePickers/DatePicker.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/DatePicker/DatePickers/DatePicker.js b/src/components/DatePicker/DatePickers/DatePicker.js index bd61619c8..efe08b723 100644 --- a/src/components/DatePicker/DatePickers/DatePicker.js +++ b/src/components/DatePicker/DatePickers/DatePicker.js @@ -468,30 +468,39 @@ const DatePicker = props => { if (event.key === 'ArrowLeft') { event.preventDefault(); + event.stopPropagation(); updateCurrentDate(getPreviousDay(currentDate)); } else if (event.key === 'ArrowRight') { event.preventDefault(); + event.stopPropagation(); updateCurrentDate(getNextDay(currentDate)); } else if (event.key === 'ArrowUp') { event.preventDefault(); + event.stopPropagation(); updateCurrentDate(subDays(currentDate, 7)); } else if (event.key === 'ArrowDown') { event.preventDefault(); + event.stopPropagation(); updateCurrentDate(addDays(currentDate, 7)); } else if (event.key === 'PageUp') { event.preventDefault(); + event.stopPropagation(); updateCurrentDate(getPreviousMonth(currentDate)); } else if (event.key === 'PageDown') { event.preventDefault(); + event.stopPropagation(); updateCurrentDate(getNextMonth(currentDate)); } else if (event.key === 'Home') { event.preventDefault(); + event.stopPropagation(); updateCurrentDate(getFirstOfMonth(currentDate)); } else if (event.key === 'End') { event.preventDefault(); + event.stopPropagation(); updateCurrentDate(getLastOfMonth(currentDate)); } else if (event.key === 'Space' || event.key === 'Enter') { event.preventDefault(); + event.stopPropagation(); onSelectDate(currentDate); } }; From 830bb6e0036c97d1f5ca21b242505ea2e7a09443 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Mon, 8 Dec 2025 21:21:18 +0200 Subject: [PATCH 25/39] LocationAutocompleteInput: add role combobox and aria-expanded, aria-controls, aria-activedescendant --- .../LocationAutocompleteInputImpl.js | 17 +++++++++++++++-- .../EditListingPage/EditListingPage.test.js | 6 ++++-- .../NotFoundPage/SearchForm/SearchForm.js | 1 + .../SearchCTA/FilterLocation/FilterLocation.js | 1 + .../Topbar/TopbarSearchForm/TopbarSearchForm.js | 1 + 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/components/LocationAutocompleteInput/LocationAutocompleteInputImpl.js b/src/components/LocationAutocompleteInput/LocationAutocompleteInputImpl.js index 1023aeeee..b01138dc9 100644 --- a/src/components/LocationAutocompleteInput/LocationAutocompleteInputImpl.js +++ b/src/components/LocationAutocompleteInput/LocationAutocompleteInputImpl.js @@ -40,6 +40,7 @@ const getGeocoderVariant = mapProvider => { // Renders the autocompletion prediction results in a list const LocationPredictionsList = props => { const { + id, rootClassName, className, useDarkText, @@ -68,6 +69,8 @@ const LocationPredictionsList = props => { useDarkText ? css.listItemBlackText : css.listItemWhiteText )} key={predictionId} + id={predictionId} + role="option" onTouchStart={e => { e.preventDefault(); onSelectStart(getTouchCoordinates(e.nativeEvent)); @@ -111,8 +114,10 @@ const LocationPredictionsList = props => { ); return ( -
    -
      {predictions.map(item)}
    +
    +
      + {predictions.map(item)} +
    {children}
    ); @@ -505,6 +510,8 @@ class LocationAutocompleteInputImplementation extends Component { ? { ref: inputRef } : {}; + const predictionsId = `${id}.predictions`; + return (
    @@ -540,9 +547,15 @@ class LocationAutocompleteInputImplementation extends Component { title={search} data-testid="location-search" {...ariaLabelMaybe} + role="combobox" + aria-autocomplete="list" + aria-expanded={renderPredictions} + aria-controls={predictionsId} + aria-activedescendant={predictions[this.state.highlightedIndex]?.id} /> {renderPredictions ? ( { // Simulate user selecting subcategory await waitFor(() => { - const selectSubcategory = screen.getAllByRole('combobox')[1]; + // first combobox is location searc, second the first category, third the subcategory + const selectSubcategory = screen.getAllByRole('combobox')[2]; userEvent.selectOptions(selectSubcategory, screen.getByRole('option', { name: 'Adidas' })); }); expect(getByRole('option', { name: 'Adidas' }).selected).toBe(true); @@ -515,7 +516,8 @@ describe('EditListingPage', () => { // Simulate user interaction and select sub level category await waitFor(() => { - const selectSubcategory = screen.getAllByRole('combobox')[1]; + // first combobox is location searc, second the first category, third the subcategory + const selectSubcategory = screen.getAllByRole('combobox')[2]; userEvent.selectOptions(selectSubcategory, screen.getByRole('option', { name: 'Adidas' })); }); expect(getByRole('option', { name: 'Adidas' }).selected).toBe(true); diff --git a/src/containers/NotFoundPage/SearchForm/SearchForm.js b/src/containers/NotFoundPage/SearchForm/SearchForm.js index d058b6279..f2fcb9acd 100644 --- a/src/containers/NotFoundPage/SearchForm/SearchForm.js +++ b/src/containers/NotFoundPage/SearchForm/SearchForm.js @@ -74,6 +74,7 @@ const LocationSearchField = props => { const searchInput = { ...restInput, onChange: searchOnChange }; return ( { return ( { return ( Date: Mon, 8 Dec 2025 21:23:28 +0200 Subject: [PATCH 26/39] SearchPage/SearchResultsPanel: turn results into a list --- .../SearchResultsPanel/SearchResultsPanel.js | 19 ++++++++++--------- .../SearchResultsPanel.module.css | 7 +++++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/containers/SearchPage/SearchResultsPanel/SearchResultsPanel.js b/src/containers/SearchPage/SearchResultsPanel/SearchResultsPanel.js index 6f787c64a..55964c94b 100644 --- a/src/containers/SearchPage/SearchResultsPanel/SearchResultsPanel.js +++ b/src/containers/SearchPage/SearchResultsPanel/SearchResultsPanel.js @@ -74,18 +74,19 @@ const SearchResultsPanel = props => { return (
    -
    +
      {listings.map(l => ( - +
    • + +
    • ))} {props.children} -
    + {paginationLinks}
    ); diff --git a/src/containers/SearchPage/SearchResultsPanel/SearchResultsPanel.module.css b/src/containers/SearchPage/SearchResultsPanel/SearchResultsPanel.module.css index b00581851..6b11625af 100644 --- a/src/containers/SearchPage/SearchResultsPanel/SearchResultsPanel.module.css +++ b/src/containers/SearchPage/SearchResultsPanel/SearchResultsPanel.module.css @@ -7,6 +7,7 @@ .listingCardsMapVariant { padding: 0 0 96px 0; + margin: 0; display: grid; grid-template-columns: repeat(1, 1fr); @@ -22,6 +23,12 @@ } } +.resultItem { + list-style: none; + padding: 0; + margin: 0; +} + .listingCards { padding: 0 0 72px 0; From c83ecc77a78de336a975ac0791e71b177c51573e Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Mon, 8 Dec 2025 21:24:34 +0200 Subject: [PATCH 27/39] FieldSelectTree: add keyboard events: arrow keys, home, end --- .../FieldSelectTree/FieldSelectTree.js | 211 +++++++++++++++++- 1 file changed, 205 insertions(+), 6 deletions(-) diff --git a/src/components/FieldSelectTree/FieldSelectTree.js b/src/components/FieldSelectTree/FieldSelectTree.js index 7fe36ca05..9cf18387a 100644 --- a/src/components/FieldSelectTree/FieldSelectTree.js +++ b/src/components/FieldSelectTree/FieldSelectTree.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { Field } from 'react-final-form'; import { useIntl } from 'react-intl'; import classNames from 'classnames'; @@ -9,6 +9,102 @@ import { ValidationError } from '../../components'; import css from './FieldSelectTree.module.css'; const MIN_LENGTH_FOR_LONG_WORDS = 16; + +/** + * Collects all focusable option buttons in the tree in DFS order. + * + * @param {HTMLElement} container - The container element containing the option buttons + * @returns {Array} Array of focusable option buttons in order + */ +const getAllFocusableOptions = container => { + if (!container) return []; + const buttons = container.querySelectorAll('button'); + return Array.from(buttons).filter(button => { + // Filter out disabled buttons and buttons with negative tabIndex + return !button.disabled && button.tabIndex !== -1; + }); +}; + +/** + * Finds the parent
      element that contains the given button. + * + * @param {HTMLElement} button - The button element + * @returns {HTMLElement|null} The parent
        element or null if not found + */ +const getParentOptionList = (button, container) => { + let parent = button.parentElement; + while (parent && parent.tagName !== 'UL') { + parent = parent.parentElement; + } + if (parent === container.firstChild) { + parent = parent.closest('form'); + } + return parent; +}; + +/** + * Finds the parent option button (the button in the parent
      • that contains the current button's
          ). + * + * @param {HTMLElement} button - The currently focused button + * @param {HTMLElement} container - The container element containing all options + * @returns {HTMLElement|null} The parent option button or null if not found + */ +const getParentOptionButton = (button, container) => { + const parentList = getParentOptionList(button, container); + if (!parentList) return null; + + // Find the parent
        • that contains this
            + const parentLi = parentList.parentElement; + if (!parentLi || parentLi.tagName !== 'LI') return null; + + // Find the button within the parent
          • + const parentButton = parentLi.querySelector('button'); + return parentButton || null; +}; + +/** + * Finds the next focusable option button within the same parent list, with looping. + * + * @param {HTMLElement} currentButton - The currently focused button + * @param {HTMLElement} container - The container element containing all options + * @returns {HTMLElement|null} The next focusable button or null if none exists + */ +const getNextOption = (currentButton, container) => { + const parentList = getParentOptionList(currentButton, container); + if (!parentList) return null; + + const siblingButtons = Array.from(parentList.querySelectorAll('button')).filter( + button => !button.disabled && button.tabIndex !== -1 + ); + const currentIndex = siblingButtons.indexOf(currentButton); + if (currentIndex === -1) return null; + + // Loop to first if at the end + const nextIndex = currentIndex === siblingButtons.length - 1 ? 0 : currentIndex + 1; + return siblingButtons[nextIndex]; +}; + +/** + * Finds the previous focusable option button within the same parent list, with looping. + * + * @param {HTMLElement} currentButton - The currently focused button + * @param {HTMLElement} container - The container element containing all options + * @returns {HTMLElement|null} The previous focusable button or null if none exists + */ +const getPreviousOption = (currentButton, container) => { + const parentList = getParentOptionList(currentButton, container); + if (!parentList) return null; + + const siblingButtons = Array.from(parentList.querySelectorAll('button')).filter( + button => !button.disabled && button.tabIndex !== -1 + ); + const currentIndex = siblingButtons.indexOf(currentButton); + if (currentIndex === -1) return null; + + // Loop to last if at the beginning + const previousIndex = currentIndex === 0 ? siblingButtons.length - 1 : currentIndex - 1; + return siblingButtons[previousIndex]; +}; /** * Pick valid option configurations with format like: * [{ option, label, suboptions: [{ option, label }] }] @@ -37,12 +133,12 @@ const hasSuboptions = optionConfig => getSuboptions(optionConfig)?.length > 0; /** * A component that represents a single option. * - * @param {*} props include: config, level, handleChange, branchPath + * @param {*} props include: config, level, handleChange, branchPath, containerRef * @returns
          • wrapped elements. */ const Option = props => { const intl = useIntl(); - const { config, level, handleChange, branchPath, ancestors = [], ...rest } = props; + const { config, level, handleChange, branchPath, ancestors = [], containerRef, ...rest } = props; const { option, label, suboptions } = config; const foundFromBranchPath = branchPath.find(bc => bc.option === option); const isOptSelected = !!foundFromBranchPath; @@ -72,6 +168,103 @@ const Option = props => { [css.optionBtnSelected]: isOptSelected, [css.optionBtnSelectedLowest]: isOptSelected && !isSuboptionSelected, }); + + const handleKeyDown = e => { + if (!containerRef?.current) return; + + switch (e.key) { + case 'ArrowDown': { + e.preventDefault(); + e.stopPropagation(); + const nextOption = getNextOption(e.target, containerRef.current); + if (nextOption) { + nextOption.focus(); + } + break; + } + case 'ArrowUp': { + e.preventDefault(); + e.stopPropagation(); + const previousOption = getPreviousOption(e.target, containerRef.current); + if (previousOption) { + previousOption.focus(); + } + break; + } + case 'ArrowRight': { + if (hasSuboptions(config) && !isOptSelected) { + e.preventDefault(); + e.stopPropagation(); + // Expand suboptions by selecting this option + handleChange(option, level); + // Focus will move to first child after suboptions are rendered + setTimeout(() => { + const allOptions = getAllFocusableOptions(containerRef.current); + const currentIndex = allOptions.indexOf(e.target); + if (currentIndex !== -1 && currentIndex < allOptions.length - 1) { + allOptions[currentIndex + 1]?.focus(); + } + }, 100); + } + break; + } + case 'ArrowLeft': { + // Find parent button first (before DOM changes) + const parentButton = getParentOptionButton(e.target, containerRef.current); + + // If there's a parent option (level > 1), deselect it + if (level >= 1 && ancestors.length >= 0) { + e.preventDefault(); + e.stopPropagation(); + + if (branchPath.length > 0) { + // Get the parent option (last ancestor) + const parentLevel = level - 1; + + // Clear selections at parent level and below + const updatedValue = branchPath.reduce((picked, selectedOptionByLevel) => { + const { level: branchPathLevel, levelKey } = selectedOptionByLevel; + if (branchPathLevel <= parentLevel && levelKey) { + return { ...picked, [levelKey]: selectedOptionByLevel.option }; + } + return picked; + }, {}); + rest.onChange?.(updatedValue); + } + + // Move focus to parent option button + if (parentButton) { + setTimeout(() => { + parentButton.focus(); + }, 0); + } + } + break; + } + case 'Home': { + e.preventDefault(); + e.stopPropagation(); + const firstOption = getAllFocusableOptions(containerRef.current)?.[0]; + if (firstOption) { + firstOption.focus(); + } + break; + } + case 'End': { + e.preventDefault(); + e.stopPropagation(); + const allOptions = getAllFocusableOptions(containerRef.current); + const lastOption = allOptions?.[allOptions.length - 1]; + if (lastOption) { + lastOption.focus(); + } + break; + } + default: + break; + } + }; + return (
          • ); diff --git a/src/containers/TopbarContainer/Topbar/TopbarDesktop/TopbarDesktop.js b/src/containers/TopbarContainer/Topbar/TopbarDesktop/TopbarDesktop.js index 587ffce63..66d6cf455 100644 --- a/src/containers/TopbarContainer/Topbar/TopbarDesktop/TopbarDesktop.js +++ b/src/containers/TopbarContainer/Topbar/TopbarDesktop/TopbarDesktop.js @@ -64,11 +64,12 @@ const ProfileMenu = ({ currentPage, currentUser, onLogout, showManageListingsLin }; return ( - + From d700f1fb8f44b36d4311cccd0bba9159d955e190 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 9 Dec 2025 16:02:17 +0200 Subject: [PATCH 31/39] PageBuilder/Primitives/SearchCTA/FilterKeyword: use placeholder as aria-label too --- .../Primitives/SearchCTA/FilterKeyword/FilterKeyword.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/containers/PageBuilder/Primitives/SearchCTA/FilterKeyword/FilterKeyword.js b/src/containers/PageBuilder/Primitives/SearchCTA/FilterKeyword/FilterKeyword.js index 078d4ec89..39a607ad1 100644 --- a/src/containers/PageBuilder/Primitives/SearchCTA/FilterKeyword/FilterKeyword.js +++ b/src/containers/PageBuilder/Primitives/SearchCTA/FilterKeyword/FilterKeyword.js @@ -21,6 +21,9 @@ const FilterKeyword = props => { placeholder={intl.formatMessage({ id: 'PageBuilder.SearchCTA.keywordFilterPlaceholder', })} + aria-label={intl.formatMessage({ + id: 'PageBuilder.SearchCTA.keywordFilterPlaceholder', + })} />
    ); From 367c3f9f43a1804bebfa968d5b589fbc94f4bb26 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 9 Dec 2025 16:18:34 +0200 Subject: [PATCH 32/39] SelectMultipleFilter: checkboxes should be wrapped to fieldset and fieldset needs legend We hide the legend on UI, but it's there since accessibility checkers request it. --- src/containers/SearchPage/SearchPage.test.js | 14 +++++++------- .../SelectMultipleFilter/SelectMultipleFilter.js | 9 ++++++--- .../SelectMultipleFilter.module.css | 6 ++++++ 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/containers/SearchPage/SearchPage.test.js b/src/containers/SearchPage/SearchPage.test.js index ff6a04ee8..bdc5ba7a6 100644 --- a/src/containers/SearchPage/SearchPage.test.js +++ b/src/containers/SearchPage/SearchPage.test.js @@ -346,8 +346,8 @@ describe('SearchPage', () => { expect(queryByText('Cat')).not.toBeInTheDocument(); // Has no Boat filter (primary filter tied to 'sell-bicycles' listing type) expect(queryByText('Boat')).not.toBeInTheDocument(); - // Has(!) Amenities filter (secondary filter) - expect(getByText('Amenities')).toBeInTheDocument(); + // Has(!) Amenities filter (secondary filter) (it contains also legend for screen readers) + expect(getAllByText('Amenities')).toHaveLength(2); // Has Single Select Test filter expect(getByText('Single Select Test')).toBeInTheDocument(); expect(getByText('Enum 1')).toBeInTheDocument(); @@ -532,13 +532,13 @@ describe('SearchPage', () => { expect(queryByText('Freshwater')).not.toBeInTheDocument(); }); - // Test category intercation: click "Fish" + // Test category intercation: click "Cats" await waitFor(() => { userEvent.click(getByRole('button', { name: 'Choose Cats.' })); }); - // Has no Cat filter (primary) - expect(getByText('Cat')).toBeInTheDocument(); + // Has Cat filter (enum) using SelectMultipleFilter component (it contains also legend for screen readers) + expect(getAllByText('Cat')).toHaveLength(2); expect(getByText('Dogs')).toBeInTheDocument(); expect(queryByText('Poodle')).not.toBeInTheDocument(); @@ -588,8 +588,8 @@ describe('SearchPage', () => { userEvent.click(getByRole('button', { name: 'Choose Sell bicycles.' })); }); - // Has Boat filter filter (primary) - expect(getByText('Boat')).toBeInTheDocument(); + // Has Boat filter (enum) using SelectMultipleFilter component (it contains also legend for screen readers) + expect(getAllByText('Boat')).toHaveLength(2); }); it('Check that Listing type filter is not revealed when using a listing type path param', async () => { diff --git a/src/containers/SearchPage/SelectMultipleFilter/SelectMultipleFilter.js b/src/containers/SearchPage/SelectMultipleFilter/SelectMultipleFilter.js index a67064bae..a75eeda89 100644 --- a/src/containers/SearchPage/SelectMultipleFilter/SelectMultipleFilter.js +++ b/src/containers/SearchPage/SelectMultipleFilter/SelectMultipleFilter.js @@ -16,9 +16,10 @@ import css from './SelectMultipleFilter.module.css'; // TODO: Live edit didn't work with FieldCheckboxGroup // There's a mutation problem: formstate.dirty is not reliable with it. const GroupOfFieldCheckboxes = props => { - const { id, className, name, options } = props; + const { id, className, name, options, legend } = props; return ( -
    +
    + {legend ? {legend} : null}
      {options.map(optionConfig => { const { option, label } = optionConfig; @@ -30,7 +31,7 @@ const GroupOfFieldCheckboxes = props => { ); })}
    -
    + ); }; @@ -139,6 +140,7 @@ const SelectMultipleFilter = props => { name={name} id={`${id}-checkbox-group`} options={options} + legend={label} /> ) : ( @@ -160,6 +162,7 @@ const SelectMultipleFilter = props => { name={name} id={`${id}-checkbox-group`} options={options} + legend={label} /> ); diff --git a/src/containers/SearchPage/SelectMultipleFilter/SelectMultipleFilter.module.css b/src/containers/SearchPage/SelectMultipleFilter/SelectMultipleFilter.module.css index 490f4eb69..bf97c0047 100644 --- a/src/containers/SearchPage/SelectMultipleFilter/SelectMultipleFilter.module.css +++ b/src/containers/SearchPage/SelectMultipleFilter/SelectMultipleFilter.module.css @@ -23,6 +23,12 @@ } } +.accessibilityLegend { + visibility: hidden; + position: absolute; + left: -9999px; +} + .list { margin: 0; } From e66d2c69e877de02932e3e34e13317c9bbe72e9c Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 9 Dec 2025 16:19:59 +0200 Subject: [PATCH 33/39] TopbarSearchForm: be explicit about button being a submit button --- .../TopbarContainer/Topbar/TopbarSearchForm/TopbarSearchForm.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/containers/TopbarContainer/Topbar/TopbarSearchForm/TopbarSearchForm.js b/src/containers/TopbarContainer/Topbar/TopbarSearchForm/TopbarSearchForm.js index 52e9139fa..ba5e9f8db 100644 --- a/src/containers/TopbarContainer/Topbar/TopbarSearchForm/TopbarSearchForm.js +++ b/src/containers/TopbarContainer/Topbar/TopbarSearchForm/TopbarSearchForm.js @@ -53,6 +53,7 @@ const LocationSearchField = props => { From 862e0ab0176963fb04d12b27cc16c9a937ff0f83 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 9 Dec 2025 18:25:37 +0200 Subject: [PATCH 34/39] FilterPopupForSidebar: add handlers for arrow up and down on label toggle --- .../BookingDateRangeFilter/FilterPopupForSidebar.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/containers/SearchPage/BookingDateRangeFilter/FilterPopupForSidebar.js b/src/containers/SearchPage/BookingDateRangeFilter/FilterPopupForSidebar.js index cc34cded7..6b5efa9b3 100644 --- a/src/containers/SearchPage/BookingDateRangeFilter/FilterPopupForSidebar.js +++ b/src/containers/SearchPage/BookingDateRangeFilter/FilterPopupForSidebar.js @@ -101,6 +101,14 @@ class FilterPopupForSidebar extends Component { button.focus(); } + this.toggleOpen(false); + } else if (e.key === 'ArrowDown' && this.filter.contains(e.target) && !this.state.isOpen) { + e.preventDefault(); + e.stopPropagation(); + this.toggleOpen(true); + } else if (e.key === 'ArrowUp' && this.filter.contains(e.target) && this.state.isOpen) { + e.preventDefault(); + e.stopPropagation(); this.toggleOpen(false); } } From 311eabd61dd9bb76670739cddd8c97d46d6e2e01 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 9 Dec 2025 21:38:05 +0200 Subject: [PATCH 35/39] ContactDetailsForm, DeleteAccountForm, PasswordChangeForm should disable currentPassword input until the requirements are met. --- .../ContactDetailsForm/ContactDetailsForm.js | 1 + .../ManageAccountPage/DeleteAccountForm/DeleteAccountForm.js | 3 ++- .../PasswordChangeForm/PasswordChangeForm.js | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/containers/ContactDetailsPage/ContactDetailsForm/ContactDetailsForm.js b/src/containers/ContactDetailsPage/ContactDetailsForm/ContactDetailsForm.js index 1be69e10f..edc48f49a 100644 --- a/src/containers/ContactDetailsPage/ContactDetailsForm/ContactDetailsForm.js +++ b/src/containers/ContactDetailsPage/ContactDetailsForm/ContactDetailsForm.js @@ -405,6 +405,7 @@ class ContactDetailsFormComponent extends Component { type="password" name="currentPassword" id={formId ? `${formId}.currentPassword` : 'currentPassword'} + disabled={!emailChanged} autoComplete="current-password" label={passwordLabel} placeholder={passwordPlaceholder} diff --git a/src/containers/ManageAccountPage/DeleteAccountForm/DeleteAccountForm.js b/src/containers/ManageAccountPage/DeleteAccountForm/DeleteAccountForm.js index 21b86bbd2..8447eb2a8 100644 --- a/src/containers/ManageAccountPage/DeleteAccountForm/DeleteAccountForm.js +++ b/src/containers/ManageAccountPage/DeleteAccountForm/DeleteAccountForm.js @@ -165,7 +165,7 @@ const DeleteAccountForm = props => { />

    -
    +

    @@ -183,6 +183,7 @@ const DeleteAccountForm = props => { type="password" name="currentPassword" id={formId ? `${formId}.currentPassword` : 'currentPassword'} + disabled={!deleteAccountConfirmed} autoComplete="current-password" label={intl.formatMessage({ id: 'DeleteAccountForm.passwordLabel', diff --git a/src/containers/PasswordChangePage/PasswordChangeForm/PasswordChangeForm.js b/src/containers/PasswordChangePage/PasswordChangeForm/PasswordChangeForm.js index 6a87d606b..2882e515b 100644 --- a/src/containers/PasswordChangePage/PasswordChangeForm/PasswordChangeForm.js +++ b/src/containers/PasswordChangePage/PasswordChangeForm/PasswordChangeForm.js @@ -233,6 +233,7 @@ class PasswordChangeForm extends Component { type="password" id="currentPassword" name="currentPassword" + disabled={pristine} autoComplete="current-password" label={passwordLabel} placeholder={passwordPlaceholder} From 9299d9dd8a0df8b864708a51a96227c36f9870a0 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Wed, 10 Dec 2025 19:06:04 +0200 Subject: [PATCH 36/39] Add 'skip to main content' feature --- .../LayoutSideNavigation.js | 4 ++- .../LayoutSideNavigation.module.css | 3 ++ .../LayoutSingleColumn/LayoutSingleColumn.js | 2 +- .../LayoutSingleColumn.module.css | 6 ++++ src/containers/PageBuilder/PageBuilder.js | 2 +- .../PageBuilder/PageBuilder.module.css | 4 +++ .../SearchPage/SearchPage.module.css | 3 ++ .../SearchPage/SearchPageWithGrid.js | 2 +- .../SearchPage/SearchPageWithMap.js | 2 +- .../TopbarContainer/Topbar/Topbar.js | 24 +++++++++++++++ .../TopbarContainer/Topbar/Topbar.module.css | 30 +++++++++++++++++++ src/translations/en.json | 1 + 12 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/components/LayoutComposer/LayoutSideNavigation/LayoutSideNavigation.js b/src/components/LayoutComposer/LayoutSideNavigation/LayoutSideNavigation.js index 8ad882db4..7e5e69e59 100644 --- a/src/components/LayoutComposer/LayoutSideNavigation/LayoutSideNavigation.js +++ b/src/components/LayoutComposer/LayoutSideNavigation/LayoutSideNavigation.js @@ -73,7 +73,9 @@ const LayoutSideNavigation = props => { ) : null} {sideNavContent} -
    {children}
    +
    + {children} +
    {footerContent}
    diff --git a/src/components/LayoutComposer/LayoutSideNavigation/LayoutSideNavigation.module.css b/src/components/LayoutComposer/LayoutSideNavigation/LayoutSideNavigation.module.css index da2facf44..9e1fa4f13 100644 --- a/src/components/LayoutComposer/LayoutSideNavigation/LayoutSideNavigation.module.css +++ b/src/components/LayoutComposer/LayoutSideNavigation/LayoutSideNavigation.module.css @@ -40,6 +40,7 @@ flex-direction: column; padding: 24px; + scroll-margin-top: var(--topbarHeight); @media (--viewportLarge) { /** @@ -51,6 +52,8 @@ border-left-width: 1px; border-left-style: solid; border-left-color: var(--colorGrey100); + + scroll-margin-top: var(--topbarHeightDesktop); } @media (--viewportLargeWithPaddings) { diff --git a/src/components/LayoutComposer/LayoutSingleColumn/LayoutSingleColumn.js b/src/components/LayoutComposer/LayoutSingleColumn/LayoutSingleColumn.js index c22cfaf78..a0dd94399 100644 --- a/src/components/LayoutComposer/LayoutSingleColumn/LayoutSingleColumn.js +++ b/src/components/LayoutComposer/LayoutSingleColumn/LayoutSingleColumn.js @@ -45,7 +45,7 @@ const LayoutSingleColumn = props => { {topbarContent} -
    +
    {children}
    {footerContent}
    diff --git a/src/components/LayoutComposer/LayoutSingleColumn/LayoutSingleColumn.module.css b/src/components/LayoutComposer/LayoutSingleColumn/LayoutSingleColumn.module.css index 721b11757..af3544388 100644 --- a/src/components/LayoutComposer/LayoutSingleColumn/LayoutSingleColumn.module.css +++ b/src/components/LayoutComposer/LayoutSingleColumn/LayoutSingleColumn.module.css @@ -1,3 +1,4 @@ +@import '../../../styles/customMediaQueries.css'; .root { grid-template-rows: auto 1fr auto; min-height: 100vh; @@ -11,4 +12,9 @@ .main { display: grid; + scroll-margin-top: var(--topbarHeight); + + @media (--viewportMedium) { + scroll-margin-top: var(--topbarHeightDesktop); + } } diff --git a/src/containers/PageBuilder/PageBuilder.js b/src/containers/PageBuilder/PageBuilder.js index b20d5f7d8..152e9dbab 100644 --- a/src/containers/PageBuilder/PageBuilder.js +++ b/src/containers/PageBuilder/PageBuilder.js @@ -136,7 +136,7 @@ const PageBuilder = props => { -
    +
    {sections.length === 0 && inProgress ? ( ) : ( diff --git a/src/containers/PageBuilder/PageBuilder.module.css b/src/containers/PageBuilder/PageBuilder.module.css index a42b64dd7..cc2e650ca 100644 --- a/src/containers/PageBuilder/PageBuilder.module.css +++ b/src/containers/PageBuilder/PageBuilder.module.css @@ -11,6 +11,10 @@ .main { display: grid; + scroll-margin-top: var(--topbarHeight); + @media (--viewportMedium) { + scroll-margin-top: var(--topbarHeightDesktop); + } } .loading { diff --git a/src/containers/SearchPage/SearchPage.module.css b/src/containers/SearchPage/SearchPage.module.css index f19a78083..39a74aae1 100644 --- a/src/containers/SearchPage/SearchPage.module.css +++ b/src/containers/SearchPage/SearchPage.module.css @@ -32,6 +32,7 @@ position: relative; padding-top: var(--topbarHeightDesktop); min-height: calc(100vh - var(--topbarHeightDesktop)); + scroll-margin-top: var(--topbarHeightDesktop); } } @@ -151,6 +152,8 @@ border-left-width: 1px; border-left-style: solid; border-left-color: var(--colorGrey100); + + scroll-margin-top: var(--topbarHeightDesktop); } @media (--viewportLarge) { diff --git a/src/containers/SearchPage/SearchPageWithGrid.js b/src/containers/SearchPage/SearchPageWithGrid.js index 389e73119..245abf76d 100644 --- a/src/containers/SearchPage/SearchPageWithGrid.js +++ b/src/containers/SearchPage/SearchPageWithGrid.js @@ -420,7 +420,7 @@ export class SearchPageComponent extends Component {
    -
    +
    -
    +
    {
    ); + const handleSkipToMainContent = e => { + e.preventDefault(); + const mainContent = document.getElementById('main-content'); + if (mainContent) { + mainContent.scrollIntoView({ behavior: 'smooth', block: 'start' }); + // Focus the main content for screen readers + mainContent.setAttribute('tabindex', '-1'); + mainContent.focus(); + // Remove tabindex after blur to avoid tabbing into it later + mainContent.addEventListener( + 'blur', + () => { + mainContent.removeAttribute('tabindex'); + }, + { once: true } + ); + } + }; + return (
    +
    ); }; - -const LocationSearchField = props => { - const { desktopInputRootClass, intl, isMobile = false, inputRef, onLocationChange } = props; - const submitButton = ({}) => ( +const SubmitButton = props => { + const intl = useIntl(); + return ( ); +}; + +const LocationSearchField = props => { + const { desktopInputRootClass, intl, isMobile = false, inputRef, onLocationChange } = props; return ( { inputRef={inputRef} input={{ ...restInput, onChange: searchOnChange }} meta={meta} - submitButton={submitButton} + submitButton={SubmitButton} ariaLabel={intl.formatMessage({ id: 'TopbarDesktop.screenreader.search' })} /> ); From 7b490ea37a08d064979d897a75764b224eb6c657 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Wed, 17 Dec 2025 17:39:53 +0200 Subject: [PATCH 39/39] wip: change number inputs to text inputs to avoid clash between arrow up and down keyboard behaviour. --- src/containers/SearchPage/FilterPlain/FilterPlain.js | 5 +++-- src/containers/SearchPage/FilterPopup/FilterPopup.js | 6 ++++-- .../IntegerRangeFilter/FieldSelectIntegerRange.js | 8 ++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/containers/SearchPage/FilterPlain/FilterPlain.js b/src/containers/SearchPage/FilterPlain/FilterPlain.js index 1d24c87fb..2111a7a7e 100644 --- a/src/containers/SearchPage/FilterPlain/FilterPlain.js +++ b/src/containers/SearchPage/FilterPlain/FilterPlain.js @@ -133,6 +133,7 @@ class FilterPlainComponent extends Component { const formId = `${id}.form`; const classes = classNames(rootClassName || css.root, className); const inertMaybe = this.state.isOpen ? {} : { inert: '' }; + const isInput = element => element?.tagName?.toLowerCase() === 'input'; return ( element?.tagName?.toLowerCase() === 'input'; + return ( { [css.valueInSidebar]: isInSideBar, [css.invalidInput]: isMinInvalid, })} - type="number" + inputMode="numeric" + pattern="\d*" name={`${name}_min`} min={defaultMinValue} max={defaultMaxValue} - step={step} placeholder={defaultMinValue} value={fieldValues.minValue} onChange={handleMinValueChange} @@ -230,12 +230,12 @@ const RangeInput = props => { [css.valueInSidebar]: isInSideBar, [css.invalidInput]: isMaxInvalid, })} - type="number" + inputMode="numeric" + pattern="\d*" name={`${name}_max`} min={defaultMinValue} max={defaultMaxValue} placeholder={defaultMaxValue} - step={step} value={fieldValues.maxValue} onChange={handleMaxValueChange} onBlur={handleMaxValueBlur}