diff --git a/.changeset/README.md b/.changeset/README.md index c2668e7..5aa11a9 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -8,17 +8,19 @@ We have a quick list of common questions to get you started engaging with this p [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) # Usage + 1. `npx changeset` to create a changeset -2. Select change type: +2. Select change type: + ``` -Major: +Major: - Describes when to use a major change, focusing on breaking changes. -Minor: +Minor: - Describes when to use a minor change, focusing on new features that are backward compatible. -Patch: +Patch: - Describes when to use a patch change, focusing on bug fixes and small improvements. ``` @@ -31,5 +33,6 @@ Patch: 9. After reviewing, merge the `Version Packages` PR on Github, the package version is then updated and published to npm. Notes: + - The `Version Packages` is automatically updated with the latest changeset(s). - The `Version Packages` will only update the package versions and publish to npm, not the actual code. diff --git a/.changeset/weak-drinks-punch.md b/.changeset/weak-drinks-punch.md new file mode 100644 index 0000000..6275ca6 --- /dev/null +++ b/.changeset/weak-drinks-punch.md @@ -0,0 +1,5 @@ +--- +"@babylonlabs-io/bbn-core-ui": minor +--- + +add table component diff --git a/README.md b/README.md index b852e48..aa6f915 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ npm run storybook Provide examples of how to use the library in a project. Include code snippets and explanations. ```javascript -import { ComponentName } from '@babylonlabs-io/bbn-core-ui'; +import { ComponentName } from "@babylonlabs-io/bbn-core-ui"; function App() { return <ComponentName prop="value" />; diff --git a/package-lock.json b/package-lock.json index 520c12c..5da55f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@babylonlabs-io/bbn-core-ui", - "version": "0.0.7", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@babylonlabs-io/bbn-core-ui", - "version": "0.0.7", + "version": "0.2.0", "devDependencies": { "@changesets/cli": "^2.27.9", "@chromatic-com/storybook": "^3.2.2", diff --git a/public/images/fps/lombard.jpeg b/public/images/fps/lombard.jpeg new file mode 100644 index 0000000..b529dce Binary files /dev/null and b/public/images/fps/lombard.jpeg differ diff --git a/public/images/fps/pumpbtc.jpeg b/public/images/fps/pumpbtc.jpeg new file mode 100644 index 0000000..dd0356a Binary files /dev/null and b/public/images/fps/pumpbtc.jpeg differ diff --git a/public/images/fps/solv.jpeg b/public/images/fps/solv.jpeg new file mode 100644 index 0000000..e01360a Binary files /dev/null and b/public/images/fps/solv.jpeg differ diff --git a/src/components/Accordion/Accordion.css b/src/components/Accordion/Accordion.css index 2977212..9e42d9c 100644 --- a/src/components/Accordion/Accordion.css +++ b/src/components/Accordion/Accordion.css @@ -2,7 +2,7 @@ @apply transition-opacity; &-summary { - @apply cursor-pointer relative pr-5 transition-colors; + @apply relative cursor-pointer pr-5 transition-colors; } &-details { diff --git a/src/components/Dialog/index.css b/src/components/Dialog/index.css index d9ab95f..d3f3a21 100644 --- a/src/components/Dialog/index.css +++ b/src/components/Dialog/index.css @@ -24,4 +24,4 @@ &-mobile { @apply fixed inset-x-0 bottom-0 z-50 flex flex-col rounded-t-3xl bg-[#ffffff] px-4 pb-4 pt-6; } -} \ No newline at end of file +} diff --git a/src/components/Table/Table.css b/src/components/Table/Table.css new file mode 100644 index 0000000..48ca32c --- /dev/null +++ b/src/components/Table/Table.css @@ -0,0 +1,189 @@ +/* Table wrapper styles - controls scrolling behavior */ +.bbn-table-wrapper { + @apply relative h-full w-full overflow-auto; + + /* Hide scrollbar for Firefox */ + scrollbar-width: none; + /* Hide scrollbar for IE and Edge */ + -ms-overflow-style: none; + + /* Hide WebKit scrollbar */ + &::-webkit-scrollbar { + display: none; + } +} + +/* Main table styles */ +.bbn-table { + @apply w-full border-separate border-spacing-0; + + /* Header row styles */ + &-header { + @apply sticky top-0 z-30 bg-secondary-contrast text-sm font-medium text-primary-light transition-shadow; + + tr { + @apply relative; + } + + /* Header cell styles */ + th { + @apply whitespace-nowrap px-6 py-3 transition-all border-b border-[#f9f9f9]; + width: var(--column-width); + min-width: var(--column-width); + max-width: var(--column-width); + + &:first-child { + @apply border-l; + } + + &:last-child { + @apply border-r; + } + } + + /* First header cell when fixed */ + th:first-child.bbn-table-fixed { + @apply sticky left-0 z-30 bg-secondary-contrast; + width: fit-content; + min-width: max-content; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &.scrolled-top { + @apply shadow-[0_2px_4px_rgba(0,0,0,0.05)]; + } + } + + /* Table body styles */ + &-body { + /* Row styles */ + tr { + @apply transition-colors; + + td { + @apply border-b border-t border-[#f9f9f9]; + + &:first-child { + @apply border-l; + } + + &:last-child { + @apply border-r; + } + } + + &:hover td { + @apply bg-primary-contrast; + } + + &.selected td { + @apply bg-primary-contrast border-secondary-main; + } + } + + /* Odd row styles */ + tr:nth-child(odd) { + @apply bg-[#F9F9F9]; + + &:hover { + @apply bg-primary-contrast; + } + + td.bbn-table-cell-hover { + @apply bg-primary-contrast/50; + } + + /* First cell when fixed in odd rows */ + td:first-child.bbn-table-fixed { + @apply sticky left-0 z-20 bg-[#F9F9F9]; + width: fit-content; + min-width: max-content; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + /* Even row styles */ + tr:nth-child(even) { + @apply bg-secondary-contrast; + + &:hover { + @apply bg-primary-contrast; + } + + td.bbn-table-cell-hover { + @apply bg-primary-contrast/50; + } + + /* First cell when fixed in even rows */ + td:first-child.bbn-table-fixed { + @apply sticky left-0 z-20 bg-secondary-contrast; + width: fit-content; + min-width: max-content; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + /* Standard cell styles */ + td { + @apply px-6 py-4 text-sm text-primary-light transition-colors; + width: var(--column-width); + min-width: var(--column-width); + max-width: var(--column-width); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + /* Sortable column styles */ + &-sortable { + @apply cursor-pointer select-none; + } + + /* Sort icons container */ + &-sort-icons { + @apply ml-1 inline-flex flex-col; + + svg { + @apply h-4 w-4; + } + + /* Individual sort icon styles */ + .bbn-sort-icon { + @apply text-primary/20 transition-colors; + + &.bbn-sort-icon-up { + @apply -mb-1; + } + + &.bbn-sort-icon-down { + @apply -mt-1; + } + + &.bbn-sort-icon-active { + @apply !text-primary-light; + } + + &.bbn-sort-icon-inactive { + @apply !text-primary/10; + } + } + } + + /* Cell alignment styles */ + &-cell { + &-right { + @apply text-right; + } + + &-left { + @apply text-left; + } + } +} \ No newline at end of file diff --git a/src/components/Table/Table.stories.tsx b/src/components/Table/Table.stories.tsx new file mode 100644 index 0000000..d9355d1 --- /dev/null +++ b/src/components/Table/Table.stories.tsx @@ -0,0 +1,121 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; + +import { Table } from "./"; +import { Avatar } from "../Avatar"; + +const meta: Meta<typeof Table> = { + component: Table, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj<typeof meta>; + +interface FinalityProvider { + id: string; + name: string; + icon: string; + status: string; + btcPk: string; + totalDelegation: number; + commission: number; +} + +const data: FinalityProvider[] = [ + { + id: "1", + name: "Lombard", + icon: "/images/fps/lombard.jpeg", + status: "Active", + btcPk: "1234...4321", + totalDelegation: 10, + commission: 1, + }, + { + id: "2", + name: "Solv Protocol", + icon: "/images/fps/solv.jpeg", + status: "Active", + btcPk: "1234...4321", + totalDelegation: 20, + commission: 3, + }, + { + id: "3", + name: "PumpBTC", + icon: "/images/fps/pumpbtc.jpeg", + status: "Active", + btcPk: "1234...4321", + totalDelegation: 30, + commission: 5, + }, +]; + +export const Default: Story = { + render: () => { + const [tableData, setTableData] = useState(data.slice(0, 3)); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + + const handleLoadMore = async () => { + setLoading(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const nextItems = data.slice(tableData.length, tableData.length + 3); + setTableData((prev) => [...prev, ...nextItems]); + setHasMore(tableData.length + nextItems.length < data.length); + setLoading(false); + }; + + const handleRowSelect = (row: FinalityProvider) => { + console.log(row); + }; + + return ( + <div className="h-[150px]"> + <Table + data={tableData} + hasMore={hasMore} + loading={loading} + onLoadMore={handleLoadMore} + onRowSelect={handleRowSelect} + columns={[ + { + key: "name", + header: "Finality Provider", + render: (_, row) => ( + <div className="flex items-center gap-2"> + <Avatar size="small" url={row.icon} alt={row.name} /> + <span className="text-primary-light">{row.name}</span> + </div> + ), + sorter: (a, b) => a.name.localeCompare(b.name), + }, + { + key: "status", + header: "Status", + }, + { + key: "btcPk", + header: "BTC PK", + }, + { + key: "totalDelegation", + header: "Total Delegation", + render: (value) => `${value} sBTC`, + sorter: (a, b) => a.totalDelegation - b.totalDelegation, + }, + { + key: "commission", + header: "Commission", + render: (value) => `${value}%`, + sorter: (a, b) => a.commission - b.commission, + }, + ]} + /> + </div> + ); + }, +}; diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx new file mode 100644 index 0000000..2e9882c --- /dev/null +++ b/src/components/Table/Table.tsx @@ -0,0 +1,148 @@ +import { useRef, useMemo, useState } from "react"; +import { twJoin } from "tailwind-merge"; +import { useTableScroll } from "@/hooks/useTableScroll"; +import { TableContext, TableContextType } from "../../context/Table.context"; +import { Column } from "./components/Column"; +import { Cell } from "./components/Cell"; +import type { TableProps } from "./types"; +import "./Table.css"; + +export function Table<T extends { id: string | number }>({ + data, + columns, + className, + hasMore = false, + loading = false, + onLoadMore, + onRowSelect, + ...restProps +}: TableProps<T>) { + const tableRef = useRef<HTMLDivElement>(null); + const [hoveredColumn, setHoveredColumn] = useState<string | undefined>(undefined); + const [sortStates, setSortStates] = useState<{ + [key: string]: { direction: "asc" | "desc" | null; priority: number }; + }>({}); + const [selectedRow, setSelectedRow] = useState<string | number | undefined>(undefined); + + const { isScrolledTop } = useTableScroll(tableRef, { + onLoadMore, + hasMore, + loading, + }); + + const handleHoveredColumn = (column: string) => { + if (hoveredColumn === column) return; + setHoveredColumn(column); + }; + + const handleRowSelect = (row: T) => { + if (selectedRow === row.id) return; + setSelectedRow(row.id); + onRowSelect?.(row); + }; + + const handleColumnSort = (columnKey: string, sorter?: (a: T, b: T) => number) => { + if (!sorter) return; + + setSortStates((prev) => { + const currentState = prev[columnKey]?.direction ?? null; + const currentPriority = prev[columnKey]?.priority ?? 0; + + const nextDirection: "asc" | "desc" | null = + currentState === null ? "asc" : currentState === "asc" ? "desc" : null; + + if (nextDirection === null) { + const newState = { ...prev }; + delete newState[columnKey]; + + for (const key in newState) { + if (newState[key].priority > currentPriority) { + newState[key].priority--; + } + } + return newState; + } + + const highestPriority = Math.max(0, ...Object.values(prev).map((s) => s.priority)); + return { + ...prev, + [columnKey]: { + direction: nextDirection, + priority: highestPriority + 1, + }, + }; + }); + }; + + const sortedData = useMemo(() => { + const activeSorters = Object.entries(sortStates) + .filter(([, state]) => state.direction !== null) + .sort((a, b) => b[1].priority - a[1].priority) + .map(([key, state]) => ({ + column: columns.find((col) => col.key === key), + direction: state.direction, + })) + .filter(({ column }) => column?.sorter); + + if (activeSorters.length === 0) return data; + + return [...data].sort((a, b) => { + for (const { column, direction } of activeSorters) { + const result = column!.sorter!(a, b); + if (result !== 0) { + return direction === "asc" ? result : -result; + } + } + return 0; + }); + }, [data, columns, sortStates]); + + const contextValue = useMemo( + () => ({ + data: sortedData, + columns, + sortStates, + hoveredColumn, + onColumnHover: handleHoveredColumn, + onColumnSort: handleColumnSort, + onRowSelect: handleRowSelect, + }), + [sortedData, columns, sortStates, hoveredColumn, handleHoveredColumn, handleColumnSort, handleRowSelect], + ); + + return ( + <TableContext.Provider value={contextValue as TableContextType<unknown>}> + <div ref={tableRef} className="bbn-table-wrapper"> + <table className={twJoin("bbn-table", className)} {...restProps}> + <thead className={twJoin("bbn-table-header", isScrolledTop && "scrolled-top")}> + <tr> + {columns.map((column) => ( + <Column key={column.key} name={column.key} sorter={column.sorter}> + {column.header} + </Column> + ))} + </tr> + </thead> + <tbody className="bbn-table-body"> + {sortedData.map((row) => ( + <tr + key={row.id} + className={twJoin(selectedRow === row.id && "selected", "cursor-pointer")} + onClick={() => handleRowSelect(row)} + > + {columns.map((column) => ( + <Cell + key={column.key} + value={row[column.key as keyof T]} + columnName={column.key} + render={column.render ? (value) => column.render!(value, row) : undefined} + /> + ))} + </tr> + ))} + </tbody> + </table> + </div> + </TableContext.Provider> + ); +} diff --git a/src/components/Table/components/Cell.tsx b/src/components/Table/components/Cell.tsx new file mode 100644 index 0000000..ad57302 --- /dev/null +++ b/src/components/Table/components/Cell.tsx @@ -0,0 +1,30 @@ +import { type PropsWithChildren, type HTMLAttributes, useContext, type ReactNode } from "react"; +import { twJoin } from "tailwind-merge"; +import { TableContext } from "../../../context/Table.context"; + +interface CellProps { + className?: string; + render?: (value: unknown) => ReactNode; + columnName?: string; + value: unknown; +} + +export function Cell({ + className, + render, + value, + columnName, + ...restProps +}: PropsWithChildren<CellProps & HTMLAttributes<HTMLTableCellElement>>) { + const { hoveredColumn } = useContext(TableContext); + + return ( + <td + className={twJoin(`bbn-cell-left`, columnName === hoveredColumn && "bbn-table-cell-hover", className)} + data-column={columnName} + {...restProps} + > + {render ? render(value) : (value as ReactNode)} + </td> + ); +} diff --git a/src/components/Table/components/Column.tsx b/src/components/Table/components/Column.tsx new file mode 100644 index 0000000..5805c3a --- /dev/null +++ b/src/components/Table/components/Column.tsx @@ -0,0 +1,58 @@ +import { type PropsWithChildren, type HTMLAttributes, useContext } from "react"; +import { twJoin } from "tailwind-merge"; +import { RiArrowUpSFill, RiArrowDownSFill } from "react-icons/ri"; +import { TableContext } from "../../../context/Table.context"; + +interface ColumnProps<T = unknown> { + name?: string; + sorter?: (a: T, b: T) => number; + className?: string; +} + +export function Column<T>({ + name, + className, + children, + sorter, + ...restProps +}: PropsWithChildren<ColumnProps<T> & HTMLAttributes<HTMLTableCellElement>>) { + const { columns, sortStates, onColumnSort, onColumnHover } = useContext(TableContext); + const sortState = sortStates[name ?? ""]; + const sortDirection = sortState?.direction; + + return ( + <th + className={twJoin(`bbn-cell-left`, sorter && "bbn-table-sortable", className)} + onClick={() => { + if (sorter && name) { + const column = columns.find((col) => col.key === name); + onColumnSort?.(name, column?.sorter); + } + }} + onMouseEnter={() => name && onColumnHover?.(name)} + onMouseLeave={() => onColumnHover?.(undefined)} + data-column={name} + {...restProps} + > + <div className="flex items-center justify-between gap-1"> + <span>{children}</span> + {sorter && ( + <span className="bbn-table-sort-icons"> + <RiArrowUpSFill + className={twJoin( + "bbn-sort-icon bbn-sort-icon-up", + sortDirection === "asc" ? "bbn-sort-icon-active" : "bbn-sort-icon-inactive", + )} + /> + <RiArrowDownSFill + className={twJoin( + "bbn-sort-icon bbn-sort-icon-down", + sortDirection === "desc" ? "bbn-sort-icon-active" : "bbn-sort-icon-inactive", + )} + /> + </span> + )} + </div> + </th> + ); +} diff --git a/src/components/Table/index.ts b/src/components/Table/index.ts new file mode 100644 index 0000000..d69eb77 --- /dev/null +++ b/src/components/Table/index.ts @@ -0,0 +1,4 @@ +export * from "./Table"; +export * from "./types"; +export * from "./components/Cell"; +export * from "./components/Column"; diff --git a/src/components/Table/types/index.ts b/src/components/Table/types/index.ts new file mode 100644 index 0000000..127f9bd --- /dev/null +++ b/src/components/Table/types/index.ts @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; + +export type ColumnProps<T = unknown> = { + key: string; + header: string; + render?: (value: unknown, row: T) => ReactNode; + sorter?: (a: T, b: T) => number; +}; + +export interface TableProps<T extends { id: string | number }> { + data: T[]; + columns: ColumnProps<T>[]; + className?: string; + hasMore?: boolean; + loading?: boolean; + onLoadMore?: () => void; + onRowSelect?: (row: T) => void; +} diff --git a/src/context/Table.context.tsx b/src/context/Table.context.tsx new file mode 100644 index 0000000..f9818d6 --- /dev/null +++ b/src/context/Table.context.tsx @@ -0,0 +1,27 @@ +import { createContext } from "react"; +import type { ColumnProps } from "../components/Table/types"; + +export interface TableContextType<T = unknown> { + data: T[]; + columns: ColumnProps<T>[]; + sortStates: { + [key: string]: { + direction: "asc" | "desc" | null; + priority: number; + }; + }; + hoveredColumn?: string; + onColumnHover?: (column: string | undefined) => void; + onColumnSort?: (columnKey: string, sorter?: (a: T, b: T) => number) => void; + onRowSelect?: (row: T) => void; +} + +export const TableContext = createContext<TableContextType<unknown>>({ + data: [], + columns: [], + sortStates: {}, + hoveredColumn: undefined, + onColumnHover: undefined, + onColumnSort: undefined, + onRowSelect: undefined, +}); diff --git a/src/hooks/useControlledState.ts b/src/hooks/useControlledState.ts index 8af30e8..bfbe88e 100644 --- a/src/hooks/useControlledState.ts +++ b/src/hooks/useControlledState.ts @@ -6,7 +6,11 @@ interface Options<V> { onStateChange?: (state: V) => void; } -export function useControlledState<V>({ value: controlledState, defaultValue: defaultState, onStateChange }: Options<V> = {}): [V | undefined, (state: V) => void] { +export function useControlledState<V>({ + value: controlledState, + defaultValue: defaultState, + onStateChange, +}: Options<V> = {}): [V | undefined, (state: V) => void] { const [uncontrolledState, setUncontrolledState] = useState(defaultState); const { current: isControlled } = useRef(controlledState != null); @@ -20,7 +24,7 @@ export function useControlledState<V>({ value: controlledState, defaultValue: de onStateChange?.(newValue); }, - [isControlled, onStateChange, setUncontrolledState] + [isControlled, onStateChange, setUncontrolledState], ); return [state, handleStateChange]; diff --git a/src/hooks/useTableScroll.ts b/src/hooks/useTableScroll.ts new file mode 100644 index 0000000..1ef6ea4 --- /dev/null +++ b/src/hooks/useTableScroll.ts @@ -0,0 +1,35 @@ +import { RefObject, useEffect, useState } from "react"; + +interface UseTableScrollOptions { + onLoadMore?: () => void; + hasMore?: boolean; + loading?: boolean; +} + +export function useTableScroll( + tableRef: RefObject<HTMLDivElement>, + { onLoadMore, hasMore = false, loading = false }: UseTableScrollOptions = {}, +) { + const [isScrolledTop, setIsScrolledTop] = useState(false); + + useEffect(() => { + const handleScroll = (e: Event) => { + const target = e.target as HTMLDivElement; + setIsScrolledTop(target.scrollTop > 0); + + if (!loading && hasMore && target.scrollHeight - target.scrollTop <= target.clientHeight + 100) { + onLoadMore?.(); + } + }; + + const tableWrapper = tableRef.current; + if (tableWrapper) { + tableWrapper.addEventListener("scroll", handleScroll); + return () => tableWrapper.removeEventListener("scroll", handleScroll); + } + }, [loading, hasMore, onLoadMore, tableRef]); + + return { + isScrolledTop, + }; +} diff --git a/src/index.css b/src/index.css index eeba232..40c76b2 100644 --- a/src/index.css +++ b/src/index.css @@ -26,4 +26,4 @@ .custom-scrollbar::-webkit-scrollbar-track { @apply bg-primary; } -} \ No newline at end of file +} diff --git a/tsconfig.app.json b/tsconfig.app.json index fc713ae..07fb3ab 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -29,7 +29,7 @@ "paths": { "@/*": ["*", "./*"] }, - "outDir": "dist", + "outDir": "dist" }, "include": ["src"], "keywords": ["react", "ui-components", "babylonlabs", "bitcoin", "bitcoin-ui"]