feat: floating mobile toolbar (#43444)

- move navigation components to `apps/studio/components/layouts/Navigation`
- add [FloatingMobileToolbar](https://github.com/supabase/supabase/pull/43444/changes#diff-3dffe47fd51ca851d612d8728e03b2dc344ec213d4f3a46a824d3fa32a7cc851) as quick access to tools such as search, assistant, inline editor, etc - behind feature flag and feature preview (true by default as it's a bit
annoying to have to enable it all the time as previews are stored in
local-storage)
- fix sidebar panels closing on viewport resizing (regression from previous pr)

https://github.com/user-attachments/assets/d6881e3b-5128-4306-bb82-3ca39c755dba

<img width="986" height="697" alt="Screenshot 2026-03-12 at 12 40 11"
src="https://github.com/user-attachments/assets/da8511e2-7d01-4237-b814-596031c747c5"
/>
This commit is contained in:
Francesco Sansalvadore authored and GitHub committed 2026-03-13 12:13:07 +00:00
1 parent ed5a54aa51
commit 156f904018
67 files changed
+842 -149

No files matched your search

@@ -21,6 +21,17 @@ import { useConfigureOrganizationCommand, useProjectSwitchCommand } from './OrgP
import { useSupportCommands } from './Support'
import { useConnectCommands } from '@/components/interfaces/ConnectButton/Connect.Commands'
export function CommandMenuInnerContent() {
return (
<>
<CommandHeader>
<CommandInput />
</CommandHeader>
<CommandList />
</>
)
}
export default function StudioCommandMenu() {
useCreateCommands()
useApiKeysCommands()
@@ -46,10 +57,7 @@ export default function StudioCommandMenu() {
return (
<CommandMenu>
<CommandHeader>
<CommandInput />
</CommandHeader>
<CommandList />
<CommandMenuInnerContent />
</CommandMenu>
)
}
@@ -124,6 +124,13 @@ export const useIsTableFilterBarEnabled = () => {
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR]
}
export const useIsFloatingMobileToolbarEnabled = () => {
const { flags } = useFeaturePreviewContext()
const showFloatingMobileToolbar = useFlag('enableFloatingMobileToolbar')
return showFloatingMobileToolbar && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_FLOATING_MOBILE_TOOLBAR]
}
export const useFeaturePreviewModal = () => {
const featurePreviews = useFeaturePreviews()
const [featurePreviewModal, setFeaturePreviewModal] = useQueryState('featurePreviewModal')
@@ -29,6 +29,7 @@ import { AdvisorRulesPreview } from './AdvisorRulesPreview'
import { Branching2Preview } from './Branching2Preview'
import { CLSPreview } from './CLSPreview'
import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext'
import { FloatingMobileToolbarPreview } from './FloatingMobileToolbarPreview'
import { PgDeltaDiffPreview } from './PgDeltaDiffPreview'
import { PlatformWebhooksPreview } from './PlatformWebhooksPreview'
import { QueueOperationsPreview } from './QueueOperationsPreview'
@@ -48,6 +49,7 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: {
[LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS]: <QueueOperationsPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR]: <TableFilterBarPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]: <PlatformWebhooksPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_FLOATING_MOBILE_TOOLBAR]: <FloatingMobileToolbarPreview />,
}
export const FeaturePreviewModal = () => {
@@ -0,0 +1,33 @@
import { BASE_PATH } from 'lib/constants'
import Image from 'next/image'
export const FloatingMobileToolbarPreview = () => {
return (
<div className="flex flex-col gap-2">
<p className="text-foreground-light text-sm mb-4">
A floating toolbar conveniently positioned at the bottom of the screen on mobile viewports
to quickly access the Command Menu, Help, Advisor Panel, Editor Panel, and AI Assistant
buttons.
</p>
<p className="text-foreground-light text-sm mb-4">
It can be dragged to any position on the screen to move it if it gets in the way of
important content so you can keep working on your project.
</p>
<Image
src={`${BASE_PATH}/img/previews/floating-toolbar-preview.png`}
width={1296}
height={900}
quality={100}
alt="floating-mobile-toolbar-preview"
className="rounded border"
/>
<div className="space-y-2 !mt-4">
<p className="text-sm">Enabling this preview will:</p>
<ul className="list-disc pl-6 text-sm text-foreground-light space-y-1">
<li>Show the floating toolbar at the bottom of the screen on mobile viewports</li>
<li>Hide the second row of mobile navigation bar</li>
</ul>
</div>
</div>
)
}
@@ -17,6 +17,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
const isUnifiedLogsPreviewAvailable = useFlag('unifiedLogs')
const tableEditorNewFilterBar = useFlag('tableEditorNewFilterBar')
const pgDeltaDiffEnabled = useFlag('pgdeltaDiff')
const showFloatingMobileToolbar = useFlag('enableFloatingMobileToolbar')
const platformWebhooksEnabled = useFlag('platformWebhooks')
return [
@@ -102,5 +103,14 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
isPlatformOnly: false,
isDefaultOptIn: tableEditorNewFilterBar,
},
{
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_FLOATING_MOBILE_TOOLBAR,
name: 'Floating Mobile Toolbar',
discussionsUrl: 'https://github.com/orgs/supabase/discussions/43721',
enabled: showFloatingMobileToolbar,
isNew: true,
isPlatformOnly: false,
isDefaultOptIn: true,
},
].sort((a, b) => Number(b.isNew) - Number(a.isNew))
}
@@ -1,3 +1,4 @@
import { HomeIcon } from 'components/layouts/Navigation/LayoutHeader/HomeIcon'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { BoxPlus } from 'icons'
import { Plus } from 'lucide-react'
@@ -16,7 +17,6 @@ import {
import { EmptyStatePresentational } from 'ui-patterns'
import { ShimmeringCard } from './ShimmeringCard'
import { HomeIcon } from '@/components/layouts/ProjectLayout/LayoutHeader/HomeIcon'
export const Header = () => {
return (
@@ -1,9 +1,8 @@
import { UserDropdown } from 'components/interfaces/UserDropdown'
import { FeedbackDropdown } from 'components/layouts/Navigation/LayoutHeader/FeedbackDropdown/FeedbackDropdown'
import { BASE_PATH } from 'lib/constants'
import Image from 'next/image'
import { PropsWithChildren, ReactNode } from 'react'
import { UserDropdown } from 'components/interfaces/UserDropdown'
import { FeedbackDropdown } from 'components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown'
import { BASE_PATH } from 'lib/constants'
import { cn, Separator } from 'ui'
export const ProjectClaimLayout = ({
@@ -4,7 +4,7 @@ import {
generateProductRoutes,
generateSettingsRoutes,
generateToolRoutes,
} from 'components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils'
} from 'components/layouts/Navigation/NavigationBar/NavigationBar.utils'
import { ProjectIndexPageLink } from 'data/prefetchers/project.$ref'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { AnimatePresence, motion, MotionProps } from 'framer-motion'
@@ -12,7 +12,7 @@ import { useEffect, useLayoutEffect, useMemo } from 'react'
import { useAppStateSnapshot } from 'state/app-state'
import { cn } from 'ui'
import { useMobileSheet } from '../ProjectLayout/NavigationBar/MobileSheetContext'
import { useMobileSheet } from '../Navigation/NavigationBar/MobileSheetContext'
import { AccountMenuContent } from './AccountMenuContent'
import { WithSidebar } from './WithSidebar'
@@ -10,12 +10,12 @@ import { ResizablePanel, ResizablePanelGroup, SidebarProvider } from 'ui'
import { BannerStack } from '../ui/BannerStack/BannerStack'
import { BannerStackProvider } from '../ui/BannerStack/BannerStackProvider'
import { LayoutHeader } from './ProjectLayout/LayoutHeader/LayoutHeader'
import { LayoutHeader } from './Navigation/LayoutHeader/LayoutHeader'
import MobileNavigationBar from './Navigation/NavigationBar/MobileNavigationBar'
import { MobileSheetProvider } from './Navigation/NavigationBar/MobileSheetContext'
import { StudioMobileSheetNav } from './Navigation/NavigationBar/StudioMobileSheetNav'
import { LayoutSidebar } from './ProjectLayout/LayoutSidebar'
import { LayoutSidebarProvider } from './ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import MobileNavigationBar from './ProjectLayout/NavigationBar/MobileNavigationBar'
import { MobileSheetProvider } from './ProjectLayout/NavigationBar/MobileSheetContext'
import { StudioMobileSheetNav } from './ProjectLayout/NavigationBar/StudioMobileSheetNav'
import { ProjectContextProvider } from './ProjectLayout/ProjectContext'
export interface DefaultLayoutProps {
@@ -41,19 +41,19 @@ export const DefaultLayout = ({
const { ref } = useParams()
const router = useRouter()
const appSnap = useAppStateSnapshot()
const showProductMenu = !!ref && router.pathname !== '/project/[ref]'
const [lastVisitedOrganization] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
''
)
const backToDashboardURL =
appSnap.lastRouteBeforeVisitingAccountPage.length > 0
const backToDashboardURL = router.pathname.startsWith('/account')
? appSnap.lastRouteBeforeVisitingAccountPage.length > 0
? appSnap.lastRouteBeforeVisitingAccountPage
: !!lastVisitedOrganization
? `/org/${lastVisitedOrganization}`
: '/organizations'
: undefined
useCheckLatestDeploy()
@@ -82,14 +82,11 @@ export const DefaultLayout = ({
{/* Top Banner */}
<AppBannerWrapper />
<div className="flex-shrink-0">
<MobileNavigationBar hideMobileMenu={hideMobileMenu} />
<LayoutHeader
showProductMenu={showProductMenu}
headerTitle={headerTitle}
backToDashboardURL={
router.pathname.startsWith('/account') ? backToDashboardURL : undefined
}
<MobileNavigationBar
hideMobileMenu={hideMobileMenu}
backToDashboardURL={backToDashboardURL}
/>
<LayoutHeader headerTitle={headerTitle} backToDashboardURL={backToDashboardURL} />
</div>
{/* Main Content Area */}
<div className="flex flex-1 w-full overflow-y-hidden">
@@ -0,0 +1,133 @@
import { useParams, useViewport } from 'common'
import { AdvisorButton } from 'components/layouts/AppLayout/AdvisorButton'
import { AssistantButton } from 'components/layouts/AppLayout/AssistantButton'
import { InlineEditorButton } from 'components/layouts/AppLayout/InlineEditorButton'
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { HelpButton } from 'components/ui/HelpPanel/HelpButton'
import { AnimatePresence } from 'framer-motion'
import { Menu, Search, X } from 'lucide-react'
import { useRef } from 'react'
import { Button, cn, KeyboardShortcut } from 'ui'
import { getToolbarStyle } from './FloatingMobileToolbar.utils'
import { useFloatingToolbarDrag } from './useFloatingToolbarDrag'
import { useFloatingToolbarNavSize } from './useFloatingToolbarNavSize'
import { useFloatingToolbarSheet } from './useFloatingToolbarSheet'
import { useFloatingToolbarSidebarClick } from './useFloatingToolbarSidebarClick'
const FloatingMobileToolbar = ({ hideMobileMenu }: { hideMobileMenu?: boolean }) => {
const navRef = useRef<HTMLElement | null>(null)
const sheet = useFloatingToolbarSheet(hideMobileMenu)
const drag = useFloatingToolbarDrag(navRef)
const handleNavClickCapture = useFloatingToolbarSidebarClick()
const navSize = useFloatingToolbarNavSize(navRef, sheet.isSheetOpen)
const viewport = useViewport()
const { handleSearchClick, isSearchOpen } = sheet
const style = getToolbarStyle({
position: drag.position,
navSize,
isSheetOpen: sheet.isSheetOpen,
viewport,
isDragging: drag.dragStartRef.current !== null,
})
const { ref: projectRef } = useParams()
return (
<nav
ref={navRef}
aria-label="Floating toolbar"
className={cn(
'flex pointer-events-auto cursor-grab active:cursor-grabbing flex-row items-centerw-auto',
'gap-2',
'fixed md:hidden'
)}
style={style}
onClickCapture={handleNavClickCapture}
onPointerDown={drag.handlePointerDown}
onPointerMove={drag.handlePointerMove}
>
<div
className={cn(
'flex pointer-events-auto cursor-grab active:cursor-grabbing flex-row items-center justify-between w-auto rounded-full',
'bg-overlay/80 backdrop-blur-md px-2.5 py-2 gap-2',
'border border-strong shadow-[0px_3px_6px_-2px_rgba(0,0,0,0.07),0px_10px_30px_0px_rgba(0,0,0,0.10)]'
)}
>
<AnimatePresence initial={false}>
<ButtonTooltip
type={isSearchOpen ? 'secondary' : 'outline'}
size="tiny"
id="search-trigger"
className={cn(
'rounded-full w-[32px] h-[32px] flex items-center justify-center p-0',
isSearchOpen && 'text-background'
)}
tooltip={{
content: {
className: 'p-1 pl-2.5',
text: (
<div className="flex items-center gap-2.5">
<span>Search</span>
<KeyboardShortcut keys={['Meta', 'K']} />
</div>
),
},
}}
onClick={handleSearchClick}
>
<Search size={16} strokeWidth={1} />
</ButtonTooltip>
<span data-sidebar-id={SIDEBAR_KEYS.HELP_PANEL}>
<HelpButton />
</span>
<span data-sidebar-id={SIDEBAR_KEYS.ADVISOR_PANEL}>
<AdvisorButton projectRef={projectRef} />
</span>
{!!projectRef && (
<>
<span data-sidebar-id={SIDEBAR_KEYS.EDITOR_PANEL}>
<InlineEditorButton />
</span>
<span data-sidebar-id={SIDEBAR_KEYS.AI_ASSISTANT}>
<AssistantButton />
</span>
</>
)}
{sheet.showMenuButton && sheet.isSheetOpen && (
<Button
title="Menu dropdown button"
type={sheet.isMenuOpen ? 'secondary' : 'default'}
className={cn(
'flex lg:hidden mr-1 rounded-md min-w-[30px] w-[30px] h-[30px] data-[state=open]:bg-overlay-hover/30',
!sheet.isMenuOpen && '!bg-surface-300'
)}
icon={<Menu />}
onClick={sheet.handleMenuClick}
/>
)}
</AnimatePresence>
</div>
<AnimatePresence initial={false}>
<Button
title="close"
type="text"
className={cn(
'flex flex-row items-center justify-center rounded-full',
'bg-overlay/50 backdrop-blur-md my-auto !p-1 gap-2',
'border border-strong shadow-[0px_3px_6px_-2px_rgba(0,0,0,0.07),0px_10px_30px_0px_rgba(0,0,0,0.10)]',
'!w-10 !h-10 !min-w-10 !min-h-10',
'rounded-full',
!sheet.isSheetOpen && 'hidden'
)}
icon={<X />}
onClick={sheet.handleClose}
/>
</AnimatePresence>
</nav>
)
}
export default FloatingMobileToolbar
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import {
clampPosition,
getToolbarStyle,
getNextPosition,
isMenuContent,
shouldShowMenuButton,
} from './FloatingMobileToolbar.utils'
describe('isMenuContent', () => {
it('returns false for null', () => {
expect(isMenuContent(null)).toBe(false)
})
it('returns false for sidebar id string', () => {
expect(isMenuContent('help-panel')).toBe(false)
})
it('returns true for non-string content', () => {
expect(isMenuContent({})).toBe(true)
expect(isMenuContent(0)).toBe(true)
})
})
describe('shouldShowMenuButton', () => {
it('returns true for project, org, account paths', () => {
expect(shouldShowMenuButton('/project/ref')).toBe(true)
expect(shouldShowMenuButton('/org/slug')).toBe(true)
expect(shouldShowMenuButton('/account/me')).toBe(true)
})
it('returns false for other paths', () => {
expect(shouldShowMenuButton('/organizations')).toBe(false)
expect(shouldShowMenuButton('/')).toBe(false)
})
})
describe('clampPosition', () => {
const viewport = { width: 400, height: 800 }
const navSize = { width: 100, height: 48 }
it('clamps to viewport bounds', () => {
const next = clampPosition({ x: 0, y: 0 }, { dx: 500, dy: 900 }, viewport, navSize)
expect(next.x).toBe(300)
expect(next.y).toBe(752)
})
it('does not go negative', () => {
const next = clampPosition({ x: 10, y: 10 }, { dx: -20, dy: -20 }, viewport, navSize)
expect(next.x).toBe(0)
expect(next.y).toBe(0)
})
})
describe('getNextPosition', () => {
const viewport = { width: 400, height: 800 }
const navSize = { width: 100, height: 48 }
const dragStart = { x: 50, y: 100, startX: 60, startY: 110 }
it('returns null when under threshold', () => {
expect(getNextPosition(dragStart, 62, 111, viewport, navSize, 8)).toBeNull()
})
it('returns clamped position when over threshold', () => {
const next = getNextPosition(dragStart, 80, 130, viewport, navSize, 5)
expect(next).toEqual({ x: 70, y: 120 })
})
})
describe('getToolbarStyle', () => {
const viewport = { width: 390, height: 844 }
const navSize = { width: 200, height: 48 }
it('returns style with transform and zIndex', () => {
const style = getToolbarStyle({
position: null,
navSize,
isSheetOpen: false,
viewport,
isDragging: false,
})
expect(style.zIndex).toBe(41)
expect(style.transform).toBeDefined()
expect(style.left).toBe('50%')
})
it('uses higher zIndex when sheet open', () => {
const style = getToolbarStyle({
position: null,
navSize,
isSheetOpen: true,
viewport,
isDragging: false,
})
expect(style.zIndex).toBe(101)
})
})
@@ -0,0 +1,109 @@
import type { CSSProperties } from 'react'
export const DRAG_THRESHOLD_PX = 8
export const GAP_FROM_BOTTOM = 50
export const SHEET_OPEN_GAP_FRACTION = 0.15
export const DEFAULT_NAV_HEIGHT = 56
export function isMenuContent(content: unknown): boolean {
return content !== null && typeof content !== 'string'
}
export function shouldShowMenuButton(pathname: string): boolean {
return (
pathname.startsWith('/project/') ||
pathname.startsWith('/org/') ||
pathname.startsWith('/account')
)
}
export type Viewport = { width: number; height: number }
export type NavSize = { width: number; height: number }
export type Position = { x: number; y: number }
export function clampPosition(
current: Position,
delta: { dx: number; dy: number },
viewport: Viewport,
navSize: NavSize
): Position {
const maxX = Math.max(0, viewport.width - navSize.width)
const maxY = Math.max(0, viewport.height - navSize.height)
return {
x: Math.max(0, Math.min(maxX, current.x + delta.dx)),
y: Math.max(0, Math.min(maxY, current.y + delta.dy)),
}
}
export type DragStartState = {
x: number
y: number
startX: number
startY: number
}
export function getNextPosition(
dragStart: DragStartState,
clientX: number,
clientY: number,
viewport: Viewport,
navSize: NavSize,
threshold: number = DRAG_THRESHOLD_PX
): Position | null {
const dist = Math.hypot(clientX - dragStart.startX, clientY - dragStart.startY)
if (dist < threshold) return null
const dx = clientX - dragStart.startX
const dy = clientY - dragStart.startY
return clampPosition({ x: dragStart.x, y: dragStart.y }, { dx, dy }, viewport, navSize)
}
export function getToolbarStyle(params: {
position: Position | null
navSize: NavSize
isSheetOpen: boolean
viewport: Viewport
isDragging: boolean
}): CSSProperties {
const { position, navSize, isSheetOpen, viewport, isDragging } = params
const { width: navW, height: navH } = navSize
const vw = viewport.width
const vh = viewport.height
const transition = isDragging
? 'transform 0ms, z-index 0s'
: 'transform 300ms cubic-bezier(0.4, 0, 0.2, 1), z-index 0s'
const base: CSSProperties = {
zIndex: isSheetOpen ? 101 : 41,
transition,
touchAction: 'none',
}
const centerX = vw > 0 && navW > 0 ? vw / 2 - navW / 2 : 0
const sheetOpenGapPx = vh * SHEET_OPEN_GAP_FRACTION
const topWhenSheetOpen = vh > 0 ? sheetOpenGapPx / 2 - navH / 2 : 0
const defaultYClosed = vh > 0 ? vh - GAP_FROM_BOTTOM - (navH > 0 ? navH : DEFAULT_NAV_HEIGHT) : 0
if (position === null) {
return {
...base,
left: '50%',
top: 0,
transform: isSheetOpen
? `translate(-50%, ${topWhenSheetOpen}px)`
: `translate(-50%, ${defaultYClosed}px)`,
}
}
if (isSheetOpen) {
return {
...base,
left: 0,
top: 0,
transform: `translate(${centerX}px, ${topWhenSheetOpen}px)`,
}
}
return {
...base,
left: 0,
top: 0,
transform: `translate(${position.x}px, ${position.y}px)`,
}
}
@@ -0,0 +1,97 @@
import { useCallback, useRef, useState } from 'react'
import {
DRAG_THRESHOLD_PX,
getNextPosition,
type NavSize,
type Position,
type Viewport,
} from './FloatingMobileToolbar.utils'
type DragStartState = {
x: number
y: number
startX: number
startY: number
pointerId: number
}
export function useFloatingToolbarDrag(navRef: React.RefObject<HTMLElement | null>) {
const [position, setPosition] = useState<Position | null>(null)
const dragStartRef = useRef<DragStartState | null>(null)
const applyMove = useCallback(
(clientX: number, clientY: number) => {
const state = dragStartRef.current
if (!state) return
const rect = navRef.current?.getBoundingClientRect()
const viewport: Viewport = {
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0,
}
const navSize: NavSize = {
width: rect?.width ?? 200,
height: rect?.height ?? 48,
}
const next = getNextPosition(state, clientX, clientY, viewport, navSize, DRAG_THRESHOLD_PX)
if (next) setPosition(next)
},
[navRef]
)
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault()
e.stopPropagation()
const rect = navRef.current?.getBoundingClientRect()
if (!rect) return
const currentX = position?.x ?? rect.left
const currentY = position?.y ?? rect.top
dragStartRef.current = {
x: currentX,
y: currentY,
startX: e.clientX,
startY: e.clientY,
pointerId: e.pointerId,
}
const onMove = (moveEvent: PointerEvent) => {
if (dragStartRef.current?.pointerId !== moveEvent.pointerId) return
const dist = Math.hypot(
moveEvent.clientX - dragStartRef.current.startX,
moveEvent.clientY - dragStartRef.current.startY
)
if (dist >= DRAG_THRESHOLD_PX) {
navRef.current?.setPointerCapture?.(moveEvent.pointerId)
}
applyMove(moveEvent.clientX, moveEvent.clientY)
}
const onUpOrCancel = (upEvent: PointerEvent) => {
if (dragStartRef.current?.pointerId !== upEvent.pointerId) return
;(upEvent.target as HTMLElement)?.releasePointerCapture?.(upEvent.pointerId)
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUpOrCancel)
window.removeEventListener('pointercancel', onUpOrCancel)
dragStartRef.current = null
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUpOrCancel)
window.addEventListener('pointercancel', onUpOrCancel)
},
[navRef, position, applyMove]
)
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
applyMove(e.clientX, e.clientY)
},
[applyMove]
)
return {
position,
dragStartRef,
handlePointerDown,
handlePointerMove,
}
}
@@ -0,0 +1,40 @@
import { useLayoutEffect, useState } from 'react'
import type { NavSize } from './FloatingMobileToolbar.utils'
function measure(
el: HTMLElement | null,
setNavSize: React.Dispatch<React.SetStateAction<NavSize>>
) {
if (!el) return
const rect = el.getBoundingClientRect()
setNavSize((prev) =>
prev.width !== rect.width || prev.height !== rect.height
? { width: rect.width, height: rect.height }
: prev
)
}
export function useFloatingToolbarNavSize(
navRef: React.RefObject<HTMLElement | null>,
isSheetOpen: boolean
): NavSize {
const [navSize, setNavSize] = useState<NavSize>({ width: 0, height: 0 })
useLayoutEffect(() => {
const el = navRef.current
if (!el) return
measure(el, setNavSize)
const ro = new ResizeObserver(() => measure(el, setNavSize))
ro.observe(el)
return () => ro.disconnect()
}, [navRef])
useLayoutEffect(() => {
if (!isSheetOpen) return
const raf = requestAnimationFrame(() => measure(navRef.current, setNavSize))
return () => cancelAnimationFrame(raf)
}, [isSheetOpen, navRef])
return navSize
}
@@ -0,0 +1,56 @@
import { useRouter } from 'next/router'
import { useCallback } from 'react'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { useMobileSheet } from '../NavigationBar/MobileSheetContext'
import { isMenuContent, shouldShowMenuButton } from './FloatingMobileToolbar.utils'
export function useFloatingToolbarSheet(hideMobileMenu?: boolean) {
const router = useRouter()
const pathname = router.asPath?.split('?')[0] ?? router.pathname
const { content: sheetContent, setContent: setSheetContent, openMenu } = useMobileSheet()
const { clearActiveSidebar, closeActive, activeSidebar } = useSidebarManagerSnapshot()
const isSheetOpen = sheetContent !== null
const isMenuOpen = isMenuContent(sheetContent)
const isSearchOpen = sheetContent === 'search'
const showMenuButton = shouldShowMenuButton(pathname) && !hideMobileMenu
const handleMenuClick = useCallback(() => {
if (isMenuOpen) {
clearActiveSidebar()
setSheetContent(null)
return
}
clearActiveSidebar()
openMenu()
}, [isMenuOpen, clearActiveSidebar, openMenu, setSheetContent])
const handleClose = useCallback(() => {
if (activeSidebar) {
closeActive()
} else {
setSheetContent(null)
}
}, [activeSidebar, closeActive, setSheetContent])
const handleSearchClick = useCallback(() => {
if (isSearchOpen) {
clearActiveSidebar()
setSheetContent(null)
return
}
clearActiveSidebar()
setSheetContent('search')
}, [isSearchOpen, clearActiveSidebar, setSheetContent])
return {
isSheetOpen,
isMenuOpen,
isSearchOpen,
showMenuButton,
handleMenuClick,
handleClose,
handleSearchClick,
}
}
@@ -0,0 +1,23 @@
import { useCallback } from 'react'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { useMobileSheet } from '../NavigationBar/MobileSheetContext'
export function useFloatingToolbarSidebarClick() {
const { setContent: setSheetContent } = useMobileSheet()
const { activeSidebar, openSidebar } = useSidebarManagerSnapshot()
return useCallback(
(e: React.MouseEvent) => {
const target = (e.target as HTMLElement).closest?.('[data-sidebar-id]')
const sidebarId = target?.getAttribute('data-sidebar-id')
if (sidebarId && activeSidebar?.id !== sidebarId) {
e.preventDefault()
e.stopPropagation()
openSidebar(sidebarId)
setSheetContent(sidebarId)
}
},
[activeSidebar?.id, openSidebar, setSheetContent]
)
}
@@ -1,5 +1,8 @@
import { IS_PLATFORM } from 'common'
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { ASSISTANT_SUGGESTIONS } from 'components/ui/HelpPanel/HelpPanel.constants'
import { getSupportLinkQueryParams } from 'components/ui/HelpPanel/HelpPanel.utils'
import { HelpSection } from 'components/ui/HelpPanel/HelpSection'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Lightbulb, TriangleAlert } from 'lucide-react'
@@ -15,9 +18,6 @@ import {
PopoverTrigger_Shadcn_,
} from 'ui'
import { ASSISTANT_SUGGESTIONS } from '../HelpPanel/HelpPanel.constants'
import { getSupportLinkQueryParams } from '../HelpPanel/HelpPanel.utils'
import { HelpSection } from '../HelpPanel/HelpSection'
import { FeedbackWidget } from './FeedbackWidget'
export const FeedbackDropdown = ({ className }: { className?: string }) => {
@@ -1,5 +1,11 @@
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { useIsBranching2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import {
useIsBranching2Enabled,
useIsFloatingMobileToolbarEnabled,
} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { Connect } from 'components/interfaces/Connect/Connect'
import { ConnectButton } from 'components/interfaces/ConnectButton/ConnectButton'
import { ConnectSheet } from 'components/interfaces/ConnectSheet/ConnectSheet'
import { LocalDropdown } from 'components/interfaces/LocalDropdown'
import { UserDropdown } from 'components/interfaces/UserDropdown'
import { AdvisorButton } from 'components/layouts/AppLayout/AdvisorButton'
@@ -8,6 +14,7 @@ import { BranchDropdown } from 'components/layouts/AppLayout/BranchDropdown'
import { InlineEditorButton } from 'components/layouts/AppLayout/InlineEditorButton'
import { OrganizationDropdown } from 'components/layouts/AppLayout/OrganizationDropdown'
import { ProjectDropdown } from 'components/layouts/AppLayout/ProjectDropdown'
import { HelpButton } from 'components/ui/HelpPanel/HelpButton'
import { getResourcesExceededLimitsOrg } from 'components/ui/OveragesBanner/OveragesBanner.utils'
import { useOrgUsageQuery } from 'data/usage/org-usage-query'
import { DevToolbarTrigger } from 'dev-tools'
@@ -15,26 +22,21 @@ import { AnimatePresence, motion } from 'framer-motion'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { usePHFlag } from 'hooks/ui/useFlag'
import { IS_PLATFORM } from 'lib/constants'
import { ChevronLeft } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { ReactNode, useMemo } from 'react'
import { useAppStateSnapshot } from 'state/app-state'
import { Badge, cn } from 'ui'
import { CommandMenuTriggerInput } from 'ui-patterns'
import { BreadcrumbsView } from './BreadcrumbsView'
import { FeedbackDropdown } from './FeedbackDropdown/FeedbackDropdown'
import { HelpButton } from './HelpPanel/HelpButton'
import { HomeIcon } from './HomeIcon'
import { LocalVersionPopover } from './LocalVersionPopover'
import { MergeRequestButton } from './MergeRequestButton'
import { Connect } from '@/components/interfaces/Connect/Connect'
import { ConnectButton } from '@/components/interfaces/ConnectButton/ConnectButton'
import { ConnectSheet } from '@/components/interfaces/ConnectSheet/ConnectSheet'
import type { ConnectSectionVariant } from '@/components/interfaces/ProjectHome/ConnectSection.config'
import { usePHFlag } from '@/hooks/ui/useFlag'
const LayoutHeaderDivider = ({ className, ...props }: React.HTMLProps<HTMLSpanElement>) => (
<span className={cn('text-border-stronger pr-2', className)} {...props}>
@@ -59,7 +61,6 @@ interface LayoutHeaderProps {
customHeaderComponents?: ReactNode
breadcrumbs?: any[]
headerTitle?: string
showProductMenu?: boolean
backToDashboardURL?: string
}
@@ -67,7 +68,6 @@ export const LayoutHeader = ({
customHeaderComponents,
breadcrumbs = [],
headerTitle,
showProductMenu,
backToDashboardURL,
}: LayoutHeaderProps) => {
const router = useRouter()
@@ -76,6 +76,7 @@ export const LayoutHeader = ({
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const gitlessBranching = useIsBranching2Enabled()
const showFloatingMobileToolbar = useIsFloatingMobileToolbarEnabled()
const connectSectionVariant = usePHFlag<ConnectSectionVariant | false>('connectSection')
const isConnectSheetEnabled = connectSectionVariant === 'connect'
@@ -102,7 +103,12 @@ export const LayoutHeader = ({
return (
<>
<header className="flex h-11 md:h-12 items-center flex-shrink-0 border-b">
<header
className={cn(
'flex h-11 md:h-12 items-center flex-shrink-0 border-b',
showFloatingMobileToolbar && 'hidden md:flex'
)}
>
{backToDashboardURL && isAccountPage && (
<div className="flex items-center justify-center border-r flex-0 md:hidden h-full aspect-square">
<Link
@@ -0,0 +1,112 @@
import { useParams } from 'common'
import { useIsFloatingMobileToolbarEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { ConnectButton } from 'components/interfaces/ConnectButton/ConnectButton'
import { LocalDropdown } from 'components/interfaces/LocalDropdown'
import { SidebarContent } from 'components/interfaces/Sidebar'
import { UserDropdown } from 'components/interfaces/UserDropdown'
import FloatingMobileToolbar from 'components/layouts/Navigation/FloatingMobileToolbar/FloatingMobileToolbar'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
import { ChevronLeft, Menu, Search } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { Button, cn } from 'ui'
import { CommandMenuTrigger, MobileSheetNav } from 'ui-patterns'
import { HomeIcon } from '../LayoutHeader/HomeIcon'
import { useMobileSheet } from './MobileSheetContext'
import { OrgSelector } from './OrgSelector'
import { ProjectBranchSelector } from './ProjectBranchSelector'
export const ICON_SIZE = 20
export const ICON_STROKE_WIDTH = 1.5
const MobileNavigationBar = ({
hideMobileMenu,
backToDashboardURL,
}: {
hideMobileMenu?: boolean
backToDashboardURL?: string
}) => {
const [isSheetOpen, setIsSheetOpen] = useState(false)
const showFloatingMobileToolbar = useIsFloatingMobileToolbarEnabled()
const { ref: projectRef, slug } = useParams()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const isProjectScope = !!projectRef
const showOrgSelection = slug || (selectedOrganization && projectRef)
const { openMenu } = useMobileSheet()
return (
<div className="w-full flex flex-row md:hidden">
<nav
className={cn(
'group pr-3 pl-2 z-10 w-full h-12 gap-2',
'border-b bg-dash-sidebar border-default shadow-[0_0_30px_0_rgba(0,0,0,0.07)]',
'transition-width duration-200',
'hide-scrollbar flex flex-row items-center justify-between overflow-x-auto'
)}
>
<div className={cn('flex min-w-0 flex-shrink items-center gap-2', !IS_PLATFORM && 'pl-2')}>
{showFloatingMobileToolbar && backToDashboardURL && (
<div className="flex items-center justify-center ml-1 flex-0 md:hidden h-full aspect-square">
<Link
href={backToDashboardURL}
className="flex items-center justify-center !bg-transparent rounded-md min-w-[30px] w-[30px] h-[30px] border text-foreground-lighter hover:text-foreground transition-colors"
>
<ChevronLeft strokeWidth={1.5} size={16} />
</Link>
</div>
)}
{!IS_PLATFORM && <HomeIcon />}
{isProjectScope ? (
<>
<ProjectBranchSelector />
<ConnectButton className="[&_span]:hidden h-8 w-8" />
</>
) : IS_PLATFORM && showOrgSelection ? (
<OrgSelector />
) : (
<HomeIcon className="ml-1" />
)}
</div>
<div className="flex flex-shrink-0 gap-2">
{!showFloatingMobileToolbar && (
<CommandMenuTrigger>
<button
type="button"
className={cn(
'group',
'flex-grow h-[30px] rounded-md',
'p-2',
'flex items-center justify-between',
'bg-transparent border-none text-foreground-lighter',
'hover:bg-opacity-100 hover:border-strong hover:text-foreground-light',
'focus-visible:!outline-4 focus-visible:outline-offset-1 focus-visible:outline-brand-600',
'transition'
)}
>
<Search size={18} strokeWidth={2} />
</button>
</CommandMenuTrigger>
)}
{IS_PLATFORM ? <UserDropdown /> : <LocalDropdown />}
{!hideMobileMenu && (
<Button
title="Menu dropdown button"
type="default"
className="flex lg:hidden border-default bg-surface-100/75 text-foreground-light rounded-md min-w-[30px] w-[30px] h-[30px] data-[state=open]:bg-overlay-hover/30"
icon={<Menu />}
onClick={() => openMenu()}
/>
)}
</div>
</nav>
<MobileSheetNav open={isSheetOpen} onOpenChange={setIsSheetOpen} data-state="expanded">
<SidebarContent />
</MobileSheetNav>
{showFloatingMobileToolbar && <FloatingMobileToolbar hideMobileMenu={hideMobileMenu} />}
</div>
)
}
export default MobileNavigationBar
@@ -1,7 +1,7 @@
import type { PropsWithChildren, ReactNode } from 'react'
import { createContext, useCallback, useContext, useRef, useState } from 'react'
import type { TYPEOF_SIDEBAR_KEYS } from '../LayoutSidebar/LayoutSidebarProvider'
import type { TYPEOF_SIDEBAR_KEYS } from '../../ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
/**
* Sheet content: null = closed; sidebar id = one of SIDEBAR_KEYS; ReactNode = custom content (menu, search, etc.).
@@ -1,4 +1,5 @@
import { useBreakpoint, useParams } from 'common'
import { OrgCommandItem } from 'components/layouts/AppLayout/OrgCommandItem'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
@@ -25,8 +26,6 @@ import {
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { OrgCommandItem } from 'components/layouts/AppLayout/OrgCommandItem'
import { OrgSelectorSheet } from './OrgSelectorSheet'
export function OrgSelector() {
@@ -1,9 +1,12 @@
import { CommandMenuInnerContent } from 'components/interfaces/App/CommandMenu/CommandMenu'
import type { ReactNode } from 'react'
import { sidebarManagerState, useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { MobileSheetNav } from 'ui-patterns'
import { CommandWrapper, MobileSheetNav } from 'ui-patterns'
import { SIDEBAR_KEYS } from '../LayoutSidebar/LayoutSidebarProvider'
import type { TYPEOF_SIDEBAR_KEYS } from '../LayoutSidebar/LayoutSidebarProvider'
import {
SIDEBAR_KEYS,
type TYPEOF_SIDEBAR_KEYS,
} from '../../ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import type { MobileSheetContentType } from './MobileSheetContext'
import { useMobileSheet } from './MobileSheetContext'
@@ -19,6 +22,13 @@ function getSheetChildren(
activeSidebar: { id: string; component?: () => ReactNode } | null
): ReactNode {
if (content === null) return null
if (content === 'search') {
return (
<CommandWrapper className="h-full flex flex-col bg-background">
<CommandMenuInnerContent />
</CommandWrapper>
)
}
if (isSidebarId(content) && activeSidebar?.id === content) {
return activeSidebar.component?.() ?? null
}
@@ -39,7 +49,11 @@ const StudioMobileSheetNav = () => {
}
return (
<MobileSheetNav open={content !== null} onOpenChange={handleOpenChange}>
<MobileSheetNav
open={content !== null}
onOpenChange={handleOpenChange}
shouldCloseOnViewportResize={!activeSidebar}
>
{sheetChildren}
</MobileSheetNav>
)
@@ -3,8 +3,8 @@
import { useRouter } from 'next/router'
import { useLayoutEffect } from 'react'
import { useMobileSheet } from '../Navigation/NavigationBar/MobileSheetContext'
import { OrgMenuContent } from '../ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent'
import { useMobileSheet } from '../ProjectLayout/NavigationBar/MobileSheetContext'
import { getPathnameWithoutQuery, isOrgMenuScope } from './OrganizationLayout.utils'
/**
@@ -11,7 +11,7 @@ import {
generateProductRoutes,
generateSettingsRoutes,
generateToolRoutes,
} from 'components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils'
} from 'components/layouts/Navigation/NavigationBar/NavigationBar.utils'
import type { Route } from 'components/ui/ui.types'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
@@ -1,4 +1,5 @@
import { LOCAL_STORAGE_KEYS } from 'common'
import { getSupportLinkQueryParams } from 'components/ui/HelpPanel/HelpPanel.utils'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import useLatest from 'hooks/misc/useLatest'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
@@ -10,8 +11,6 @@ import { parseAsString, useQueryState } from 'nuqs'
import { useEffect, type PropsWithChildren } from 'react'
import { useRegisterSidebar, useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { getSupportLinkQueryParams } from '../LayoutHeader/HelpPanel/HelpPanel.utils'
const AdvisorPanel = dynamic(() =>
import('components/ui/AdvisorPanel/AdvisorPanel').then((m) => m.AdvisorPanel)
)
@@ -22,9 +21,7 @@ const EditorPanel = dynamic(() =>
import('components/ui/EditorPanel/EditorPanel').then((m) => m.EditorPanel)
)
const HelpPanel = dynamic(() =>
import('components/layouts/ProjectLayout/LayoutHeader/HelpPanel/HelpPanel').then(
(m) => m.HelpPanel
)
import('components/ui/HelpPanel/HelpPanel').then((m) => m.HelpPanel)
)
export const SIDEBAR_KEYS = {
@@ -1,11 +1,11 @@
import { act, screen, waitFor } from '@testing-library/react'
import { MobileSheetProvider } from 'components/layouts/Navigation/NavigationBar/MobileSheetContext'
import { sidebarManagerState } from 'state/sidebar-manager-state'
import { render } from 'tests/helpers'
import { routerMock } from 'tests/lib/route-mock'
import { ResizablePanel, ResizablePanelGroup } from 'ui'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MobileSheetProvider } from '../NavigationBar/MobileSheetContext'
import { LayoutSidebar } from './index'
import { LayoutSidebarProvider, SIDEBAR_KEYS } from './LayoutSidebarProvider'
@@ -1,12 +1,18 @@
import { useBreakpoint } from 'common'
import { useMobileSheet } from 'components/layouts/Navigation/NavigationBar/MobileSheetContext'
import { useEffect } from 'react'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { cn, ResizableHandle, ResizablePanel } from 'ui'
import { useMobileSheet } from '../NavigationBar/MobileSheetContext'
import { SIDEBAR_KEYS, type TYPEOF_SIDEBAR_KEYS } from './LayoutSidebarProvider'
function isSidebarId(content: unknown): content is TYPEOF_SIDEBAR_KEYS {
return (
typeof content === 'string' &&
Object.values(SIDEBAR_KEYS).includes(content as TYPEOF_SIDEBAR_KEYS)
)
}
// Having these params as props as otherwise it's quite hard to visually check the sizes in DefaultLayout
// as react resizeable panels requires all these values to be valid to render correctly
interface LayoutSidebarProps {
minSize?: string | number
maxSize?: string | number
@@ -20,9 +26,8 @@ export const LayoutSidebar = ({
}: LayoutSidebarProps) => {
const { activeSidebar } = useSidebarManagerSnapshot()
const isMobile = useBreakpoint('md')
const { setContent: setMobileSheetContent } = useMobileSheet()
const { content: sheetContent, setContent: setMobileSheetContent } = useMobileSheet()
// On mobile, sidebar content is shown in the sheet. Sync sheet content with active sidebar; clear when none or when switching to desktop.
useEffect(() => {
if (!isMobile) {
setMobileSheetContent(null)
@@ -30,10 +35,10 @@ export const LayoutSidebar = ({
}
if (activeSidebar?.component) {
setMobileSheetContent(activeSidebar.id)
} else {
} else if (isSidebarId(sheetContent)) {
setMobileSheetContent(null)
}
}, [isMobile, activeSidebar, setMobileSheetContent])
}, [isMobile, activeSidebar, sheetContent, setMobileSheetContent])
if (!activeSidebar?.component) return null
if (isMobile) return null
@@ -1,79 +0,0 @@
import { useParams } from 'common'
import { ConnectButton } from 'components/interfaces/ConnectButton/ConnectButton'
import { UserDropdown } from 'components/interfaces/UserDropdown'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
import { Menu, Search } from 'lucide-react'
import { Button, cn } from 'ui'
import { CommandMenuTrigger } from 'ui-patterns'
import { HomeIcon } from '../LayoutHeader/HomeIcon'
import { useMobileSheet } from './MobileSheetContext'
import { OrgSelector } from './OrgSelector'
import { ProjectBranchSelector } from './ProjectBranchSelector'
const MobileNavigationBar = ({ hideMobileMenu }: { hideMobileMenu?: boolean }) => {
const { ref: projectRef, slug } = useParams()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const isProjectScope = !!projectRef
const showOrgSelection = slug || (selectedOrganization && projectRef)
const { openMenu } = useMobileSheet()
return (
<div className="w-full flex flex-row md:hidden">
<nav
className={cn(
'group pr-3 pl-2 z-10 w-full h-12 gap-2',
'border-b bg-dash-sidebar border-default shadow-xl',
'transition-width duration-200',
'hide-scrollbar flex flex-row items-center justify-between overflow-x-auto'
)}
>
<div className={cn('flex min-w-0 flex-shrink items-center gap-2', !IS_PLATFORM && 'pl-2')}>
{!IS_PLATFORM && <HomeIcon />}
{isProjectScope ? (
<>
<ProjectBranchSelector />
<ConnectButton className="[&_span]:hidden h-8 w-8" />
</>
) : IS_PLATFORM && showOrgSelection ? (
<OrgSelector />
) : (
<HomeIcon className="ml-1" />
)}
</div>
<div className="flex flex-shrink-0 gap-2">
<CommandMenuTrigger>
<button
type="button"
className={cn(
'group',
'flex-grow h-[30px] rounded-md',
'p-2',
'flex items-center justify-between',
'bg-transparent border-none text-foreground-lighter',
'hover:bg-opacity-100 hover:border-strong hover:text-foreground-light',
'focus-visible:!outline-4 focus-visible:outline-offset-1 focus-visible:outline-brand-600',
'transition'
)}
>
<Search size={18} strokeWidth={2} />
</button>
</CommandMenuTrigger>
<UserDropdown />
{!hideMobileMenu && (
<Button
title="Menu dropdown button"
type="default"
className="flex lg:hidden border-default bg-surface-100/75 text-foreground-light rounded-md min-w-[30px] w-[30px] h-[30px] data-[state=open]:bg-overlay-hover/30"
icon={<Menu />}
onClick={() => openMenu()}
/>
)}
</div>
</nav>
</div>
)
}
export default MobileNavigationBar
@@ -2,6 +2,8 @@ import { render, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MobileSheetProvider } from '../Navigation/NavigationBar/MobileSheetContext'
import { ProjectLayout } from './index'
import { STUDIO_PAGE_TITLE_SEPARATOR } from '@/lib/page-title'
const { mockRouter, mockSetSelectedDatabaseId, mockSetMobileMenuOpen } = vi.hoisted(() => ({
@@ -158,9 +160,6 @@ vi.mock('@/state/database-selector', () => ({
}),
}))
import { MobileSheetProvider } from './NavigationBar/MobileSheetContext'
import { ProjectLayout } from './index'
describe('ProjectLayout title', () => {
beforeEach(() => {
mockRouter.pathname = '/project/[ref]/observability/query-performance'
@@ -22,17 +22,15 @@ import {
import { useEditorType } from '../editors/EditorsLayout.hooks'
import { useSetMainScrollContainer } from '../MainScrollContainerContext'
import { useMobileSheet } from '../Navigation/NavigationBar/MobileSheetContext'
import ProductMenuBar from '../Navigation/ProductMenuBar'
import BuildingState from './BuildingState'
import ConnectingState from './ConnectingState'
import { getPathnameWithoutQuery } from '@/lib/pathname.utils'
import { getSectionKeyFromPathname, MobileMenuContent } from './LayoutHeader/MobileMenuContent'
import { LoadingState } from './LoadingState'
import { useMobileSheet } from './NavigationBar/MobileSheetContext'
import { ProjectPausedState } from './PausedState/ProjectPausedState'
import { PauseFailedState } from './PauseFailedState'
import { PausingState } from './PausingState'
import ProductMenuBar from './ProductMenuBar'
import { ResizingState } from './ResizingState'
import RestartingState from './RestartingState'
import { RestoreFailedState } from './RestoreFailedState'
@@ -47,6 +45,7 @@ import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { withAuth } from '@/hooks/misc/withAuth'
import { PROJECT_STATUS } from '@/lib/constants'
import { buildStudioPageTitle } from '@/lib/page-title'
import { getPathnameWithoutQuery } from '@/lib/pathname.utils'
import { useAppStateSnapshot } from '@/state/app-state'
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
@@ -31,7 +31,7 @@ export const HelpButton = () => {
})
}
}}
tooltip={{ content: { side: 'bottom', text: 'Help' } }}
tooltip={{ content: { text: 'Help' } }}
>
<HelpCircle
size={16}
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

@@ -32,6 +32,7 @@ type SidebarManagerState = SidebarManagerData & {
closeSidebar: (id: string) => void
isSidebarOpen: (id: string) => boolean
closeActive: () => void
clearActiveSidebar: () => void
}
const INITIAL_SIDEBAR_MANAGER_DATA: SidebarManagerData = {
@@ -145,6 +146,10 @@ const createSidebarManagerState = () => {
state.activeSidebar?.onClose?.()
state.activeSidebar = undefined
},
clearActiveSidebar() {
state.activeSidebar = undefined
},
})
return state
@@ -25,6 +25,7 @@ export const LOCAL_STORAGE_KEYS = {
UI_PREVIEW_QUEUE_OPERATIONS: 'supabase-ui-queue-operations',
UI_PREVIEW_TABLE_FILTER_BAR: 'supabase-ui-table-filter-bar',
UI_PREVIEW_PG_DELTA_DIFF: 'supabase-ui-pg-delta-diff',
UI_PREVIEW_FLOATING_MOBILE_TOOLBAR: 'supabase-ui-floating-mobile-toolbar',
UI_PREVIEW_PLATFORM_WEBHOOKS: 'supabase-ui-platform-webhooks',
NEW_LAYOUT_NOTICE_ACKNOWLEDGED: 'new-layout-notice-acknowledge',
+1
View File
@@ -14,3 +14,4 @@ export * from './useParams'
export * from './useSearchParamsShallow'
export * from './useTelemetryCookie'
export * from './useThemeSandbox'
export * from './useViewport'
+22
View File
@@ -0,0 +1,22 @@
'use client'
import { useEffect, useState } from 'react'
export type Viewport = { width: number; height: number }
function getViewport(): Viewport {
if (typeof window === 'undefined') return { width: 0, height: 0 }
return { width: window.innerWidth, height: window.innerHeight }
}
export function useViewport(): Viewport {
const [viewport, setViewport] = useState<Viewport>(getViewport)
useEffect(() => {
const onResize = () => setViewport(getViewport())
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
return viewport
}
@@ -124,7 +124,7 @@ const CommandInput = forwardRef<
onCompositionStart={() => setImeComposing(true)}
onCompositionEnd={() => setImeComposing(false)}
className={cn(
'flex h-11 w-full rounded-md bg-transparent px-2 py-7 outline-none',
'flex h-11 w-full rounded-md bg-transparent px-2 y-4 md:py-7 outline-none',
'focus:shadow-none focus:ring-transparent',
'text-base text-foreground-light placeholder:text-foreground-muted disabled:cursor-not-allowed disabled:opacity-50 border-0',
className
@@ -20,7 +20,7 @@ const generateCommandClassNames = (isLink: boolean) =>
'rounded-md',
'text-sm',
'group',
'py-3',
'py-2 md:py-3',
'text-foreground-light',
'relative',
'flex',