mirror of
https://github.com/urbit/shrub.git
synced 2024-12-24 20:47:27 +03:00
leap: autocomplete and bksp improvements
This commit is contained in:
parent
40a0fa7698
commit
e5f839b52c
@ -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');
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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');
|
||||
|
@ -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');
|
||||
|
@ -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;
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user