feat: finish command menu base

This commit is contained in:
Charis Lam committed 2024-07-02 17:01:19 -04:00
1 parent ce808954df
commit 649257b4ef
23 files changed
+612 -23

No files matched your search

+2
View File
@@ -42943,6 +42943,7 @@
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.5.0",
"react-tooltip": "*",
"react-use": "^17.5.0",
"reactflow": "*",
"remark-gfm": "^4.0.0",
"sql-formatter": "^15.3.1",
@@ -42962,6 +42963,7 @@
"api-types": "*",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"next-router-mock": "^0.9.13",
"ts-jest": "^29.1.2"
},
"peerDependencies": {
@@ -0,0 +1,17 @@
import { Microscope } from 'lucide-react'
import { Badge } from 'ui'
interface BadgeExperimentalProps {
className?: string
}
function BadgeExperimental({ className }: BadgeExperimentalProps) {
return (
<Badge className={className}>
<Microscope className="!mr-1.5 !w-3.5 !h-3.5" /> Experimental
</Badge>
)
}
export { BadgeExperimental }
@@ -0,0 +1,36 @@
'use client'
import { forwardRef } from 'react'
import { CommandInput_Shadcn_, cn } from 'ui'
import { useQuery, useSetQuery } from './hooks/queryHooks'
const CommandInput = forwardRef<
React.ElementRef<typeof CommandInput_Shadcn_>,
React.ComponentPropsWithoutRef<typeof CommandInput_Shadcn_>
>(({ className, ...props }, ref) => {
const query = useQuery()
const setQuery = useSetQuery()
return (
<CommandInput_Shadcn_
autoFocus
ref={ref}
value={query}
onValueChange={setQuery}
placeholder="Type a command or search..."
className={cn(
'flex h-11 w-full rounded-md bg-transparent px-4 py-7 text-sm outline-none',
'focus:shadow-none focus:ring-transparent',
'text-foreground-light placeholder:text-foreground-muted disabled:cursor-not-allowed disabled:opacity-50 border-0',
className
)}
{...props}
/>
)
})
CommandInput.displayName = CommandInput_Shadcn_.displayName
export { CommandInput }
@@ -0,0 +1,60 @@
'use client'
import { forwardRef, useRef } from 'react'
import { CommandList_Shadcn_, cn } from 'ui'
import { CommandItem, type ICommand } from '../internal/Command'
import { CommandEmpty } from '../internal/CommandEmpty'
import { CommandGroup } from '../internal/CommandGroup'
import { useCommands } from './hooks/commandsHooks'
import { useQuery } from './hooks/queryHooks'
import { TextHighlighter } from './TextHighlighter'
const CommandList = forwardRef<
React.ElementRef<typeof CommandList_Shadcn_>,
React.ComponentPropsWithoutRef<typeof CommandList_Shadcn_>
>(({ className, ...props }, ref) => {
const commandSections = useCommands()
const query = useQuery()
const innerRef = useRef<HTMLDivElement | undefined>(undefined)
const setInnerRef = (elem: HTMLDivElement) => (innerRef.current = elem)
const setRef = (elem: HTMLDivElement) => {
if (ref) typeof ref === 'function' ? ref(elem) : (ref.current = elem)
setInnerRef(elem)
}
return (
<CommandList_Shadcn_
ref={setRef}
className={cn('overflow-y-auto overflow-x-hidden bg-transparent', className)}
{...props}
>
<CommandEmpty listRef={innerRef}>No results found.</CommandEmpty>
{commandSections.map((section) => {
if (section.commands.every((command) => command.defaultHidden) && !query) return null
return (
<CommandGroup key={section.id} heading={section.name} forceMount={section.forceMount}>
{section.commands
.filter((command) => !command.defaultHidden || query)
.map((_command) => {
const command = _command as ICommand // strip the readonly applied from the proxy
return (
<CommandItem key={command.id} command={command}>
<TextHighlighter>{command.name}</TextHighlighter>
</CommandItem>
)
})}
</CommandGroup>
)
})}
</CommandList_Shadcn_>
)
})
CommandList.displayName = CommandList_Shadcn_.displayName
export { CommandList }
@@ -0,0 +1,115 @@
'use client'
import { AlertTriangle, ArrowLeft } from 'lucide-react'
import { type PropsWithChildren, forwardRef, useEffect, useState } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { Button, Command_Shadcn_, Dialog, DialogContent, cn } from 'ui'
import { useCurrentPage, usePageComponent, usePopPage } from './hooks/pagesHooks'
import { useQuery, useSetQuery } from './hooks/queryHooks'
import { useCommandMenuSize, useCommandMenuOpen, useSetCommandMenuOpen } from './hooks/viewHooks'
import { PageType } from './utils'
function Breadcrumb() {
const currPage = useCurrentPage()
const popPage = usePopPage()
if (!currPage) return
return (
<div className="flex items-center gap-2 mt-2 ml-2 text-xs text-foreground-muted">
<button onClick={popPage}>
<ArrowLeft width={12} height={12} />
</button>
{currPage.name}
</div>
)
}
const CommandWrapper = forwardRef<
React.ElementRef<typeof Command_Shadcn_>,
React.ComponentPropsWithoutRef<typeof Command_Shadcn_>
>(({ children, className, ...props }, ref) => {
const currPage = useCurrentPage()
return (
<Command_Shadcn_
ref={ref}
className={cn(
'h-full w-full flex flex-col overflow-hidden',
'[&_[cmdk-group]]:px-2 [&_[cmdk-group]]:!bg-transparent [&_[cmdk-group-heading]]:!bg-transparent [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-border-stronger [&_[cmdk-input]]:h-12',
'[&_[cmdk-item]_svg]:h-5',
'[&_[cmdk-item]_svg]:w-5',
'[&_[cmdk-item]_svg]:stroke-1',
'[&_[cmdk-input-wrapper]_svg]:h-5',
'[&_[cmdk-input-wrapper]_svg]:w-5',
'[&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0',
className
)}
{...props}
>
{currPage?.type === PageType.Commands && <Breadcrumb />}
{children}
</Command_Shadcn_>
)
})
CommandWrapper.displayName = Command_Shadcn_.displayName
function CommandError({ resetErrorBoundary }: { resetErrorBoundary: () => void }) {
return (
<div className={cn('min-h-64', 'flex items-center justify-center')}>
<div className="p-10 flex flex-col items-center gap-6 mt-4">
<AlertTriangle strokeWidth={1.5} size={40} />
<p className="text-lg text-center">
Sorry, looks like we&apos;re having some issues with the command menu!
</p>
<p className="text-sm text-center">Please try again in a bit.</p>
<Button size="tiny" type="secondary" onClick={resetErrorBoundary}>
Try again?
</Button>
</div>
</div>
)
}
function PageSwitch({ children }: PropsWithChildren) {
const PageComponent = usePageComponent()
return PageComponent ? <PageComponent /> : <CommandWrapper>{children}</CommandWrapper>
}
function CommandMenu({ children }: PropsWithChildren) {
const open = useCommandMenuOpen()
const setOpen = useSetCommandMenuOpen()
const size = useCommandMenuSize()
const page = useCurrentPage()
const popPage = usePopPage()
const query = useQuery()
const setQuery = useSetQuery()
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
hideClose
onInteractOutside={() => setOpen(false)}
onEscapeKeyDown={(e) => {
e.preventDefault()
return query ? setQuery('') : page ? popPage() : setOpen(false)
}}
size={size}
className={cn('place-self-start mx-auto top-24')}
>
<ErrorBoundary FallbackComponent={CommandError}>
<PageSwitch>{children}</PageSwitch>
</ErrorBoundary>
</DialogContent>
</Dialog>
)
}
export { CommandMenu, CommandWrapper }
@@ -1,12 +1,15 @@
'use client'
import { useConstant } from 'common'
import { useEffect, type PropsWithChildren } from 'react'
import { type PropsWithChildren, useEffect, useMemo, useCallback } from 'react'
import { useToggleCommandMenu } from './hooks/viewHooks'
import { CommandContext } from '../internal/Context'
import { initCommandsState } from '../internal/state/commandsState'
import { initPagesState } from '../internal/state/pagesState'
import { initQueryState } from '../internal/state/queryState'
import { initViewState } from '../internal/state/viewState'
import { useCrossCompatRouter } from './hooks/useCrossCompatRouter'
import { useSetCommandMenuOpen, useToggleCommandMenu } from './hooks/viewHooks'
const CommandProviderInternal = ({ children }: PropsWithChildren) => {
const combinedState = useConstant(() => ({
@@ -19,6 +22,7 @@ const CommandProviderInternal = ({ children }: PropsWithChildren) => {
return <CommandContext.Provider value={combinedState}>{children}</CommandContext.Provider>
}
// This is a component not a hook so it can access the wrapping context.
const CommandShortcut = () => {
const toggleOpen = useToggleCommandMenu()
@@ -38,9 +42,27 @@ const CommandShortcut = () => {
return null
}
// This is a component not a hook so it can access the wrapping context.
function CloseOnNavigation() {
const setIsOpen = useSetCommandMenuOpen()
const router = useCrossCompatRouter()
const completeNavigation = useCallback(() => {
setIsOpen(false)
}, [setIsOpen])
useEffect(() => {
router.events.onRouteChangeComplete(completeNavigation)
return () => router.events.offRouteChangeComplete(completeNavigation)
}, [router])
return null
}
const CommandProvider = ({ children }: PropsWithChildren) => (
<CommandProviderInternal>
<CommandShortcut />
<CloseOnNavigation />
{children}
</CommandProviderInternal>
)
@@ -0,0 +1,42 @@
'use client'
import {
Children,
type DetailedHTMLProps,
type HTMLAttributes,
type PropsWithChildren,
} from 'react'
import { useQuery } from './hooks/queryHooks'
interface TextHighlighterBaseProps
extends PropsWithChildren<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>> {
query: string
text?: string
}
function TextHighlighterBase({ children, text, query, ...props }: TextHighlighterBaseProps) {
const child = text ?? Children.toArray(children)[0]
if (typeof child !== 'string') return child
const idx = child.toLowerCase().indexOf(query.toLowerCase())
if (idx === -1) {
return <span {...props}>{child}</span>
}
return (
<span {...props}>
{child.substring(0, idx)}
<span className="text-foreground">{child.substring(idx, idx + query.length)}</span>
{child.substring(idx + query.length)}
</span>
)
}
function TextHighlighter(props: Omit<TextHighlighterBaseProps, 'query'>) {
const query = useQuery()
return <TextHighlighterBase query={query} {...props} />
}
export { TextHighlighter, TextHighlighterBase }
@@ -1,3 +1,5 @@
'use client'
import { isEqual } from 'lodash'
import { useEffect, useMemo, useRef } from 'react'
import { useSnapshot } from 'valtio'
@@ -1,3 +1,5 @@
'use client'
import { isEqual } from 'lodash'
import { useEffect, useMemo, useRef } from 'react'
import { useSnapshot } from 'valtio'
@@ -0,0 +1,67 @@
'use client'
import { useRouter as useLegacyRouter } from 'next/compat/router'
import { useRouter as useNextRouter } from 'next/navigation'
import { useEffect, useMemo, useRef, useTransition } from 'react'
import { usePrevious } from 'react-use'
type Handler = (...evts: any[]) => void
function useHasChanged<T>(value: T, { onlyTo }: { onlyTo?: T } = {}) {
const prev = usePrevious(value)
if (prev === value) return false
if (onlyTo && value !== onlyTo) return false
return true
}
function useCrossCompatRouter() {
const legacyRouter = useLegacyRouter()
const newRouter = useNextRouter()
const isUsingLegacyRouting = !!legacyRouter
const [isPending, startTransition] = useTransition()
const hasPendingEnded = useHasChanged(isPending, { onlyTo: false })
const onPendingEnd = useRef(new Set<() => void>())
useEffect(() => {
if (!isUsingLegacyRouting && hasPendingEnded) {
onPendingEnd.current.forEach((fn) => fn())
}
}, [hasPendingEnded, isUsingLegacyRouting])
const api = useMemo(
() => ({
events: {
onRouteChangeComplete: (fn: Handler) => {
if (isUsingLegacyRouting) {
legacyRouter.events.on('routeChangeComplete', fn)
} else {
onPendingEnd.current.add(fn)
}
},
offRouteChangeComplete: (fn: Handler) => {
if (isUsingLegacyRouting) {
legacyRouter.events.off('routeChangeComplete', fn)
} else {
onPendingEnd.current.delete(fn)
}
},
},
push: (path: string) => {
if (isUsingLegacyRouting) {
legacyRouter.push(path)
} else {
startTransition(() => {
newRouter.push(path)
})
}
},
}),
[]
)
return api
}
export { useCrossCompatRouter }
@@ -0,0 +1,46 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useSetQuery } from './queryHooks'
interface UseHistoryKeysOptions {
stack: string[]
enable?: boolean
}
/**
* Enables shell-style message history when hitting up/down on the keyboard
*/
function useHistoryKeys({ stack, enable = true }: UseHistoryKeysOptions) {
const [idx, setIdx] = useState(0)
const setQuery = useSetQuery()
const navigateIdx = useCallback((newIdx: number) => {
if (!enable) return
setQuery(stack[newIdx] ?? '')
setIdx(newIdx)
}, [])
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowUp':
return navigateIdx(Math.max(idx - 1, 0))
case 'ArrowDown':
return navigateIdx(Math.min(idx + 1, stack.length - 1))
default:
return
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
}
}, [navigateIdx, stack])
}
export { useHistoryKeys }
@@ -1,3 +1,5 @@
'use client'
import { useLayoutEffect, useRef } from 'react'
import { useSnapshot } from 'valtio'
@@ -10,7 +12,7 @@ const useCommandMenuInitiated = () => {
return initiated
}
const useCommandMenuVisible = () => {
const useCommandMenuOpen = () => {
const { viewState } = useCommandContext()
const { open } = useSnapshot(viewState)
return open
@@ -28,18 +30,6 @@ const useToggleCommandMenu = () => {
return toggleOpen
}
const useIsCommandNavigating = () => {
const { viewState } = useCommandContext()
const { isNavigating } = useSnapshot(viewState)
return isNavigating
}
const useSetIsCommandNavigating = () => {
const { viewState } = useCommandContext()
const { setIsNavigating } = useSnapshot(viewState)
return setIsNavigating
}
const useCommandMenuSize = () => {
const { viewState } = useCommandContext()
const { size } = useSnapshot(viewState)
@@ -60,11 +50,9 @@ const useSetCommandMenuSize = (newSize: DialogSize) => {
export {
useCommandMenuInitiated,
useCommandMenuVisible,
useCommandMenuOpen,
useSetCommandMenuOpen,
useToggleCommandMenu,
useIsCommandNavigating,
useSetIsCommandNavigating,
useCommandMenuSize,
useSetCommandMenuSize,
}
@@ -1 +1,6 @@
const escapeAttributeSelector = (str: string) => encodeURIComponent(str)
export { escapeAttributeSelector }
export { generateCommandClassNames } from '../internal/Command'
export { orderSectionFirst } from '../internal/state/commandsState'
export { PageType } from '../internal/state/pagesState'
@@ -0,0 +1,14 @@
export * from './api/Badges'
export { CommandInput } from './api/CommandInput'
export { CommandList } from './api/CommandList'
export { CommandMenu, CommandWrapper } from './api/CommandMenu'
export { CommandProvider } from './api/CommandProvider'
export { TextHighlighter, TextHighlighterBase } from './api/TextHighlighter'
export * from './api/hooks/commandsHooks'
export * from './api/hooks/pagesHooks'
export * from './api/hooks/queryHooks'
export { useHistoryKeys } from './api/hooks/useHistoryKeys'
export * from './api/hooks/viewHooks'
export * from './api/utils'
export * from './api/types'
@@ -1,4 +1,10 @@
import { type ReactNode } from 'react'
'use client'
import { type PropsWithChildren, type ReactNode, forwardRef } from 'react'
import { CommandItem_Shadcn_, cn } from 'ui'
import { useCrossCompatRouter } from '../api/hooks/useCrossCompatRouter'
import { useSetCommandMenuOpen } from '../api/hooks/viewHooks'
type ICommand = IActionCommand | IRouteCommand
@@ -32,4 +38,89 @@ interface IRouteCommand extends IBaseCommand {
route: `/${string}` | `http${string}`
}
const isActionCommand = (command: ICommand): command is IActionCommand => 'action' in command
const isRouteCommand = (command: ICommand): command is IRouteCommand => 'route' in command
const generateCommandClassNames = (isLink: boolean) =>
cn(
'cursor-default',
'select-none',
'items-center gap-2',
'rounded-md',
'text-sm',
'group',
'py-3',
'text-foreground-light',
'relative',
'flex',
isLink
? `
bg-transparent
px-2
transition-all
outline-none
aria-selected:border-overlay
aria-selected:bg-selection/90
aria-selected:shadow-sm
aria-selected:scale-[100.3%]
data-[disabled]:pointer-events-none data-[disabled]:opacity-50`
: `
px-2
aria-selected:bg-selection/80
aria-selected:backdrop-filter
aria-selected:backdrop-blur-md
data-[disabled]:pointer-events-none
data-[disabled]:opacity-50
`
)
interface CommandItemProps extends React.ComponentPropsWithoutRef<typeof CommandItem_Shadcn_> {
command: ICommand
}
const CommandItem = forwardRef<
React.ElementRef<typeof CommandItem_Shadcn_>,
PropsWithChildren<CommandItemProps>
>(({ children, className, command: _command, ...props }, ref) => {
const router = useCrossCompatRouter()
const setIsOpen = useSetCommandMenuOpen()
const command = _command as ICommand // strip the readonly applied from the proxy
return (
<CommandItem_Shadcn_
ref={ref}
forceMount={command.forceMount}
onSelect={
isActionCommand(command)
? command.action
: isRouteCommand(command)
? () => {
command.route.startsWith('http')
? (setIsOpen(false), window.open(command.route, '_blank', 'noreferrer,noopener'))
: router.push(command.route)
}
: () => {}
}
value={command.value}
className={cn(
generateCommandClassNames(isRouteCommand(command)),
className,
command.className
)}
{...props}
>
<div className="w-full flex flex-row justify-between items-center">
<div className="flex flex-row gap-2 flex-grow items-center">
{command.icon?.()}
{children}
</div>
{command.badge?.()}
</div>
</CommandItem_Shadcn_>
)
})
CommandItem.displayName = CommandItem_Shadcn_.displayName
export { CommandItem, generateCommandClassNames, isActionCommand, isRouteCommand }
export type { ICommand }
@@ -0,0 +1,46 @@
'use client'
import { type PropsWithChildren, type RefObject, useMemo, useState } from 'react'
import { cn } from 'ui'
import { useQuery } from '../api/hooks/queryHooks'
const CommandEmpty = ({
children,
className,
listRef,
...props
}: PropsWithChildren<{
className?: string
/**
* Reference to the div that contains the command item list in the DOM.
*
* Hacking around a bug in cmdk where the empty state will show even when
* there are force-mounted items.
*/
listRef: RefObject<HTMLDivElement | undefined>
}>) => {
const query = useQuery()
const [render, setRender] = useState(false)
useMemo(() => {
if (!query) setRender(false)
/**
* In a timeout so the DOM update happens before we query.
*/
setTimeout(() => {
setRender(!listRef?.current?.querySelector('[cmdk-item]'))
})
}, [query])
return (
render && (
<div className={cn('py-6 text-center text-sm text-foreground-muted', className)} {...props}>
{children}
</div>
)
)
}
export { CommandEmpty }
@@ -0,0 +1,24 @@
'use client'
import { forwardRef } from 'react'
import { CommandGroup_Shadcn_, cn } from 'ui'
const CommandGroup = forwardRef<
React.ElementRef<typeof CommandGroup_Shadcn_>,
React.ComponentPropsWithoutRef<typeof CommandGroup_Shadcn_>
>(({ className, ...props }, ref) => {
return (
<CommandGroup_Shadcn_
ref={ref}
className={cn(
'overflow-hidden py-3 px-2 text-foreground-muted [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pb-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-normal [&_[cmdk-group-heading]]:text-foreground-muted',
className
)}
{...props}
/>
)
})
CommandGroup.displayName = CommandGroup_Shadcn_.displayName
export { CommandGroup }
@@ -1,3 +1,5 @@
'use client'
import { createContext, useContext } from 'react'
import { type ICommandsState } from '../internal/state/commandsState'
@@ -8,8 +8,6 @@ type IViewState = {
open: boolean
setOpen: (open: boolean) => void
toggleOpen: () => void
isNavigating: boolean
setIsNavigating: (isNavigating: boolean) => void
size: DialogSize
setSize: (size: DialogSize) => void
}
@@ -27,8 +25,6 @@ const initViewState = () => {
state.init()
state.open = !state.open
},
isNavigating: false,
setIsNavigating: (isNavigating) => (state.isNavigating = isNavigating),
size: 'large',
setSize: (size) => (state.size = size),
})
+4
View File
@@ -2,4 +2,8 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^.+\\.s?css$': '<rootDir>/stubs/cssStub.js',
},
}
+1
View File
@@ -0,0 +1 @@
jest.mock('next/navigation', () => require('next-router-mock'))
+2
View File
@@ -24,6 +24,7 @@
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.5.0",
"react-tooltip": "*",
"react-use": "^17.5.0",
"reactflow": "*",
"remark-gfm": "^4.0.0",
"sql-formatter": "^15.3.1",
@@ -43,6 +44,7 @@
"api-types": "*",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"next-router-mock": "^0.9.13",
"ts-jest": "^29.1.2"
},
"peerDependencies": {
+5
View File
@@ -0,0 +1,5 @@
/**
* A testing stub for CSS imports.
*/
module.exports = {}