Home New: Report (#38341)
* new home top * advisors * fix ts * add report section * add report * Nit refactor * refactor row * prevent adding snippet twice --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core'
|
||||
import { SortableContext, arrayMove, rectSortingStrategy, useSortable } from '@dnd-kit/sortable'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import dayjs from 'dayjs'
|
||||
import { Plus } from 'lucide-react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { SnippetDropdown } from 'components/interfaces/HomeNew/SnippetDropdown'
|
||||
import { ReportBlock } from 'components/interfaces/Reports/ReportBlock/ReportBlock'
|
||||
import type { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
|
||||
import { DEFAULT_CHART_CONFIG } from 'components/ui/QueryBlock/QueryBlock'
|
||||
import { AnalyticsInterval } from 'data/analytics/constants'
|
||||
import { useContentInfiniteQuery } from 'data/content/content-infinite-query'
|
||||
import { Content } from 'data/content/content-query'
|
||||
import { useContentUpsertMutation } from 'data/content/content-upsert-mutation'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import type { Dashboards } from 'types'
|
||||
import { Button } from 'ui'
|
||||
import { Row } from 'ui-patterns'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function CustomReportSection() {
|
||||
const startDate = dayjs().subtract(7, 'day').toISOString()
|
||||
const endDate = dayjs().toISOString()
|
||||
const { ref } = useParams()
|
||||
const { profile } = useProfile()
|
||||
|
||||
const { data: reportsData } = useContentInfiniteQuery(
|
||||
{ projectRef: ref, type: 'report', name: 'Home', limit: 1 },
|
||||
{ keepPreviousData: true }
|
||||
)
|
||||
const homeReport = reportsData?.pages?.[0]?.content?.[0] as Content | undefined
|
||||
const reportContent = homeReport?.content as Dashboards.Content | undefined
|
||||
const [editableReport, setEditableReport] = useState<Dashboards.Content | undefined>(
|
||||
reportContent
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (reportContent) setEditableReport(reportContent)
|
||||
}, [reportContent])
|
||||
|
||||
const { can: canUpdateReport } = useAsyncCheckProjectPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
'user_content',
|
||||
{
|
||||
resource: {
|
||||
type: 'report',
|
||||
visibility: homeReport?.visibility,
|
||||
owner_id: homeReport?.owner_id,
|
||||
},
|
||||
subject: { id: profile?.id },
|
||||
}
|
||||
)
|
||||
|
||||
const { can: canCreateReport } = useAsyncCheckProjectPermissions(
|
||||
PermissionAction.CREATE,
|
||||
'user_content',
|
||||
{ resource: { type: 'report', owner_id: profile?.id }, subject: { id: profile?.id } }
|
||||
)
|
||||
|
||||
const { mutate: upsertContent } = useContentUpsertMutation()
|
||||
|
||||
const persistReport = useCallback(
|
||||
(updated: Dashboards.Content) => {
|
||||
if (!ref || !homeReport) return
|
||||
upsertContent({ projectRef: ref, payload: { ...homeReport, content: updated } })
|
||||
},
|
||||
[homeReport, ref, upsertContent]
|
||||
)
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
|
||||
|
||||
const handleDragStart = () => {}
|
||||
|
||||
const recomputeSimpleGrid = useCallback(
|
||||
(layout: Dashboards.Chart[]) =>
|
||||
layout.map(
|
||||
(block, idx): Dashboards.Chart => ({
|
||||
...block,
|
||||
x: idx % 2,
|
||||
y: Math.floor(idx / 2),
|
||||
w: 1,
|
||||
h: 1,
|
||||
})
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!editableReport || !active || !over || active.id === over.id) return
|
||||
const items = editableReport.layout.map((x) => String(x.id))
|
||||
const oldIndex = items.indexOf(String(active.id))
|
||||
const newIndex = items.indexOf(String(over.id))
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
|
||||
const moved = arrayMove(editableReport.layout, oldIndex, newIndex)
|
||||
const recomputed = recomputeSimpleGrid(moved)
|
||||
const updated = { ...editableReport, layout: recomputed }
|
||||
setEditableReport(updated)
|
||||
persistReport(updated)
|
||||
},
|
||||
[editableReport, persistReport, recomputeSimpleGrid]
|
||||
)
|
||||
|
||||
const findNextPlacement = useCallback((current: Dashboards.Chart[]) => {
|
||||
const occupied = new Set(current.map((c) => `${c.y}-${c.x}`))
|
||||
let y = 0
|
||||
for (; ; y++) {
|
||||
const left = occupied.has(`${y}-0`)
|
||||
const right = occupied.has(`${y}-1`)
|
||||
if (!left || !right) {
|
||||
const x = left ? 1 : 0
|
||||
return { x, y }
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const createSnippetChartBlock = useCallback(
|
||||
(
|
||||
snippet: { id: string; name: string },
|
||||
position: { x: number; y: number }
|
||||
): Dashboards.Chart => ({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
w: 1,
|
||||
h: 1,
|
||||
id: snippet.id,
|
||||
label: snippet.name,
|
||||
attribute: `snippet_${snippet.id}` as unknown as Dashboards.Chart['attribute'],
|
||||
provider: 'daily-stats',
|
||||
chart_type: 'bar',
|
||||
chartConfig: DEFAULT_CHART_CONFIG,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const addSnippetToReport = (snippet: { id: string; name: string }) => {
|
||||
if (
|
||||
editableReport?.layout?.some(
|
||||
(x) =>
|
||||
String(x.id) === String(snippet.id) || String(x.attribute) === `snippet_${snippet.id}`
|
||||
)
|
||||
) {
|
||||
toast('This block is already in your report')
|
||||
return
|
||||
}
|
||||
// If the Home report doesn't exist yet, create it with the new block
|
||||
if (!editableReport || !homeReport) {
|
||||
if (!ref || !profile) return
|
||||
|
||||
// Initial placement for first block
|
||||
const initialBlock = createSnippetChartBlock(snippet, { x: 0, y: 0 })
|
||||
|
||||
const newReport: Dashboards.Content = {
|
||||
schema_version: 1,
|
||||
period_start: { time_period: '7d', date: '' },
|
||||
period_end: { time_period: 'today', date: '' },
|
||||
interval: '1d',
|
||||
layout: [initialBlock],
|
||||
}
|
||||
|
||||
setEditableReport(newReport)
|
||||
upsertContent({
|
||||
projectRef: ref,
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
type: 'report',
|
||||
name: 'Home',
|
||||
description: '',
|
||||
visibility: 'project',
|
||||
owner_id: profile.id,
|
||||
content: newReport,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
const current = [...editableReport.layout]
|
||||
const { x, y } = findNextPlacement(current)
|
||||
current.push(createSnippetChartBlock(snippet, { x, y }))
|
||||
const updated = { ...editableReport, layout: current }
|
||||
setEditableReport(updated)
|
||||
persistReport(updated)
|
||||
}
|
||||
|
||||
const handleRemoveChart = ({ metric }: { metric: { key: string } }) => {
|
||||
if (!editableReport) return
|
||||
const nextLayout = editableReport.layout.filter(
|
||||
(x) => x.attribute !== (metric.key as unknown as Dashboards.Chart['attribute'])
|
||||
)
|
||||
const updated = { ...editableReport, layout: nextLayout }
|
||||
setEditableReport(updated)
|
||||
persistReport(updated)
|
||||
}
|
||||
|
||||
const handleUpdateChart = (
|
||||
id: string,
|
||||
{
|
||||
chart,
|
||||
chartConfig,
|
||||
}: { chart?: Partial<Dashboards.Chart>; chartConfig?: Partial<ChartConfig> }
|
||||
) => {
|
||||
if (!editableReport) return
|
||||
const currentChart = editableReport.layout.find((x) => x.id === id)
|
||||
if (!currentChart) return
|
||||
const updatedChart: Dashboards.Chart = { ...currentChart, ...(chart ?? {}) }
|
||||
if (chartConfig) {
|
||||
updatedChart.chartConfig = { ...(currentChart.chartConfig ?? {}), ...chartConfig }
|
||||
}
|
||||
const updatedLayouts = editableReport.layout.map((x) => (x.id === id ? updatedChart : x))
|
||||
const updated = { ...editableReport, layout: updatedLayouts }
|
||||
setEditableReport(updated)
|
||||
persistReport(updated)
|
||||
}
|
||||
|
||||
const layout = useMemo(() => editableReport?.layout ?? [], [editableReport])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="heading-section">At a glance</h3>
|
||||
{canUpdateReport || canCreateReport ? (
|
||||
<SnippetDropdown
|
||||
projectRef={ref}
|
||||
onSelect={addSnippetToReport}
|
||||
trigger={
|
||||
<Button type="default" icon={<Plus />}>
|
||||
Add block
|
||||
</Button>
|
||||
}
|
||||
side="bottom"
|
||||
align="end"
|
||||
autoFocus
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative">
|
||||
{(() => {
|
||||
if (layout.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-[270px] items-center justify-center rounded border-2 border-dashed p-16 border-default">
|
||||
{canUpdateReport || canCreateReport ? (
|
||||
<SnippetDropdown
|
||||
projectRef={ref}
|
||||
onSelect={addSnippetToReport}
|
||||
trigger={
|
||||
<Button type="default" iconRight={<Plus size={14} />}>
|
||||
Add your first chart
|
||||
</Button>
|
||||
}
|
||||
side="bottom"
|
||||
align="center"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-foreground-light">No charts set up yet in report</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={(editableReport?.layout ?? []).map((x) => String(x.id))}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<Row columns={[3, 2, 1]}>
|
||||
{layout.map((item) => (
|
||||
<SortableReportBlock key={item.id} id={String(item.id)}>
|
||||
<div className="h-64">
|
||||
<ReportBlock
|
||||
key={item.id}
|
||||
item={item}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
interval={
|
||||
(editableReport?.interval as AnalyticsInterval) ??
|
||||
('1d' as AnalyticsInterval)
|
||||
}
|
||||
disableUpdate={false}
|
||||
isRefreshing={false}
|
||||
onRemoveChart={handleRemoveChart}
|
||||
onUpdateChart={(config) => handleUpdateChart(item.id, config)}
|
||||
/>
|
||||
</div>
|
||||
</SortableReportBlock>
|
||||
))}
|
||||
</Row>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableReportBlock({ id, children }: { id: string; children: ReactNode }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: transform
|
||||
? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)`
|
||||
: undefined,
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={isDragging ? 'opacity-70 will-change-transform' : 'will-change-transform'}
|
||||
{...attributes}
|
||||
{...(listeners ?? {})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
import { useAppStateSnapshot } from 'state/app-state'
|
||||
import { AdvisorSection } from './AdvisorSection'
|
||||
import { CustomReportSection } from './CustomReportSection'
|
||||
import {
|
||||
GettingStartedSection,
|
||||
type GettingStartedState,
|
||||
@@ -131,6 +132,13 @@ export const HomeV2 = () => {
|
||||
</SortableSection>
|
||||
)
|
||||
}
|
||||
if (id === 'custom-report') {
|
||||
return (
|
||||
<SortableSection key={id} id={id}>
|
||||
<CustomReportSection />
|
||||
</SortableSection>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
108
apps/studio/components/interfaces/HomeNew/SnippetDropdown.tsx
Normal file
108
apps/studio/components/interfaces/HomeNew/SnippetDropdown.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useIntersectionObserver } from '@uidotdev/usehooks'
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { useContentInfiniteQuery } from 'data/content/content-infinite-query'
|
||||
import type { Content } from 'data/content/content-query'
|
||||
import { SNIPPET_PAGE_LIMIT } from 'data/content/sql-folders-query'
|
||||
import {
|
||||
Command_Shadcn_,
|
||||
CommandEmpty_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandInput_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
|
||||
type SnippetDropdownProps = {
|
||||
projectRef?: string
|
||||
trigger: ReactNode
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
align?: 'start' | 'center' | 'end'
|
||||
className?: string
|
||||
autoFocus?: boolean
|
||||
onSelect: (snippet: { id: string; name: string }) => void
|
||||
}
|
||||
|
||||
type SqlContentItem = Extract<Content, { type: 'sql' }>
|
||||
|
||||
export const SnippetDropdown = ({
|
||||
projectRef,
|
||||
trigger,
|
||||
side = 'bottom',
|
||||
align = 'end',
|
||||
className,
|
||||
autoFocus = false,
|
||||
onSelect,
|
||||
}: SnippetDropdownProps) => {
|
||||
const [snippetSearch, setSnippetSearch] = useState('')
|
||||
const scrollRootRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } =
|
||||
useContentInfiniteQuery(
|
||||
{
|
||||
projectRef,
|
||||
type: 'sql',
|
||||
limit: SNIPPET_PAGE_LIMIT,
|
||||
name: snippetSearch.length === 0 ? undefined : snippetSearch,
|
||||
},
|
||||
{ keepPreviousData: true }
|
||||
)
|
||||
|
||||
const snippets = useMemo(() => {
|
||||
const items = data?.pages.flatMap((page) => page.content) ?? []
|
||||
return items as SqlContentItem[]
|
||||
}, [data?.pages])
|
||||
|
||||
const [sentinelRef, entry] = useIntersectionObserver({
|
||||
root: scrollRootRef.current,
|
||||
threshold: 0,
|
||||
rootMargin: '0px',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage && !isLoading) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [entry?.isIntersecting, hasNextPage, isFetchingNextPage, isLoading, fetchNextPage])
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side={side}
|
||||
align={align}
|
||||
className={['w-80 p-0', className].filter(Boolean).join(' ')}
|
||||
>
|
||||
<Command_Shadcn_ shouldFilter={false}>
|
||||
<CommandInput_Shadcn_
|
||||
autoFocus={autoFocus}
|
||||
placeholder="Search snippets..."
|
||||
value={snippetSearch}
|
||||
onValueChange={setSnippetSearch}
|
||||
/>
|
||||
<CommandList_Shadcn_ ref={scrollRootRef}>
|
||||
{isLoading ? (
|
||||
<CommandEmpty_Shadcn_>Loading…</CommandEmpty_Shadcn_>
|
||||
) : snippets.length === 0 ? (
|
||||
<CommandEmpty_Shadcn_>No snippets found</CommandEmpty_Shadcn_>
|
||||
) : null}
|
||||
<CommandGroup_Shadcn_>
|
||||
{snippets.map((snippet) => (
|
||||
<CommandItem_Shadcn_
|
||||
key={snippet.id}
|
||||
onSelect={() => onSelect({ id: snippet.id, name: snippet.name })}
|
||||
>
|
||||
{snippet.name}
|
||||
</CommandItem_Shadcn_>
|
||||
))}
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
</CommandGroup_Shadcn_>
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
38
packages/ui-patterns/src/Row/Row.utils.ts
Normal file
38
packages/ui-patterns/src/Row/Row.utils.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useLayoutEffect, useState } from 'react'
|
||||
|
||||
export const useMeasuredWidth = <T extends HTMLElement>(ref: React.RefObject<T>) => {
|
||||
const [measuredWidth, setMeasuredWidth] = useState<number | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element) return
|
||||
|
||||
const initial = element.getBoundingClientRect().width
|
||||
setMeasuredWidth((prev) => (prev === initial ? prev : initial))
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
let frame = 0
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const width = entries[0]?.contentRect.width ?? 0
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
frame = requestAnimationFrame(() => {
|
||||
setMeasuredWidth((prev) => (prev === width ? prev : width))
|
||||
})
|
||||
})
|
||||
resizeObserver.observe(element)
|
||||
return () => {
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
} else {
|
||||
const handleResize = () => {
|
||||
const width = element.getBoundingClientRect().width
|
||||
setMeasuredWidth((prev) => (prev === width ? prev : width))
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
return measuredWidth
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import type { ReactNode } 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'
|
||||
import { useMeasuredWidth } from './Row.utils'
|
||||
|
||||
interface RowProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** columns can be a fixed number or an array [lg, md, sm] */
|
||||
// columns can be a fixed number or an array [lg, md, sm]
|
||||
columns: number | [number, number, number]
|
||||
children: ReactNode
|
||||
className?: string
|
||||
@@ -24,13 +25,11 @@ export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
|
||||
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 measuredWidth = useMeasuredWidth(containerRef)
|
||||
|
||||
const resolveColumnsForWidth = (width: number): number => {
|
||||
if (!Array.isArray(columns)) return columns
|
||||
@@ -41,101 +40,77 @@ export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
|
||||
return smCols
|
||||
}
|
||||
|
||||
const getRenderColumns = (): number => {
|
||||
const width = containerRef.current?.getBoundingClientRect().width ?? 0
|
||||
return resolveColumnsForWidth(width)
|
||||
}
|
||||
const renderColumns = useMemo(
|
||||
() => resolveColumnsForWidth(measuredWidth ?? 0),
|
||||
[measuredWidth, columns]
|
||||
)
|
||||
|
||||
const scrollByStep = (direction: -1 | 1) => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const widthLocal = el.getBoundingClientRect().width
|
||||
const colsLocal = resolveColumnsForWidth(widthLocal)
|
||||
const widthLocal = measuredWidth ?? el.getBoundingClientRect().width
|
||||
const colsLocal = renderColumns
|
||||
const columnWidth = (widthLocal - (colsLocal - 1) * gap) / colsLocal
|
||||
const scrollAmount = columnWidth + gap
|
||||
setScrollPosition((prev) => Math.max(0, Math.min(maxScroll, prev + direction * scrollAmount)))
|
||||
setScrollPosition((prev) => {
|
||||
const next = Math.max(0, Math.min(maxScroll, prev + direction * scrollAmount))
|
||||
return next === prev ? prev : next
|
||||
})
|
||||
}
|
||||
|
||||
const scrollLeft = () => scrollByStep(-1)
|
||||
const scrollRight = () => scrollByStep(1)
|
||||
|
||||
const maxScroll = useMemo(() => {
|
||||
if (measuredWidth == null) return -1
|
||||
const colsLocal = renderColumns
|
||||
const columnWidth = (measuredWidth - (colsLocal - 1) * gap) / colsLocal
|
||||
const totalWidth = childrenArray.length * columnWidth + (childrenArray.length - 1) * gap
|
||||
return Math.max(0, totalWidth - measuredWidth)
|
||||
}, [measuredWidth, renderColumns, childrenArray.length, gap])
|
||||
|
||||
const canScrollLeft = scrollPosition > 0
|
||||
const canScrollRight = scrollPosition < maxScroll
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current
|
||||
if (!element) return
|
||||
const rafIdRef = useRef(0 as number)
|
||||
const pendingDeltaRef = useRef(0)
|
||||
|
||||
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)
|
||||
}
|
||||
const handleWheel: React.WheelEventHandler<HTMLDivElement> = (e) => {
|
||||
if (e.deltaX === 0) return
|
||||
|
||||
// Initial calculation
|
||||
computeMaxScroll(element.getBoundingClientRect().width)
|
||||
const delta = Math.abs(e.deltaX) * 2 * (e.deltaX > 0 ? 1 : -1)
|
||||
pendingDeltaRef.current += delta
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
computeMaxScroll(entry.contentRect.width)
|
||||
}
|
||||
if (!rafIdRef.current) {
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = 0
|
||||
const accumulated = pendingDeltaRef.current
|
||||
pendingDeltaRef.current = 0
|
||||
setScrollPosition((prev) => {
|
||||
const target = prev + accumulated
|
||||
const next = Math.max(0, Math.min(maxScroll, target))
|
||||
return next === prev ? prev : next
|
||||
})
|
||||
})
|
||||
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)
|
||||
}
|
||||
setScrollPosition((prev) => {
|
||||
const next = Math.min(prev, maxScroll)
|
||||
return next === prev ? prev : next
|
||||
})
|
||||
}, [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()
|
||||
}
|
||||
}
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||
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}>
|
||||
@@ -145,8 +120,9 @@ export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
|
||||
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"
|
||||
icon={<ChevronLeft className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showArrows && canScrollRight && (
|
||||
@@ -155,8 +131,9 @@ export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
|
||||
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"
|
||||
icon={<ChevronRight className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div
|
||||
@@ -166,14 +143,18 @@ export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
aria-label="Horizontally scrollable content"
|
||||
style={{ overscrollBehaviorX: 'contain' }}
|
||||
onWheel={handleWheel}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<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()})`,
|
||||
'--column-width': `calc((100% - ${(renderColumns - 1) * gap}px) / ${renderColumns})`,
|
||||
transform: `translateX(-${scrollPosition}px)`,
|
||||
willChange: 'transform',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user