cmdk mobile improvements (#27815)

mobile cmdk new layout and animation
This commit is contained in:
Francesco Sansalvadore authored and GitHub committed 2024-07-10 10:30:07 +02:00
1 parent 9d06a27e91
commit 6b0ea89f5b
9 files changed
+278 -188

No files matched your search

+2 -1
View File
@@ -9794,7 +9794,8 @@
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.0.5",
"license": "MIT",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz",
"integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.1",
+1
View File
@@ -3,6 +3,7 @@ export * from './useConstant'
export * from './useCopy'
export * from './useDebounce'
export * from './useDocsSearch'
export * from './useDragToClose'
export * from './useParams'
export * from './useSearchParamsShallow'
export * from './useTelemetryProps'
+42
View File
@@ -0,0 +1,42 @@
import React from 'react'
/**
* Custom hook to enable closing bottom drawers by dragging down.
*/
const useDragToClose = ({
onClose,
threshold = 100,
}: {
onClose: Function
threshold?: number
}) => {
const [startY, setStartY] = React.useState(0)
const [currentY, setCurrentY] = React.useState(0)
const ref = React.useRef<HTMLDivElement>(null)
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
setStartY(e.touches[0].clientY)
}
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
setCurrentY(e.touches[0].clientY)
const translateY = currentY - startY
if (translateY > 0 && ref.current) {
ref.current.style.transform = `translateY(${translateY}px)`
}
}
const handleTouchEnd = () => {
if (currentY - startY > threshold) {
onClose()
} else if (ref.current) {
ref.current.style.transform = 'translateY(0)'
}
setStartY(0)
setCurrentY(0)
}
return { ref, handleTouchStart, handleTouchMove, handleTouchEnd }
}
export default useDragToClose
-1
View File
@@ -1,6 +1,5 @@
const deepMerge = require('deepmerge')
const forms = require('@tailwindcss/forms')
const plugin = require('tailwindcss/plugin')
const radixUiColors = require('@radix-ui/colors')
const brandColors = require('./default-colors')
const svgToDataUri = require('mini-svg-data-uri')
+137 -120
View File
@@ -31,6 +31,7 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { cn } from 'ui/src/lib/utils'
import { StatusIcon } from '../Icons/StatusIcons'
import { isBrowser } from 'common'
const questions = [
'How do I get started with Supabase?',
@@ -320,12 +321,17 @@ export function queryAi(messages: Message[], timeout = 0) {
const AiCommand = () => {
const { isLoading, setIsLoading, search, setSearch } = useCommandMenu()
const isMobile = isBrowser && window.screen.width <= 640
const { submit, reset, messages, isResponding, hasError } = useAiChat({
setIsLoading,
})
const inputRef = useAutoInputFocus()
/**
* Disable autofocusing on the input on mobile
* since it messes up with the dialog height and positioning
* */
const inputRef = useAutoInputFocus(!isMobile)
useHistoryKeys({
enable: !isResponding,
@@ -353,139 +359,43 @@ const AiCommand = () => {
handleSubmit(search)
}
}, [])
// Detect an IME composition (so that we can ignore Enter keypress)
const [isImeComposing, setIsImeComposing] = useState(false)
const showSubmitButton = !isLoading && !isResponding && search
return (
<div onClick={(e) => e.stopPropagation()}>
<div className={cn('relative mb-[145px] py-4')}>
{!hasError &&
messages.map((message, index) => {
switch (message.role) {
case MessageRole.User:
return (
<div key={index} className="flex gap-6 mx-4 [overflow-anchor:none] mb-6">
<div
className="
w-7 h-7 bg-background rounded-full border border-muted flex items-center justify-center text-foreground-lighter first-letter:
ring-background
ring-1
shadow-sm
"
>
<IconUser strokeWidth={1.5} size={16} />
</div>
<div className="prose text-foreground-lighter">{message.content}</div>
</div>
)
case MessageRole.Assistant:
return (
<div key={index} className="px-4 [overflow-anchor:none] mb-[25px]">
<div className="flex gap-6 [overflow-anchor:none] mb-6">
<AiIconChat
loading={
message.status === MessageStatus.Pending ||
message.status === MessageStatus.InProgress
}
/>
<>
{message.status === MessageStatus.Pending ? (
<div className="bg-border-strong h-[21px] w-[13px] mt-1 animate-pulse animate-bounce"></div>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
...markdownComponents,
a: (props) => (
<a {...props} target="_blank" rel="noopener noreferrer" />
),
}}
className="prose dark:prose-dark"
urlTransform={(href: string) => {
const supabaseUrl = new URL('https://supabase.com')
const linkUrl = new URL(href, 'https://supabase.com')
if (linkUrl.origin === supabaseUrl.origin) {
return linkUrl.toString()
}
return href
}}
>
{message.content}
</ReactMarkdown>
)}
</>
</div>
</div>
)
}
})}
{messages.length === 0 && !hasError && (
<CommandGroup heading="Examples">
{questions.map((question) => {
const key = question.replace(/\s+/g, '_')
return (
<CommandItem
type="command"
onSelect={() => {
if (!search) {
handleSubmit(question)
}
}}
key={key}
>
<AiIconAnimation />
{question}
</CommandItem>
)
})}
</CommandGroup>
)}
{hasError && (
<div className="p-6 flex flex-col items-center gap-2 mt-4">
<StatusIcon variant="warning" />
<div>
<p className="text-sm text-foreground text-center">
Sorry, looks like Supabase AI is having a hard time!
</p>
<p className="text-sm text-foreground-lighter text-center">
Please try again in a bit.
</p>
</div>
<Button size="tiny" type="default" onClick={handleReset}>
Try again?
</Button>
</div>
)}
<div className="[overflow-anchor:auto] h-px w-full"></div>
</div>
<div className="absolute bottom-0 w-full bg-background py-3">
{messages.length > 0 && !hasError && <AiWarning className="mb-3 mx-3" />}
<div className="absolute z-10 top-[45px] md:top-auto md:bottom-0 w-full bg-background pt-2 md:pt-0 md:pb-2">
<Input
className="bg-alternative rounded mx-3 [&_input]:pr-32 md:[&_input]:pr-40"
className={cn(
'bg-alternative rounded mx-3 [&_input]:text-base [&_input]:placeholder:text-sm',
showSubmitButton && '[&_input]:pr-32 md:[&_input]:pr-40'
)}
inputRef={inputRef}
autoFocus
autoFocus={!isMobile}
placeholder={
isLoading || isResponding ? 'Waiting on an answer...' : 'Ask Supabase AI a question...'
}
value={search}
actions={
<>
{!isLoading && !isResponding ? (
<div
className={`flex items-center gap-3 mr-3 transition-opacity duration-700 ${
search ? 'opacity-100' : 'opacity-0'
}`}
{showSubmitButton ? (
<Button
onClick={() => {
if (!search || isLoading || isResponding || isImeComposing) {
return
}
return handleSubmit(search)
}}
className={`flex items-center mr-3 ${search ? 'opacity-100' : 'opacity-0'}`}
iconRight={
<div className="hidden md:flex">
<IconCornerDownLeft size={12} strokeWidth={1.5} />
</div>
}
>
<span className="text-foreground-light">Submit message</span>
<div className="hidden text-foreground-light md:flex items-center justify-center h-6 w-6 rounded bg-overlay-hover">
<IconCornerDownLeft size={12} strokeWidth={1.5} />
</div>
</div>
Submit message
</Button>
) : null}
</>
}
@@ -509,6 +419,113 @@ const AiCommand = () => {
}}
/>
</div>
<div className="pt-14 md:pt-0">
{messages.length > 0 && !hasError && <AiWarning className="mt-2 mx-3 px-3" />}
<div className={cn('relative py-3')}>
{!hasError &&
messages.map((message, index) => {
switch (message.role) {
case MessageRole.User:
return (
<div key={index} className="flex gap-3 mx-4 [overflow-anchor:none] mb-6">
<div
className="
w-7 h-7 bg-background rounded-full border border-muted flex items-center justify-center text-foreground-lighter first-letter:
ring-background
ring-1
shadow-sm
"
>
<IconUser strokeWidth={1.5} size={16} />
</div>
<div className="prose text-foreground-lighter">{message.content}</div>
</div>
)
case MessageRole.Assistant:
return (
<div key={index} className="px-4 pb-8 [overflow-anchor:none] mb-[25px]">
<div className="flex gap-4 [overflow-anchor:none] mb-6">
<AiIconChat
loading={
message.status === MessageStatus.Pending ||
message.status === MessageStatus.InProgress
}
/>
<>
{message.status === MessageStatus.Pending ? (
<div className="bg-border-strong h-[21px] w-[13px] mt-1 animate-pulse"></div>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
...markdownComponents,
a: (props) => (
<a {...props} target="_blank" rel="noopener noreferrer" />
),
}}
className="prose dark:prose-dark"
urlTransform={(href: string) => {
const supabaseUrl = new URL('https://supabase.com')
const linkUrl = new URL(href, 'https://supabase.com')
if (linkUrl.origin === supabaseUrl.origin) {
return linkUrl.toString()
}
return href
}}
>
{message.content}
</ReactMarkdown>
)}
</>
</div>
</div>
)
}
})}
{messages.length === 0 && !hasError && (
<CommandGroup heading="Examples">
{questions.map((question) => {
const key = question.replace(/\s+/g, '_')
return (
<CommandItem
type="command"
onSelect={() => {
if (!search) {
handleSubmit(question)
}
}}
key={key}
>
<AiIconAnimation />
{question}
</CommandItem>
)
})}
</CommandGroup>
)}
{hasError && (
<div className="p-6 flex flex-col items-center gap-2 mt-4">
<StatusIcon variant="warning" />
<div>
<p className="text-sm text-foreground text-center">
Sorry, looks like Supabase AI is having a hard time!
</p>
<p className="text-sm text-foreground-lighter text-center">
Please try again in a bit.
</p>
</div>
<Button size="tiny" type="default" onClick={handleReset}>
Try again?
</Button>
</div>
)}
<div className="[overflow-anchor:auto] h-px w-full"></div>
</div>
</div>
</div>
)
}
+60 -50
View File
@@ -1,5 +1,5 @@
import { Command as CommandPrimitive } from 'cmdk'
import * as React from 'react'
import { Command as CommandPrimitive } from 'cmdk'
import { ErrorBoundary } from 'react-error-boundary'
import { cn } from 'ui/src/lib/utils'
@@ -10,6 +10,7 @@ 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>
@@ -59,52 +60,62 @@ interface CommandDialogProps extends React.ComponentProps<typeof Dialog> {
setIsOpen: (open: boolean) => void
}
export const CommandDialog = ({
children,
onKeyDown,
page,
setIsOpen,
...props
}: CommandDialogProps) => {
const [animateBounce, setAnimateBounce] = React.useState(false)
type CommandPrimitiveDialogElement = React.ElementRef<typeof CommandPrimitive.Dialog>
React.useEffect(() => {
setAnimateBounce(true)
setTimeout(() => setAnimateBounce(false), 126)
}, [page])
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={props.visible || props.open}>
<DialogContent
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'}
className={cn('place-self-start mx-auto top-20')}
>
<ErrorBoundary FallbackComponent={CommandError}>
<Command
className={[
'[&_[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',
].join(' ')}
>
{children}
</Command>
</ErrorBoundary>
</DialogContent>
</Dialog>
)
}
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'
@@ -121,11 +132,10 @@ export const CommandInput = React.forwardRef<
<div className="flex flex-col items-center" cmdk-input-wrapper="">
<CommandPrimitive.Input
value={value}
autoFocus
onValueChange={onValueChange}
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent px-4 py-7 text-sm outline-none',
'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
@@ -418,12 +428,12 @@ export function useHistoryKeys({ enable, messages, setPrompt }: UseHistoryKeysOp
*
* @returns An input ref for the input to focus
*/
export function useAutoInputFocus() {
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 (inputElement) {
if (isEnabled && inputElement) {
setInput(inputElement)
// We need to delay the focus until the end of the call stack
@@ -442,7 +452,7 @@ export function useAutoInputFocus() {
}
}
window.addEventListener('keydown', onKeyDown)
isEnabled && window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
+32 -12
View File
@@ -38,6 +38,8 @@ import GenerateSQL from './GenerateSQL'
import SearchableStudioItems from './SearchableStudioItems'
import ThemeOptions from './ThemeOptions'
import sharedItems from './utils/shared-nav-items.json'
import useDragToClose from 'common/hooks/useDragToClose'
import { DialogHeader } from 'ui'
export const CHAT_ROUTES = [
COMMAND_ROUTES.AI, // this one is temporary
@@ -86,12 +88,22 @@ const CommandMenu = () => {
currentPage === COMMAND_ROUTES.DOCS_SEARCH ||
currentPage === COMMAND_ROUTES.AI ||
currentPage === COMMAND_ROUTES.GENERATE_SQL
? 'min(600px, 60vh)'
: '425px'
? 'min(600px, 70vh)'
: 'auto'
const {
ref: dialogRef,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
} = useDragToClose({
onClose: () => setIsOpen(!open),
})
return (
<>
<CommandDialog
ref={dialogRef}
setIsOpen={setIsOpen}
page={currentPage}
visible={isOpen}
@@ -99,15 +111,23 @@ const CommandMenu = () => {
setIsOpen(!isOpen)
}}
>
{pages.length > 0 && <CommandMenuShortcuts />}
{showCommandInput && (
<CommandInput
ref={commandInputRef}
placeholder="Type a command or search..."
value={search}
onValueChange={handleInputChange}
/>
)}
<DialogHeader
className="p-0 gap-0"
// Close dialog by dragging the header down
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{pages.length > 0 && <CommandMenuShortcuts />}
{showCommandInput && (
<CommandInput
ref={commandInputRef}
placeholder="Type a command or search..."
value={search}
onValueChange={handleInputChange}
/>
)}
</DialogHeader>
<CommandList
style={{
maxHeight: commandListMaxHeight,
@@ -118,7 +138,7 @@ const CommandMenu = () => {
? commandListMaxHeight
: 'auto',
}}
className="my-2"
className="pb-20 md:pb-0"
>
{!currentPage && (
<>
@@ -9,7 +9,7 @@ export default function CommandMenuShortcuts() {
const { setPages, pages, currentPage } = useCommandMenu()
return (
<div className="flex w-full gap-2 px-4 pt-4 justify-items-start flex-row items-center justify-between">
<div className="flex w-full gap-2 px-4 pt-4 pb-2 justify-items-start flex-row items-center justify-between">
<div className="flex gap-2 flex-row items-center">
<CommandShortcut onClick={() => setPages([])}>
<div className="flex items-center gap-2">
@@ -42,7 +42,7 @@ const DialogOverlay = React.forwardRef<
ref={ref}
className={cn(
'bg-black/40 backdrop-blur-sm',
'z-50 fixed inset-0 grid place-items-center overflow-y-auto data-open:animate-overlay-show data-closed:animate-overlay-hide',
'z-50 fixed inset-0 grid place-items-center overflow-y-auto data-closed:animate-overlay-hide',
className
)}
{...props}
@@ -54,9 +54,9 @@ const DialogContentVariants = cva(
cn(
'my-8',
'relative z-50 grid w-full border shadow-md dark:shadow-sm duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-[0%] data-[state=closed]:slide-out-to-top-[0%',
'data-[state=closed]:slide-out-to-left-[0%] data-[state=closed]:slide-out-to-top-[0%]',
'data-[state=open]:slide-in-from-left-[0%] data-[state=open]:slide-in-from-top-[0%]',
'sm:rounded-lg md:w-full',
'bg-200'