Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
7af81c9
Routes.js: if window scope has __focusedElementId__ set,
Gnito Dec 1, 2025
dd15ecd
Menu: add focus handling when menu-item is selected
Gnito Dec 2, 2025
d686c2e
Menu: take focus handling into use in different menus
Gnito Dec 2, 2025
5c55e1a
NamedLink: add focus handling with id and holdFocus props
Gnito Dec 2, 2025
03c7448
NamedLink: add some id to named links on Topbar
Gnito Dec 2, 2025
c15f05d
EditListingAvailabilityPanel/WeeklyCalendar: add aria-label to button…
Gnito Dec 2, 2025
25f5df4
EditListingPage/ListingImage: add aria-label to remove image button
Gnito Dec 2, 2025
ad73b3b
PageBuilder/SearchCTA/FilterCategories: use placeholder text as aria-…
Gnito Nov 26, 2025
8d4703f
PageBuilder/FilterCategories: improve UI for active option
Gnito Dec 2, 2025
b8aa889
Filters on search page: improve focus highlighting
Gnito Dec 4, 2025
c31f59f
Filters on search page: remove form's tabIndex and add
Gnito Dec 4, 2025
f3640c8
FilterForm: move clear button inside filter form
Gnito Dec 4, 2025
a579bfa
PaginationLinks: describe pagination page and next/prev buttons bette…
Gnito Dec 4, 2025
fc578ae
Menu: add keyboard actions for ArrowDown, ArrowUp, Home and End
Gnito Dec 8, 2025
272c3e8
BookingDateRangeFilter/FilterPopupForSidebar: keep focus context with…
Gnito Dec 8, 2025
c25b973
SearchPage: add KeyboardListener component
Gnito Dec 8, 2025
1e6cecc
FilterPopup: add keyboard listeners for Enter, ArrowDown, ArrowUp, Ho…
Gnito Dec 8, 2025
a409d04
FilterPlain: add keyboard listeners for Enter, ArrowDown, ArrowUp, Ho…
Gnito Dec 8, 2025
415e938
Add proactively containerId to filters, so that their context can be …
Gnito Dec 9, 2025
2a236ad
Move KeyboardListener to components directory
Gnito Dec 8, 2025
0840cc6
FilterPopup: close open filters, when focus moves away
Gnito Dec 9, 2025
b115b46
CustomLinksMenu: use NewListingPage route instead of manually resolvi…
Gnito Dec 8, 2025
98252a1
TopbarMobileMenu: convert links into proper link lists
Gnito Dec 8, 2025
5ae4962
DatePicker: don't allow event propagation from 'handled' keys
Gnito Dec 8, 2025
830bb6e
LocationAutocompleteInput: add role combobox and aria-expanded, aria-…
Gnito Dec 8, 2025
5eea2c7
SearchPage/SearchResultsPanel: turn results into a list
Gnito Dec 8, 2025
c83ecc7
FieldSelectTree: add keyboard events: arrow keys, home, end
Gnito Dec 8, 2025
e7bb682
SearchPage/SearchFiltersPrimary: add arrow up and down keyboard handlers
Gnito Dec 8, 2025
6975fef
TopbarSearchForm: separate ids for mobile and desktop
Gnito Dec 9, 2025
d2a7df6
Menu: Move aria-label to active element (label button) instead of con…
Gnito Dec 9, 2025
d700f1f
PageBuilder/Primitives/SearchCTA/FilterKeyword: use placeholder as ar…
Gnito Dec 9, 2025
367c3f9
SelectMultipleFilter: checkboxes should be wrapped to fieldset and fi…
Gnito Dec 9, 2025
e66d2c6
TopbarSearchForm: be explicit about button being a submit button
Gnito Dec 9, 2025
862e0ab
FilterPopupForSidebar: add handlers for arrow up and down on label to…
Gnito Dec 9, 2025
311eabd
ContactDetailsForm, DeleteAccountForm, PasswordChangeForm should disable
Gnito Dec 9, 2025
9299d9d
Add 'skip to main content' feature
Gnito Dec 10, 2025
162fc73
skip focus on topbar menu on navigation: skip to content feature shou…
Gnito Dec 29, 2025
66ae87e
Fix: tab-navigation ending up to undefined element due to unnecessari…
Gnito Dec 12, 2025
7b490ea
wip: change number inputs to text inputs
Gnito Dec 17, 2025
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
10 changes: 10 additions & 0 deletions src/components/DatePicker/DatePickers/DatePicker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -467,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);
}
};
Expand Down
211 changes: 205 additions & 6 deletions src/components/FieldSelectTree/FieldSelectTree.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<HTMLElement>} 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 <ul> element that contains the given button.
*
* @param {HTMLElement} button - The button element
* @returns {HTMLElement|null} The parent <ul> 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 <li> that contains the current button's <ul>).
*
* @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 <li> that contains this <ul>
const parentLi = parentList.parentElement;
if (!parentLi || parentLi.tagName !== 'LI') return null;

