diff --git a/.changeset/small-things-push.md b/.changeset/small-things-push.md new file mode 100644 index 00000000..7d0d1ad8 --- /dev/null +++ b/.changeset/small-things-push.md @@ -0,0 +1,5 @@ +--- +"@shipfox/react-ui": minor +--- + +Add KpiCardGroupFromQuery, enhance Table components diff --git a/.changeset/tricky-eels-rest.md b/.changeset/tricky-eels-rest.md new file mode 100644 index 00000000..93ee0299 --- /dev/null +++ b/.changeset/tricky-eels-rest.md @@ -0,0 +1,5 @@ +--- +"@shipfox/react-ui": minor +--- + +Update KpiCard and Table components diff --git a/libs/react/ui/src/components/count-up/count-up.stories.tsx b/libs/react/ui/src/components/count-up/count-up.stories.tsx new file mode 100644 index 00000000..c9da33c2 --- /dev/null +++ b/libs/react/ui/src/components/count-up/count-up.stories.tsx @@ -0,0 +1,380 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {Code} from 'components/typography'; +import {useInView, useMotionValue, useSpring} from 'framer-motion'; +import {useCallback, useEffect, useRef, useState} from 'react'; +import {formatNumberCompact} from 'utils/format/number'; +import {CountUp} from './count-up'; + +const meta = { + title: 'Components/CountUp', + component: CountUp, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, + argTypes: { + to: { + control: 'number', + }, + from: { + control: 'number', + }, + direction: { + control: 'select', + options: ['up', 'down'], + }, + delay: { + control: 'number', + }, + duration: { + control: 'number', + }, + separator: { + control: 'text', + }, + startWhen: { + control: 'boolean', + }, + }, + args: { + to: 1000, + from: 0, + direction: 'up', + delay: 0, + duration: 2, + separator: '', + startWhen: true, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + to: 1000, + from: 0, + }, +}; + +export const Basic: Story = { + render: () => ( +
+
+ + Count from 0 to 1000 + +
+ +
+
+ +
+ + Count from 500 to 1000 + +
+ +
+
+ +
+ + Count down from 1000 to 0 + +
+ +
+
+
+ ), +}; + +export const WithSeparator: Story = { + render: () => ( +
+
+ + With comma separator + +
+ +
+
+ +
+ + With space separator + +
+ +
+
+
+ ), +}; + +export const WithDecimals: Story = { + render: () => ( +
+
+ + With 1 decimal place + +
+ +
+
+ +
+ + With 2 decimal places + +
+ +
+
+
+ ), +}; + +export const WithDelay: Story = { + render: () => ( +
+
+ + With 1 second delay + +
+ +
+
+ +
+ + With 2 second delay + +
+ +
+
+
+ ), +}; + +export const DifferentDurations: Story = { + render: () => ( +
+
+ + Fast (0.5s) + +
+ +
+
+ +
+ + Normal (2s) + +
+ +
+
+ +
+ + Slow (5s) + +
+ +
+
+
+ ), +}; + +export const WithCallbacks: Story = { + render: () => { + const [started, setStarted] = useState(false); + const [ended, setEnded] = useState(false); + + const handleStart = () => { + setStarted(true); + }; + + const handleEnd = () => { + setEnded(true); + }; + + return ( +
+
+ + Callbacks triggered + +
+ +
+
+
+
Started: {started ? 'Yes' : 'No'}
+
Ended: {ended ? 'Yes' : 'No'}
+
+
+ ); + }, +}; + +function CountUpCompact({ + to, + from = 0, + direction = 'up', + delay = 0, + duration = 2, + className = '', + startWhen = true, + onStart, + onEnd, +}: { + to: number; + from?: number; + direction?: 'up' | 'down'; + delay?: number; + duration?: number; + className?: string; + startWhen?: boolean; + onStart?: () => void; + onEnd?: () => void; +}) { + const ref = useRef(null); + const motionValue = useMotionValue(direction === 'down' ? to : from); + + const damping = 20 + 40 * (1 / duration); + const stiffness = 100 * (1 / duration); + + const springValue = useSpring(motionValue, { + damping, + stiffness, + }); + + const isInView = useInView(ref, {once: true, margin: '0px'}); + + const formatValue = useCallback((latest: number) => { + if (Math.abs(latest) >= 999) { + return formatNumberCompact(latest); + } + const hasDecimals = latest % 1 !== 0; + const options: Intl.NumberFormatOptions = { + useGrouping: false, + minimumFractionDigits: hasDecimals ? 1 : 0, + maximumFractionDigits: hasDecimals ? 1 : 0, + }; + return Intl.NumberFormat('en-US', options).format(latest); + }, []); + + useEffect(() => { + if (ref.current) { + ref.current.textContent = formatValue(direction === 'down' ? to : from); + } + }, [from, to, direction, formatValue]); + + useEffect(() => { + if (isInView && startWhen) { + if (typeof onStart === 'function') { + onStart(); + } + + const timeoutId = setTimeout(() => { + motionValue.set(direction === 'down' ? from : to); + }, delay * 1000); + + const durationTimeoutId = setTimeout( + () => { + if (typeof onEnd === 'function') { + onEnd(); + } + }, + delay * 1000 + duration * 1000, + ); + + return () => { + clearTimeout(timeoutId); + clearTimeout(durationTimeoutId); + }; + } + }, [isInView, startWhen, motionValue, direction, from, to, delay, onStart, onEnd, duration]); + + useEffect(() => { + const unsubscribe = springValue.on('change', (latest: number) => { + if (ref.current) { + ref.current.textContent = formatValue(latest); + } + }); + + return () => unsubscribe(); + }, [springValue, formatValue]); + + return ; +} + +export const WithCompactFormat: Story = { + render: () => ( +
+
+ + From 999 to 1.1K + +
+ +
+
+ +
+ + From 0 to 1.5K + +
+ +
+
+ +
+ + From 999,999 to 1.1M + +
+ +
+
+ +
+ + From 0 to 2.5M + +
+ +
+
+ +
+ + From 0 to 1.2B + +
+ +
+
+ +
+ + Count down from 1.5K to 999 + +
+ +
+
+
+ ), +}; diff --git a/libs/react/ui/src/components/count-up/count-up.tsx b/libs/react/ui/src/components/count-up/count-up.tsx new file mode 100644 index 00000000..8ec23129 --- /dev/null +++ b/libs/react/ui/src/components/count-up/count-up.tsx @@ -0,0 +1,115 @@ +import {useInView, useMotionValue, useSpring} from 'framer-motion'; +import {useCallback, useEffect, useRef} from 'react'; + +export interface CountUpProps { + to: number; + from?: number; + direction?: 'up' | 'down'; + delay?: number; + duration?: number; + className?: string; + startWhen?: boolean; + separator?: string; + onStart?: () => void; + onEnd?: () => void; +} + +export function CountUp({ + to, + from = 0, + direction = 'up', + delay = 0, + duration = 2, + className = '', + startWhen = true, + separator = '', + onStart, + onEnd, +}: CountUpProps) { + const ref = useRef(null); + const motionValue = useMotionValue(direction === 'down' ? to : from); + + const damping = 20 + 40 * (1 / duration); + const stiffness = 100 * (1 / duration); + + const springValue = useSpring(motionValue, { + damping, + stiffness, + }); + + const isInView = useInView(ref, {once: true, margin: '0px'}); + + const getDecimalPlaces = (num: number): number => { + const str = num.toString(); + if (str.includes('.')) { + const decimals = str.split('.')[1]; + if (parseInt(decimals, 10) !== 0) { + return decimals.length; + } + } + return 0; + }; + + const maxDecimals = Math.max(getDecimalPlaces(from), getDecimalPlaces(to)); + + const formatValue = useCallback( + (latest: number) => { + const hasDecimals = maxDecimals > 0; + + const options: Intl.NumberFormatOptions = { + useGrouping: !!separator, + minimumFractionDigits: hasDecimals ? maxDecimals : 0, + maximumFractionDigits: hasDecimals ? maxDecimals : 0, + }; + + const formattedNumber = Intl.NumberFormat('en-US', options).format(latest); + + return separator ? formattedNumber.replace(/,/g, separator) : formattedNumber; + }, + [maxDecimals, separator], + ); + + useEffect(() => { + if (ref.current) { + ref.current.textContent = formatValue(direction === 'down' ? to : from); + } + }, [from, to, direction, formatValue]); + + useEffect(() => { + if (isInView && startWhen) { + if (typeof onStart === 'function') { + onStart(); + } + + const timeoutId = setTimeout(() => { + motionValue.set(direction === 'down' ? from : to); + }, delay * 1000); + + const durationTimeoutId = setTimeout( + () => { + if (typeof onEnd === 'function') { + onEnd(); + } + }, + delay * 1000 + duration * 1000, + ); + + return () => { + clearTimeout(timeoutId); + clearTimeout(durationTimeoutId); + }; + } + }, [isInView, startWhen, motionValue, direction, from, to, delay, onStart, onEnd, duration]); + + useEffect(() => { + const unsubscribe = springValue.on('change', (latest: number) => { + if (ref.current) { + ref.current.textContent = formatValue(latest); + } + }); + + return () => unsubscribe(); + }, [springValue, formatValue]); + + return ; +} diff --git a/libs/react/ui/src/components/count-up/index.ts b/libs/react/ui/src/components/count-up/index.ts new file mode 100644 index 00000000..da90f60e --- /dev/null +++ b/libs/react/ui/src/components/count-up/index.ts @@ -0,0 +1 @@ +export * from './count-up'; diff --git a/libs/react/ui/src/components/dashboard/components/kpi-card.tsx b/libs/react/ui/src/components/dashboard/components/kpi-card.tsx index e3e8964a..6503fb77 100644 --- a/libs/react/ui/src/components/dashboard/components/kpi-card.tsx +++ b/libs/react/ui/src/components/dashboard/components/kpi-card.tsx @@ -1,13 +1,16 @@ +import {Card} from 'components/card'; +import {Skeleton} from 'components/skeleton'; import {Text} from 'components/typography'; +import type {ComponentProps, ReactNode} from 'react'; import {cn} from 'utils/cn'; -export type KpiVariant = 'neutral' | 'success' | 'warning' | 'error' | 'info'; +export type KpiVariant = 'neutral' | 'success' | 'warning' | 'error' | 'info' | 'purple'; -export interface KpiCardProps { +export interface KpiCardProps extends Omit, 'title'> { label: string; - value: string | number; + value: string | number | ReactNode; variant?: KpiVariant; - className?: string; + isLoading?: boolean; } const variantDotStyles: Record = { @@ -16,73 +19,64 @@ const variantDotStyles: Record = { warning: 'bg-orange-500', error: 'bg-red-500', info: 'bg-blue-500', + purple: 'bg-purple-500', }; -export function KpiCard({label, value, variant = 'neutral', className}: KpiCardProps) { +export function KpiCard({ + label, + value, + variant = 'neutral', + isLoading, + className, + ...props +}: KpiCardProps) { return ( -
-

{label}

+ + + {label} +
- - - {value} - + + {isLoading ? ( + + ) : ( + + {value} + + )}
-
+ ); } -export interface KpiCardsGroupProps { +export interface KpiCardsGroupProps extends ComponentProps<'div'> { cards: KpiCardProps[]; - className?: string; } -export function KpiCardsGroup({cards, className}: KpiCardsGroupProps) { +export function KpiCardsGroup({cards, className, ...props}: KpiCardsGroupProps) { return (
-
- {cards.map((card, index) => ( - - ))} +
+ {cards.map((card, index) => { + const {key: _key, ...cardProps} = card; + return ( + + ); + })}
); } - -export const defaultKpiCards: KpiCardProps[] = [ - {label: 'Total', value: '1211', variant: 'neutral'}, - {label: 'Success', value: '1200', variant: 'success'}, - {label: 'Neutral', value: '11', variant: 'neutral'}, - {label: 'Failure rate', value: '0%', variant: 'success'}, -]; diff --git a/libs/react/ui/src/components/dashboard/index.ts b/libs/react/ui/src/components/dashboard/index.ts index 2d828129..6fae4582 100644 --- a/libs/react/ui/src/components/dashboard/index.ts +++ b/libs/react/ui/src/components/dashboard/index.ts @@ -1,15 +1,7 @@ -/** - * Dashboard Component Exports - * - * Comprehensive export file for all dashboard-related components and utilities. - */ - export type {BarChartProps, ChartColor, LineChartProps} from './components/charts'; -// Chart Components export {BarChart, LineChart} from './components/charts'; -// Shared Components export {DashboardAlert} from './components/dashboard-alert'; -export type {KpiCardProps} from './components/kpi-card'; +export type {KpiCardProps, KpiCardsGroupProps, KpiVariant} from './components/kpi-card'; export {KpiCard, KpiCardsGroup} from './components/kpi-card'; export type {MobileSidebarProps} from './components/mobile-sidebar'; export {MobileSidebar} from './components/mobile-sidebar'; @@ -23,7 +15,6 @@ export type { TimePeriod, ViewColumn, } from './context'; -// Context API export { DashboardProvider, DEFAULT_COLUMN_ID_TO_ACCESSOR_KEY, @@ -32,12 +23,9 @@ export { viewColumnsToVisibilityState, } from './context'; export type {DashboardProps} from './dashboard'; -// Main Dashboard Component export {Dashboard} from './dashboard'; export type {ExpressionFilterBarProps, ResourceTypeOption} from './filters'; -// Filter Components export {ExpressionFilterBar} from './filters'; -// Page Components export {AnalyticsPage, JobsPage} from './pages'; export type {TableWrapperProps} from './table'; export {TableWrapper} from './table'; @@ -47,5 +35,4 @@ export type { ToolbarActionsProps, ViewDropdownProps, } from './toolbar'; -// Generic Reusable Components export {FilterButton, PageToolbar, ToolbarActions, ToolbarSearch, ViewDropdown} from './toolbar'; diff --git a/libs/react/ui/src/components/dashboard/pages/analytics-page.tsx b/libs/react/ui/src/components/dashboard/pages/analytics-page.tsx index e6671d52..9618e72b 100644 --- a/libs/react/ui/src/components/dashboard/pages/analytics-page.tsx +++ b/libs/react/ui/src/components/dashboard/pages/analytics-page.tsx @@ -1,9 +1,7 @@ -/** - * Analytics Page Component - * - * Refactored analytics page using DashboardContext and generic components. - */ - +import {Button} from 'components/button'; +import {CountUp} from 'components/count-up/count-up'; +import {Icon} from 'components/icon'; +import {SearchInline} from 'components/search/search-inline'; import {jobColumns} from 'components/table/table.stories.columns'; import {jobsData} from 'components/table/table.stories.data'; import {useMediaQuery} from 'hooks/useMediaQuery'; @@ -19,7 +17,6 @@ import {ExpressionFilterBar} from '../filters'; import {TableWrapper} from '../table'; import {PageToolbar, ToolbarActions} from '../toolbar'; -// Sample data for the performance chart const performanceData = [ {label: '1', dataA: 150, dataB: 200, dataC: 280, dataD: 180}, {label: '2', dataA: 250, dataB: 180, dataC: 320, dataD: 220}, @@ -29,7 +26,6 @@ const performanceData = [ {label: '6', dataA: 180, dataB: 250, dataC: 220, dataD: 160}, ]; -// Generate sample data for duration distribution function generateDurationData() { const count = 40; const data: {label: string; value: number}[] = []; @@ -46,29 +42,70 @@ function generateDurationData() { const durationData = generateDurationData(); -/** - * KPI Cards configuration - */ +interface ParsedValue { + prefix: string; + numericValue: number; + suffix: string; + isNumeric: boolean; +} + +const KPI_VALUE_REGEX = /^([^\d]*)([\d.,]+)([^\d]*)$/; + +function parseKpiValue(value: string | number): ParsedValue { + if (typeof value === 'number') { + return { + prefix: '', + numericValue: value, + suffix: '', + isNumeric: true, + }; + } + + const match = value.match(KPI_VALUE_REGEX); + if (match) { + const [, prefix, numericStr, suffix] = match; + const numericValue = parseFloat(numericStr.replace(/,/g, '')); + if (!Number.isNaN(numericValue)) { + return { + prefix, + numericValue, + suffix, + isNumeric: true, + }; + } + } + + return { + prefix: '', + numericValue: 0, + suffix: '', + isNumeric: false, + }; +} + +function renderKpiValue(value: string | number) { + const parsed = parseKpiValue(value); + + if (parsed.isNumeric) { + return ( + <> + {parsed.prefix} + + {parsed.suffix} + + ); + } + + return value; +} + const kpiCards: KpiCardProps[] = [ - {label: 'Total', value: '1211', variant: 'neutral'}, - {label: 'Success', value: '1200', variant: 'success'}, - {label: 'Neutral', value: '11', variant: 'neutral'}, - {label: 'Failure rate', value: '0%', variant: 'success'}, + {label: 'Total', value: renderKpiValue('1211'), variant: 'info'}, + {label: 'Success', value: renderKpiValue('1200'), variant: 'success'}, + {label: 'Neutral', value: renderKpiValue('11'), variant: 'neutral'}, + {label: 'Failure rate', value: renderKpiValue('0%'), variant: 'success'}, ]; -/** - * Analytics Page - * - * Main analytics page with KPI cards, charts, and jobs table. - * Uses DashboardContext for state management and generic reusable components. - * - * @example - * ```tsx - * - * - * - * ``` - */ export function AnalyticsPage() { const { searchQuery, @@ -84,16 +121,13 @@ export function AnalyticsPage() { setResourceType, } = useDashboardContext(); - // Responsive breakpoints const isDesktop = useMediaQuery('(min-width: 1024px)'); - // Get the active sidebar item label for the title const pageTitle = useMemo(() => { const activeItem = defaultSidebarItems.find((item) => item.id === activeSidebarItem); - return activeItem?.label || 'Reliability'; // Default to Reliability + return activeItem?.label || 'Reliability'; }, [activeSidebarItem]); - // Filter data based on search query const filteredData = useMemo( () => jobsData.filter((job) => job.name.toLowerCase().includes(searchQuery.toLowerCase())), [searchQuery], @@ -101,14 +135,12 @@ export function AnalyticsPage() { return (
- {/* Page Toolbar with Mobile Menu */} - {/* Mobile Sidebar Trigger */} {!isDesktop && ( -
- {/* Desktop Sidebar */} +
{isDesktop && ( )} - {/* Main Content */}
- {/* Promotional Alert Banner */} - {/* Expression Filter Bar */} - {/* Toolbar Actions: Filter, Search, View */} - {/* KPI Cards */} - {/* Charts Row - Responsive */}
- {/* Performance over time - Line Chart */} - {/* Duration distribution - Bar Chart */}
- {/* Analytics Table */} setSearchQuery('')} + headerActions={ + <> + setSearchQuery(e.target.value)} + onClear={() => setSearchQuery('')} + className="flex-1 md:w-240" + /> + + + } columnVisibility={columnVisibility} onColumnVisibilityChange={updateColumnVisibility} /> diff --git a/libs/react/ui/src/components/dashboard/pages/jobs-page.tsx b/libs/react/ui/src/components/dashboard/pages/jobs-page.tsx index d0f61d9b..6acd5b15 100644 --- a/libs/react/ui/src/components/dashboard/pages/jobs-page.tsx +++ b/libs/react/ui/src/components/dashboard/pages/jobs-page.tsx @@ -1,9 +1,6 @@ -/** - * Jobs Page Component - * - * Refactored jobs page using DashboardContext and generic components. - */ - +import {Button} from 'components/button'; +import {Icon} from 'components/icon'; +import {SearchInline} from 'components/search/search-inline'; import {jobColumns} from 'components/table/table.stories.columns'; import {jobsData} from 'components/table/table.stories.data'; import {useMemo} from 'react'; @@ -11,19 +8,6 @@ import {useDashboardContext} from '../context'; import {TableWrapper} from '../table'; import {PageToolbar} from '../toolbar'; -/** - * Jobs Page - * - * Simple jobs page with table showing job breakdown. - * Uses DashboardContext for state management and generic reusable components. - * - * @example - * ```tsx - * - * - * - * ``` - */ export function JobsPage() { const { searchQuery, @@ -35,7 +19,6 @@ export function JobsPage() { updateColumnVisibility, } = useDashboardContext(); - // Filter data based on search query const filteredData = useMemo( () => jobsData.filter((job) => job.name.toLowerCase().includes(searchQuery.toLowerCase())), [searchQuery], @@ -43,7 +26,6 @@ export function JobsPage() { return (
- {/* Page Toolbar */} - {/* Main Content - Responsive padding and spacing */} -
- {/* Jobs Table */} +
setSearchQuery('')} + headerActions={ + <> + setSearchQuery(e.target.value)} + onClear={() => setSearchQuery('')} + className="flex-1 md:w-240" + /> + + + } columnVisibility={columnVisibility} onColumnVisibilityChange={updateColumnVisibility} /> diff --git a/libs/react/ui/src/components/dashboard/table/table-wrapper.tsx b/libs/react/ui/src/components/dashboard/table/table-wrapper.tsx index 79eadbf4..1b56d805 100644 --- a/libs/react/ui/src/components/dashboard/table/table-wrapper.tsx +++ b/libs/react/ui/src/components/dashboard/table/table-wrapper.tsx @@ -5,11 +5,8 @@ */ import type {ColumnDef, VisibilityState} from '@tanstack/react-table'; -import {Button} from 'components/button'; -import {Icon} from 'components/icon'; -import {SearchInline} from 'components/search/search-inline'; +import {Card, CardAction, CardContent, CardHeader, CardTitle} from 'components/card'; import {DataTable} from 'components/table/data-table'; -import {Header as TypographyHeader} from 'components/typography'; import type {ComponentProps, ReactNode} from 'react'; import {cn} from 'utils/cn'; @@ -26,23 +23,6 @@ export interface TableWrapperProps extends Omit void; - /** - * Search clear handler - */ - onSearchClear?: () => void; - /** - * Search placeholder text - * @default 'Search...' - */ - searchPlaceholder?: string; /** * Column visibility state */ @@ -80,14 +60,20 @@ export interface TableWrapperProps extends Omit extends Omit + * setSearchQuery(e.target.value)} + * /> + * + * + * } * columnVisibility={columnVisibility} * onColumnVisibilityChange={updateColumnVisibility} * /> @@ -113,10 +107,6 @@ export function TableWrapper({ title, columns, data, - searchQuery = '', - onSearchChange, - onSearchClear, - searchPlaceholder = 'Search...', columnVisibility, onColumnVisibilityChange, pagination = true, @@ -126,61 +116,40 @@ export function TableWrapper({ onRowClick, emptyState, headerActions, - showDefaultActions = true, + isLoading, + scopedContainer, className, ...props }: TableWrapperProps) { return ( -
- {/* Table Header */} -
- {typeof title === 'string' ? ( - - {title} - - ) : ( - title - )} + + + {typeof title === 'string' ? {title} : title} - {/* Actions */} - {(showDefaultActions || headerActions) && ( -
- {showDefaultActions && ( - <> - onSearchChange?.(e.target.value)} - onClear={() => onSearchClear?.()} - className="flex-1 md:w-240" - /> - - - )} + {headerActions && ( + {headerActions} -
+ )} -
+ - {/* Data Table */} - -
+ + + + ); } diff --git a/libs/react/ui/src/components/index.ts b/libs/react/ui/src/components/index.ts index 5034e513..a574816f 100644 --- a/libs/react/ui/src/components/index.ts +++ b/libs/react/ui/src/components/index.ts @@ -9,6 +9,7 @@ export * from './checkbox'; export * from './code-block'; export * from './command'; export * from './confetti'; +export * from './count-up'; export * from './dashboard'; export * from './date-picker'; export * from './date-time-range-picker'; diff --git a/libs/react/ui/src/components/item/item.stories.tsx b/libs/react/ui/src/components/item/item.stories.tsx index 0226ef1c..d2dddd45 100644 --- a/libs/react/ui/src/components/item/item.stories.tsx +++ b/libs/react/ui/src/components/item/item.stories.tsx @@ -3,6 +3,7 @@ import {Button} from 'components/button/button'; import {DatePicker} from 'components/date-picker'; import {Icon} from 'components/icon/icon'; import {Input} from 'components/input/input'; +import {Kbd} from 'components/kbd'; import {Label} from 'components/label/label'; import {useState} from 'react'; import { @@ -104,9 +105,7 @@ export const ImportPastJobsModal: Story = { Import past jobs from GitHub
- - esc - + Esc - + column.toggleSorting(false)} diff --git a/libs/react/ui/src/components/table/table-pagination.tsx b/libs/react/ui/src/components/table/table-pagination.tsx index 7b1b5db5..0da8306d 100644 --- a/libs/react/ui/src/components/table/table-pagination.tsx +++ b/libs/react/ui/src/components/table/table-pagination.tsx @@ -1,6 +1,7 @@ import type {Table} from '@tanstack/react-table'; import {Text} from 'components/typography'; import type {ComponentProps} from 'react'; +import {useRef} from 'react'; import {Button} from '../button'; import {Icon} from '../icon'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '../select/select'; @@ -53,6 +54,19 @@ interface TablePaginationProps extends ComponentProps<'tfoot'> { * ``` */ showSelectedCount?: boolean; + /** + * Optional scoped container element for select dropdown portal. + * + * When provided, the select dropdown will be rendered inside this container + * instead of the document body. This is useful for scoped CSS styling. + * + * @example + * ```tsx + * const {scopedContainer} = useScopedContainer(); + * + * ``` + */ + scopedContainer?: HTMLElement | null; } export function TablePagination({ @@ -60,8 +74,10 @@ export function TablePagination({ className, pageSizeOptions = [10, 20, 50, 100], showSelectedCount = false, + scopedContainer, ...props }: TablePaginationProps) { + const paginationRef = useRef(null); const currentPage = table.getState().pagination.pageIndex + 1; const pageSize = table.getState().pagination.pageSize; const totalRows = table.getFilteredRowModel().rows.length; @@ -69,8 +85,8 @@ export function TablePagination({ const endRow = Math.min(currentPage * pageSize, totalRows); return ( - - + + ({ - + {pageSizeOptions.map((size) => ( {size} diff --git a/libs/react/ui/src/components/table/table.tsx b/libs/react/ui/src/components/table/table.tsx index 77212dba..23e283ae 100644 --- a/libs/react/ui/src/components/table/table.tsx +++ b/libs/react/ui/src/components/table/table.tsx @@ -3,7 +3,7 @@ import {cn} from 'utils/cn'; function Table({className, ...props}: ComponentProps<'table'>) { return ( -
+
) { data-slot="table-row" className={cn( 'group/row border-b border-border-neutral-base transition-colors', - 'last:border-b-0 last:rounded-b-8', + 'last:rounded-b-8 last:border-b-0', 'hover:bg-background-neutral-hover', 'data-[selected=true]:bg-background-neutral-pressed data-[selected=true]:hover:bg-background-neutral-pressed', className, @@ -56,7 +56,7 @@ function TableHead({className, ...props}: ComponentProps<'th'>) { data-slot="table-head" className={cn( 'h-40 px-16 text-left align-middle text-xs font-medium leading-20 text-foreground-neutral-subtle', - 'bg-background-subtle-base', + 'bg-background-subtle-base border-b border-border-neutral-base', '[&:has([role=checkbox])]:pr-0 [&:has([role=checkbox])]:px-12 [&:has([role=checkbox])]:w-0 [&:has([role=checkbox])]:pt-6', className, )}