From 6b0ea89f5be3cb61704daf3a517e4e68198ab32c Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Wed, 10 Jul 2024 10:30:07 +0200 Subject: [PATCH] cmdk mobile improvements (#27815) mobile cmdk new layout and animation --- package-lock.json | 3 +- packages/common/hooks/index.ts | 1 + packages/common/hooks/useDragToClose.tsx | 42 +++ packages/config/ui.config.js | 1 - packages/ui-patterns/Cmdk/AiCommand.tsx | 257 ++++++++++-------- packages/ui-patterns/Cmdk/Command.utils.tsx | 110 ++++---- packages/ui-patterns/Cmdk/CommandMenu.tsx | 44 ++- .../ui-patterns/Cmdk/CommandMenuShortcuts.tsx | 2 +- .../ui/src/components/shadcn/ui/dialog.tsx | 6 +- 9 files changed, 278 insertions(+), 188 deletions(-) create mode 100644 packages/common/hooks/useDragToClose.tsx diff --git a/package-lock.json b/package-lock.json index b934e886737..d861b512f8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/common/hooks/index.ts b/packages/common/hooks/index.ts index 8d3a720025b..b94155dac83 100644 --- a/packages/common/hooks/index.ts +++ b/packages/common/hooks/index.ts @@ -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' diff --git a/packages/common/hooks/useDragToClose.tsx b/packages/common/hooks/useDragToClose.tsx new file mode 100644 index 00000000000..afa0596a400 --- /dev/null +++ b/packages/common/hooks/useDragToClose.tsx @@ -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(null) + + const handleTouchStart = (e: React.TouchEvent) => { + setStartY(e.touches[0].clientY) + } + + const handleTouchMove = (e: React.TouchEvent) => { + 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 diff --git a/packages/config/ui.config.js b/packages/config/ui.config.js index 6005aef8551..b4fbd57d498 100644 --- a/packages/config/ui.config.js +++ b/packages/config/ui.config.js @@ -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') diff --git a/packages/ui-patterns/Cmdk/AiCommand.tsx b/packages/ui-patterns/Cmdk/AiCommand.tsx index c9130f73e98..e8d3c6c4274 100644 --- a/packages/ui-patterns/Cmdk/AiCommand.tsx +++ b/packages/ui-patterns/Cmdk/AiCommand.tsx @@ -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 (
e.stopPropagation()}> -
- {!hasError && - messages.map((message, index) => { - switch (message.role) { - case MessageRole.User: - return ( -
-
- -
-
{message.content}
-
- ) - case MessageRole.Assistant: - return ( - - ) - } - })} - - {messages.length === 0 && !hasError && ( - - {questions.map((question) => { - const key = question.replace(/\s+/g, '_') - return ( - { - if (!search) { - handleSubmit(question) - } - }} - key={key} - > - - {question} - - ) - })} - - )} - {hasError && ( -
- -
-

- Sorry, looks like Supabase AI is having a hard time! -

-

- Please try again in a bit. -

-
- -
- )} - -
-
-
- {messages.length > 0 && !hasError && } +
- {!isLoading && !isResponding ? ( -
{ + if (!search || isLoading || isResponding || isImeComposing) { + return + } + return handleSubmit(search) + }} + className={`flex items-center mr-3 ${search ? 'opacity-100' : 'opacity-0'}`} + iconRight={ +
+ +
+ } > - Submit message -
- -
-
+ Submit message + ) : null} } @@ -509,6 +419,113 @@ const AiCommand = () => { }} />
+
+ {messages.length > 0 && !hasError && } +
+ {!hasError && + messages.map((message, index) => { + switch (message.role) { + case MessageRole.User: + return ( +
+
+ +
+
{message.content}
+
+ ) + case MessageRole.Assistant: + return ( +
+ ) + } + })} + + {messages.length === 0 && !hasError && ( + + {questions.map((question) => { + const key = question.replace(/\s+/g, '_') + return ( + { + if (!search) { + handleSubmit(question) + } + }} + key={key} + > + + {question} + + ) + })} + + )} + {hasError && ( +
+ +
+

+ Sorry, looks like Supabase AI is having a hard time! +

+

+ Please try again in a bit. +

+
+ +
+ )} + +
+
+
) } diff --git a/packages/ui-patterns/Cmdk/Command.utils.tsx b/packages/ui-patterns/Cmdk/Command.utils.tsx index a736896754c..ee561c3cb89 100644 --- a/packages/ui-patterns/Cmdk/Command.utils.tsx +++ b/packages/ui-patterns/Cmdk/Command.utils.tsx @@ -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 type CommandPrimitiveProps = React.ComponentPropsWithoutRef @@ -59,52 +60,62 @@ interface CommandDialogProps extends React.ComponentProps { setIsOpen: (open: boolean) => void } -export const CommandDialog = ({ - children, - onKeyDown, - page, - setIsOpen, - ...props -}: CommandDialogProps) => { - const [animateBounce, setAnimateBounce] = React.useState(false) +type CommandPrimitiveDialogElement = React.ElementRef - React.useEffect(() => { - setAnimateBounce(true) - setTimeout(() => setAnimateBounce(false), 126) - }, [page]) +export const CommandDialog = React.forwardRef( + ({ children, onKeyDown, page, setIsOpen, ...props }: CommandDialogProps, ref) => { + const isOpen = props.visible || props.open + const isMobile = useBreakpoint() - return ( - - { - // 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')} - > - - - {children} - - - - - ) -} + return ( + + 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%]' + )} + > + + + {children} + + + + + ) + } +) CommandDialog.displayName = 'CommandDialog' @@ -121,11 +132,10 @@ export const CommandInput = React.forwardRef<
() // 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) diff --git a/packages/ui-patterns/Cmdk/CommandMenu.tsx b/packages/ui-patterns/Cmdk/CommandMenu.tsx index a4576590d5e..63a09ff37a5 100644 --- a/packages/ui-patterns/Cmdk/CommandMenu.tsx +++ b/packages/ui-patterns/Cmdk/CommandMenu.tsx @@ -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 ( <> { setIsOpen(!isOpen) }} > - {pages.length > 0 && } - {showCommandInput && ( - - )} + + {pages.length > 0 && } + {showCommandInput && ( + + )} + { ? commandListMaxHeight : 'auto', }} - className="my-2" + className="pb-20 md:pb-0" > {!currentPage && ( <> diff --git a/packages/ui-patterns/Cmdk/CommandMenuShortcuts.tsx b/packages/ui-patterns/Cmdk/CommandMenuShortcuts.tsx index 9fa19d9b72b..3a0eed9b84f 100644 --- a/packages/ui-patterns/Cmdk/CommandMenuShortcuts.tsx +++ b/packages/ui-patterns/Cmdk/CommandMenuShortcuts.tsx @@ -9,7 +9,7 @@ export default function CommandMenuShortcuts() { const { setPages, pages, currentPage } = useCommandMenu() return ( -
+
setPages([])}>
diff --git a/packages/ui/src/components/shadcn/ui/dialog.tsx b/packages/ui/src/components/shadcn/ui/dialog.tsx index ee1300610bd..a8820a4aed8 100644 --- a/packages/ui/src/components/shadcn/ui/dialog.tsx +++ b/packages/ui/src/components/shadcn/ui/dialog.tsx @@ -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'