Files
supabase/packages/ui-patterns/Cmdk/Command.utils.tsx
Francesco Sansalvadore 6b0ea89f5b cmdk mobile improvements (#27815)
mobile cmdk new layout and animation
2024-07-10 10:30:07 +02:00

464 lines
15 KiB
TypeScript

import * as React from 'react'
import { Command as CommandPrimitive } from 'cmdk'
import { ErrorBoundary } from 'react-error-boundary'
import { cn } from 'ui/src/lib/utils'
import { AlertTriangle } from 'lucide-react'
import { DetailedHTMLProps, HTMLAttributes, KeyboardEventHandler } from 'react'
import { Dialog, DialogContent } from 'ui'
import { Button } from 'ui/src/components/Button'
import { LoadingLine } from 'ui/src/components/LoadingLine/LoadingLine'
import { useCommandMenu } from './CommandMenuContext'
import { useBreakpoint } from 'common'
type CommandPrimitiveElement = React.ElementRef<typeof CommandPrimitive>
type CommandPrimitiveProps = React.ComponentPropsWithoutRef<typeof CommandPrimitive>
export const copyToClipboard = (str: string, callback = () => {}) => {
const focused = window.document.hasFocus()
if (focused) {
window.navigator?.clipboard?.writeText(str).then(callback)
} else {
console.warn('Unable to copy to clipboard')
}
}
export const Command = React.forwardRef<CommandPrimitiveElement, CommandPrimitiveProps>(
({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn('flex h-full w-full flex-col overflow-hidden', className)}
{...props}
/>
)
)
Command.displayName = CommandPrimitive.displayName
export 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>
)
}
interface CommandDialogProps extends React.ComponentProps<typeof Dialog> {
onKeyDown?: KeyboardEventHandler<HTMLDivElement>
page?: number | string
visible?: boolean
setIsOpen: (open: boolean) => void
}
type CommandPrimitiveDialogElement = React.ElementRef<typeof CommandPrimitive.Dialog>
export const CommandDialog = React.forwardRef<CommandPrimitiveDialogElement, CommandDialogProps>(
({ children, onKeyDown, page, setIsOpen, ...props }: CommandDialogProps, ref) => {
const isOpen = props.visible || props.open
const isMobile = useBreakpoint()
return (
<Dialog {...props} open={isOpen} onOpenChange={setIsOpen}>
<DialogContent
ref={ref}
forceMount
onOpenAutoFocus={(e) => isMobile && e.preventDefault()}
onInteractOutside={(e) => {
// Only hide menu when clicking outside, not focusing outside
// Prevents Firefox dropdown issue that immediately closes menu after opening
if (e.type === 'dismissableLayer.pointerDownOutside') {
setIsOpen(!open)
}
}}
hideClose
size="xlarge"
dialogOverlayProps={{
className: cn('overflow-hidden flex data-closed:delay-100'),
}}
className={cn(
'relative my-0 mx-auto rounded-t-lg overflow-y-scroll',
'h-[85dvh] mt-[15vh] md:max-h-[500px] md:mt-0 left-0 bottom-0 md:bottom-auto',
'place-self-start md:place-self-auto',
isOpen && '!animate-in !slide-in-from-bottom !duration-300',
'data-[state=closed]:!animate-out data-[state=closed]:!slide-out-to-bottom',
'md:data-[state=open]:!animate-in md:data-[state=closed]:!animate-out',
'md:data-[state=closed]:!zoom-out-95 md:data-[state=open]:!zoom-in-95',
'md:data-[state=closed]:!slide-out-to-left-[0%] md:data-[state=closed]:!slide-out-to-top-[0%]',
'md:data-[state=open]:!slide-in-from-left-[0%] md:data-[state=open]:!slide-in-from-top-[0%]'
)}
>
<ErrorBoundary FallbackComponent={CommandError}>
<Command
className={cn(
'[&_[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-input-wrapper]_svg]:h-5',
'[&_[cmdk-input-wrapper]_svg]:w-5',
'[&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0'
)}
>
{children}
</Command>
</ErrorBoundary>
</DialogContent>
</Dialog>
)
}
)
CommandDialog.displayName = 'CommandDialog'
type CommandPrimitiveInputElement = React.ElementRef<typeof CommandPrimitive.Input>
type CommandPrimitiveInputProps = React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
export const CommandInput = React.forwardRef<
CommandPrimitiveInputElement,
CommandPrimitiveInputProps
>(({ className, value, onValueChange, ...props }, ref) => {
const { isLoading } = useCommandMenu()
return (
<div className="flex flex-col items-center" cmdk-input-wrapper="">
<CommandPrimitive.Input
value={value}
onValueChange={onValueChange}
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent px-4 py-7 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}
/>
<LoadingLine loading={isLoading} />
</div>
)
})
CommandInput.displayName = CommandPrimitive.Input.displayName
type CommandPrimitiveListElement = React.ElementRef<typeof CommandPrimitive.List>
type CommandPrimitiveListProps = React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
export const CommandList = React.forwardRef<CommandPrimitiveListElement, CommandPrimitiveListProps>(
({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('overflow-y-auto overflow-x-hidden bg-transparent', className)}
{...props}
/>
)
)
CommandList.displayName = CommandPrimitive.List.displayName
type CommandPrimitiveEmptyElement = React.ElementRef<typeof CommandPrimitive.Empty>
type CommandPrimitiveEmptyProps = React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
export const CommandEmpty = React.forwardRef<
CommandPrimitiveEmptyElement,
CommandPrimitiveEmptyProps
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm text-foreground-muted"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
type CommandPrimitiveGroupElement = React.ElementRef<typeof CommandPrimitive.Group>
type CommandPrimitiveGroupProps = React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
export const CommandGroup = React.forwardRef<
CommandPrimitiveGroupElement,
CommandPrimitiveGroupProps
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
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 = CommandPrimitive.Group.displayName
type CommandPrimitiveSeparatorElement = React.ElementRef<typeof CommandPrimitive.Separator>
type CommandPrimitiveSeparatorProps = React.ComponentPropsWithoutRef<
typeof CommandPrimitive.Separator
>
export const CommandSeparator = React.forwardRef<
CommandPrimitiveSeparatorElement,
CommandPrimitiveSeparatorProps
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn(
`h-px
w-full
bg-border
`,
className
)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
type CommandPrimitiveItemElement = React.ElementRef<typeof CommandPrimitive.Item>
type CommandPrimitiveItemProps = React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
export interface CommandItemProps extends CommandPrimitiveItemProps {
type: 'link' | 'block-link' | 'command'
badge?: React.ReactNode
}
export const CommandItem = React.forwardRef<CommandPrimitiveItemElement, CommandItemProps>(
({ className, type, children, badge, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'cursor-default',
'select-none',
'items-center',
'rounded-md',
'text-sm',
'group',
'py-3',
'text-foreground-light',
'relative',
'flex',
type === 'block-link'
? `
bg-surface-200 dark:bg-surface-100
px-5
transition-all
outline-none
aria-selected:border-foreground-muted
aria-selected:bg-selection
dark:aria-selected:bg-selection
aria-selected:shadow-sm
data-[disabled]:pointer-events-none data-[disabled]:opacity-50`
: type === 'link'
? `
px-2
transition-all
outline-none
aria-selected:bg-selection/90
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
`,
className
)}
{...props}
>
<div className="w-full flex flex-row justify-between items-center">
<div className="flex flex-row gap-2 flex-grow items-center">{children}</div>
{badge}
</div>
</CommandPrimitive.Item>
)
)
CommandItem.displayName = CommandPrimitive.Item.displayName
export const CommandItemStale = React.forwardRef<CommandPrimitiveItemElement, CommandItemProps>(
({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'text-foreground-light relative flex cursor-default select-none items-center rounded-md py-1.5 px-2 text-sm outline-none aria-selected:bg-overlay-selection data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
/>
)
)
CommandItemStale.displayName = 'CommandItemStale'
interface CommandShortcutProps {
className?: string
children?: React.ReactNode
onClick?: () => void
type?: 'default' | 'breadcrumb'
}
export const CommandShortcut = ({
className,
children,
onClick,
type = 'default',
}: CommandShortcutProps) => {
return (
<button
onClick={onClick}
className={cn(
'cursor-default px-1.5 py-0.5 rounded text-xs [&:not(:last-child)]:hover:cursor-pointer',
'justify-end',
type === 'breadcrumb'
? 'text-foreground-muted'
: 'bg-overlay-hover text-foreground-muted [&:not(:last-child)]:hover:bg-selection last:bg-selection last:text-foreground-muted',
className
)}
>
{children}
</button>
)
}
CommandShortcut.displayName = 'CommandShortcut'
export const CommandLabel = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span {...props} className={cn('grow', className)} />
}
CommandShortcut.displayName = 'CommandLabel'
export interface TextHighlighterProps
extends DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement> {
text: string
query: string
}
export const TextHighlighter = ({ text, query, ...props }: TextHighlighterProps) => {
// Wrap all instances of `query` in a span to make them bold
const elements = text.split(query).flatMap((part, index, parts) => {
const returnValue = [<>{part}</>]
// Add back the wrapped `query` (if it's not the last element)
if (index !== parts.length - 1) {
returnValue.push(<span className="text-foreground">{query}</span>)
}
return returnValue
})
return <span {...props}>{elements}</span>
}
TextHighlighter.displayName = 'TextHighlighter'
export interface UseHistoryKeysOptions {
enable: boolean
messages: string[]
setPrompt: (prompt: string) => void
}
/**
* Enables a shell-style message history when hitting
* up/down on the keyboard
*/
export function useHistoryKeys({ enable, messages, setPrompt }: UseHistoryKeysOptions) {
// Message index when hitting up/down on the keyboard (shell style)
const [, setMessageSelectionIndex] = React.useState(0)
React.useEffect(() => {
if (enable) {
return
}
// Note: intentionally setting index to 1 greater than max index
setMessageSelectionIndex(messages.length)
}, [messages, enable])
React.useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowUp':
setMessageSelectionIndex((index) => {
const newIndex = Math.max(index - 1, 0)
const newMessage = messages[newIndex]
if (newMessage) {
setPrompt(newMessage)
}
return newIndex
})
return
case 'ArrowDown':
setMessageSelectionIndex((index) => {
const newIndex = Math.min(index + 1, messages.length)
const newMessage = messages[newIndex]
if (newMessage) {
setPrompt(newMessage)
}
return newIndex
})
return
default:
return
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
}
}, [messages])
}
/**
* Automatically focuses an input on key press
* and on load (after the call stack)
*
* @returns An input ref for the input to focus
*/
export function useAutoInputFocus(isEnabled: boolean = true) {
const [input, setInput] = React.useState<HTMLInputElement>()
// Use a callback-style ref to access the element when it mounts
const inputRef = React.useCallback((inputElement: HTMLInputElement) => {
if (isEnabled && inputElement) {
setInput(inputElement)
// We need to delay the focus until the end of the call stack
// due to order of operations
setTimeout(() => {
inputElement.focus()
}, 0)
}
}, [])
// Focus the input when typing from anywhere
React.useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (!e.ctrlKey && !e.altKey && !e.metaKey && e.key !== 'Tab') {
input?.focus()
}
}
isEnabled && window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
}
}, [input])
return inputRef
}