diff --git a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx index 8c0ff3af65754..d9cc6f64e2d88 100644 --- a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx +++ b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx @@ -69,9 +69,10 @@ const Pagination = () => { } ) - const count = data?.is_estimate ? formatEstimatedCount(data.count) : data?.count.toLocaleString() - const maxPages = Math.ceil((data?.count ?? 0) / tableEditorSnap.rowsPerPage) - const totalPages = (data?.count ?? 0) > 0 ? maxPages : 1 + const count = data?.count ?? 0 + const countString = data?.is_estimate ? formatEstimatedCount(count) : count.toLocaleString() + const maxPages = Math.ceil(count / tableEditorSnap.rowsPerPage) + const totalPages = count > 0 ? maxPages : 1 const onPreviousPage = () => { if (page > 1) { @@ -202,7 +203,7 @@ const Pagination = () => {

- {`${count} ${data.count === 0 || data.count > 1 ? `records` : 'record'}`}{' '} + {`${countString} ${count === 0 || count > 1 ? `records` : 'record'}`}{' '} {data.is_estimate ? '(estimated)' : ''}

@@ -217,7 +218,7 @@ const Pagination = () => { icon={} onClick={() => { // Show warning if either NOT a table entity, or table rows estimate is beyond threshold - if (rowsCountEstimate === null || data.count > THRESHOLD_COUNT) { + if (rowsCountEstimate === null || count > THRESHOLD_COUNT) { setIsConfirmFetchExactCountModalOpen(true) } else snap.setEnforceExactCount(true) }} diff --git a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx index 75d12fc5d41b2..6f7245d9d5e14 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx @@ -33,6 +33,7 @@ import { import { BillingChangeBadge } from '../ui/BillingChangeBadge' import FormMessage from '../ui/FormMessage' import { NoticeBar } from '../ui/NoticeBar' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' /** * to do: this could be a type from api-types @@ -55,6 +56,8 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { const { data: org } = useSelectedOrganizationQuery() const { data: project, isLoading: isProjectLoading } = useSelectedProjectQuery() + const showComputePrice = useIsFeatureEnabled('project_addons:show_compute_price') + const { computeSize, storageType } = form.watch() const { @@ -221,21 +224,23 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) {
) : ( - <> - - ${price} - - - {' '} - /{' '} - {compute.price_interval === 'monthly' - ? 'month' - : 'hour'} - - + showComputePrice && ( + <> + + ${price} + + + {' '} + /{' '} + {compute.price_interval === 'monthly' + ? 'month' + : 'hour'} + + + ) )} diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx index 650cb768cbd7a..ada3f39721b7e 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx @@ -28,8 +28,7 @@ export const ProjectCard = ({ resourceWarnings, }: ProjectCardProps) => { const { name, ref: projectRef } = project - const infraInformation = project.databases.find((x) => x.identifier === project.ref) - const desc = `${infraInformation?.cloud_provider} | ${project.region}` + const desc = `${project.cloud_provider} | ${project.region}` const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([ 'project_homepage:show_instance_size', diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx index 30563af30bb53..7039687da790a 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx @@ -110,7 +110,7 @@ export const ProjectTableRow = ({ - {infraInformation?.cloud_provider} | {project.region || 'N/A'} + {project.cloud_provider} | {project.region || 'N/A'} diff --git a/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx b/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx new file mode 100644 index 0000000000000..19f479ada64ba --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx @@ -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( + 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; chartConfig?: Partial } + ) => { + 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 ( +
+
+

At a glance

+ {canUpdateReport || canCreateReport ? ( + }> + Add block + + } + side="bottom" + align="end" + autoFocus + /> + ) : null} +
+
+ {(() => { + if (layout.length === 0) { + return ( +
+ {canUpdateReport || canCreateReport ? ( + }> + Add your first chart + + } + side="bottom" + align="center" + autoFocus + /> + ) : ( +

No charts set up yet in report

+ )} +
+ ) + } + return ( + + String(x.id))} + strategy={rectSortingStrategy} + > + + {layout.map((item) => ( + +
+ handleUpdateChart(item.id, config)} + /> +
+
+ ))} +
+
+
+ ) + })()} +
+
+ ) +} + +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 ( +
+ {children} +
+ ) +} diff --git a/apps/studio/components/interfaces/HomeNew/Home.tsx b/apps/studio/components/interfaces/HomeNew/Home.tsx index e2e85de77ffac..6dd50690283ba 100644 --- a/apps/studio/components/interfaces/HomeNew/Home.tsx +++ b/apps/studio/components/interfaces/HomeNew/Home.tsx @@ -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 = () => { ) } + if (id === 'custom-report') { + return ( + + + + ) + } })} diff --git a/apps/studio/components/interfaces/HomeNew/SnippetDropdown.tsx b/apps/studio/components/interfaces/HomeNew/SnippetDropdown.tsx new file mode 100644 index 0000000000000..b9f8fc56abf0b --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/SnippetDropdown.tsx @@ -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 + +export const SnippetDropdown = ({ + projectRef, + trigger, + side = 'bottom', + align = 'end', + className, + autoFocus = false, + onSelect, +}: SnippetDropdownProps) => { + const [snippetSearch, setSnippetSearch] = useState('') + const scrollRootRef = useRef(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 ( + + {trigger} + + + + + {isLoading ? ( + Loading… + ) : snippets.length === 0 ? ( + No snippets found + ) : null} + + {snippets.map((snippet) => ( + onSelect({ id: snippet.id, name: snippet.name })} + > + {snippet.name} + + ))} +
+ + + + + + ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts index 1dbff8c42edad..03236f0f374b9 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts @@ -46,7 +46,7 @@ export const generateRowFields = ( table: PostgresTable, foreignKeys: ForeignKey[] ): RowField[] => { - const { primary_keys } = table + const { primary_keys = [] } = table const primaryKeyColumns = primary_keys.map((key) => key.name) return (table.columns ?? []).map((column) => { diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 5a31e04f07c87..2cceac5f76d64 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -8,9 +8,11 @@ import { SupabaseGrid } from 'components/grid/SupabaseGrid' import { useLoadTableEditorStateFromLocalStorageIntoUrl } from 'components/grid/SupabaseGrid.utils' import { Entity, + isForeignTable, isMaterializedView, isTableLike, isView, + TableLike, } from 'data/table-editor/table-editor-types' import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useDashboardHistory } from 'hooks/misc/useDashboardHistory' @@ -147,6 +149,7 @@ export const TableGridEditor = ({ const isViewSelected = isView(selectedTable) || isMaterializedView(selectedTable) const isTableSelected = isTableLike(selectedTable) + const isForeignTableSelected = isForeignTable(selectedTable) const canEditViaTableEditor = isTableSelected && !isSchemaLocked const editable = !isReadOnly && canEditViaTableEditor @@ -188,11 +191,13 @@ export const TableGridEditor = ({ diff --git a/apps/studio/data/table-rows/table-rows-count-query.ts b/apps/studio/data/table-rows/table-rows-count-query.ts index acfede4a8caa1..f6d57cb4a2ee6 100644 --- a/apps/studio/data/table-rows/table-rows-count-query.ts +++ b/apps/studio/data/table-rows/table-rows-count-query.ts @@ -90,7 +90,7 @@ from approximation; } export type TableRowsCount = { - count: number + count?: number is_estimate?: boolean } @@ -144,8 +144,8 @@ export async function getTableRowsCount( ) return { - count: result[0].count, - is_estimate: result[0].is_estimate ?? false, + count: result?.[0]?.count, + is_estimate: result?.[0]?.is_estimate ?? false, } as TableRowsCount } diff --git a/packages/common/enabled-features/enabled-features.json b/packages/common/enabled-features/enabled-features.json index 16b692a3b8359..30ca1cb6f7b18 100644 --- a/packages/common/enabled-features/enabled-features.json +++ b/packages/common/enabled-features/enabled-features.json @@ -63,6 +63,7 @@ "project_homepage:show_examples": true, "project_addons:dedicated_ipv4_address": true, + "project_addons:show_compute_price": true, "project_settings:custom_domains": true, "project_settings:show_disable_legacy_api_keys": true, diff --git a/packages/common/enabled-features/enabled-features.schema.json b/packages/common/enabled-features/enabled-features.schema.json index 6e49d7d2f12a9..313975ad619ab 100644 --- a/packages/common/enabled-features/enabled-features.schema.json +++ b/packages/common/enabled-features/enabled-features.schema.json @@ -209,6 +209,10 @@ "type": "boolean", "description": "Show the dedicated IPv4 address addon" }, + "project_addons:show_compute_price": { + "type": "boolean", + "description": "Show the compute price in the compute and disk page" + }, "project_settings:custom_domains": { "type": "boolean", @@ -295,6 +299,7 @@ "project_homepage:show_instance_size", "project_homepage:show_examples", "project_addons:dedicated_ipv4_address", + "project_addons:show_compute_price", "project_settings:custom_domains", "project_settings:show_disable_legacy_api_keys", "project_settings:legacy_jwt_keys", diff --git a/packages/ui-patterns/src/Row/Row.utils.ts b/packages/ui-patterns/src/Row/Row.utils.ts new file mode 100644 index 0000000000000..4abd879e8ca5f --- /dev/null +++ b/packages/ui-patterns/src/Row/Row.utils.ts @@ -0,0 +1,38 @@ +import { useLayoutEffect, useState } from 'react' + +export const useMeasuredWidth = (ref: React.RefObject) => { + const [measuredWidth, setMeasuredWidth] = useState(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 +} diff --git a/packages/ui-patterns/src/Row/index.tsx b/packages/ui-patterns/src/Row/index.tsx index c28abb6e11e47..76a8922ec874b 100644 --- a/packages/ui-patterns/src/Row/index.tsx +++ b/packages/ui-patterns/src/Row/index.tsx @@ -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 { - /** 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(function Row( ref ) { const containerRef = useRef(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(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 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) - } + const rafIdRef = useRef(0 as number) + const pendingDeltaRef = useRef(0) + + const handleWheel: React.WheelEventHandler = (e) => { + if (e.deltaX === 0) return + + const delta = Math.abs(e.deltaX) * 2 * (e.deltaX > 0 ? 1 : -1) + pendingDeltaRef.current += delta + + 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) - } - }, [maxScroll]) + } useEffect(() => { - setScrollPosition((prev) => Math.min(prev, maxScroll)) + setScrollPosition((prev) => { + const next = Math.min(prev, maxScroll) + return next === prev ? prev : next + }) }, [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 = (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 (
@@ -145,8 +120,9 @@ export const Row = forwardRef(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={} - /> + > + + )} {showArrows && canScrollRight && ( @@ -155,8 +131,9 @@ export const Row = forwardRef(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={} - /> + > + + )}
(function Row( role="region" aria-roledescription="carousel" aria-label="Horizontally scrollable content" + style={{ overscrollBehaviorX: 'contain' }} + onWheel={handleWheel} + onKeyDown={handleKeyDown} >