diff --git a/pkg/grid/src/nav/Help.tsx b/pkg/grid/src/nav/Help.tsx index f340e76565..c3d121d709 100644 --- a/pkg/grid/src/nav/Help.tsx +++ b/pkg/grid/src/nav/Help.tsx @@ -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'); diff --git a/pkg/grid/src/nav/Leap.tsx b/pkg/grid/src/nav/Leap.tsx index 6fff511d4a..c378437413 100644 --- a/pkg/grid/src/nav/Leap.tsx +++ b/pkg/grid/src/nav/Leap.tsx @@ -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(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) => { - 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) => { + 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) => { 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) => { + 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 diff --git a/pkg/grid/src/nav/Nav.tsx b/pkg/grid/src/nav/Nav.tsx index 81c299ef35..9f8b52608d 100644 --- a/pkg/grid/src/nav/Nav.tsx +++ b/pkg/grid/src/nav/Nav.tsx @@ -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((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((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 = ({ menu = 'closed' }) => { const { push } = useHistory(); - const location = useLocation(); const inputRef = useRef(null); const navRef = useRef(null); const dialogNavRef = useRef(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 = ({ 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 = ({ menu = 'closed' }) => { const onDialogClose = useCallback((open: boolean) => { if (!open) { select(null); - setDelayedOpen(false); + setDialogContentOpen(false); push('/'); } }, []); - const onDialogKey = useCallback( - (e: KeyboardEvent) => { - 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 ( <> - + = ({ 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' )} /> @@ -158,20 +121,18 @@ export const Nav: FunctionComponent = ({ 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" > -
-
-
- - - - - - -
+
+
+ + + + + +
diff --git a/pkg/grid/src/nav/Notifications.tsx b/pkg/grid/src/nav/Notifications.tsx index 35304e383f..faee3838cf 100644 --- a/pkg/grid/src/nav/Notifications.tsx +++ b/pkg/grid/src/nav/Notifications.tsx @@ -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'); diff --git a/pkg/grid/src/nav/SystemPreferences.tsx b/pkg/grid/src/nav/SystemPreferences.tsx index 2eaa656189..2c6b6060ee 100644 --- a/pkg/grid/src/nav/SystemPreferences.tsx +++ b/pkg/grid/src/nav/SystemPreferences.tsx @@ -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'); diff --git a/pkg/grid/src/nav/search/AppInfo.tsx b/pkg/grid/src/nav/search/AppInfo.tsx index d99690ed8b..ec6fd251d8 100644 --- a/pkg/grid/src/nav/search/AppInfo.tsx +++ b/pkg/grid/src/nav/search/AppInfo.tsx @@ -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; diff --git a/pkg/grid/src/nav/search/Apps.tsx b/pkg/grid/src/nav/search/Apps.tsx index 397345d294..2e583f894e 100644 --- a/pkg/grid/src/nav/search/Apps.tsx +++ b/pkg/grid/src/nav/search/Apps.tsx @@ -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 && (
    {results.map((app) => ( -
  • +
  • ; 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 (
    @@ -31,7 +39,7 @@ export const Providers = ({ match }: ProvidersProps) => { {data && (
      {data.map((p) => ( -
    • +