nav: fixing dimming and inter nav clicks

This commit is contained in:
Hunter Miller 2021-09-14 17:52:12 -05:00
parent f8bfbf1bbb
commit 65b9f229c5
7 changed files with 224 additions and 231 deletions

View File

@ -22,7 +22,7 @@ const variants: Record<ButtonVariant, string> = {
secondary: 'text-black bg-gray-100',
caution: 'text-white bg-orange-400',
destructive: 'text-white bg-red-500',
'alt-primary': 'text-white bg-blue-400',
'alt-primary': 'text-white bg-blue-400 ring-blue-300',
'alt-secondary': 'text-blue-400 bg-blue-50'
};

View File

@ -35,212 +35,215 @@ type LeapProps = {
menu: MenuState;
dropdown: string;
navOpen: boolean;
shouldDim: boolean;
} & HTMLAttributes<HTMLDivElement>;
export const Leap = React.forwardRef(({ menu, dropdown, navOpen, className }: LeapProps, ref) => {
const { push } = useHistory();
const match = useRouteMatch<{ menu?: MenuState; 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 { rawInput, selectedMatch, matches, selection, select } = useLeapStore();
export const Leap = React.forwardRef(
({ menu, dropdown, navOpen, shouldDim, className }: LeapProps, ref) => {
const { push } = useHistory();
const match = useRouteMatch<{ menu?: MenuState; 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 { rawInput, selectedMatch, matches, selection, select } = useLeapStore();
useEffect(() => {
if (selection && rawInput === '') {
inputRef.current?.focus();
}
}, [selection, rawInput]);
useEffect(() => {
if (selection && rawInput === '') {
inputRef.current?.focus();
}
}, [selection, rawInput]);
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) {
const toggleSearch = useCallback(() => {
if (selection || menu === 'search') {
return;
}
toggleSearch();
},
[toggleSearch]
);
push('/leap/search');
}, [selection, menu]);
const getMatch = useCallback(
(value: string) => {
const normValue = value.toLocaleLowerCase();
return matches.find(
(m) =>
m.display?.toLocaleLowerCase().startsWith(normValue) ||
m.value.toLocaleLowerCase().startsWith(normValue)
);
},
[matches]
);
const onFocus = useCallback(
(e: FocusEvent<HTMLInputElement>) => {
// refocusing tab with input focused is false trigger
const windowFocus = e.nativeEvent.currentTarget === document.body;
if (windowFocus) {
return;
}
const navigateByInput = useCallback(
(input: string) => {
const normalizedValue = input.trim().replace(/(~?[\w^_-]{3,13})\//, '$1/apps/');
push(`/leap/${menu}/${normalizedValue}`);
},
[menu]
);
toggleSearch();
},
[toggleSearch]
);
const debouncedSearch = useDebounce(
(input: string) => {
if (!match || appsMatch) {
return;
}
const getMatch = useCallback(
(value: string) => {
const normValue = value.toLocaleLowerCase();
return matches.find(
(m) =>
m.display?.toLocaleLowerCase().startsWith(normValue) ||
m.value.toLocaleLowerCase().startsWith(normValue)
);
},
[matches]
);
useLeapStore.setState({ searchInput: input });
navigateByInput(input);
},
300,
{ leading: true }
);
const navigateByInput = useCallback(
(input: string) => {
const normalizedValue = input.trim().replace(/(~?[\w^_-]{3,13})\//, '$1/apps/');
push(`/leap/${menu}/${normalizedValue}`);
},
[menu]
);
const handleSearch = useCallback(debouncedSearch, [match]);
const debouncedSearch = useDebounce(
(input: string) => {
if (!match || appsMatch) {
return;
}
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const input = e.target as HTMLInputElement;
const value = input.value.trim();
const isDeletion = (e.nativeEvent as InputEvent).inputType === 'deleteContentBackward';
const inputMatch = getMatch(value);
const matchValue = inputMatch?.display || inputMatch?.value;
useLeapStore.setState({ searchInput: input });
navigateByInput(input);
},
300,
{ leading: true }
);
if (matchValue && inputRef.current && !isDeletion) {
inputRef.current.value = matchValue;
inputRef.current.setSelectionRange(value.length, matchValue.length);
useLeapStore.setState({
rawInput: matchValue,
selectedMatch: inputMatch
});
} else {
useLeapStore.setState({
rawInput: value,
selectedMatch: matches[0]
});
}
const handleSearch = useCallback(debouncedSearch, [match]);
handleSearch(value);
},
[matches]
);
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const input = e.target as HTMLInputElement;
const value = input.value.trim();
const isDeletion = (e.nativeEvent as InputEvent).inputType === 'deleteContentBackward';
const inputMatch = getMatch(value);
const matchValue = inputMatch?.display || inputMatch?.value;
const onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (matchValue && inputRef.current && !isDeletion) {
inputRef.current.value = matchValue;
inputRef.current.setSelectionRange(value.length, matchValue.length);
useLeapStore.setState({
rawInput: matchValue,
selectedMatch: inputMatch
});
} else {
useLeapStore.setState({
rawInput: value,
selectedMatch: matches[0]
});
}
const value = inputRef.current?.value.trim();
const currentMatch = selectedMatch || (value && getMatch(value));
handleSearch(value);
},
[matches]
);
if (!currentMatch) {
return;
}
if (currentMatch?.openInNewTab) {
window.open(currentMatch.url, currentMatch.value);
return;
}
push(currentMatch.url);
useLeapStore.setState({ rawInput: '' });
},
[match, selectedMatch]
);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
const deletion = e.key === 'Backspace' || e.key === 'Delete';
const arrow = e.key === 'ArrowDown' || e.key === 'ArrowUp';
if (deletion && !rawInput && selection) {
e.preventDefault();
select(null, appsMatch && !appsMatch.isExact ? undefined : match?.params.query);
const pathBack = createPreviousPath(match?.url || '');
push(pathBack);
}
if (arrow) {
const onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const currentIndex = selectedMatch
? matches.findIndex((m) => {
const matchValue = m.display || m.value;
const searchValue = selectedMatch.display || selectedMatch.value;
return matchValue === searchValue;
})
: 0;
const unsafeIndex = e.key === 'ArrowUp' ? currentIndex - 1 : currentIndex + 1;
const index = (unsafeIndex + matches.length) % matches.length;
const value = inputRef.current?.value.trim();
const currentMatch = selectedMatch || (value && getMatch(value));
const newMatch = matches[index];
const matchValue = newMatch.display || newMatch.value;
useLeapStore.setState({
rawInput: matchValue,
// searchInput: matchValue,
selectedMatch: newMatch
});
}
},
[selection, rawInput, match, matches, selectedMatch]
);
if (!currentMatch) {
return;
}
return (
<div className="relative z-50 w-full">
<form
className={classNames(
'flex items-center h-full w-full px-2 rounded-full bg-white default-ring focus-within:ring-2',
navOpen && menu !== 'search' && 'opacity-60',
!navOpen ? 'bg-gray-50' : '',
className
)}
onSubmit={onSubmit}
>
<label
htmlFor="leap"
if (currentMatch?.openInNewTab) {
window.open(currentMatch.url, currentMatch.value);
return;
}
push(currentMatch.url);
useLeapStore.setState({ rawInput: '' });
},
[match, selectedMatch]
);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
const deletion = e.key === 'Backspace' || e.key === 'Delete';
const arrow = e.key === 'ArrowDown' || e.key === 'ArrowUp';
if (deletion && !rawInput && selection) {
e.preventDefault();
select(null, appsMatch && !appsMatch.isExact ? undefined : match?.params.query);
const pathBack = createPreviousPath(match?.url || '');
push(pathBack);
}
if (arrow) {
e.preventDefault();
const currentIndex = selectedMatch
? matches.findIndex((m) => {
const matchValue = m.display || m.value;
const searchValue = selectedMatch.display || selectedMatch.value;
return matchValue === searchValue;
})
: 0;
const unsafeIndex = e.key === 'ArrowUp' ? currentIndex - 1 : currentIndex + 1;
const index = (unsafeIndex + matches.length) % matches.length;
const newMatch = matches[index];
const matchValue = newMatch.display || newMatch.value;
useLeapStore.setState({
rawInput: matchValue,
// searchInput: matchValue,
selectedMatch: newMatch
});
}
},
[selection, rawInput, match, matches, selectedMatch]
);
return (
<div className="relative z-50 w-full">
<form
className={classNames(
'inline-block flex-none p-2 h4 text-blue-400',
!selection && 'sr-only'
'flex items-center h-full w-full px-2 rounded-full bg-white default-ring focus-within:ring-2',
shouldDim && 'opacity-60',
!navOpen ? 'bg-gray-50' : '',
className
)}
onSubmit={onSubmit}
>
{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={rawInput}
onClick={toggleSearch}
onFocus={onFocus}
onChange={onChange}
onKeyDown={onKeyDown}
aria-autocomplete="both"
aria-controls={dropdown}
aria-activedescendant={selectedMatch?.display || selectedMatch?.value}
/>
</form>
{navOpen && (
<Link
to="/"
className="absolute top-1/2 right-2 flex-none circle-button w-8 h-8 text-gray-400 bg-gray-50 default-ring -translate-y-1/2"
onClick={() => select(null)}
>
<Cross className="w-3 h-3 fill-current" />
<span className="sr-only">Close</span>
</Link>
)}
</div>
);
});
<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={rawInput}
onClick={toggleSearch}
onFocus={onFocus}
onChange={onChange}
onKeyDown={onKeyDown}
aria-autocomplete="both"
aria-controls={dropdown}
aria-activedescendant={selectedMatch?.display || selectedMatch?.value}
/>
</form>
{navOpen && (
<Link
to="/"
className="absolute top-1/2 right-2 flex-none circle-button w-8 h-8 text-gray-400 bg-gray-50 default-ring -translate-y-1/2"
onClick={() => select(null)}
>
<Cross className="w-3 h-3 fill-current" />
<span className="sr-only">Close</span>
</Link>
)}
</div>
);
}
);

