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 { useNavStore } from './Nav';
import { useLeapStore } from './Nav';
export const Help = () => {
const select = useNavStore((state) => state.select);
const select = useLeapStore((state) => state.select);
useEffect(() => {
select('Help and Support');

View File

@ -4,15 +4,32 @@ import React, {
ChangeEvent,
FocusEvent,
FormEvent,
KeyboardEvent,
HTMLAttributes,
useCallback,
useEffect,
useImperativeHandle,
useRef
} from 'react';
import { Link, useHistory, useRouteMatch } from 'react-router-dom';
import slugify from 'slugify';
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 = {
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 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 { rawInput, searchInput, matches, selection, select } = useLeapStore();
const toggleSearch = useCallback(() => {
if (selection || menu === 'search') {
@ -75,17 +63,69 @@ export const Leap = React.forwardRef(({ menu, className }: LeapProps, ref) => {
toggleSearch();
}, []);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const input = e.target as HTMLInputElement;
const value = input.value.trim();
setSearchInput(value);
}, []);
const getMatch = useCallback(
(value: string) => {
return matches.find((m) => m.display?.startsWith(value) || m.value.startsWith(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(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const input = [searchInput];
const value = inputRef.current?.value.trim();
if (!value) {
return;
}
const input = [slugify(getMatch(value)?.value || value)];
if (appsMatch) {
input.unshift(match?.params.query || '');
} else {
@ -93,8 +133,25 @@ export const Leap = React.forwardRef(({ menu, className }: LeapProps, ref) => {
}
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 (
@ -120,10 +177,11 @@ export const Leap = React.forwardRef(({ menu, className }: LeapProps, ref) => {
ref={inputRef}
placeholder={selection ? '' : 'Search Landscape'}
className="flex-1 w-full h-full px-2 h4 rounded-full bg-transparent outline-none"
value={searchInput}
value={rawInput}
onClick={toggleSearch}
onFocus={onFocus}
onChange={onChange}
onKeyDown={onKeyDown}
role="combobox"
aria-controls="leap-items"
aria-expanded

View File

@ -1,15 +1,8 @@
import { DialogContent } from '@radix-ui/react-dialog';
import * as Portal from '@radix-ui/react-portal';
import classNames from 'classnames';
import React, {
FunctionComponent,
KeyboardEvent,
useCallback,
useEffect,
useRef,
useState
} from 'react';
import { Link, Route, Switch, useHistory, useLocation } from 'react-router-dom';
import React, { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react';
import { Link, Route, Switch, useHistory } from 'react-router-dom';
import create from 'zustand';
import { Dialog } from '../components/Dialog';
import { Help } from './Help';
@ -19,6 +12,32 @@ import { Search } from './Search';
import { SystemMenu } from './SystemMenu';
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 =
| 'closed'
| 'search'
@ -30,60 +49,17 @@ interface NavProps {
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' }) => {
const { push } = useHistory();
const location = useLocation();
const inputRef = useRef<HTMLInputElement>(null);
const navRef = 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 [delayedOpen, setDelayedOpen] = useState(false);
const [dialogContentOpen, setDialogContentOpen] = useState(false);
const isOpen = menu !== 'closed';
const eitherOpen = isOpen || systemMenuOpen;
@ -92,7 +68,7 @@ export const Nav: FunctionComponent<NavProps> = ({ menu = 'closed' }) => {
(event: Event) => {
event.preventDefault();
setDelayedOpen(true);
setDialogContentOpen(true);
if (menu === 'search' && inputRef.current) {
setTimeout(() => {
inputRef.current?.focus();
@ -109,30 +85,17 @@ export const Nav: FunctionComponent<NavProps> = ({ menu = 'closed' }) => {
const onDialogClose = useCallback((open: boolean) => {
if (!open) {
select(null);
setDelayedOpen(false);
setDialogContentOpen(false);
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 (
<>
<Portal.Root containerRef={delayedOpen ? dialogNavRef : navRef} className="flex space-x-2">
<Portal.Root
containerRef={dialogContentOpen ? dialogNavRef : navRef}
className="flex space-x-2"
>
<SystemMenu
open={systemMenuOpen}
setOpen={setSystemMenuOpen}
@ -150,7 +113,7 @@ export const Nav: FunctionComponent<NavProps> = ({ menu = 'closed' }) => {
ref={navRef}
className={classNames(
'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}>
@ -158,20 +121,18 @@ export const Nav: FunctionComponent<NavProps> = ({ menu = 'closed' }) => {
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"
>
<div tabIndex={-1} onKeyDown={onDialogKey} role="presentation">
<header ref={dialogNavRef} className="my-6" />
<div
id="leap-items"
className="grid grid-rows-[fit-content(calc(100vh-7.5rem))] bg-white rounded-3xl overflow-hidden"
role="listbox"
>
<Switch>
<Route path="/leap/notifications" component={Notifications} />
<Route path="/leap/system-preferences" component={SystemPreferences} />
<Route path="/leap/help-and-support" component={Help} />
<Route path={['/leap/search', '/leap']} component={Search} />
</Switch>
</div>
<header ref={dialogNavRef} className="my-6" />
<div
id="leap-items"
className="grid grid-rows-[fit-content(calc(100vh-7.5rem))] bg-white rounded-3xl overflow-hidden"
role="listbox"
>
<Switch>
<Route path="/leap/notifications" component={Notifications} />
<Route path="/leap/system-preferences" component={SystemPreferences} />
<Route path="/leap/help-and-support" component={Help} />
<Route path={['/leap/search', '/leap']} component={Search} />
</Switch>
</div>
</DialogContent>
</Dialog>

View File

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

View File

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

View File

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

View File

@ -6,13 +6,13 @@ import slugify from 'slugify';
import { ShipName } from '../../components/ShipName';
import { fetchProviderTreaties, treatyKey } from '../../state/docket';
import { Treaty } from '../../state/docket-types';
import { useNavStore } from '../Nav';
import { useLeapStore } from '../Nav';
type AppsProps = RouteComponentProps<{ ship: string }>;
export const Apps = ({ match }: AppsProps) => {
const queryClient = useQueryClient();
const { searchInput, select } = useNavStore((state) => ({
const { searchInput, select } = useLeapStore((state) => ({
searchInput: state.searchInput,
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(
(app: Treaty) => {
queryClient.setQueryData(treatyKey([provider, app.desk]), app);
@ -58,7 +66,7 @@ export const Apps = ({ match }: AppsProps) => {
{results && (
<ul className="space-y-8" aria-labelledby="developed-by">
{results.map((app) => (
<li key={app.desk}>
<li key={app.desk} role="option" aria-selected={false}>
<Link
to={`${match?.path.replace(':ship', provider)}/${slugify(app.desk)}`}
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 { ShipName } from '../../components/ShipName';
import { fetchProviders, providersKey } from '../../state/docket';
import { useNavStore } from '../Nav';
import { useLeapStore } from '../Nav';
type ProvidersProps = RouteComponentProps<{ ship: string }>;
export const Providers = ({ match }: ProvidersProps) => {
const select = useNavStore((state) => state.select);
const select = useLeapStore((state) => state.select);
const provider = match?.params.ship;
const { data } = useQuery(providersKey([provider]), () => fetchProviders(provider), {
enabled: !!provider,
@ -20,6 +20,14 @@ export const Providers = ({ match }: ProvidersProps) => {
select(null, provider);
}, []);
useEffect(() => {
if (data) {
useLeapStore.setState({
matches: data.map((p) => ({ value: p.shipName, display: p.nickname }))
});
}
}, [data]);
return (
<div className="dialog-inner-container md:px-6 md:py-8 h4 text-gray-400" aria-live="polite">
<div id="providers">
@ -31,7 +39,7 @@ export const Providers = ({ match }: ProvidersProps) => {
{data && (
<ul className="space-y-8" aria-labelledby="providers">
{data.map((p) => (
<li key={p.shipName}>
<li key={p.shipName} role="option" aria-selected={false}>
<Link
to={`${match?.path.replace(':ship', p.shipName)}/apps`}
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': {
title: 'My Apps',
ship: '~zod',
desk: 'groups',
desk: 'my-apps',
status: 'active',
base: 'my-apps',
info: 'A lengthier description of the app down here',