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 Jun 29, 2023
1 parent 9633d3c commit a18ee38
Show file tree
Hide file tree
Showing 8 changed files with 312 additions and 63 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,7 +82,11 @@ const Navigation = ({
[items.length, currentPath]
);

const clickAndNavigate = (_onClick, href) => {
if (!items.length) return null;

const clickAndNavigate = (_onClick, href, event) => {
if (event.ctrlKey) return;
event.preventDefault();
if (_onClick && typeof _onClick === 'function') {
_onClick();
} else {
Expand All @@ -93,6 +98,7 @@ const Navigation = ({
return (
<Nav id="foreman-nav">
<NavList>
<NavigationSearch clickAndNavigate={clickAndNavigate} items={items} />
{groupedItems.map(({ title, iconClass, groups, className }, index) => (
<React.Fragment key={index}>
<NavExpandable
Expand Down Expand Up @@ -128,8 +134,10 @@ const Navigation = ({
<NavItem
className={subItemClassName}
id={id}
// to={href}
onClick={() => clickAndNavigate(onClick, href)}
to={href}
onClick={event =>
clickAndNavigate(onClick, href, event)
}
isActive={isActive}
>
{subItemTitle}
Expand Down Expand Up @@ -166,7 +174,9 @@ const Navigation = ({
className={subItemClassName}
id={id}
to={href}
onClick={() => clickAndNavigate(onClick, href)}
onClick={event =>
clickAndNavigate(onClick, href, event)
}
isActive={isActive}
>
{subItemTitle}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import React, { useState, useEffect, 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 [value, setValue] = React.useState('');
const [isExpanded, setIsExpanded] = useState(false);
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false);
const onToggleExpand = (_event, _isExpanded) => {
setIsExpanded(!_isExpanded);
if (_isExpanded) {
setIsAutocompleteOpen(false);
}
};
const [autocompleteOptions, setAutocompleteOptions] = useState(
navItems.slice(0, 10).map(menuNav)
);

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

const onChange = newValue => {
if (
newValue !== '' &&
searchInputRef &&
searchInputRef.current &&
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);
} else {
options = [
...options,
...navItems
.filter(
option =>
!option.includes(newValue.toLowerCase()) &&
option.includes(newValue.toLowerCase())
)
.map(menuNav),
].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, itemId) => {
e.stopPropagation();
setValue(itemId);
setIsAutocompleteOpen(false);
searchInputRef.current.focus();
};

useEffect(() => {
const handleMenuKeys = event => {
// keyboard shortcut to focus the search, will not focus if the key is typed into an input
if (
event.key === '/' &&
event.target.tagName !== 'INPUT' &&
event.target.tagName !== 'TEXTAREA'
) {
event.preventDefault();
searchInputRef.current.focus();
}
// if the autocomplete is open and the browser focus is on the search input,
else if (isAutocompleteOpen && 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
} else if (
autocompleteRef?.current?.contains(event.target) &&
event.key === 'Tab'
) {
event.preventDefault();

setIsAutocompleteOpen(false);
searchInputRef.current.focus();
}
}
};
// The autocomplete menu should close if the user clicks outside the menu.
const handleClickOutside = event => {
if (
isAutocompleteOpen &&
autocompleteRef?.current &&
!autocompleteRef.current.contains(event.target) &&
searchInputRef?.current &&
!searchInputRef.current.contains(event.target)
) {
setIsAutocompleteOpen(false);
}
};
window.addEventListener('keydown', handleMenuKeys);
window.addEventListener('click', handleClickOutside);

return () => {
window.removeEventListener('keydown', handleMenuKeys);
window.removeEventListener('click', handleClickOutside);
};
}, [isAutocompleteOpen]);

const searchInput = (
<SearchInput
className="navigation-search"
placeholder={__('Search and go')}
value={value}
onChange={onChange}
expandableInput={{
isExpanded,
onToggleExpand,
toggleAriaLabel: 'Expandable search input toggle',
}}
ref={searchInputRef}
id="navigation-search"
onClick={e => {
// if the user clicks on the search input, open the autocomplete menu
if (e.target.type === 'text') setIsAutocompleteOpen(true);
else if (!isExpanded) setIsExpanded(true);
}}
/>
);

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}
appendTo={() =>
document.querySelector('.pf-c-masthead.pf-m-display-inline')
}
/>
);
};
NavigationSearch.propTypes = {
items: PropTypes.array.isRequired,
clickAndNavigate: PropTypes.func.isRequired,
};
Loading

0 comments on commit a18ee38

Please sign in to comment.