Skip to content

Commit

Permalink
Fixes #35601 - Search in navigation
Browse files Browse the repository at this point in the history
  • Loading branch information
MariaAga committed Oct 17, 2023
1 parent 7585e55 commit bdd6dcc
Show file tree
Hide file tree
Showing 8 changed files with 296 additions and 59 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ const subItemsA = [
href: '/c',
id: 'menu_item_cc',
},

{
title: 'Ac',
isDivider: false,
onClick: mockOnClick,
href: '/ac',
id: 'menu_item_ac',
},
];
const subItemsB = [
{
Expand All @@ -26,7 +34,7 @@ const subItemsB = [
},
];

const PFitems = [
export const PFitems = [
{
title: 'Monitor',
initialActive: true,
Expand All @@ -40,31 +48,6 @@ const PFitems = [
subItems: subItemsB,
},
];
// Server Hash Data
const monitorChildren = [
{
type: 'item',
name: 'Dashboard',
title: 'Dashboard',
exact: true,
url: '/',
},
{
type: 'item',
name: 'Facts',
title: 'Facts',
url: '/fact_values',
},
];

const hostsChildren = [
{
type: 'item',
name: 'All Hosts',
title: 'All Hosts',
url: '/hosts/new',
},
];

const namelessChildren = [
{
Expand All @@ -76,21 +59,6 @@ const namelessChildren = [
},
];

const hashItemsA = [
{
type: 'sub_menu',
name: 'Monitor',
icon: 'fa fa-tachometer',
children: monitorChildren,
},
{
type: 'sub_menu',
name: 'Hosts',
icon: 'fa fa-server',
children: hostsChildren,
},
];

export const hashItemNameless = [
{
type: 'sub_menu',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ const childToMenuItem = (child, currentLocation, currentOrganization) => ({
child.title === currentLocation || child.title === currentOrganization
? 'mobile-active'
: '',
href: child.url || '#',
preventHref: true,
href: child.url,
onClick: child.onClick || null,
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
NavItemSeparator,
} from '@patternfly/react-core';
import { getCurrentPath } from './LayoutHelper';
import { NavigationSearch } from './NavigationSearch';

const titleWithIcon = (title, iconClass) => (
<div>
Expand Down Expand Up @@ -45,7 +46,7 @@ const Navigation = ({

items.forEach(item => {
item.subItems.forEach(subItem => {
if (!subItem.isDivider) {
if (!subItem.isDivider && subItem.href) {
// don't keep the query parameters for the key
subItemToItemMap[pathFragment(subItem.href)] = item.title;
}
Expand All @@ -70,7 +71,7 @@ const Navigation = ({
} else {
groups[currIndex].groupItems.push({
...sub,
isActive: currentPath === sub.href.split('?')[0],
isActive: currentPath === sub.href?.split('?')[0],
});
}
});
Expand All @@ -81,6 +82,8 @@ const Navigation = ({
[items.length, currentPath]
);

if (!items.length) return null;

const clickAndNavigate = (_onClick, href, event) => {
if (event.ctrlKey) return;
event.preventDefault();
Expand All @@ -95,6 +98,7 @@ const Navigation = ({
return (
<Nav id="foreman-nav" ouiaId="foreman-nav">
<NavList>
<NavigationSearch clickAndNavigate={clickAndNavigate} items={items} />
{groupedItems.map(({ title, iconClass, groups, className }, index) => (
<React.Fragment key={index}>
<NavExpandable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import {
Menu,
MenuContent,
MenuItem,
MenuList,
Popper,
SearchInput,
} from '@patternfly/react-core';
import { translate as __ } from '../../common/I18n';

export const NavigationSearch = ({ items, clickAndNavigate }) => {
const navLinks = {};
let parent = null;
items.forEach(item => {
item.subItems.forEach(group => {
if (group.isDivider) {
parent = group.title;
} else {
navLinks[group.title] = {
...group,
parents: [item.title, parent].filter(Boolean),
};
}
});
parent = null;
});

const navItems = Object.keys(navLinks);
const menuNav = navItem => (
<MenuItem
to={navLinks[navItem].href}
onClick={event =>
clickAndNavigate(
navLinks[navItem].onClick,
navLinks[navItem].href,
event
)
}
itemId={navItem}
key={navItem}
description={[...navLinks[navItem].parents, navItem].join(' > ')}
>
{navItem}
</MenuItem>
);
const [autocompleteOptions, setAutocompleteOptions] = useState(
navItems.slice(0, 10).map(menuNav)
);
const [value, setValue] = useState('');
const [hint, setHint] = useState('');

const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false);

const searchInputRef = useRef(null);
const autocompleteRef = useRef(null);

const onClear = () => {
setValue('');
};

const onChange = newValue => {
if (
newValue !== '' &&
searchInputRef?.current?.contains(document.activeElement)
) {
setIsAutocompleteOpen(true);

// When the value of the search input changes, build a list of no more than 10 autocomplete options.
// Options which start with the search input value are listed first, followed by options which contain
// the search input value.
let options = navItems
.filter(option => option.toLowerCase().includes(newValue.toLowerCase()))
.map(menuNav);
if (options.length > 10) {
options = options.slice(0, 10);
}

// The menu is hidden if there are no options
setIsAutocompleteOpen(options.length > 0);

setAutocompleteOptions(options);
} else {
setIsAutocompleteOpen(false);
}
setValue(newValue);
};
// Whenever an autocomplete option is selected, set the search input value, close the menu, and put the browser
// focus back on the search input
const onSelect = e => {
e.stopPropagation();
setIsAutocompleteOpen(false);
};

React.useEffect(() => {
const handleClick = event => {
if (
isAutocompleteOpen &&
autocompleteRef?.current &&
!autocompleteRef.current.contains(event.target)
) {
// The autocomplete menu should close if the user clicks outside the menu.
setIsAutocompleteOpen(false);
} else if (
!isAutocompleteOpen &&
searchInputRef?.current &&
searchInputRef.current.contains(event.target)
) {
// The autocomplete menu should open if the user clicks on the search input.
setIsAutocompleteOpen(true);
}
};
const handleMenuKeys = event => {
// If there is a hint while the browser focus is on the search input, tab or right arrow will 'accept' the hint value
// and set it as the search input value
if (
hint &&
(event.key === 'Tab' || event.key === 'ArrowRight') &&
searchInputRef.current === event.target
) {
setValue(hint);
setHint('');
setIsAutocompleteOpen(false);
if (event.key === 'ArrowRight') {
event.preventDefault();
}
// if the autocomplete is open and the browser focus is on the search input,
} else if (
isAutocompleteOpen &&
searchInputRef.current &&
searchInputRef.current === event.target
) {
// the escape key closes the autocomplete menu and keeps the focus on the search input.
if (event.key === 'Escape') {
setIsAutocompleteOpen(false);
searchInputRef.current.focus();
// the up and down arrow keys move browser focus into the autocomplete menu
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
const firstElement = autocompleteRef.current.querySelector(
'li > a:not(:disabled)'
);
firstElement && firstElement.focus();
event.preventDefault(); // by default, the up and down arrow keys scroll the window
// the tab, enter, and space keys will close the menu, and the tab key will move browser
// focus forward one element (by default)
} else if (
event.key === 'Tab' ||
event.key === 'Enter' ||
event.key === 'Space'
) {
setIsAutocompleteOpen(false);
if (event.key === 'Enter' || event.key === 'Space') {
event.preventDefault();
}
}
// If the autocomplete is open and the browser focus is in the autocomplete menu
// hitting tab will close the autocomplete and but browser focus back on the search input.
} else if (
isAutocompleteOpen &&
autocompleteRef?.current?.contains(event.target) &&
event.key === 'Tab'
) {
event.preventDefault();
setIsAutocompleteOpen(false);
searchInputRef.current.focus();
}
};
window.addEventListener('keydown', handleMenuKeys);
window.addEventListener('click', handleClick);
return () => {
window.removeEventListener('keydown', handleMenuKeys);
window.removeEventListener('click', handleClick);
};
}, [isAutocompleteOpen, hint]);

const searchInput = (
<SearchInput
value={value}
placeholder={__('Search and go')}
onChange={onChange}
onClear={onClear}
ref={searchInputRef}
hint={hint}
id="navigation-search"
/>
);

const autocomplete = (
<Menu
ouiaId="navigation-search-menu"
ref={autocompleteRef}
onSelect={onSelect}
className="navigation-search-menu"
>
<MenuContent>
<MenuList>{autocompleteOptions}</MenuList>
</MenuContent>
</Menu>
);

return (
<Popper
trigger={searchInput}
popper={autocomplete}
isVisible={isAutocompleteOpen}
enableFlip={false}
// append the autocomplete menu to the search input in the DOM for the sake of the keyboard navigation experience
appendTo={() => document.querySelector('#navigation-search')}
/>
);
};

NavigationSearch.propTypes = {
items: PropTypes.array.isRequired,
clickAndNavigate: PropTypes.func.isRequired,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from 'react';
import { act, render, fireEvent } from '@testing-library/react';
import { NavigationSearch } from '../NavigationSearch';
import { PFitems } from '../Layout.fixtures';

describe('NavigationSearch', () => {
const items = PFitems;
it('should display autocomplete options when input is typed', async () => {
const {
queryAllByRole,
getByPlaceholderText,
getByRole,
getByLabelText,
} = render(
<div className="pf-c-masthead pf-m-display-inline">
<NavigationSearch items={items} clickAndNavigate={() => {}} />
</div>
);
const input = getByPlaceholderText('Search and go');
act(() => input.focus());
await act(async () => {
await fireEvent.change(input, { target: { value: 'a' } });
});
expect(queryAllByRole('menuitem')).toHaveLength(2);
});
});
Loading

0 comments on commit bdd6dcc

Please sign in to comment.