mirror of
https://github.com/urbit/shrub.git
synced 2024-12-24 20:47:27 +03:00
leap: componentizing and improving direct navigation
This commit is contained in:
parent
edde5e3ec4
commit
40a0fa7698
143
pkg/grid/src/nav/Leap.tsx
Normal file
143
pkg/grid/src/nav/Leap.tsx
Normal 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>
|
||||
);
|
||||
});
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
Loading…
Reference in New Issue
Block a user