View File

@ -52,33 +52,6 @@ export type MenuState =
| 'help-and-support'
| 'system-preferences';
export function createNextPath(current: string, nextPart?: string): string {
let end = nextPart;
const parts = current.split('/').reverse();
if (parts[1] === 'search') {
end = 'apps';
}
if (parts[0] === 'leap') {
end = `search/${nextPart}`;
}
return `${current}/${end}`;
}
export function createPreviousPath(current: string): string {
const parts = current.split('/');
parts.pop();
if (parts[parts.length - 1] === 'leap') {
parts.push('search');
}
if (parts[parts.length - 2] === 'apps') {
parts.pop();
}
return parts.join('/');
}
interface NavProps {
menu?: MenuState;
}
@ -123,6 +96,15 @@ export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
}
}, []);
const preventClose = useCallback((e) => {
const target = e.target as HTMLElement;
const hasNavAncestor = target.closest('#dialog-nav');
if (hasNavAncestor) {
e.preventDefault();
}
}, []);
return (
<>
{/* Using portal so that we can retain the same nav items both in the dialog and in the base header */}
@ -132,12 +114,20 @@ export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
>
<SystemMenu
open={!!systemMenuOpen}
menu={menuState}
navOpen={isOpen}
shouldDim={isOpen && menu !== 'system-preferences' && menu !== 'help-and-support'}
className={classNames('relative z-50 flex-none', eitherOpen ? 'bg-white' : 'bg-gray-50')}
/>
<NotificationsLink menu={menuState} navOpen={isOpen} />
<Leap ref={inputRef} menu={menuState} dropdown="leap-items" navOpen={isOpen} />
<NotificationsLink
navOpen={isOpen}
shouldDim={(isOpen && menu !== 'notifications') || !!systemMenuOpen}
/>
<Leap
ref={inputRef}
menu={menuState}
dropdown="leap-items"
navOpen={isOpen}
shouldDim={(isOpen && menu !== 'search') || !!systemMenuOpen}
/>
</Portal.Root>
<div
ref={navRef}
@ -152,6 +142,7 @@ export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
/>
<Dialog open={isOpen} onOpenChange={onDialogClose}>
<DialogContent
onInteractOutside={preventClose}
onOpenAutoFocus={onOpen}
className="fixed bottom-0 sm:top-0 sm:bottom-auto scroll-left-50 flex flex-col scroll-full-width max-w-[882px] px-4 sm:pb-4 text-gray-400 -translate-x-1/2 outline-none"
role="combobox"
@ -160,6 +151,7 @@ export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
aria-expanded={isOpen}
>
<header
id="dialog-nav"
ref={dialogNavRef}
className="max-w-[712px] w-full mx-auto my-6 sm:mb-3 order-last sm:order-none"
/>

View File

@ -4,7 +4,6 @@ import { Link, LinkProps } from 'react-router-dom';
import { Bullet } from '../components/icons/Bullet';
import { Notification } from '../state/hark-types';
import { useNotifications } from '../state/notifications';
import { MenuState } from './Nav';
type NotificationsState = 'empty' | 'unread' | 'attention-needed';
@ -25,11 +24,11 @@ function getNotificationsState(
}
type NotificationsLinkProps = Omit<LinkProps<HTMLAnchorElement>, 'to'> & {
menu: MenuState;
navOpen: boolean;
shouldDim: boolean;
};
export const NotificationsLink = ({ navOpen, menu }: NotificationsLinkProps) => {
export const NotificationsLink = ({ navOpen, shouldDim }: NotificationsLinkProps) => {
const { notifications, systemNotifications } = useNotifications();
const state = getNotificationsState(notifications, systemNotifications);
@ -39,7 +38,7 @@ export const NotificationsLink = ({ navOpen, menu }: NotificationsLinkProps) =>
className={classNames(
'relative z-50 flex-none circle-button h4 default-ring',
navOpen && 'text-opacity-60',
navOpen && menu !== 'notifications' && 'opacity-60',
shouldDim && 'opacity-60',
state === 'empty' && !navOpen && 'text-gray-400 bg-gray-50',
state === 'empty' && navOpen && 'text-gray-400 bg-white',
state === 'unread' && 'bg-blue-400 text-white',

View File

@ -7,13 +7,11 @@ import { Vat } from '@urbit/api/hood';
import { Adjust } from '../components/icons/Adjust';
import { useVat } from '../state/kiln';
import { disableDefault, handleDropdownLink } from '../state/util';
import { MenuState } from './Nav';
import { useMedia } from '../logic/useMedia';
type SystemMenuProps = HTMLAttributes<HTMLButtonElement> & {
menu: MenuState;
open: boolean;
navOpen: boolean;
shouldDim: boolean;
};
function getHash(vat: Vat): string {
@ -21,7 +19,7 @@ function getHash(vat: Vat): string {
return parts[parts.length - 1];
}
export const SystemMenu = ({ className, menu, open, navOpen }: SystemMenuProps) => {
export const SystemMenu = ({ className, open, shouldDim }: SystemMenuProps) => {
const { push } = useHistory();
const [copied, setCopied] = useState(false);
const garden = useVat('garden');
@ -61,10 +59,7 @@ export const SystemMenu = ({ className, menu, open, navOpen }: SystemMenuProps)
className={classNames(
'appearance-none circle-button default-ring',
open && 'text-gray-300',
navOpen &&
menu !== 'system-preferences' &&
menu !== 'help-and-support' &&
'opacity-60',
shouldDim && 'opacity-60',
className
)}
>

View File

@ -53,8 +53,8 @@ export const SystemPreferences = (props: RouteComponentProps<{ submenu: string }
);
return (
<div className="flex h-[600px] max-h-full">
<aside className="flex-none min-w-60 border-r-2 border-gray-50">
<div className="flex h-full overflow-y-auto">
<aside className="flex-none min-w-60">
<div className="p-8">
<input className="input h4 default-ring bg-gray-50" placeholder="Search Preferences" />
</div>
@ -84,7 +84,7 @@ export const SystemPreferences = (props: RouteComponentProps<{ submenu: string }
</ul>
</nav>
</aside>
<section className="flex-1 p-8 text-black">
<section className="flex-1 min-h-[600px] p-8 text-black border-l-2 border-gray-50">
<Switch>
<Route path={`${match.url}/system-updates`} component={SystemUpdatePrefs} />
<Route path={`${match.url}/interface`} component={InterfacePrefs} />

View File

@ -11,6 +11,10 @@ export const TileInfo = () => {
const charge = useCharge(desk);
const vat = useVat(desk);
if (!charge) {
return null;
}
return (
<Dialog open onOpenChange={(open) => !open && push('/')}>
<DialogContent>