Home New: Advisors (#38337)

* new home top

* advisors

* fix ts

* add advisor section

* Update apps/studio/components/interfaces/Linter/Linter.utils.tsx

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>

* Update apps/studio/components/interfaces/Linter/LintDetail.tsx

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>

* Update apps/studio/components/interfaces/Linter/LinterDataGrid.tsx

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>

* Update packages/ui-patterns/src/Row/index.tsx

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>

* Update packages/ui-patterns/src/Row/index.tsx

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>

* Update apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>

* row refactor

---------

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Saxon Fletcher
2025-09-10 12:49:34 +10:00
committed by GitHub
parent b65a0aefb9
commit 8da4cbc46e
10 changed files with 493 additions and 157 deletions

View File

@@ -0,0 +1,167 @@
import { BarChart, Shield } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { useParams } from 'common'
import { LINTER_LEVELS } from 'components/interfaces/Linter/Linter.constants'
import {
createLintSummaryPrompt,
LintCategoryBadge,
lintInfoMap,
} from 'components/interfaces/Linter/Linter.utils'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { Lint, useProjectLintsQuery } from 'data/lint/lint-query'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import {
AiIconAnimation,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Sheet,
SheetContent,
SheetHeader,
SheetSection,
SheetTitle,
} from 'ui'
import { Row } from 'ui-patterns'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import LintDetail from 'components/interfaces/Linter/LintDetail'
export const AdvisorSection = () => {
const { ref: projectRef } = useParams()
const { data: lints, isLoading: isLoadingLints } = useProjectLintsQuery({ projectRef })
const snap = useAiAssistantStateSnapshot()
const [selectedLint, setSelectedLint] = useState<Lint | null>(null)
const errorLints: Lint[] = useMemo(() => {
return lints?.filter((lint) => lint.level === LINTER_LEVELS.ERROR) ?? []
}, [lints])
const totalErrors = errorLints.length
const titleContent = useMemo(() => {
if (totalErrors === 0) return <h2>Assistant found no issues</h2>
const issuesText = totalErrors === 1 ? 'issue' : 'issues'
const numberDisplay = totalErrors.toString()
return (
<h2>
Assistant found {numberDisplay} {issuesText}
</h2>
)
}, [totalErrors])
const handleAskAssistant = useCallback(() => {
snap.toggleAssistant()
}, [snap])
const handleCardClick = useCallback((lint: Lint) => {
setSelectedLint(lint)
}, [])
return (
<div>
{isLoadingLints ? (
<ShimmeringLoader className="w-96 mb-6" />
) : (
<div className="flex justify-between items-center mb-6">
{titleContent}
<Button type="default" icon={<AiIconAnimation />} onClick={handleAskAssistant}>
Ask Assistant
</Button>
</div>
)}
{isLoadingLints ? (
<div className="flex flex-col p-4 gap-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : errorLints.length > 0 ? (
<>
<Row columns={[3, 2, 1]}>
{errorLints.map((lint) => {
return (
<Card
key={lint.cache_key}
className="h-full flex flex-col items-stretch cursor-pointer"
onClick={() => {
handleCardClick(lint)
}}
>
<CardHeader className="border-b-0 shrink-0 flex flex-row gap-2 space-y-0 justify-between items-center">
<div className="flex flex-row items-center gap-3">
{lint.categories[0] === 'SECURITY' ? (
<Shield size={16} strokeWidth={1.5} className="text-foreground-muted" />
) : (
<BarChart size={16} strokeWidth={1.5} className="text-foreground-muted" />
)}
<CardTitle className="text-foreground-light">{lint.categories[0]}</CardTitle>
</div>
<ButtonTooltip
type="text"
className="w-7 h-7"
icon={<AiIconAnimation size={16} />}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
snap.newChat({
name: 'Summarize lint',
open: true,
initialInput: createLintSummaryPrompt(lint),
})
}}
tooltip={{
content: { side: 'bottom', text: 'Help me fix this issue' },
}}
/>
</CardHeader>
<CardContent className="p-6 pt-16 flex flex-col justify-end flex-1 overflow-auto">
{lint.detail ? lint.detail.substring(0, 100) : lint.title}
{lint.detail && lint.detail.length > 100 && '...'}
</CardContent>
</Card>
)
})}
</Row>
<Sheet open={selectedLint !== null} onOpenChange={() => setSelectedLint(null)}>
<SheetContent>
{selectedLint && (
<>
<SheetHeader>
<div className="flex items-center gap-4">
<SheetTitle>
{lintInfoMap.find((item) => item.name === selectedLint.name)?.title ??
'Unknown'}
</SheetTitle>
<LintCategoryBadge category={selectedLint.categories[0]} />
</div>
</SheetHeader>
<SheetSection>
{selectedLint && projectRef && (
<LintDetail
lint={selectedLint}
projectRef={projectRef!}
onAskAssistant={() => setSelectedLint(null)}
/>
)}
</SheetSection>
</>
)}
</SheetContent>
</Sheet>
</>
) : (
<Card className="bg-transparent">
<CardContent className="flex flex-col items-center justify-center gap-2 p-16">
<Shield size={20} strokeWidth={1.5} className="text-foreground-muted" />
<p className="text-sm text-foreground-light text-center">
No security or performance errors found
</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -16,6 +16,7 @@ import {
} from 'hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from 'lib/constants'
import { useAppStateSnapshot } from 'state/app-state'
import { AdvisorSection } from './AdvisorSection'
export const HomeV2 = () => {
const { ref, enableBranching } = useParams()
@@ -100,11 +101,15 @@ export const HomeV2 = () => {
)}
strategy={verticalListSortingStrategy}
>
{sectionOrder.map((id) => (
<SortableSection key={id} id={id}>
{id}
</SortableSection>
))}
{sectionOrder.map((id) => {
if (id === 'advisor') {
return (
<SortableSection key={id} id={id}>
<AdvisorSection />
</SortableSection>
)
}
})}
</SortableContext>
</DndContext>
</ScaffoldSection>

View File

@@ -0,0 +1,73 @@
import Link from 'next/link'
import ReactMarkdown from 'react-markdown'
import { createLintSummaryPrompt, lintInfoMap } from 'components/interfaces/Linter/Linter.utils'
import { EntityTypeIcon, LintCTA, LintCategoryBadge, LintEntity } from './Linter.utils'
import { Lint } from 'data/lint/lint-query'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { AiIconAnimation, Button } from 'ui'
import { ExternalLink } from 'lucide-react'
interface LintDetailProps {
lint: Lint
projectRef: string
onAskAssistant?: () => void
}
const LintDetail = ({ lint, projectRef, onAskAssistant }: LintDetailProps) => {
const snap = useAiAssistantStateSnapshot()
return (
<div>
<h3 className="text-sm mb-2">Entity</h3>
<div className="flex items-center gap-1 px-2 py-0.5 bg-surface-200 border rounded-lg text-sm mb-6 w-fit">
<EntityTypeIcon type={lint.metadata?.type} />
<LintEntity metadata={lint.metadata} />
</div>
<h3 className="text-sm mb-2">Issue</h3>
<ReactMarkdown className="leading-6 text-sm text-foreground-light mb-6">
{lint.detail.replace(/\\`/g, '`')}
</ReactMarkdown>
<h3 className="text-sm mb-2">Description</h3>
<ReactMarkdown className="text-sm text-foreground-light mb-6">
{lint.description.replace(/\\`/g, '`')}
</ReactMarkdown>
<h3 className="text-sm mb-2">Resolve</h3>
<div className="flex items-center gap-2">
<Button
icon={<AiIconAnimation className="scale-75 w-3 h-3" />}
onClick={() => {
onAskAssistant?.()
snap.newChat({
name: 'Summarize lint',
open: true,
initialInput: createLintSummaryPrompt(lint),
})
}}
>
Ask Assistant
</Button>
<LintCTA title={lint.name} projectRef={projectRef} metadata={lint.metadata} />
<Button asChild type="text">
<Link
href={
lintInfoMap.find((item) => item.name === lint.name)?.docsLink ||
'https://supabase.com/docs/guides/database/database-linter'
}
target="_blank"
rel="noreferrer"
className="no-underline"
>
<span className="flex items-center gap-2">
Learn more <ExternalLink size={14} />
</span>
</Link>
</Button>
</div>
</div>
)
}
export default LintDetail

View File

@@ -20,17 +20,10 @@ import { useRouter } from 'next/router'
interface LintPageTabsProps {
currentTab: string
setCurrentTab: (value: LINTER_LEVELS) => void
setSelectedLint: (value: Lint | null) => void
isLoading: boolean
activeLints: Lint[]
}
const LintPageTabs = ({
currentTab,
setCurrentTab,
setSelectedLint,
isLoading,
activeLints,
}: LintPageTabsProps) => {
const LintPageTabs = ({ currentTab, setCurrentTab, isLoading, activeLints }: LintPageTabsProps) => {
const router = useRouter()
const warnLintsCount = activeLints.filter((x) => x.level === 'WARN').length
@@ -73,7 +66,6 @@ const LintPageTabs = ({
defaultValue={currentTab}
onValueChange={(value) => {
setCurrentTab(value as LINTER_LEVELS)
setSelectedLint(null)
const { sort, search, ...rest } = router.query
router.push({ ...router, query: { ...rest, preset: value, id: null } })
}}

View File

@@ -382,3 +382,23 @@ export const NoIssuesFound = ({ level }: { level: string }) => {
</div>
)
}
export const createLintSummaryPrompt = (lint: Lint) => {
const title = lintInfoMap.find((item) => item.name === lint.name)?.title ?? lint.title
const entity =
(lint.metadata &&
(lint.metadata.entity ||
(lint.metadata.schema &&
lint.metadata.name &&
`${lint.metadata.schema}.${lint.metadata.name}`))) ||
'N/A'
const schema = lint.metadata?.schema ?? 'N/A'
const issue = lint.detail ? lint.detail.replace(/\\`/g, '`') : 'N/A'
const description = lint.description ? lint.description.replace(/\\`/g, '`') : 'N/A'
return `Summarize the issue and suggest fixes for the following lint item:
Title: ${title}
Entity: ${entity}
Schema: ${schema}
Issue Details: ${issue}
Description: ${description}`
}

View File

@@ -1,35 +1,27 @@
import { ExternalLink, X } from 'lucide-react'
import Link from 'next/link'
import { X } from 'lucide-react'
import { useRef, useState } from 'react'
import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid'
import ReactMarkdown from 'react-markdown'
import { useParams } from 'common'
import { LINTER_LEVELS } from 'components/interfaces/Linter/Linter.constants'
import { LintEntity, NoIssuesFound, lintInfoMap } from 'components/interfaces/Linter/Linter.utils'
import {
LintCategoryBadge,
LintEntity,
NoIssuesFound,
lintInfoMap,
} from 'components/interfaces/Linter/Linter.utils'
import { Lint } from 'data/lint/lint-query'
import { useRouter } from 'next/router'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import {
AiIconAnimation,
Button,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
TabsContent_Shadcn_,
TabsList_Shadcn_,
TabsTrigger_Shadcn_,
Tabs_Shadcn_,
cn,
} from 'ui'
import { Button, ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { EntityTypeIcon, LintCTA, LintCategoryBadge } from './Linter.utils'
import { EntityTypeIcon } from './Linter.utils'
import LintDetail from './LintDetail'
interface LinterDataGridProps {
isLoading: boolean
filteredLints: Lint[]
selectedLint: Lint | null
setSelectedLint: (value: Lint | null) => void
currentTab: LINTER_LEVELS
}
@@ -37,15 +29,11 @@ const LinterDataGrid = ({
isLoading,
filteredLints,
selectedLint,
setSelectedLint,
currentTab,
}: LinterDataGridProps) => {
const gridRef = useRef<DataGridHandle>(null)
const { ref } = useParams()
const router = useRouter()
const snap = useAiAssistantStateSnapshot()
const [view, setView] = useState<'details' | 'suggestion'>('details')
const lintCols = [
{
@@ -121,7 +109,6 @@ const LinterDataGrid = ({
})
function handleSidepanelClose() {
setSelectedLint(null)
const { id, ...otherParams } = router.query
router.push({ query: otherParams })
}
@@ -158,7 +145,6 @@ const LinterDataGrid = ({
{...props}
onClick={() => {
if (typeof idx === 'number' && idx >= 0) {
setSelectedLint(props.row)
gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx })
const { id, ...rest } = router.query
router.push({ ...router, query: { ...rest, id: props.row.cache_key } })
@@ -181,109 +167,18 @@ const LinterDataGrid = ({
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={30} maxSize={45} minSize={30} className="bg-studio border-t">
<Button
type="text"
className="absolute top-3 right-3 px-1"
icon={<X />}
onClick={handleSidepanelClose}
/>
<Tabs_Shadcn_
value={view}
className="flex flex-col h-full"
onValueChange={(value: any) => {
setView(value)
}}
>
<TabsList_Shadcn_ className="px-5 flex gap-x-4 min-h-[46px]">
<TabsTrigger_Shadcn_
value="details"
className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent !shadow-none"
>
Overview
</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
<TabsContent_Shadcn_
value="details"
className="mt-0 flex-grow min-h-0 overflow-y-auto prose"
>
{selectedLint && (
<div className="py-4 px-5">
<div className="flex items-center gap-2 py-2">
<h3 className="text-sm m-0">
{lintInfoMap.find((item) => item.name === selectedLint.name)?.title}
</h3>
<LintCategoryBadge category={selectedLint.categories[0]} />
</div>
<div className="flex items-center gap-2 text-sm mt-4">
<span>Entity</span>
<div className="flex items-center gap-1 px-2 py-0.5 bg-surface-200 border rounded-lg ">
<EntityTypeIcon type={selectedLint.metadata?.type} />
<LintEntity metadata={selectedLint.metadata} />
</div>
</div>
<div className="grid">
<div>
<h3 className="text-sm">Issue</h3>
<ReactMarkdown className="leading-6 text-sm">
{selectedLint.detail.replace(/\\`/g, '`')}
</ReactMarkdown>
</div>
<div>
<h3 className="text-sm">Description</h3>
<ReactMarkdown className="text-sm">
{selectedLint.description.replace(/\\`/g, '`')}
</ReactMarkdown>
</div>
<div className="grid gap-2">
<h3 className="text-sm">Resolve</h3>
<div className="flex items-center gap-2">
<Button
icon={<AiIconAnimation className="scale-75 w-3 h-3" />}
onClick={() => {
snap.newChat({
name: 'Summarize lint',
open: true,
initialInput: `Summarize the issue and suggest fixes: ${lintInfoMap.find((item) => item.name === selectedLint.name)?.title}
\nEntity: ${(selectedLint.metadata && (selectedLint.metadata.entity || (selectedLint.metadata.schema && selectedLint.metadata.name && `${selectedLint.metadata.schema}.${selectedLint.metadata.name}`))) ?? ''}
\nSchema: ${selectedLint.metadata?.schema ?? ''}
\nIssue: ${selectedLint.detail.replace(/\\`/g, '`')}
\nDescription: ${selectedLint.description.replace(/\\`/g, '`')}\n`,
})
}}
>
Ask Assistant
</Button>
<LintCTA
title={selectedLint.name}
projectRef={ref!}
metadata={selectedLint.metadata}
/>
<Button asChild type="text">
<Link
href={
lintInfoMap.find((item) => item.name === selectedLint.name)
?.docsLink ||
'https://supabase.com/docs/guides/database/database-linter'
}
target="_blank"
rel="noreferrer"
className="no-underline"
>
<span className="flex items-center gap-2">
Learn more <ExternalLink size={14} />
</span>
</Link>
</Button>
</div>
</div>
</div>
</div>
)}
</TabsContent_Shadcn_>
</Tabs_Shadcn_>
<div className="flex items-center justify-between w-full border-b py-3 px-6">
<div className="flex items-center gap-2">
<h3 className="text-sm m-0">
{lintInfoMap.find((item) => item.name === selectedLint.name)?.title ?? 'Unknown'}
</h3>
<LintCategoryBadge category={selectedLint.categories[0]} />
</div>
<Button type="text" icon={<X />} onClick={handleSidepanelClose} />
</div>
<div className="p-6">
<LintDetail lint={selectedLint} projectRef={ref!} />
</div>
</ResizablePanel>
</>
)}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useParams } from 'common'
import LintPageTabs from 'components/interfaces/Linter/LintPageTabs'
@@ -28,8 +28,6 @@ const ProjectLints: NextPageWithLayout = () => {
const [currentTab, setCurrentTab] = useState<LINTER_LEVELS>(
(preset as LINTER_LEVELS) ?? LINTER_LEVELS.ERROR
)
const [selectedLint, setSelectedLint] = useState<Lint | null>(null)
const { data, isLoading, isRefetching, refetch } = useProjectLintsQuery({
projectRef: project?.ref,
})
@@ -53,9 +51,8 @@ const ProjectLints: NextPageWithLayout = () => {
value: type.name,
}))
useEffect(() => {
// check the URL for an ID and set the selected lint
if (id) setSelectedLint(activeLints.find((lint) => lint.cache_key === id) ?? null)
const selectedLint: Lint | null = useMemo(() => {
return activeLints.find((lint) => lint.cache_key === id) ?? null
}, [id, activeLints])
return (
@@ -70,7 +67,6 @@ const ProjectLints: NextPageWithLayout = () => {
isLoading={isLoading}
currentTab={currentTab}
setCurrentTab={setCurrentTab}
setSelectedLint={setSelectedLint}
/>
<LinterFilters
filterOptions={filterOptions}
@@ -87,7 +83,6 @@ const ProjectLints: NextPageWithLayout = () => {
filteredLints={filteredLints}
currentTab={currentTab}
selectedLint={selectedLint}
setSelectedLint={setSelectedLint}
isLoading={isLoading}
/>
<LinterPageFooter isLoading={isLoading} isRefetching={isRefetching} refetch={refetch} />

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useMemo, useState } from 'react'
import { useParams } from 'common'
import LintPageTabs from 'components/interfaces/Linter/LintPageTabs'
@@ -28,8 +28,6 @@ const ProjectLints: NextPageWithLayout = () => {
const [currentTab, setCurrentTab] = useState<LINTER_LEVELS>(
(preset as LINTER_LEVELS) ?? LINTER_LEVELS.ERROR
)
const [selectedLint, setSelectedLint] = useState<Lint | null>(null)
const { data, isLoading, isRefetching, refetch } = useProjectLintsQuery({
projectRef: project?.ref,
})
@@ -55,9 +53,8 @@ const ProjectLints: NextPageWithLayout = () => {
value: type.name,
}))
useEffect(() => {
// check the URL for an ID and set the selected lint
if (id) setSelectedLint(activeLints.find((lint) => lint.cache_key === id) ?? null)
const selectedLint: Lint | null = useMemo(() => {
return activeLints.find((lint) => lint.cache_key === id) ?? null
}, [id, activeLints])
return (
@@ -72,7 +69,6 @@ const ProjectLints: NextPageWithLayout = () => {
isLoading={isLoading}
currentTab={currentTab}
setCurrentTab={setCurrentTab}
setSelectedLint={setSelectedLint}
/>
<LinterFilters
filterOptions={filterOptions}
@@ -89,7 +85,6 @@ const ProjectLints: NextPageWithLayout = () => {
filteredLints={filteredLints}
currentTab={currentTab}
selectedLint={selectedLint}
setSelectedLint={setSelectedLint}
isLoading={isLoading}
/>
<LinterPageFooter

View File

@@ -16,3 +16,4 @@ export * from './src/ShimmeringLoader'
export * from './src/TimestampInfo'
export * from './src/Toc'
export * from './src/PromoToast'
export * from './src/Row'

View File

@@ -0,0 +1,193 @@
'use client'
import type React from 'react'
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
import { Button, cn } from 'ui'
import type { ReactNode } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
interface RowProps extends React.HTMLAttributes<HTMLDivElement> {
// columns can be a fixed number or an array [lg, md, sm]
columns: number | [number, number, number]
children: ReactNode
className?: string
/** gap between items in pixels */
gap?: number
/** show left/right arrow buttons */
showArrows?: boolean
/** scrolling behavior for arrow navigation */
scrollBehavior?: ScrollBehavior
}
export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
{ columns, children, className, gap = 16, showArrows = true, scrollBehavior = 'smooth', ...rest },
ref
) {
const containerRef = useRef<HTMLDivElement>(null)
// We forward the ref to the outer wrapper; consumers needing the scroll container
// can use a separate ref prop in the future if required.
const childrenArray = useMemo(() => (Array.isArray(children) ? children : [children]), [children])
const [scrollPosition, setScrollPosition] = useState(0)
const [maxScroll, setMaxScroll] = useState(0)
const resolveColumnsForWidth = (width: number): number => {
if (!Array.isArray(columns)) return columns
// Interpret as [lg, md, sm]
const [lgCols, mdCols, smCols] = columns
if (width >= 1024) return lgCols
if (width >= 768) return mdCols
return smCols
}
const getRenderColumns = (): number => {
const width = containerRef.current?.getBoundingClientRect().width ?? 0
return resolveColumnsForWidth(width)
}
const scrollByStep = (direction: -1 | 1) => {
const el = containerRef.current
if (!el) return
const widthLocal = el.getBoundingClientRect().width
const colsLocal = resolveColumnsForWidth(widthLocal)
const columnWidth = (widthLocal - (colsLocal - 1) * gap) / colsLocal
const scrollAmount = columnWidth + gap
setScrollPosition((prev) => Math.max(0, Math.min(maxScroll, prev + direction * scrollAmount)))
}
const scrollLeft = () => scrollByStep(-1)
const scrollRight = () => scrollByStep(1)
const canScrollLeft = scrollPosition > 0
const canScrollRight = scrollPosition < maxScroll
useEffect(() => {
const element = containerRef.current
if (!element) return
const computeMaxScroll = (width: number) => {
const colsLocal = resolveColumnsForWidth(width)
const columnWidth = (width - (colsLocal - 1) * gap) / colsLocal
const totalWidth = childrenArray.length * columnWidth + (childrenArray.length - 1) * gap
const maxScrollValue = Math.max(0, totalWidth - width)
setMaxScroll(maxScrollValue)
}
// Initial calculation
computeMaxScroll(element.getBoundingClientRect().width)
if (typeof ResizeObserver !== 'undefined') {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
computeMaxScroll(entry.contentRect.width)
}
})
resizeObserver.observe(element)
return () => resizeObserver.disconnect()
} else {
const handleResize = () => computeMaxScroll(element.getBoundingClientRect().width)
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}
}, [childrenArray.length, gap, columns])
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (containerRef.current && containerRef.current.contains(e.target as Node)) {
if (e.deltaX !== 0) {
e.preventDefault()
const scrollAmount = Math.abs(e.deltaX) * 2
const direction = e.deltaX > 0 ? 1 : -1
setScrollPosition((prev) => {
const newPosition = prev + scrollAmount * direction
return Math.max(0, Math.min(maxScroll, newPosition))
})
}
}
}
const container = containerRef.current
if (container) {
container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel)
}
}, [maxScroll])
useEffect(() => {
setScrollPosition((prev) => Math.min(prev, maxScroll))
}, [maxScroll])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (containerRef.current && document.activeElement === containerRef.current) {
if (e.key === 'ArrowLeft' && canScrollLeft) {
e.preventDefault()
scrollLeft()
} else if (e.key === 'ArrowRight' && canScrollRight) {
e.preventDefault()
scrollRight()
}
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [canScrollLeft, canScrollRight])
return (
<div ref={ref} className={cn('relative w-full', className)} {...rest}>
{showArrows && canScrollLeft && (
<Button
type="default"
onClick={scrollLeft}
className="absolute w-8 h-8 left-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2"
aria-label="Scroll left"
>
<ChevronLeft className="w-4 h-4" />
</Button>
)}
{showArrows && canScrollRight && (
<Button
type="default"
onClick={scrollRight}
className="absolute w-8 h-8 right-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2"
aria-label="Scroll right"
>
<ChevronRight className="w-4 h-4" />
</Button>
)}
<div
ref={containerRef}
className="w-full overflow-visible focus:outline-none"
tabIndex={0}
role="region"
aria-roledescription="carousel"
aria-label="Horizontally scrollable content"
>
<div
className="flex items-stretch min-w-full transition-transform duration-300 ease-out"
style={
{
gap: `${gap}px`,
'--column-width': `calc((100% - ${(getRenderColumns() - 1) * gap}px) / ${getRenderColumns()})`,
transform: `translateX(-${scrollPosition}px)`,
} as React.CSSProperties
}
>
{childrenArray.map((child, index) => (
<div key={index} className="flex-shrink-0" style={{ width: 'var(--column-width)' }}>
{child}
</div>
))}
</div>
</div>
</div>
)
})
Row.displayName = 'Row'