leap: componentizing and improving direct navigation

This commit is contained in:
Hunter Miller 2021-08-16 13:17:55 -05:00
parent edde5e3ec4
commit 40a0fa7698
4 changed files with 152 additions and 128 deletions

143
pkg/grid/src/nav/Leap.tsx Normal file
View File

@ -0,0 +1,143 @@
import classNames from 'classnames';
import { debounce } from 'lodash-es';
import React, {
ChangeEvent,
FocusEvent,
FormEvent,
HTMLAttributes,
useCallback,
useEffect,
useImperativeHandle,
useRef
} from 'react';
import { Link, useHistory, useRouteMatch } from 'react-router-dom';
import { Cross } from '../components/icons/Cross';
import { MenuState, useNavStore } from './Nav';
type LeapProps = {
menu: MenuState;
} & HTMLAttributes<HTMLDivElement>;
export const Leap = React.forwardRef(({ menu, className }: LeapProps, ref) => {
const { push } = useHistory();
const match = useRouteMatch<{ query?: string; desk?: string }>(
`/leap/${menu}/:query?/(apps)?/:desk?`
);
const appsMatch = useRouteMatch(`/leap/${menu}/${match?.params.query}/apps`);
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => inputRef.current);
const { searchInput, setSearchInput, selection, select } = useNavStore();
const navigateByInput = useCallback(
(input: string) => {
const normalizedValue = input.trim().replace(/(~?[\w^_-]{3,13})\//, '$1/apps/');
push(`/leap/${menu}/${normalizedValue}`);
},
[menu]
);
const handleSearch = useCallback(
debounce(
(input: string) => {
if (!match || appsMatch) {
return;
}
navigateByInput(input);
},
300,
{ leading: true }
),
[menu, match]
);
useEffect(() => {
if (searchInput) {
handleSearch(searchInput);
}
}, [searchInput]);
const toggleSearch = useCallback(() => {
if (selection || menu === 'search') {
return;
}
push('/leap/search');
}, [selection, menu]);
const onFocus = useCallback((e: FocusEvent<HTMLInputElement>) => {
// refocusing tab with input focused is false trigger
const windowFocus = e.nativeEvent.currentTarget === document.body;
if (windowFocus) {
return;
}
toggleSearch();
}, []);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const input = e.target as HTMLInputElement;
const value = input.value.trim();
setSearchInput(value);
}, []);
const onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const input = [searchInput];
if (appsMatch) {
input.unshift(match?.params.query || '');
} else {
input.push('');
}
navigateByInput(input.join('/'));
},
[searchInput, match]
);
return (
<form
className={classNames(
'relative z-50 flex items-center w-full px-2 rounded-full bg-white default-ring focus-within:ring-4',
className
)}
onSubmit={onSubmit}
>
<label
htmlFor="leap"
className={classNames(
'inline-block flex-none p-2 h4 text-blue-400',
!selection && 'sr-only'
)}
>
{selection || 'Search Landscape'}
</label>
<input
id="leap"
type="text"
ref={inputRef}
placeholder={selection ? '' : 'Search Landscape'}
className="flex-1 w-full h-full px-2 h4 rounded-full bg-transparent outline-none"
value={searchInput}
onClick={toggleSearch}
onFocus={onFocus}
onChange={onChange}
role="combobox"
aria-controls="leap-items"
aria-expanded
/>
{(selection || searchInput) && (
<Link
to="/"
className="circle-button w-8 h-8 text-gray-400 bg-gray-100 default-ring"
onClick={() => select(null)}
>
<Cross className="w-3 h-3 fill-current" />
<span className="sr-only">Close</span>
</Link>
)}
</form>
);
});

View File

