Skip to content

Commit c85e3de

Browse files
authored
Navigation component + Page/Layout concept (#42)
This PR adds the navigation component, and also reworks the react-router usage into a Page and Layout system. For the Navigation resizing, I tried to use resize observer, first with https://www.npmjs.com/package/@react-hook/resize-observer and then with ResizeObserver directly. The third party hook did not seem to work well for our use case, and for using ResizeObserver directly, it turns out the TypeScript types for ResizeObserver are not included by default, and I felt a little apprehensive to include third party types for it, though it would probably be fine. microsoft/TypeScript#28502 J=SLAP-1558 TEST=manual test resizing the page and seeing tabs appear and disappear test that switching tabs will run searches
1 parent 0bd77b4 commit c85e3de

15 files changed

+450
-181
lines changed

THIRD-PARTY-NOTICES

+3-3
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
3232

3333
The following NPM package may be included in this product:
3434

35-
- @reduxjs/[email protected].1
35+
- @reduxjs/[email protected].2
3636

3737
This package contains the following license and notice below:
3838

@@ -96,7 +96,7 @@ The following NPM packages may be included in this product:
9696

9797
9898
99-
99+
100100

101101
These packages each contain the following license and notice below:
102102

@@ -220,7 +220,7 @@ SOFTWARE.
220220

221221
The following NPM package may be included in this product:
222222

223-
223+
224224

225225
This package contains the following license and notice below:
226226

sample-app/src/App.tsx

+25-170
Original file line numberDiff line numberDiff line change
@@ -1,184 +1,39 @@
11
import './sass/App.scss';
2+
import VerticalSearchPage from './pages/VerticalSearchPage';
3+
import UniversalSearchPage from './pages/UniversalSearchPage';
4+
import PageRouter from './PageRouter';
5+
import StandardLayout from './pages/StandardLayout';
26
import { AnswersActionsProvider } from '@yext/answers-headless-react';
3-
import AlternativeVerticals from './components/AlternativeVerticals';
4-
import DecoratedAppliedFilters from './components/DecoratedAppliedFilters';
5-
import { StandardCard } from './components/cards/StandardCard';
6-
import ResultsCount from './components/ResultsCount';
7-
import SearchBar from './components/SearchBar';
8-
import StaticFilters from './components/StaticFilters';
9-
import VerticalResults from './components/VerticalResults';
10-
import SpellCheck from './components/SpellCheck';
11-
import LocationBias from './components/LocationBias';
12-
import UniversalResults from './components/UniversalResults';
13-
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
14-
import Facets from './components/Facets';
15-
16-
function App() {
17-
const staticFilterOptions = [
18-
{
19-
label: 'canada',
20-
fieldId: 'c_employeeCountry',
21-
value: 'Canada',
22-
},
23-
{
24-
label: 'remote',
25-
fieldId: 'c_employeeCountry',
26-
value: 'Remote'
27-
},
28-
{
29-
label: 'usa',
30-
fieldId: 'c_employeeCountry',
31-
value: 'United States',
32-
},
33-
{
34-
label: 'tech',
35-
fieldId: 'c_employeeDepartment',
36-
value: 'Technology'
37-
},
38-
{
39-
label: 'consult',
40-
fieldId: 'c_employeeDepartment',
41-
value: 'Consulting',
42-
},
43-
{
44-
label: 'fin',
45-
fieldId: 'c_employeeDepartment',
46-
value: 'Finance',
47-
}
48-
]
49-
50-
const universalResultsConfig = {
51-
people: {
52-
label: "People",
53-
viewMore: true,
54-
cardConfig: {
55-
CardComponent: StandardCard,
56-
showOrdinal: true
57-
}
58-
},
59-
events: {
60-
label: "events",
61-
cardConfig: {
62-
CardComponent: StandardCard,
63-
showOrdinal: true
64-
}
65-
},
66-
links: {
67-
label: "links",
68-
viewMore: true,
69-
cardConfig: {
70-
CardComponent: StandardCard,
71-
showOrdinal: true
72-
}
73-
},
74-
financial_professionals: {
75-
label: "Financial Professionals",
76-
},
77-
healthcare_professionals: {
78-
label: "Healthcare Professionals",
7+
import { universalResultsConfig } from './universalResultsConfig';
8+
9+
const routes = [
10+
{
11+
path: '/',
12+
exact: true,
13+
page: <UniversalSearchPage universalResultsConfig={universalResultsConfig} />
14+
},
15+
...Object.keys(universalResultsConfig).map(key => {
16+
return {
17+
path: `/${key}`,
18+
page: <VerticalSearchPage verticalKey={key} />
7919
}
80-
}
81-
82-
const universalResultsFilterConfig = {
83-
show: true
84-
};
85-
86-
const facetConfigs = {
87-
c_employeeDepartment: {
88-
label: 'Employee Department!'
89-
}
90-
}
20+
})
21+
];
9122

23+
export default function App() {
9224
return (
9325
<AnswersActionsProvider
9426
apiKey='2d8c550071a64ea23e263118a2b0680b'
9527
experienceKey='slanswers'
9628
locale='en'
9729
verticalKey='people'
9830
>
99-
{/*
100-
TODO: use Navigation component for routing when that's added to repo.
101-
current setup is for testing purposes.
102-
*/}
103-
<Router>
104-
<Switch>
105-
{/* universal search */}
106-
<Route exact path='/'>
107-
<div className='start'>
108-
test
109-
</div>
110-
<div className='end'>
111-
<SearchBar
112-
placeholder='Search...'
113-
isVertical={false}
114-
/>
115-
<div>
116-
<UniversalResults
117-
appliedFiltersConfig={universalResultsFilterConfig}
118-
verticalConfigs={universalResultsConfig}
119-
/>
120-
</div>
121-
</div>
122-
</Route>
123-
124-
{/* vertical page */}
125-
<Route path={Object.keys(universalResultsConfig).map(key => `/${key}`)}>
126-
<div>
127-
A VERTICAL PAGE!
128-
</div>
129-
</Route>
130-
131-
{/* vertical search */}
132-
<Route exact path='/vertical'>
133-
<div className='start'>
134-
test
135-
<StaticFilters
136-
title='~Country and Employee Departments~'
137-
options={staticFilterOptions}
138-
/>
139-
<Facets
140-
searchOnChange={true}
141-
searchable={true}
142-
collapsible={true}
143-
defaultExpanded={true}
144-
facetConfigs={facetConfigs}
145-
/>
146-
<SpellCheck
147-
isVertical={true}
148-
/>
149-
</div>
150-
<div className='end'>
151-
<SearchBar
152-
placeholder='Search...'
153-
isVertical={true}
154-
/>
155-
<div>
156-
<ResultsCount />
157-
<DecoratedAppliedFilters
158-
showFieldNames={true}
159-
hiddenFields={['builtin.entityType']}
160-
delimiter='|'
161-
/>
162-
<AlternativeVerticals
163-
currentVerticalLabel='People'
164-
verticalsConfig={[
165-
{ label: 'Locations', verticalKey: 'KM' },
166-
{ label: 'FAQs', verticalKey: 'faq' }
167-
]}
168-
/>
169-
<VerticalResults
170-
CardComponent={StandardCard}
171-
cardConfig={{ showOrdinal: true }}
172-
displayAllResults={true}
173-
/>
174-
<LocationBias isVertical={false} />
175-
</div>
176-
</div>
177-
</Route>
178-
</Switch>
179-
</Router>
31+
<div className='App'>
32+
<PageRouter
33+
Layout={StandardLayout}
34+
routes={routes}
35+
/>
36+
</div>
18037
</AnswersActionsProvider>
18138
);
18239
}
183-
184-
export default App;

sample-app/src/PageRouter.tsx

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { ComponentType } from 'react';
2+
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
3+
4+
interface RouteData {
5+
path: string
6+
page: JSX.Element
7+
exact?: boolean
8+
}
9+
10+
export type LayoutComponent = ComponentType<{ page: JSX.Element }>
11+
12+
interface PageProps {
13+
Layout?: LayoutComponent
14+
routes: RouteData[]
15+
}
16+
17+
/**
18+
* PageRouter abstracts away logic surrounding react-router, and provides an easy way
19+
* to specify a {@link LayoutComponent} for a page.
20+
*/
21+
export default function PageRouter({ Layout, routes }: PageProps) {
22+
const pages = routes.map(routeData => {
23+
const { path, page, exact } = routeData;
24+
if (Layout) {
25+
return (
26+
<Route key={path} path={path} exact={exact}>
27+
<Layout page={page}/>
28+
</Route>
29+
);
30+
}
31+
return <Route key={path} path={path} exact={exact}>{page}</Route>;
32+
});
33+
34+
return (
35+
<Router>
36+
<Switch>
37+
{pages}
38+
</Switch>
39+
</Router>
40+
);
41+
}
+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import classNames from 'classnames';
2+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
3+
import { NavLink, useLocation } from 'react-router-dom';
4+
import { ReactComponent as KebabIcon } from '../icons/kebab.svg';
5+
import '../sass/Navigation.scss';
6+
7+
interface LinkData {
8+
to: string
9+
label: string
10+
}
11+
12+
interface NavigationProps {
13+
links: LinkData[]
14+
}
15+
16+
export default function Navigation({ links }: NavigationProps) {
17+
// Close the menu when clicking the document
18+
const [menuOpen, setMenuOpen] = useState<boolean>(false);
19+
const menuRef = useRef<HTMLButtonElement>(null);
20+
const handleDocumentClick = (e: MouseEvent) => {
21+
if (e.target !== menuRef.current) {
22+
setMenuOpen(false);
23+
}
24+
};
25+
useLayoutEffect(() => {
26+
document.addEventListener('click', handleDocumentClick)
27+
return () => document.removeEventListener('click', handleDocumentClick);
28+
}, []);
29+
30+
// Responsive tabs
31+
const [numOverflowLinks, setNumOverflowLinks] = useState<number>(0);
32+
const navigationRef = useRef<HTMLDivElement>(null);
33+
const handleResize = useCallback(() => {
34+
const navEl = navigationRef.current;
35+
if (!navEl) {
36+
return;
37+
}
38+
const isOverflowing = navEl.scrollWidth > navEl.offsetWidth;
39+
if (isOverflowing && numOverflowLinks < links.length) {
40+
setNumOverflowLinks(numOverflowLinks + 1);
41+
}
42+
}, [links.length, numOverflowLinks])
43+
useLayoutEffect(handleResize, [handleResize]);
44+
useEffect(() => {
45+
let timeoutId: NodeJS.Timeout;
46+
function resizeListener() {
47+
clearTimeout(timeoutId);
48+
timeoutId = setTimeout(() => {
49+
setNumOverflowLinks(0);
50+
handleResize()
51+
}, 50)
52+
};
53+
window.addEventListener('resize', resizeListener);
54+
return () => window.removeEventListener('resize', resizeListener);
55+
}, [handleResize]);
56+
57+
const { search } = useLocation();
58+
const visibleLinks = links.slice(0, links.length - numOverflowLinks);
59+
const overflowLinks = links.slice(-numOverflowLinks);
60+
const menuButtonClassNames = classNames('Navigation__menuButton', {
61+
'Navigation__menuButton--open': menuOpen
62+
});
63+
return (
64+
<nav className='Navigation' ref={navigationRef}>
65+
<div className='Navigation__links'>
66+
{visibleLinks.map(l => renderLink(l, search))}
67+
</div>
68+
{numOverflowLinks > 0 &&
69+
<div className='Navigation__menuWrapper'>
70+
<button
71+
className={menuButtonClassNames}
72+
ref={menuRef}
73+
onClick={() => setMenuOpen(!menuOpen)}
74+
>
75+
<KebabIcon /> More
76+
</button>
77+
<div className='Navigation__menuLinks'>
78+
{menuOpen && overflowLinks.map(l => renderLink(l, search))}
79+
</div>
80+
</div>
81+
}
82+
</nav>
83+
)
84+
}
85+
86+
function renderLink(linkData: LinkData, queryParams: string) {
87+
const { to, label } = linkData;
88+
return (
89+
<NavLink
90+
key={to}
91+
className='Navigation__link'
92+
activeClassName='Navigation__link--currentRoute'
93+
to={`${to}${queryParams}`}
94+
exact={true}
95+
>
96+
{label}
97+
</NavLink>
98+
)
99+
}

sample-app/src/components/UniversalResults.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { CardConfig } from '../models/cardComponent';
88
import classNames from "classnames";
99
import '../sass/UniversalResults.scss';
1010

11-
interface VerticalConfig {
11+
export interface VerticalConfig {
1212
SectionComponent?: SectionComponent,
1313
cardConfig?: CardConfig,
1414
label?: string,

0 commit comments

Comments
 (0)