Create cmd-k affordance in nav

This commit is contained in:
Sam Becker 2024-02-21 12:33:31 -06:00
parent 0d9ba09dee
commit 02cfa4ee52
11 changed files with 117 additions and 95 deletions

View File

@ -2,6 +2,7 @@
"cSpell.words": [ "cSpell.words": [
"ABCDEFGHIJKLMNOP", "ABCDEFGHIJKLMNOP",
"Acros", "Acros",
"affordance",
"ARROWLEFT", "ARROWLEFT",
"ARROWRIGHT", "ARROWRIGHT",
"Astia", "Astia",

View File

@ -4,7 +4,7 @@ import { clsx } from 'clsx/lite';
import { IBM_Plex_Mono } from 'next/font/google'; import { IBM_Plex_Mono } from 'next/font/google';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { BASE_URL, SITE_DESCRIPTION, SITE_TITLE } from '@/site/config'; import { BASE_URL, SITE_DESCRIPTION, SITE_TITLE } from '@/site/config';
import StateProvider from '@/state/AppStateProvider'; import AppStateProvider from '@/state/AppStateProvider';
import ThemeProviderClient from '@/site/ThemeProviderClient'; import ThemeProviderClient from '@/site/ThemeProviderClient';
import Nav from '@/site/Nav'; import Nav from '@/site/Nav';
import ToasterWithThemes from '@/toast/ToasterWithThemes'; import ToasterWithThemes from '@/toast/ToasterWithThemes';
@ -73,7 +73,7 @@ export default function RootLayout({
suppressHydrationWarning suppressHydrationWarning
> >
<body className={ibmPlexMono.variable}> <body className={ibmPlexMono.variable}>
<StateProvider> <AppStateProvider>
<ThemeProviderClient> <ThemeProviderClient>
<main className={clsx( <main className={clsx(
'mx-3 mb-3', 'mx-3 mb-3',
@ -94,7 +94,7 @@ export default function RootLayout({
</main> </main>
<CommandK /> <CommandK />
</ThemeProviderClient> </ThemeProviderClient>
</StateProvider> </AppStateProvider>
<Analytics /> <Analytics />
<SpeedInsights /> <SpeedInsights />
<PhotoEscapeHandler /> <PhotoEscapeHandler />

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { Command } from 'cmdk'; import { Command } from 'cmdk';
import { ReactNode, useEffect, useState } from 'react'; import { ReactNode, useEffect, useMemo, useState } from 'react';
import Modal from './Modal'; import Modal from './Modal';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';
@ -10,6 +10,8 @@ import { useRouter } from 'next/navigation';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi'; import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
import { IoInvertModeSharp } from 'react-icons/io5'; import { IoInvertModeSharp } from 'react-icons/io5';
import { useAppState } from '@/state';
import { parameterize } from '@/utility/string';
const LISTENER_KEYDOWN = 'keydown'; const LISTENER_KEYDOWN = 'keydown';
const MINIMUM_QUERY_LENGTH = 2; const MINIMUM_QUERY_LENGTH = 2;
@ -34,9 +36,22 @@ export default function CommandKClient({
onQueryChange?: (query: string) => Promise<CommandKSection[]> onQueryChange?: (query: string) => Promise<CommandKSection[]>
sections?: CommandKSection[] sections?: CommandKSection[]
}) { }) {
const [isOpen, setIsOpen] = useState(false); const {
const [queryRaw, setQueryRaw] = useState(''); isCommandKOpen: isOpen,
const [queryDebounced] = useDebounce(queryRaw, 500, { trailing: true }); setIsCommandKOpen: setIsOpen,
} = useAppState();
// Raw query values
const [queryLiveRaw, setQueryLive] = useState('');
const [queryDebouncedRaw] =
useDebounce(queryLiveRaw, 500, { trailing: true });
const isPlaceholderVisible = queryLiveRaw === '';
// Parameterized query values
const queryLive = useMemo(() =>
parameterize(queryLiveRaw), [queryLiveRaw]);
const queryDebounced = useMemo(() =>
parameterize(queryDebouncedRaw), [queryDebouncedRaw]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [queriedSections, setQueriedSections] = useState<CommandKSection[]>([]); const [queriedSections, setQueriedSections] = useState<CommandKSection[]>([]);
@ -49,12 +64,12 @@ export default function CommandKClient({
const down = (e: KeyboardEvent) => { const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault(); e.preventDefault();
setIsOpen((open) => !open); setIsOpen?.((open) => !open);
} }
}; };
document.addEventListener(LISTENER_KEYDOWN, down); document.addEventListener(LISTENER_KEYDOWN, down);
return () => document.removeEventListener(LISTENER_KEYDOWN, down); return () => document.removeEventListener(LISTENER_KEYDOWN, down);
}, []); }, [setIsOpen]);
useEffect(() => { useEffect(() => {
if (queryDebounced.length >= MINIMUM_QUERY_LENGTH) { if (queryDebounced.length >= MINIMUM_QUERY_LENGTH) {
@ -67,16 +82,17 @@ export default function CommandKClient({
}, [queryDebounced, onQueryChange]); }, [queryDebounced, onQueryChange]);
useEffect(() => { useEffect(() => {
if (queryRaw === '') { if (queryLive === '') {
setQueriedSections([]); setQueriedSections([]);
} else if (queryRaw.length >= MINIMUM_QUERY_LENGTH) { setIsLoading(false);
} else if (queryLive.length >= MINIMUM_QUERY_LENGTH) {
setIsLoading(true); setIsLoading(true);
} }
}, [queryRaw]); }, [queryLive]);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
setQueryRaw(''); setQueryLive('');
setQueriedSections([]); setQueriedSections([]);
setIsLoading(false); setIsLoading(false);
} }
@ -114,30 +130,37 @@ export default function CommandKClient({
> >
<Modal <Modal
anchor='top' anchor='top'
onClose={() => setIsOpen(false)} onClose={() => setIsOpen?.(false)}
fast fast
> >
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="relative"> <div className="relative w-full max-w-full min-w-0">
<Command.Input <Command.Input
onChangeCapture={(e) => setQueryRaw(e.currentTarget.value)} onChangeCapture={(e) => setQueryLive(e.currentTarget.value)}
className={clsx( className={clsx(
'w-full', 'w-full !max-w-full !min-w-0',
'focus:ring-0', 'focus:ring-0',
isPlaceholderVisible || isLoading && '!pr-8',
'!border-gray-200 dark:!border-gray-800', '!border-gray-200 dark:!border-gray-800',
'focus:border-gray-200 focus:dark:border-gray-800', 'focus:border-gray-200 focus:dark:border-gray-800',
'placeholder:text-gray-400/80', 'placeholder:text-gray-400/80',
'placeholder:dark:text-gray-700', 'placeholder:dark:text-gray-700',
)} )}
style={{ paddingRight: '2rem' }}
placeholder="Search photos, views, settings ..." placeholder="Search photos, views, settings ..."
/> />
{isLoading && {isLoading &&
<span className="absolute top-2.5 right-3"> <span className={clsx(
'absolute top-2.5 right-0 w-8',
'flex items-center justify-center translate-y-[2px]',
)}>
<Spinner size={16} /> <Spinner size={16} />
</span>} </span>}
</div> </div>
<Command.List className="relative max-h-72 overflow-y-scroll"> <Command.List className={clsx(
'relative overflow-y-scroll',
'h-36 sm:h-auto',
'sm:max-h-72',
)}>
<Command.Empty className="mt-1 pl-3 text-dim"> <Command.Empty className="mt-1 pl-3 text-dim">
{isLoading ? 'Searching ...' : 'No results found'} {isLoading ? 'Searching ...' : 'No results found'}
</Command.Empty> </Command.Empty>
@ -185,7 +208,7 @@ export default function CommandKClient({
'data-[selected=true]:dark:bg-gray-900/75', 'data-[selected=true]:dark:bg-gray-900/75',
)} )}
onSelect={() => { onSelect={() => {
setIsOpen(false); setIsOpen?.(false);
action?.(); action?.();
if (path) { if (path) {
router.push(path); router.push(path);

View File

@ -57,7 +57,7 @@ export default function Modal({
className={clsx( className={clsx(
'fixed inset-0 z-50 flex justify-center', 'fixed inset-0 z-50 flex justify-center',
anchor === 'top' anchor === 'top'
? 'items-start pt-4 sm:pt-24' ? 'items-start pt-4 xs:pt-12 sm:pt-24'
: 'items-center', : 'items-center',
'bg-black', 'bg-black',
)} )}
@ -70,16 +70,16 @@ export default function Modal({
<AnimateItems <AnimateItems
duration={fast ? 0.1 : 0.3} duration={fast ? 0.1 : 0.3}
items={[<div items={[<div
ref={contentRef}
key="modalContent" key="modalContent"
className={clsx( className={clsx(
'w-[calc(100vw-1.5rem)] xs:w-[min(500px,90vw)]',
'p-3 rounded-lg', 'p-3 rounded-lg',
'md:p-4 md:rounded-xl',
'bg-white dark:bg-black', 'bg-white dark:bg-black',
'dark:border dark:border-gray-800', 'dark:border dark:border-gray-800',
'md:p-4 md:rounded-xl',
className, className,
)} )}
style={{ width: 'min(500px, 90vw)' }}
ref={contentRef}
> >
{children} {children}
</div>]} </div>]}

27
src/site/IconSearch.tsx Normal file
View File

@ -0,0 +1,27 @@
/* eslint-disable max-len */
const INTRINSIC_WIDTH = 28;
const INTRINSIC_HEIGHT = 24;
export default function IconSearch({
width = INTRINSIC_WIDTH,
includeTitle = true,
}: {
width?: number;
includeTitle?: boolean;
}) {
return (
<svg
width={width}
height={(INTRINSIC_HEIGHT * width) / INTRINSIC_WIDTH}
viewBox="0 0 28 24"
fill="none"
stroke="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
{includeTitle && <title>Search</title>}
<circle cx="13.5" cy="11.5" r="4.875" strokeWidth="1.25" />
<path d="M17 15L21 19" strokeWidth="1.25" strokeLinecap="round" />
</svg>
);
}

View File

@ -1,31 +0,0 @@
/* eslint-disable max-len */
const INTRINSIC_WIDTH = 28;
const INTRINSIC_HEIGHT = 24;
export default function IconSets({
width = INTRINSIC_WIDTH,
includeTitle = true,
}: {
width?: number
includeTitle?: boolean
}) {
return (
<svg
width={width}
height={INTRINSIC_HEIGHT * width / INTRINSIC_WIDTH}
viewBox="0 0 28 24"
fill="none"
stroke="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
{includeTitle && <title>Photo Sets</title>}
<path d="M18.5 16.375L9.75 16.375" strokeWidth="1.25"/>
<path d="M22.25 12.125L9.75 12.125" strokeWidth="1.25"/>
<path d="M20.5 7.875L9.75 7.875" strokeWidth="1.25"/>
<path d="M7.25 16.375L6.25 16.375" strokeWidth="1.25" strokeLinecap="round"/>
<path d="M7.25 12.125L6.25 12.125" strokeWidth="1.25" strokeLinecap="round"/>
<path d="M7.25 7.875L6.25 7.875" strokeWidth="1.25" strokeLinecap="round"/>
</svg>
);
};

View File

@ -11,7 +11,6 @@ import {
isPathAdmin, isPathAdmin,
isPathGrid, isPathGrid,
isPathProtected, isPathProtected,
isPathSets,
isPathSignIn, isPathSignIn,
} from '@/site/paths'; } from '@/site/paths';
import AnimateItems from '../components/AnimateItems'; import AnimateItems from '../components/AnimateItems';
@ -40,8 +39,6 @@ export default function NavClient({
return 'full-frame'; return 'full-frame';
} else if (isPathGrid(pathname)) { } else if (isPathGrid(pathname)) {
return 'grid'; return 'grid';
} else if (isPathSets(pathname)) {
return 'sets';
} else if (isPathProtected(pathname)) { } else if (isPathProtected(pathname)) {
return 'admin'; return 'admin';
} }

View File

@ -2,9 +2,10 @@ import Switcher from '@/components/Switcher';
import SwitcherItem from '@/components/SwitcherItem'; import SwitcherItem from '@/components/SwitcherItem';
import IconFullFrame from '@/site/IconFullFrame'; import IconFullFrame from '@/site/IconFullFrame';
import IconGrid from '@/site/IconGrid'; import IconGrid from '@/site/IconGrid';
import { PATH_ADMIN_PHOTOS, PATH_GRID, PATH_SETS } from '@/site/paths'; import { PATH_ADMIN_PHOTOS, PATH_GRID } from '@/site/paths';
import { BiLockAlt } from 'react-icons/bi'; import { BiLockAlt } from 'react-icons/bi';
import IconSets from './IconSets'; import { useAppState } from '@/state';
import IconSearch from './IconSearch';
export type SwitcherSelection = 'full-frame' | 'grid' | 'sets' | 'admin'; export type SwitcherSelection = 'full-frame' | 'grid' | 'sets' | 'admin';
@ -15,7 +16,10 @@ export default function ViewSwitcher({
currentSelection?: SwitcherSelection currentSelection?: SwitcherSelection
showAdmin?: boolean showAdmin?: boolean
}) { }) {
const { setIsCommandKOpen } = useAppState();
return ( return (
<div className="flex gap-2">
<Switcher> <Switcher>
<SwitcherItem <SwitcherItem
icon={<IconFullFrame />} icon={<IconFullFrame />}
@ -29,13 +33,6 @@ export default function ViewSwitcher({
active={currentSelection === 'grid'} active={currentSelection === 'grid'}
noPadding noPadding
/> />
<SwitcherItem
className="md:hidden"
icon={<IconSets />}
href={PATH_SETS}
active={currentSelection === 'sets'}
noPadding
/>
{showAdmin && {showAdmin &&
<SwitcherItem <SwitcherItem
icon={<BiLockAlt size={16} className="translate-y-[-0.5px]" />} icon={<BiLockAlt size={16} className="translate-y-[-0.5px]" />}
@ -43,5 +40,12 @@ export default function ViewSwitcher({
active={currentSelection === 'admin'} active={currentSelection === 'admin'}
/>} />}
</Switcher> </Switcher>
<Switcher>
<SwitcherItem
icon={<IconSearch />}
onClick={() => setIsCommandKOpen?.(true)}
/>
</Switcher>
</div>
); );
} }

View File

@ -10,7 +10,6 @@ import { FilmSimulation } from '@/simulation';
// Core paths // Core paths
export const PATH_ROOT = '/'; export const PATH_ROOT = '/';
export const PATH_GRID = '/grid'; export const PATH_GRID = '/grid';
export const PATH_SETS = '/sets';
export const PATH_ADMIN = '/admin'; export const PATH_ADMIN = '/admin';
export const PATH_API = '/api'; export const PATH_API = '/api';
export const PATH_SIGN_IN = '/sign-in'; export const PATH_SIGN_IN = '/sign-in';
@ -55,7 +54,6 @@ export const PATHS_ADMIN = [
export const PATHS_TO_CACHE = [ export const PATHS_TO_CACHE = [
PATH_ROOT, PATH_ROOT,
PATH_GRID, PATH_GRID,
PATH_SETS,
PATH_OG, PATH_OG,
PATH_PHOTO_DYNAMIC, PATH_PHOTO_DYNAMIC,
PATH_TAG_DYNAMIC, PATH_TAG_DYNAMIC,
@ -236,9 +234,6 @@ export const checkPathPrefix = (pathname = '', prefix: string) =>
export const isPathGrid = (pathname?: string) => export const isPathGrid = (pathname?: string) =>
checkPathPrefix(pathname, PATH_GRID); checkPathPrefix(pathname, PATH_GRID);
export const isPathSets = (pathname?: string) =>
checkPathPrefix(pathname, PATH_SETS);
export const isPathSignIn = (pathname?: string) => export const isPathSignIn = (pathname?: string) =>
checkPathPrefix(pathname, PATH_SIGN_IN); checkPathPrefix(pathname, PATH_SIGN_IN);

View File

@ -5,7 +5,7 @@ import { AppStateContext } from '.';
import { AnimationConfig } from '@/components/AnimateItems'; import { AnimationConfig } from '@/components/AnimateItems';
import usePathnames from '@/utility/usePathnames'; import usePathnames from '@/utility/usePathnames';
export default function StateProvider({ export default function AppStateProvider({
children, children,
}: { }: {
children: ReactNode children: ReactNode
@ -17,6 +17,8 @@ export default function StateProvider({
const [nextPhotoAnimation, setNextPhotoAnimation] = const [nextPhotoAnimation, setNextPhotoAnimation] =
useState<AnimationConfig>(); useState<AnimationConfig>();
const [isCommandKOpen, setIsCommandKOpen] = useState(false);
useEffect(() => { useEffect(() => {
setHasLoaded?.(true); setHasLoaded?.(true);
}, [setHasLoaded]); }, [setHasLoaded]);
@ -28,6 +30,8 @@ export default function StateProvider({
hasLoaded, hasLoaded,
setHasLoaded, setHasLoaded,
nextPhotoAnimation, nextPhotoAnimation,
isCommandKOpen,
setIsCommandKOpen,
setNextPhotoAnimation, setNextPhotoAnimation,
clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined), clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined),
}} }}

View File

@ -1,11 +1,13 @@
import { createContext, useContext } from 'react'; import { Dispatch, SetStateAction, createContext, useContext } from 'react';
import { AnimationConfig } from '@/components/AnimateItems'; import { AnimationConfig } from '@/components/AnimateItems';
export interface AppStateContext { export interface AppStateContext {
previousPathname?: string previousPathname?: string
hasLoaded?: boolean hasLoaded?: boolean
setHasLoaded?: (hasLoaded: boolean) => void setHasLoaded?: Dispatch<SetStateAction<boolean>>
nextPhotoAnimation?: AnimationConfig nextPhotoAnimation?: AnimationConfig
isCommandKOpen?: boolean
setIsCommandKOpen?: Dispatch<SetStateAction<boolean>>
setNextPhotoAnimation?: (animation?: AnimationConfig) => void setNextPhotoAnimation?: (animation?: AnimationConfig) => void
clearNextPhotoAnimation?: () => void clearNextPhotoAnimation?: () => void
} }