Merge pull request #5164 from urbit/hm/grid-search-improvement

Leap: improving search capability
This commit is contained in:
Hunter Miller 2021-08-19 20:36:40 -05:00 committed by GitHub
commit a1d80c2352
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 525 additions and 8814 deletions

8518
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{
"name": "urbit-bitcoin-wallet",
"name": "btc-wallet",
"version": "0.1.0",
"lockfileVersion": 1,
"requires": true,

2
pkg/grid/.gitignore vendored
View File

@ -5,4 +5,4 @@ dist-ssr
*.local
stats.html
.eslintcache
.vercel
.vercel

View File

@ -10,7 +10,6 @@
"serve": "vite preview",
"lint": "eslint --cache \"**/*.{js,jsx,ts,tsx}\"",
"lint:fix": "npm run lint -- --fix",
"prepare": "husky install",
"test": "echo \"No test yet\""
},
"dependencies": {

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');

233
pkg/grid/src/nav/Leap.tsx Normal file
View File

@ -0,0 +1,233 @@
import classNames from 'classnames';
import { debounce } from 'lodash-es';
import React, {
ChangeEvent,
FocusEvent,
FormEvent,
KeyboardEvent,
HTMLAttributes,
useCallback,
useImperativeHandle,
useRef
} from 'react';
import { Link, useHistory, useRouteMatch } from 'react-router-dom';
import slugify from 'slugify';
import { Cross } from '../components/icons/Cross';
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;
dropdown: string;
showClose: boolean;
} & HTMLAttributes<HTMLDivElement>;
export const Leap = React.forwardRef(({ menu, dropdown, showClose, 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();
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) {
return;
}
toggleSearch();
},
[toggleSearch]
);
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,
selectedMatch: inputMatch
});
} else {
useLeapStore.setState({
rawInput: value,
selectedMatch: matches[0]
});
}
handleSearch(value);
},
[matches]
);
const onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const value = inputRef.current?.value.trim();
if (!value) {
return;
}
const input = [slugify(getMatch(value)?.value || value)];
if (appsMatch) {
input.unshift(match?.params.query || '');
} else {
input.push('');
}
navigateByInput(input.join('/'));
useLeapStore.setState({ rawInput: '' });
},
[match]
);
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 ? 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 (
<form
className={classNames(
'relative z-50 flex items-center w-full px-2 rounded-full bg-white default-ring focus-within:ring-4',
className
)}
onSubmit={onSubmit}
>
<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}
/>
{showClose && (
<Link
to="/"
className="circle-button w-8 h-8 text-gray-400 bg-gray-100 default-ring"
onClick={() => select(null)}
>
<Cross className="w-3 h-3 fill-current" />
<span className="sr-only">Close</span>
</Link>
)}
</form>
);
});

View File

