mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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">
|
||||
|
||||
+133
@@ -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
|
||||
+98
@@ -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)
|
||||
})
|
||||
})
|
||||
+109
@@ -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)`,
|
||||
}
|
||||
}
|
||||
+97
@@ -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,
|
||||
}
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
+56
@@ -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,
|
||||
}
|
||||
}
|
||||
+23
@@ -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]
|
||||
)
|
||||
}
|
||||
File renamed without changes.
+3
-3
@@ -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 }) => {
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+16
-10
@@ -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
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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
-1
@@ -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.).
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-2
@@ -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() {
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+18
-4
@@ -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>
|
||||
)
|
||||
File renamed without changes.
@@ -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'
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+2
-5
@@ -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'
|
||||
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ export const HelpButton = () => {
|
||||
})
|
||||
}
|
||||
}}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Help' } }}
|
||||
tooltip={{ content: { text: 'Help' } }}
|
||||
>
|
||||
<HelpCircle
|
||||
size={16}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
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',
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from './useParams'
|
||||
export * from './useSearchParamsShallow'
|
||||
export * from './useTelemetryCookie'
|
||||
export * from './useThemeSandbox'
|
||||
export * from './useViewport'
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user