// Find the button within the parent <li>
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 }] }]
Expand Down Expand Up @@ -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 <li> 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;
Expand Down Expand Up @@ -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 (
<li className={css.option} style={{ paddingLeft: `${12}px`, ...cursorMaybe }}>
<button
Expand All @@ -83,6 +276,7 @@ const Option = props => {
handleChange(option, level);
}
}}
onKeyDown={handleKeyDown}
aria-label={ariaLabel}
>
{optionLabel}
Expand All @@ -95,6 +289,7 @@ const Option = props => {
handleChange={handleChange}
branchPath={branchPath}
ancestors={[...ancestors, { option, label }]}
containerRef={containerRef}
{...rest}
/>
) : null}
Expand All @@ -120,11 +315,11 @@ const OptionList = props => {
* @returns OptionList component.
*/
const SelectOptions = props => {
const { options, ...rest } = props;
const { options, containerRef, ...rest } = props;
return (
<OptionList hasOptions={options?.length > 0}>
{options.map(config => (
<Option key={config.option} config={config} {...rest} />
<Option key={config.option} config={config} containerRef={containerRef} {...rest} />
))}
</OptionList>
);
Expand Down Expand Up @@ -215,6 +410,7 @@ const FieldSelectTree = props => {
const namePrefix = name;
const level = 1;
const validOptions = pickValidOptions(options);
const containerRef = useRef(null);

const classes = classNames(rootClassName || css.root, className);

Expand All @@ -227,13 +423,16 @@ const FieldSelectTree = props => {
const meta = fieldProps?.meta;

return (
<div className={classes}>
<div ref={containerRef} className={classes}>
{label ? <label>{label}</label> : null}
<SelectOptions
options={validOptions}
level={level}
handleChange={handleChangeFn(validOptions, fieldValue, namePrefix, onChange)}
branchPath={branchPath}
containerRef={containerRef}
namePrefix={namePrefix}
onChange={onChange}
/>

<ValidationError fieldMeta={meta} />
Expand Down
1 change: 0 additions & 1 deletion src/components/FieldSelectTree/FieldSelectTree.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@
}

.optionBtnSelectedLowest {
&:focus,
&:hover {
color: var(--marketplaceColor);
}
Expand Down
47 changes: 47 additions & 0 deletions src/components/KeyboardListener/KeyboardListener.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React, { useEffect, useRef } from 'react';

/**
* KeyboardListener component
*
* @component
* @param {Object} props
* @param {Object} props.keyMap - The key map to use for the keyboard listener
* @param {string} props.containerId - The container ID to pass to callback functions
* @param {React.Node} props.children - The children to render
* @returns {JSX.Element}
*/
const KeyboardListener = props => {
const { children, containerId, keyMap, ...rest } = props;
const containerRef = useRef(null);

useEffect(() => {
const handleKeyDown = event => {
// Only handle events that originated from this component's descendants
if (!containerRef.current || !containerRef.current.contains(event.target)) {
return;
}

if (keyMap && keyMap[event.key]) {
const { action, callback } = keyMap[event.key];

if (action && callback) {
callback(event, containerId, action);
}
}
};

document.addEventListener('keydown', handleKeyDown);

return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [containerId, keyMap]);

return (
<div ref={containerRef} {...rest}>
{children}
</div>
);
};

export default KeyboardListener;
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ const LayoutSideNavigation = props => {
) : null}
{sideNavContent}
</aside>
<main className={classNames(css.main, mainColumnClassName)}>{children}</main>
<main id="main-content" className={classNames(css.main, mainColumnClassName)}>
{children}
</main>
</Main>
<Footer>{footerContent}</Footer>
</>
Expand Down
Loading