@ -1,25 +1,45 @@
import { DialogContent } from '@radix-ui/react-dialog';
import * as Portal from '@radix-ui/react-portal';
import classNames from 'classnames';
import React, {
ChangeEvent,
FocusEvent,
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 { Cross } from '../components/icons/Cross';
import { Help } from './Help';
import { Leap } from './Leap';
import { Notifications } from './Notifications';
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[];
selectedMatch?: MatchItem;
selection: React.ReactNode;
select: (selection: React.ReactNode, input?: string) => void;
}
export const useLeapStore = create<LeapStore>((set) => ({
rawInput: '',
searchInput: '',
matches: [],
selectedMatch: undefined,
selection: null,
select: (selection: React.ReactNode, input?: string) =>
set({
rawInput: input || '',
searchInput: input || '',
selection
})
}));
export type MenuState =
| 'closed'
| 'search'
@ -31,226 +51,113 @@ 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 })
}));
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');
}
return parts.join('/');
}
export const Nav: FunctionComponent<NavProps> = ({ menu = 'closed' }) => {
export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
const { push } = useHistory();
const location = useLocation();
const inputRef = useRef<HTMLInputElement>(null);
const { searchInput, setSearchInput, selection, select } = useNavStore();
const navRef = useRef<HTMLDivElement>(null);
const dialogNavRef = useRef<HTMLDivElement>(null);
const [systemMenuOpen, setSystemMenuOpen] = useState(false);
const [dialogContentOpen, setDialogContentOpen] = useState(false);
const { selection, select } = useLeapStore((state) => ({
selectedMatch: state.selectedMatch,
selection: state.selection,
select: state.select
}));
const isOpen = menu !== 'closed';
const menuState = menu || 'closed';
const isOpen = menuState !== 'closed';
const eitherOpen = isOpen || systemMenuOpen;
useEffect(() => {
if (!isOpen) {
select(null);
setDialogContentOpen(false);
} else {
inputRef.current?.focus();
}
}, [selection, isOpen]);
const onOpen = useCallback(
(event: Event) => {
event.preventDefault();
setDialogContentOpen(true);
if (menu === 'search' && inputRef.current) {
inputRef.current.focus();
setTimeout(() => {
inputRef.current?.focus();
}, 0);
}
},
[menu]
);
// useEffect(() => {
// if (!menu || menu === 'search') {
// select(null);
// inputRef.current?.focus();
// }
// }, [menu]);
useEffect(() => {
inputRef.current?.focus();
}, [selection]);
const toggleSearch = useCallback(() => {
if (selection || menu === 'search') {
return;
}
push('/leap/search');
}, [selection, menu]);
const onDialogClose = useCallback((open: boolean) => {
if (!open) {
select(null);
push('/');
}
}, []);
const onDialogKey = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (!selection || searchInput) {
return;
}
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
select(null);
const pathBack = createPreviousPath(location.pathname);
push(pathBack);
}
},
[selection, searchInput, location.pathname]
);
const onFocus = useCallback((e: FocusEvent<HTMLInputElement>) => {
// refocusing tab with input focused is false trigger
const windowFocus = e.nativeEvent.currentTarget === document.body;
if (windowFocus) {
return;
}
toggleSearch();
}, []);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const input = e.target as HTMLInputElement;
const value = input.value.trim();
setSearchInput(value);
}, []);
return (
<menu className="w-full max-w-3xl my-6 px-4 text-gray-400 font-semibold">
<div className={classNames('flex space-x-2', isOpen && 'invisible')}>
{!isOpen && (
<SystemMenu
showOverlay
open={systemMenuOpen}
setOpen={setSystemMenuOpen}
className={classNames(
'relative z-50 flex-none',
eitherOpen ? 'bg-white' : 'bg-gray-100'
)}
/>
)}
<>
<Portal.Root
containerRef={dialogContentOpen ? dialogNavRef : navRef}
className="flex space-x-2"
>
<SystemMenu
open={systemMenuOpen}
setOpen={setSystemMenuOpen}
className={classNames('relative z-50 flex-none', eitherOpen ? 'bg-white' : 'bg-gray-100')}
/>
<Link
to="/leap/notifications"
className="relative z-50 flex-none circle-button bg-blue-400 text-white default-ring"
className="relative z-50 flex-none circle-button bg-blue-400 text-white h4"
>
3
</Link>
<input
onClick={toggleSearch}
onFocus={onFocus}
type="text"
className="relative z-50 rounded-full w-full pl-4 h4 bg-gray-100 default-ring"
placeholder="Search Landscape"
<Leap
ref={inputRef}
menu={menuState}
dropdown="leap-items"
showClose={isOpen}
className={!isOpen ? 'bg-gray-100' : ''}
/>
</div>
</Portal.Root>
<div
ref={navRef}
className={classNames(
'w-full max-w-3xl my-6 px-4 text-gray-400 font-semibold',
dialogContentOpen && 'h-12'
)}
role="combobox"
aria-controls="leap-items"
aria-owns="leap-items"
aria-expanded={isOpen}
/>
<Dialog open={isOpen} onOpenChange={onDialogClose}>
<DialogContent
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%)] w-[calc(100%-15px)] max-w-3xl px-4 text-gray-400 -translate-x-1/2 outline-none"
role="combobox"
aria-controls="leap-items"
aria-owns="leap-items"
aria-expanded={isOpen}
>
<div tabIndex={-1} onKeyDown={onDialogKey} role="presentation">
<header className="flex my-6 space-x-2">
<SystemMenu
open={systemMenuOpen}
setOpen={setSystemMenuOpen}
className={classNames(
'relative z-50 flex-none',
eitherOpen ? 'bg-white' : 'bg-gray-100'
)}
/>
<Link
to="/leap/notifications"
className="relative z-50 flex-none circle-button bg-blue-400 text-white"
>
3
</Link>
<div className="relative z-50 flex items-center w-full px-2 rounded-full bg-white default-ring focus-within:ring-4">
<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={searchInput}
onClick={toggleSearch}
onFocus={onFocus}
onChange={onChange}
role="combobox"
aria-controls="leap-items"
aria-expanded
/>
{(selection || searchInput) && (
<Link
to="/"
className="circle-button w-8 h-8 text-gray-400 bg-gray-100 default-ring"
onClick={() => select(null)}
>
<Cross className="w-3 h-3 fill-current" />
<span className="sr-only">Close</span>
</Link>
)}
</div>
</header>
<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 default-ring"
tabIndex={0}
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>
</menu>
</>
);
};

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

