leap: autocomplete and bksp improvements

This commit is contained in:
Hunter Miller 2021-08-16 16:04:56 -05:00
parent 40a0fa7698
commit e5f839b52c
9 changed files with 181 additions and 146 deletions

View File

@ -1,8 +1,8 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useNavStore } from './Nav'; import { useLeapStore } from './Nav';
export const Help = () => { export const Help = () => {
const select = useNavStore((state) => state.select); const select = useLeapStore((state) => state.select);
useEffect(() => { useEffect(() => {
select('Help and Support'); select('Help and Support');

View File

@ -4,15 +4,32 @@ import React, {
ChangeEvent, ChangeEvent,
FocusEvent, FocusEvent,
FormEvent, FormEvent,
KeyboardEvent,
HTMLAttributes, HTMLAttributes,
useCallback, useCallback,
useEffect,
useImperativeHandle, useImperativeHandle,
useRef useRef
} from 'react'; } from 'react';
import { Link, useHistory, useRouteMatch } from 'react-router-dom'; import { Link, useHistory, useRouteMatch } from 'react-router-dom';
import slugify from 'slugify';
import { Cross } from '../components/icons/Cross'; import { Cross } from '../components/icons/Cross';
import { MenuState, useNavStore } from './Nav'; import { MenuState, useLeapStore } from './Nav';
function normalizePathEnding(path: string) {
const end = path.length - 1;
return path[end] === '/' ? path.substring(0, end - 1) : path;
}
export function createPreviousPath(current: string): string {
const parts = normalizePathEnding(current).split('/');
parts.pop();
if (parts[parts.length - 1] === 'leap') {
parts.push('search');
}
return parts.join('/');
}
type LeapProps = { type LeapProps = {
menu: MenuState; menu: MenuState;
@ -26,36 +43,7 @@ export const Leap = React.forwardRef(({ menu, className }: LeapProps, ref) => {
const appsMatch = useRouteMatch(`/leap/${menu}/${match?.params.query}/apps`); const appsMatch = useRouteMatch(`/leap/${menu}/${match?.params.query}/apps`);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => inputRef.current); useImperativeHandle(ref, () => inputRef.current);
const { searchInput, setSearchInput, selection, select } = useNavStore(); const { rawInput, searchInput, matches, selection, select } = useLeapStore();
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(() => { const toggleSearch = useCallback(() => {
if (selection || menu === 'search') { if (selection || menu === 'search') {
@ -75,17 +63,69 @@ export const Leap = React.forwardRef(({ menu, className }: LeapProps, ref) => {
toggleSearch(); toggleSearch();
}, []); }, []);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => { const getMatch = useCallback(
const input = e.target as HTMLInputElement; (value: string) => {
const value = input.value.trim(); return matches.find((m) => m.display?.startsWith(value) || m.value.startsWith(value));
setSearchInput(value); },
}, []); [matches]
);
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;
}
useLeapStore.setState({ searchInput: input });
navigateByInput(input);
},
300,
{ leading: true }
),
[menu, match]
);
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;
if (matchValue && inputRef.current && !isDeletion) {
inputRef.current.value = matchValue;
inputRef.current.setSelectionRange(value.length, matchValue.length);
useLeapStore.setState({ rawInput: matchValue });
} else {
useLeapStore.setState({ rawInput: value });
}
handleSearch(value);
},
[matches]
);
const onSubmit = useCallback( const onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => { (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const input = [searchInput]; const value = inputRef.current?.value.trim();
if (!value) {
return;
}
const input = [slugify(getMatch(value)?.value || value)];
if (appsMatch) { if (appsMatch) {
input.unshift(match?.params.query || ''); input.unshift(match?.params.query || '');
} else { } else {
@ -93,8 +133,25 @@ export const Leap = React.forwardRef(({ menu, className }: LeapProps, ref) => {
} }
navigateByInput(input.join('/')); navigateByInput(input.join('/'));
useLeapStore.setState({ rawInput: '' });
}, },
[searchInput, match] [match]
);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if ((!selection && rawInput) || rawInput) {
return;
}
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
select(null, appsMatch ? undefined : match?.params.query);
const pathBack = createPreviousPath(match?.url || '');
push(pathBack);
}
},
[selection, rawInput, match]
); );
return ( return (
@ -120,10 +177,11 @@ export const Leap = React.forwardRef(({ menu, className }: LeapProps, ref) => {
ref={inputRef} ref={inputRef}
placeholder={selection ? '' : 'Search Landscape'} placeholder={selection ? '' : 'Search Landscape'}
className="flex-1 w-full h-full px-2 h4 rounded-full bg-transparent outline-none" className="flex-1 w-full h-full px-2 h4 rounded-full bg-transparent outline-none"
value={searchInput} value={rawInput}
onClick={toggleSearch} onClick={toggleSearch}
onFocus={onFocus} onFocus={onFocus}
onChange={onChange} onChange={onChange}
onKeyDown={onKeyDown}
role="combobox" role="combobox"
aria-controls="leap-items" aria-controls="leap-items"
aria-expanded aria-expanded

View File

@ -1,15 +1,8 @@
import { DialogContent } from '@radix-ui/react-dialog'; import { DialogContent } from '@radix-ui/react-dialog';
import * as Portal from '@radix-ui/react-portal'; import * as Portal from '@radix-ui/react-portal';
import classNames from 'classnames'; import classNames from 'classnames';
import React, { import React, { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react';
FunctionComponent, import { Link, Route, Switch, useHistory } from 'react-router-dom';
KeyboardEvent,
useCallback,
useEffect,
useRef,
useState
} from 'react';
import { Link, Route, Switch, useHistory, useLocation } from 'react-router-dom';
import create from 'zustand'; import create from 'zustand';
import { Dialog } from '../components/Dialog'; import { Dialog } from '../components/Dialog';
import { Help } from './Help'; import { Help } from './Help';
@ -19,6 +12,32 @@ import { Search } from './Search';
import { SystemMenu } from './SystemMenu'; import { SystemMenu } from './SystemMenu';
import { SystemPreferences } from './SystemPreferences'; import { SystemPreferences } from './SystemPreferences';
export interface MatchItem {
value: string;
display?: string;
}
interface LeapStore {
rawInput: string;
searchInput: string;
matches: MatchItem[];
selection: React.ReactNode;
select: (selection: React.ReactNode, input?: string) => void;
}
export const useLeapStore = create<LeapStore>((set) => ({
rawInput: '',
searchInput: '',
matches: [],
selection: null,
select: (selection: React.ReactNode, input?: string) =>
set({
rawInput: input || '',
searchInput: input || '',
selection
})
}));
export type MenuState = export type MenuState =
| 'closed' | 'closed'
| 'search' | 'search'
@ -30,60 +49,17 @@ interface NavProps {
menu?: MenuState; menu?: MenuState;
} }
interface NavStore {
searchInput: string;
setSearchInput: (input: string) => void;
selection: React.ReactNode;
select: (selection: React.ReactNode, input?: string) => void;
}
export const useNavStore = create<NavStore>((set) => ({
searchInput: '',
setSearchInput: (input: string) => set({ searchInput: input }),
selection: null,
select: (selection: React.ReactNode, input?: string) =>
set({ searchInput: input || '', selection })
}));
function normalizePathEnding(path: string) {
const end = path.length - 1;
return path[end] === '/' ? path.substring(0, end - 1) : path;
}
export function createNextPath(current: string, nextPart?: string): string {
let end = nextPart;
const parts = normalizePathEnding(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 = normalizePathEnding(current).split('/');
parts.pop();
if (parts[parts.length - 1] === 'leap') {
parts.push('search');
}
return parts.join('/');
}
export const Nav: FunctionComponent<NavProps> = ({ menu = 'closed' }) => { export const Nav: FunctionComponent<NavProps> = ({ menu = 'closed' }) => {
const { push } = useHistory(); const { push } = useHistory();
const location = useLocation();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const navRef = useRef<HTMLDivElement>(null); const navRef = useRef<HTMLDivElement>(null);
const dialogNavRef = useRef<HTMLDivElement>(null); const dialogNavRef = useRef<HTMLDivElement>(null);
const { searchInput, selection, select } = useNavStore(); const { selection, select } = useLeapStore((state) => ({
selection: state.selection,
select: state.select
}));
const [systemMenuOpen, setSystemMenuOpen] = useState(false); const [systemMenuOpen, setSystemMenuOpen] = useState(false);
const [delayedOpen, setDelayedOpen] = useState(false); const [dialogContentOpen, setDialogContentOpen] = useState(false);
const isOpen = menu !== 'closed'; const isOpen = menu !== 'closed';
const eitherOpen = isOpen || systemMenuOpen; const eitherOpen = isOpen || systemMenuOpen;
@ -92,7 +68,7 @@ export const Nav: FunctionComponent<NavProps> = ({ menu = 'closed' }) => {
(event: Event) => { (event: Event) => {
event.preventDefault(); event.preventDefault();
setDelayedOpen(true); setDialogContentOpen(true);
if (menu === 'search' && inputRef.current) { if (menu === 'search' && inputRef.current) {
setTimeout(() => { setTimeout(() => {
inputRef.current?.focus(); inputRef.current?.focus();
@ -109,30 +85,17 @@ export const Nav: FunctionComponent<NavProps> = ({ menu = 'closed' }) => {
const onDialogClose = useCallback((open: boolean) => { const onDialogClose = useCallback((open: boolean) => {
if (!open) { if (!open) {
select(null); select(null);
setDelayedOpen(false); setDialogContentOpen(false);
push('/'); push('/');
} }
}, []); }, []);
const onDialogKey = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if ((!selection && searchInput) || searchInput) {
return;
}
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
select(null);
const pathBack = createPreviousPath(location.pathname);
push(pathBack);
}
},
[selection, searchInput, location.pathname]
);
return ( return (
<> <>
<Portal.Root containerRef={delayedOpen ? dialogNavRef : navRef} className="flex space-x-2"> <Portal.Root
containerRef={dialogContentOpen ? dialogNavRef : navRef}
className="flex space-x-2"
>
<SystemMenu <SystemMenu
open={systemMenuOpen} open={systemMenuOpen}
setOpen={setSystemMenuOpen} setOpen={setSystemMenuOpen}
@ -150,7 +113,7 @@ export const Nav: FunctionComponent<NavProps> = ({ menu = 'closed' }) => {
ref={navRef} ref={navRef}
className={classNames( className={classNames(
'w-full max-w-3xl my-6 px-4 text-gray-400 font-semibold', 'w-full max-w-3xl my-6 px-4 text-gray-400 font-semibold',
delayedOpen && 'h-12' dialogContentOpen && 'h-12'
)} )}
/> />
<Dialog open={isOpen} onOpenChange={onDialogClose}> <Dialog open={isOpen} onOpenChange={onDialogClose}>
@ -158,20 +121,18 @@ export const Nav: FunctionComponent<NavProps> = ({ menu = 'closed' }) => {
onOpenAutoFocus={onOpen} onOpenAutoFocus={onOpen}
className="fixed top-0 left-[calc(50%-7.5px)] w-[calc(100%-15px)] max-w-3xl px-4 text-gray-400 -translate-x-1/2 outline-none" className="fixed top-0 left-[calc(50%-7.5px)] w-[calc(100%-15px)] max-w-3xl px-4 text-gray-400 -translate-x-1/2 outline-none"
> >
<div tabIndex={-1} onKeyDown={onDialogKey} role="presentation"> <header ref={dialogNavRef} className="my-6" />
<header ref={dialogNavRef} className="my-6" /> <div
<div id="leap-items"
id="leap-items" className="grid grid-rows-[fit-content(calc(100vh-7.5rem))] bg-white rounded-3xl overflow-hidden"
className="grid grid-rows-[fit-content(calc(100vh-7.5rem))] bg-white rounded-3xl overflow-hidden" role="listbox"
role="listbox" >
> <Switch>
<Switch> <Route path="/leap/notifications" component={Notifications} />
<Route path="/leap/notifications" component={Notifications} /> <Route path="/leap/system-preferences" component={SystemPreferences} />
<Route path="/leap/system-preferences" component={SystemPreferences} /> <Route path="/leap/help-and-support" component={Help} />
<Route path="/leap/help-and-support" component={Help} /> <Route path={['/leap/search', '/leap']} component={Search} />
<Route path={['/leap/search', '/leap']} component={Search} /> </Switch>
</Switch>
</div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -1,8 +1,8 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useNavStore } from './Nav'; import { useLeapStore } from './Nav';
export const Notifications = () => { export const Notifications = () => {
const select = useNavStore((state) => state.select); const select = useLeapStore((state) => state.select);
useEffect(() => { useEffect(() => {
select('Notifications'); select('Notifications');

View File

@ -1,8 +1,8 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useNavStore } from './Nav'; import { useLeapStore } from './Nav';
export const SystemPreferences = () => { export const SystemPreferences = () => {
const select = useNavStore((state) => state.select); const select = useLeapStore((state) => state.select);
useEffect(() => { useEffect(() => {
select('System Preferences'); select('System Preferences');

View File

@ -7,10 +7,10 @@ import { Spinner } from '../../components/Spinner';
import { TreatyMeta } from '../../components/TreatyMeta'; import { TreatyMeta } from '../../components/TreatyMeta';
import { useTreaty } from '../../logic/useTreaty'; import { useTreaty } from '../../logic/useTreaty';
import { chargesKey, fetchCharges } from '../../state/docket'; import { chargesKey, fetchCharges } from '../../state/docket';
import { useNavStore } from '../Nav'; import { useLeapStore } from '../Nav';
export const AppInfo = () => { export const AppInfo = () => {
const select = useNavStore((state) => state.select); const select = useLeapStore((state) => state.select);
const { ship, desk, treaty, installStatus, copyApp, installApp } = useTreaty(); const { ship, desk, treaty, installStatus, copyApp, installApp } = useTreaty();
const { data: charges } = useQuery(chargesKey(), fetchCharges); const { data: charges } = useQuery(chargesKey(), fetchCharges);
const installed = (charges || {})[desk] || installStatus.isSuccess; const installed = (charges || {})[desk] || installStatus.isSuccess;

View File

@ -6,13 +6,13 @@ import slugify from 'slugify';
import { ShipName } from '../../components/ShipName'; import { ShipName } from '../../components/ShipName';
import { fetchProviderTreaties, treatyKey } from '../../state/docket'; import { fetchProviderTreaties, treatyKey } from '../../state/docket';
import { Treaty } from '../../state/docket-types'; import { Treaty } from '../../state/docket-types';
import { useNavStore } from '../Nav'; import { useLeapStore } from '../Nav';
type AppsProps = RouteComponentProps<{ ship: string }>; type AppsProps = RouteComponentProps<{ ship: string }>;
export const Apps = ({ match }: AppsProps) => { export const Apps = ({ match }: AppsProps) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { searchInput, select } = useNavStore((state) => ({ const { searchInput, select } = useLeapStore((state) => ({
searchInput: state.searchInput, searchInput: state.searchInput,
select: state.select select: state.select
})); }));
@ -38,6 +38,14 @@ export const Apps = ({ match }: AppsProps) => {
); );
}, []); }, []);
useEffect(() => {
if (data) {
useLeapStore.setState({
matches: data.map((treaty) => ({ value: treaty.desk, display: treaty.title }))
});
}
}, [data]);
const preloadApp = useCallback( const preloadApp = useCallback(
(app: Treaty) => { (app: Treaty) => {
queryClient.setQueryData(treatyKey([provider, app.desk]), app); queryClient.setQueryData(treatyKey([provider, app.desk]), app);
@ -58,7 +66,7 @@ export const Apps = ({ match }: AppsProps) => {
{results && ( {results && (
<ul className="space-y-8" aria-labelledby="developed-by"> <ul className="space-y-8" aria-labelledby="developed-by">
{results.map((app) => ( {results.map((app) => (
<li key={app.desk}> <li key={app.desk} role="option" aria-selected={false}>
<Link <Link
to={`${match?.path.replace(':ship', provider)}/${slugify(app.desk)}`} to={`${match?.path.replace(':ship', provider)}/${slugify(app.desk)}`}
className="flex items-center space-x-3 default-ring ring-offset-2 rounded-lg" className="flex items-center space-x-3 default-ring ring-offset-2 rounded-lg"

View File

@ -3,12 +3,12 @@ import { useQuery } from 'react-query';
import { Link, RouteComponentProps } from 'react-router-dom'; import { Link, RouteComponentProps } from 'react-router-dom';
import { ShipName } from '../../components/ShipName'; import { ShipName } from '../../components/ShipName';
import { fetchProviders, providersKey } from '../../state/docket'; import { fetchProviders, providersKey } from '../../state/docket';
import { useNavStore } from '../Nav'; import { useLeapStore } from '../Nav';
type ProvidersProps = RouteComponentProps<{ ship: string }>; type ProvidersProps = RouteComponentProps<{ ship: string }>;
export const Providers = ({ match }: ProvidersProps) => { export const Providers = ({ match }: ProvidersProps) => {
const select = useNavStore((state) => state.select); const select = useLeapStore((state) => state.select);
const provider = match?.params.ship; const provider = match?.params.ship;
const { data } = useQuery(providersKey([provider]), () => fetchProviders(provider), { const { data } = useQuery(providersKey([provider]), () => fetchProviders(provider), {
enabled: !!provider, enabled: !!provider,
@ -20,6 +20,14 @@ export const Providers = ({ match }: ProvidersProps) => {
select(null, provider); select(null, provider);
}, []); }, []);
useEffect(() => {
if (data) {
useLeapStore.setState({
matches: data.map((p) => ({ value: p.shipName, display: p.nickname }))
});
}
}, [data]);
return ( return (
<div className="dialog-inner-container md:px-6 md:py-8 h4 text-gray-400" aria-live="polite"> <div className="dialog-inner-container md:px-6 md:py-8 h4 text-gray-400" aria-live="polite">
<div id="providers"> <div id="providers">
@ -31,7 +39,7 @@ export const Providers = ({ match }: ProvidersProps) => {
{data && ( {data && (
<ul className="space-y-8" aria-labelledby="providers"> <ul className="space-y-8" aria-labelledby="providers">
{data.map((p) => ( {data.map((p) => (
<li key={p.shipName}> <li key={p.shipName} role="option" aria-selected={false}>
<Link <Link
to={`${match?.path.replace(':ship', p.shipName)}/apps`} to={`${match?.path.replace(':ship', p.shipName)}/apps`}
className="flex items-center space-x-3 default-ring ring-offset-2 rounded-lg" className="flex items-center space-x-3 default-ring ring-offset-2 rounded-lg"

View File

@ -98,7 +98,7 @@ export const treaties: Treaties = {
'my-apps': { 'my-apps': {
title: 'My Apps', title: 'My Apps',
ship: '~zod', ship: '~zod',
desk: 'groups', desk: 'my-apps',
status: 'active', status: 'active',
base: 'my-apps', base: 'my-apps',
info: 'A lengthier description of the app down here', info: 'A lengthier description of the app down here',