diff options
| author | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
| commit | 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch) | |
| tree | b9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/components/metrics/ListTable.tsx | |
| download | umami-main.tar.xz umami-main.zip | |
Created from https://vercel.com/new
Diffstat (limited to 'src/components/metrics/ListTable.tsx')
| -rw-r--r-- | src/components/metrics/ListTable.tsx | 152 |
1 files changed, 152 insertions, 0 deletions
diff --git a/src/components/metrics/ListTable.tsx b/src/components/metrics/ListTable.tsx new file mode 100644 index 0000000..f233bfe --- /dev/null +++ b/src/components/metrics/ListTable.tsx @@ -0,0 +1,152 @@ +import { config, useSpring } from '@react-spring/web'; +import { Column, Grid, Row, Text } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { FixedSizeList } from 'react-window'; +import { AnimatedDiv } from '@/components/common/AnimatedDiv'; +import { Empty } from '@/components/common/Empty'; +import { useMessages, useMobile } from '@/components/hooks'; +import { formatLongCurrency, formatLongNumber } from '@/lib/format'; + +const ITEM_SIZE = 30; + +interface ListData { + label: string; + count: number; + percent: number; +} + +export interface ListTableProps { + data?: ListData[]; + title?: string; + metric?: string; + className?: string; + renderLabel?: (data: ListData, index: number) => ReactNode; + renderChange?: (data: ListData, index: number) => ReactNode; + animate?: boolean; + virtualize?: boolean; + showPercentage?: boolean; + itemCount?: number; + currency?: string; +} + +export function ListTable({ + data = [], + title, + metric, + renderLabel, + renderChange, + animate = true, + virtualize = false, + showPercentage = true, + itemCount = 10, + currency, +}: ListTableProps) { + const { formatMessage, labels } = useMessages(); + const { isPhone } = useMobile(); + + const getRow = (row: ListData, index: number) => { + const { label, count, percent } = row; + + return ( + <AnimatedRow + key={`${label}${index}`} + label={renderLabel ? renderLabel(row, index) : (label ?? formatMessage(labels.unknown))} + value={count} + percent={percent} + animate={animate && !virtualize} + showPercentage={showPercentage} + change={renderChange ? renderChange(row, index) : null} + currency={currency} + isPhone={isPhone} + /> + ); + }; + + const ListTableRow = ({ index, style }) => { + return <div style={style}>{getRow(data[index], index)}</div>; + }; + + return ( + <Column gap> + <Grid alignItems="center" justifyContent="space-between" paddingLeft="2" columns="1fr 100px"> + <Text weight="bold">{title}</Text> + <Text weight="bold" align="center"> + {metric} + </Text> + </Grid> + <Column gap="1"> + {data?.length === 0 && <Empty />} + {virtualize && data.length > 0 ? ( + <FixedSizeList + width="100%" + height={itemCount * ITEM_SIZE} + itemCount={data.length} + itemSize={ITEM_SIZE} + > + {ListTableRow} + </FixedSizeList> + ) : ( + data.map(getRow) + )} + </Column> + </Column> + ); +} + +const AnimatedRow = ({ + label, + value = 0, + percent, + change, + animate, + showPercentage = true, + currency, + isPhone, +}) => { + const props = useSpring({ + width: percent, + y: !Number.isNaN(value) ? value : 0, + from: { width: 0, y: 0 }, + config: animate ? config.default : { duration: 0 }, + }); + + return ( + <Grid + columns="1fr 50px 50px" + paddingLeft="2" + alignItems="center" + hoverBackgroundColor="2" + borderRadius + gap + > + <Row alignItems="center"> + <Text truncate={true} style={{ maxWidth: isPhone ? '200px' : '400px' }}> + {label} + </Text> + </Row> + <Row alignItems="center" height="30px" justifyContent="flex-end"> + {change} + <Text weight="bold"> + <AnimatedDiv title={props?.y as any}> + {currency + ? props.y?.to(n => formatLongCurrency(n, currency)) + : props.y?.to(formatLongNumber)} + </AnimatedDiv> + </Text> + </Row> + {showPercentage && ( + <Row + alignItems="center" + justifyContent="flex-start" + position="relative" + border="left" + borderColor="8" + color="muted" + paddingLeft="3" + > + <AnimatedDiv>{props.width.to(n => `${n?.toFixed?.(0)}%`)}</AnimatedDiv> + </Row> + )} + </Grid> + ); +}; |