@ -5,11 +5,11 @@ import { ShipName } from '../../components/ShipName';
import { Spinner } from '../../components/Spinner';
import { TreatyMeta } from '../../components/TreatyMeta';
import { useTreaty } from '../../logic/useTreaty';
import { useLeapStore } from '../Nav';
import { useCharges } from '../../state/docket';
import { useNavStore } 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 charges = useCharges();
const installed = (charges || {})[desk] || installStatus === 'success';

View File

@ -1,19 +1,43 @@
import React, { useState, useEffect } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, RouteComponentProps } from 'react-router-dom';
import fuzzy from 'fuzzy';
import slugify from 'slugify';
import classNames from 'classnames';
import { ShipName } from '../../components/ShipName';
import useDocketState 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 { select } = useNavStore();
const { searchInput, selectedMatch, select } = useLeapStore((state) => ({
searchInput: state.searchInput,
select: state.select,
selectedMatch: state.selectedMatch
}));
const provider = match?.params.ship;
const fetchProviderTreaties = useDocketState((s) => s.fetchProviderTreaties);
const [treaties, setTreaties] = useState<Treaty[]>();
const count = treaties?.length;
const results = useMemo(
() =>
treaties
? fuzzy
.filter(
searchInput,
treaties.map((t) => t.title)
)
.sort((a, b) => {
const left = a.string.startsWith(searchInput) ? a.score + 1 : a.score;
const right = b.string.startsWith(searchInput) ? b.score + 1 : b.score;
return right - left;
})
.map((result) => treaties[result.index])
: undefined,
[treaties, searchInput]
);
const count = results?.length;
useEffect(() => {
select(
@ -23,14 +47,36 @@ export const Apps = ({ match }: AppsProps) => {
);
}, []);
useEffect(() => {
if (results) {
useLeapStore.setState({
matches: results.map((treaty) => ({ value: treaty.desk, display: treaty.title }))
});
}
}, [results]);
useEffect(() => {
async function getTreaties() {
setTreaties(await fetchProviderTreaties(provider));
}
getTreaties();
if (provider) {
getTreaties();
}
}, [provider]);
const isSelected = useCallback(
(target: Treaty) => {
if (!selectedMatch) {
return false;
}
const matchValue = selectedMatch.display || selectedMatch.value;
return target.title === matchValue || target.desk === matchValue;
},
[selectedMatch]
);
return (
<div className="dialog-inner-container md:px-6 md:py-8 h4 text-gray-400">
<div id="developed-by">
@ -41,13 +87,21 @@ export const Apps = ({ match }: AppsProps) => {
{count} result{count === 1 ? '' : 's'}
</p>
</div>
{treaties && (
{results && (
<ul className="space-y-8" aria-labelledby="developed-by">
{treaties.map((app) => (
<li key={app.desk}>
{results.map((app) => (
<li
key={app.desk}
id={app.title || app.desk}
role="option"
aria-selected={isSelected(app)}
>
<Link
to={`${match?.path.replace(':ship', provider)}/${slugify(app.desk)}`}
className="flex items-center space-x-3 default-ring ring-offset-2 rounded-lg"
className={classNames(
'flex items-center space-x-3 default-ring ring-offset-2 rounded-lg',
isSelected(app) && 'ring-4'
)}
>
<div
className="flex-none relative w-12 h-12 bg-gray-200 rounded-lg"

View File

@ -1,28 +1,6 @@
import { debounce } from 'lodash-es';
import React, { useCallback, useEffect } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { createNextPath, useNavStore } from '../Nav';
type HomeProps = RouteComponentProps;
export const Home = ({ match, history }: HomeProps) => {
const searchInput = useNavStore((state) => state.searchInput);
const { push } = history;
const { path } = match;
const handleSearch = useCallback(
debounce((input: string) => {
push(createNextPath(path, input.trim()));
}, 300),
[path]
);
useEffect(() => {
if (searchInput) {
handleSearch(searchInput);
}
}, [searchInput]);
import React from 'react';
export const Home = () => {
return (
<div className="h-full p-4 md:p-8 space-y-8 overflow-y-auto">
<h2 className="h4 text-gray-500">Recent Apps</h2>

View File

@ -1,24 +1,42 @@
import { debounce } from 'lodash-es';
import React, { useCallback, useEffect, useState } from 'react';
import fuzzy from 'fuzzy';
import classNames from 'classnames';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, RouteComponentProps } from 'react-router-dom';
import { ShipName } from '../../components/ShipName';
import useDocketState from '../../state/docket';
import { Provider } from '../../state/docket-types';
import { useNavStore } from '../Nav';
import useDocketState from '../../state/docket';
import { useLeapStore } from '../Nav';
type ProvidersProps = RouteComponentProps<{ ship: string }>;
export const Providers = ({ match, history }: ProvidersProps) => {
const { searchInput, select } = useNavStore((state) => ({
searchInput: state.searchInput,
select: state.select
export const Providers = ({ match }: ProvidersProps) => {
const { selectedMatch, select } = useLeapStore((state) => ({
select: state.select,
selectedMatch: state.selectedMatch
}));
const { push } = history;
const { path } = match;
const provider = match?.params.ship;
const fetchProviders = useDocketState((s) => s.fetchProviders);
const [providers, setProviders] = useState<Provider[]>();
const count = providers?.length;
const search = provider || '';
const results = useMemo(
() =>
providers
? fuzzy
.filter(
search,
providers.map((p) => p.shipName + (p.nickname || ''))
)
.sort((a, b) => {
const left = a.string.startsWith(search) ? a.score + 1 : a.score;
const right = b.string.startsWith(search) ? b.score + 1 : b.score;
return right - left;
})
.map((el) => providers[el.index])
: [],
[providers, search]
);
const count = results?.length;
useEffect(() => {
async function getProviders() {
@ -29,18 +47,25 @@ export const Providers = ({ match, history }: ProvidersProps) => {
getProviders();
}, [provider]);
const handleSearch = useCallback(
debounce((input: string) => {
push(match?.path.replace(':ship', input.trim()));
}, 300),
[path]
);
useEffect(() => {
if (searchInput) {
handleSearch(searchInput);
if (results) {
useLeapStore.setState({
matches: results.map((p) => ({ value: p.shipName, display: p.nickname }))
});
}
}, [searchInput]);
}, [results]);
const isSelected = useCallback(
(target: Provider) => {
if (!selectedMatch) {
return false;
}
const matchValue = selectedMatch.display || selectedMatch.value;
return target.nickname === matchValue || target.shipName === matchValue;
},
[selectedMatch]
);
return (
<div className="dialog-inner-container md:px-6 md:py-8 h4 text-gray-400" aria-live="polite">
@ -50,13 +75,21 @@ export const Providers = ({ match, history }: ProvidersProps) => {
{count} result{count === 1 ? '' : 's'}
</p>
</div>
{providers && (
{results && (
<ul className="space-y-8" aria-labelledby="providers">
{providers.map((p) => (
<li key={p.shipName}>
{results.map((p) => (
<li
key={p.shipName}
id={p.nickname || p.shipName}
role="option"
aria-selected={isSelected(p)}
>
<Link
to={`${match?.path.replace(':ship', p.shipName)}/apps`}
className="flex items-center space-x-3 default-ring ring-offset-2 rounded-lg"
className={classNames(
'flex items-center space-x-3 default-ring ring-offset-2 rounded-lg',
isSelected(p) && 'ring-4'
)}
>
<div className="flex-none relative w-12 h-12 bg-black rounded-lg">
{/* TODO: Handle sigils */}

View File

@ -42,11 +42,7 @@ const useDocketState = create<DocketState>((set, get) => ({
? await fakeRequest(mockTreaties)
: ((await (await fetch('/~/scry/docket/charges.json')).json()) as ChargesResponse).initial;
const charges = Object.entries(dockets).reduce((obj: Dockets, [key, value]) => {
// eslint-disable-next-line no-param-reassign
obj[key] = normalizeDocket(value);
return obj;
}, {});
const charges = normalizeDockets(dockets);
set({ charges });
},
@ -58,7 +54,7 @@ const useDocketState = create<DocketState>((set, get) => ({
fetchProviderTreaties: async (provider: string) => {
const { treaties, providers } = get();
const dev = providers[provider];
const treatyList = Object.values(treaties).map((treaty) => normalizeDocket(treaty));
const treatyList = Object.values(treaties);
if (dev.treaties) {
return dev.treaties.map((key) => treaties[key]);
@ -80,8 +76,9 @@ const useDocketState = create<DocketState>((set, get) => ({
}
providerTreaties.forEach((treaty) => {
const key = `${provider}/${treaty.desk}`;
draft.treaties[key] = treaty;
// may need to do this when not mock data
// const key = `${provider}/${treaty.desk}`;
// draft.treaties[key] = treaty;
draft.providers[provider].treaties?.push(treaty.desk);
});
})
@ -143,7 +140,7 @@ const useDocketState = create<DocketState>((set, get) => ({
})
);
},
treaties: useMockData ? mockTreaties : {},
treaties: useMockData ? normalizeDockets(mockTreaties) : {},
charges: {},
providers: useMockData ? mockProviders : {},
set
@ -157,6 +154,14 @@ function normalizeDocket<T extends Docket>(docket: T): T {
};
}
function normalizeDockets<T extends Docket>(dockets: Record<string, T>): Record<string, T> {
return Object.entries(dockets).reduce((obj: Record<string, T>, [key, value]) => {
// eslint-disable-next-line no-param-reassign
obj[key] = normalizeDocket(value);
return obj;
}, {});
}
interface AddDockEvent {
'add-dock': {
desk: string;

View File

@ -3,25 +3,25 @@
}
.button {
@apply inline-flex items-center justify-center px-4 py-2 font-semibold text-base tracking-tight rounded-lg cursor-pointer;
@apply inline-flex items-center justify-center px-4 py-2 font-semibold text-base tracking-tight rounded-lg cursor-pointer;
}
.dialog-container {
@apply fixed z-40 top-1/2 left-1/2 min-w-80 transform -translate-x-1/2 -translate-y-1/2;
@apply fixed z-40 top-1/2 left-1/2 min-w-80 transform -translate-x-1/2 -translate-y-1/2;
}
.dialog {
@apply relative p-5 sm:p-8 bg-white rounded-3xl;
@apply relative p-5 sm:p-8 bg-white rounded-3xl;
}
.dialog-inner-container {
@apply h-full p-4 md:p-8 space-y-8 overflow-y-auto;
@apply h-full p-4 md:p-8 space-y-8 overflow-y-auto;
}
.dropdown {
@apply min-w-52 p-4 space-y-4 rounded-xl;
@apply min-w-52 p-4 rounded-xl;
}
.spinner {
@apply inline-flex items-center w-6 h-6 animate-spin;
}
@apply inline-flex items-center w-6 h-6 animate-spin;
}

View File

@ -44,7 +44,7 @@ export const Tile: FunctionComponent<TileProps> = ({ docket, desk }) => {
active={active}
menuColor={menuColor}
lightText={lightText}
className="absolute z-10 top-2.5 right-2.5 sm:top-4 sm:right-4 opacity-0 hover-none:opacity-100 focus:opacity-100 group-hover:opacity-100"
className="absolute z-10 top-2.5 right-2.5 sm:top-4 sm:right-4 opacity-0 hover-none:opacity-100 pointer-coarse:opacity-100 focus:opacity-100 group-hover:opacity-100"
/>
<div className="h4 absolute bottom-4 left-4 lg:bottom-8 lg:left-8">
<h3

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import type * as Polymorphic from '@radix-ui/react-polymorphic';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import classNames from 'classnames';
@ -30,7 +30,7 @@ const Item = React.forwardRef(({ children, ...props }, ref) => (
<DropdownMenu.Item
ref={ref}
{...props}
className="block w-full px-4 py-1 leading-none rounded mix-blend-hard-light select-none default-ring ring-gray-600"
className="block w-full px-4 py-3 leading-none rounded mix-blend-hard-light select-none default-ring ring-gray-600"
>
{children}
</DropdownMenu.Item>
@ -40,6 +40,10 @@ export const TileMenu = ({ desk, active, menuColor, lightText, className }: Tile
const [open, setOpen] = useState(false);
const toggleDocket = useDocketState((s) => s.toggleDocket);
const menuBg = { backgroundColor: menuColor };
const linkOnSelect = useCallback((e: Event) => {
e.preventDefault();
setTimeout(() => setOpen(false), 15);
}, []);
return (
<DropdownMenu.Root open={open} onOpenChange={(isOpen) => setOpen(isOpen)}>
@ -64,50 +68,29 @@ export const TileMenu = ({ desk, active, menuColor, lightText, className }: Tile
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
className={classNames(
'dropdown font-semibold',
'dropdown py-2 font-semibold',
lightText ? 'text-gray-100' : 'text-gray-800'
)}
style={menuBg}
>
<DropdownMenu.Group className="space-y-4">
<DropdownMenu.Group>
{/*
TODO: revisit with Liam
<Item as={Link} to={`/leap/search/${provider}/apps/${name.toLowerCase()}`} onSelect={(e) => { e.preventDefault(); setTimeout(() => setOpen(false), 0) }}>App Info</Item>
*/}
<Item
as={Link}
to={`/app/${desk}`}
onSelect={(e) => {
e.preventDefault();
setTimeout(() => setOpen(false), 0);
}}
>
<Item as={Link} to={`/app/${desk}`} onSelect={linkOnSelect}>
App Info
</Item>
</DropdownMenu.Group>
<DropdownMenu.Separator className="-mx-4 my-2 border-t-2 border-solid border-gray-500 mix-blend-soft-light" />
<DropdownMenu.Group className="space-y-4">
<DropdownMenu.Group>
{active && (
<Item
as={Link}
to={`/app/${desk}/suspend`}
onSelect={(e) => {
e.preventDefault();
setTimeout(() => setOpen(false), 0);
}}
>
<Item as={Link} to={`/app/${desk}/suspend`} onSelect={linkOnSelect}>
Suspend App
</Item>
)}
{!active && <Item onSelect={() => toggleDocket(desk)}>Resume App</Item>}
<Item
as={Link}
to={`/app/${desk}/remove`}
onSelect={(e) => {
e.preventDefault();
setTimeout(() => setOpen(false), 0);
}}
>
<Item as={Link} to={`/app/${desk}/remove`} onSelect={linkOnSelect}>
Remove App
</Item>
</DropdownMenu.Group>

View File

@ -268,11 +268,6 @@
"eslint-visitor-keys": "^2.0.0"
}
},
"@urbit/eslint-config": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@urbit/eslint-config/-/eslint-config-1.0.1.tgz",
"integrity": "sha512-7NY/3R1S7yEh+L2y9iIhe7f9nIlHuiZqV0dsqbTNxGlp64QaQ88njHwSDgtYURJMotdm02g7d46BGPsiRM6UQg=="
},
"ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",