@ -2,8 +2,6 @@ import { DialogContent } from '@radix-ui/react-dialog';
import * as Portal from '@radix-ui/react-portal';
import classNames from 'classnames';
import React, {
ChangeEvent,
FocusEvent,
FunctionComponent,
KeyboardEvent,
useCallback,
@ -14,8 +12,8 @@ import React, {
import { Link, Route, Switch, useHistory, useLocation } from 'react-router-dom';
import create from 'zustand';
import { Dialog } from '../components/Dialog';
import { Cross } from '../components/icons/Cross';
import { Help } from './Help';
import { Leap } from './Leap';
import { Notifications } from './Notifications';
import { Search } from './Search';
import { SystemMenu } from './SystemMenu';
@ -83,7 +81,7 @@ export const Nav: FunctionComponent<NavProps> = ({ menu = 'closed' }) => {
const inputRef = useRef<HTMLInputElement>(null);
const navRef = useRef<HTMLDivElement>(null);
const dialogNavRef = useRef<HTMLDivElement>(null);
const { searchInput, setSearchInput, selection, select } = useNavStore();
const { searchInput, selection, select } = useNavStore();
const [systemMenuOpen, setSystemMenuOpen] = useState(false);
const [delayedOpen, setDelayedOpen] = useState(false);
@ -104,25 +102,10 @@ export const Nav: FunctionComponent<NavProps> = ({ menu = 'closed' }) => {
[menu]
);
// useEffect(() => {
// if (!menu || menu === 'search') {
// select(null);
// inputRef.current?.focus();
// }
// }, [menu]);
useEffect(() => {
inputRef.current?.focus();
}, [selection]);
const toggleSearch = useCallback(() => {
if (selection || menu === 'search') {
return;
}
push('/leap/search');
}, [selection, menu]);
const onDialogClose = useCallback((open: boolean) => {
if (!open) {
select(null);
@ -133,7 +116,7 @@ export const Nav: FunctionComponent<NavProps> = ({ menu = 'closed' }) => {
const onDialogKey = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (!selection || searchInput) {
if ((!selection && searchInput) || searchInput) {
return;
}
@ -147,22 +130,6 @@ export const Nav: FunctionComponent<NavProps> = ({ menu = 'closed' }) => {
[selection, searchInput, location.pathname]
);
const onFocus = useCallback((e: FocusEvent<HTMLInputElement>) => {
// refocusing tab with input focused is false trigger
const windowFocus = e.nativeEvent.currentTarget === document.body;
if (windowFocus) {
return;
}
toggleSearch();
}, []);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const input = e.target as HTMLInputElement;
const value = input.value.trim();
setSearchInput(value);
}, []);
return (
<>
<Portal.Root containerRef={delayedOpen ? dialogNavRef : navRef} className="flex space-x-2">
@ -177,47 +144,7 @@ export const Nav: FunctionComponent<NavProps> = ({ menu = 'closed' }) => {
>
3
</Link>
<form
className={classNames(
'relative z-50 flex items-center w-full px-2 rounded-full bg-white default-ring focus-within:ring-4',
!isOpen && 'bg-gray-100'
)}
onSubmit={() => {}}
>
<label
htmlFor="leap"
className={classNames(
'inline-block flex-none p-2 h4 text-blue-400',
!selection && 'sr-only'
)}
>
{selection || 'Search Landscape'}
</label>
<input
id="leap"
type="text"
ref={inputRef}
placeholder={selection ? '' : 'Search Landscape'}
className="flex-1 w-full h-full px-2 h4 rounded-full bg-transparent outline-none"
value={searchInput}
onClick={toggleSearch}
onFocus={onFocus}
onChange={onChange}
role="combobox"
aria-controls="leap-items"
aria-expanded
/>
{(selection || searchInput) && (
<Link
to="/"
className="circle-button w-8 h-8 text-gray-400 bg-gray-100 default-ring"
onClick={() => select(null)}
>
<Cross className="w-3 h-3 fill-current" />
<span className="sr-only">Close</span>
</Link>
)}
</form>
<Leap ref={inputRef} menu={menu} className={!isOpen ? 'bg-gray-100' : ''} />
</Portal.Root>
<menu
ref={navRef}

View File

@ -1,28 +1,6 @@
import { debounce } from 'lodash-es';
import React, { useCallback, useEffect } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { createNextPath, useNavStore } from '../Nav';
type HomeProps = RouteComponentProps;
export const Home = ({ match, history }: HomeProps) => {
const searchInput = useNavStore((state) => state.searchInput);
const { push } = history;
const { path } = match;
const handleSearch = useCallback(
debounce((input: string) => {
push(createNextPath(path, input.trim()));
}, 300),
[path]
);
useEffect(() => {
if (searchInput) {
handleSearch(searchInput);
}
}, [searchInput]);
import React from 'react';
export const Home = () => {
return (
<div className="h-full p-4 md:p-8 space-y-8 overflow-y-auto">
<h2 className="h4 text-gray-500">Recent Apps</h2>

View File

@ -1,5 +1,4 @@
import { debounce } from 'lodash-es';
import React, { useCallback, useEffect } from 'react';
import React, { useEffect } from 'react';
import { useQuery } from 'react-query';
import { Link, RouteComponentProps } from 'react-router-dom';
import { ShipName } from '../../components/ShipName';
@ -8,13 +7,8 @@ import { useNavStore } from '../Nav';
type ProvidersProps = RouteComponentProps<{ ship: string }>;
export const Providers = ({ match, history }: ProvidersProps) => {
const { searchInput, select } = useNavStore((state) => ({
searchInput: state.searchInput,
select: state.select
}));
const { push } = history;
const { path } = match;
export const Providers = ({ match }: ProvidersProps) => {
const select = useNavStore((state) => state.select);
const provider = match?.params.ship;
const { data } = useQuery(providersKey([provider]), () => fetchProviders(provider), {
enabled: !!provider,
@ -26,24 +20,6 @@ export const Providers = ({ match, history }: ProvidersProps) => {
select(null, provider);
}, []);
const handleSearch = useCallback(
debounce(
(input: string) => {
const normalizedValue = input.trim().replace(/(~?[\w^_-]{3,13})\//, '$1/apps');
push(match?.path.replace(':ship', normalizedValue));
},
300,
{ leading: true }
),
[path]
);
useEffect(() => {
if (searchInput) {
handleSearch(searchInput);
}
}, [searchInput]);
return (
<div className="dialog-inner-container md:px-6 md:py-8 h4 text-gray-400" aria-live="polite">
<div id="providers">