diff --git a/.changeset/heavy-otters-knock.md b/.changeset/heavy-otters-knock.md new file mode 100644 index 0000000000..3d4bb0f277 --- /dev/null +++ b/.changeset/heavy-otters-knock.md @@ -0,0 +1,6 @@ +--- +"@heroui/shared-icons": patch +"@heroui/table": patch +--- + +support custom sort icon in Table (#5223) diff --git a/apps/docs/app/examples/table/sort-icon/page.tsx b/apps/docs/app/examples/table/sort-icon/page.tsx new file mode 100644 index 0000000000..b410570505 --- /dev/null +++ b/apps/docs/app/examples/table/sort-icon/page.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { + Table, + TableHeader, + TableColumn, + TableBody, + TableRow, + TableCell, + getKeyValue, + Spinner, +} from "@heroui/react"; +import {SortIcon} from "@heroui/shared-icons"; +import {useAsyncList} from "@react-stately/data"; +import {useState} from "react"; + +type SWCharacter = { + name: string; + height: string; + mass: string; + birth_year: string; +}; + +export default function Page() { + const [isLoading, setIsLoading] = useState(true); + + let list = useAsyncList({ + async load({signal}) { + let res = await fetch(`https://swapi.py4e.com/api/people/?search`, { + signal, + }); + let json = await res.json(); + + setIsLoading(false); + + return { + items: json.results, + }; + }, + async sort({items, sortDescriptor}) { + return { + items: items.sort((a, b) => { + let first = a[sortDescriptor.column as keyof SWCharacter]; + let second = b[sortDescriptor.column as keyof SWCharacter]; + let cmp = (parseInt(first) || first) < (parseInt(second) || second) ? -1 : 1; + + if (sortDescriptor.direction === "descending") { + cmp *= -1; + } + + return cmp; + }), + }; + }, + }); + + return ( +
+ + + + Name + + + Height + + + Mass + + + Birth year + + + } + > + {(item) => ( + + {(columnKey) => {getKeyValue(item, columnKey)}} + + )} + +
+
+ ); +} diff --git a/apps/docs/content/components/table/index.ts b/apps/docs/content/components/table/index.ts index 19a8fbe483..4b2462c288 100644 --- a/apps/docs/content/components/table/index.ts +++ b/apps/docs/content/components/table/index.ts @@ -13,6 +13,7 @@ import disabledRows from "./disabled-rows"; import selectionBehavior from "./selection-behavior"; import rowActions from "./row-actions"; import sorting from "./sorting"; +import sortIcon from "./sort-icon"; import loadMore from "./load-more"; import paginated from "./paginated"; import asyncPagination from "./async-pagination"; @@ -40,6 +41,7 @@ export const tableContent = { selectionBehavior, rowActions, sorting, + sortIcon, loadMore, paginated, asyncPagination, diff --git a/apps/docs/content/components/table/sort-icon.raw.jsx b/apps/docs/content/components/table/sort-icon.raw.jsx new file mode 100644 index 0000000000..e934c9a60c --- /dev/null +++ b/apps/docs/content/components/table/sort-icon.raw.jsx @@ -0,0 +1,84 @@ +import { + Table, + TableHeader, + TableColumn, + TableBody, + TableRow, + TableCell, + getKeyValue, + Spinner, +} from "@heroui/react"; +import {SortIcon} from "@heroui/shared-icons"; +import {useAsyncList} from "@react-stately/data"; + +export default function App() { + const [isLoading, setIsLoading] = React.useState(true); + + let list = useAsyncList({ + async load({signal}) { + let res = await fetch("https://swapi.py4e.com/api/people/?search", { + signal, + }); + let json = await res.json(); + + setIsLoading(false); + + return { + items: json.results, + }; + }, + async sort({items, sortDescriptor}) { + return { + items: items.sort((a, b) => { + let first = a[sortDescriptor.column]; + let second = b[sortDescriptor.column]; + let cmp = (parseInt(first) || first) < (parseInt(second) || second) ? -1 : 1; + + if (sortDescriptor.direction === "descending") { + cmp *= -1; + } + + return cmp; + }), + }; + }, + }); + + return ( + + + + Name + + + Height + + + Mass + + + Birth year + + + } + > + {(item) => ( + + {(columnKey) => {getKeyValue(item, columnKey)}} + + )} + +
+ ); +} diff --git a/apps/docs/content/components/table/sort-icon.ts b/apps/docs/content/components/table/sort-icon.ts new file mode 100644 index 0000000000..60305fa04b --- /dev/null +++ b/apps/docs/content/components/table/sort-icon.ts @@ -0,0 +1,9 @@ +import App from "./sort-icon.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/docs/components/table.mdx b/apps/docs/content/docs/components/table.mdx index 3ee4cfa102..797e2ab87a 100644 --- a/apps/docs/content/docs/components/table.mdx +++ b/apps/docs/content/docs/components/table.mdx @@ -247,6 +247,20 @@ import {useAsyncList} from "@react-stately/data"; > Note that we passed the `isLoading` and `loadingContent` props to `TableBody` to > render a loading state while the data is being fetched. +### Sort Icon + +You can override the default sort icon by specifying `sortIcon`. This prop is only applied when `allowsSorting` is `true`. + + + ### Loading more data Table allows you to add a custom component at the end of the table, on the example below we are @@ -737,6 +751,12 @@ You can customize the `Table` component by passing custom Tailwind CSS classes t description: "Whether the column allows sorting", default: "-" }, + { + attribute: "sortIcon", + type: "ReactNode", + description: "Overrides the default sort icon. Only applied when `allowsSorting` is `true`", + default: "-" + }, { attribute: "isRowHeader", type: "boolean", diff --git a/packages/components/table/src/table-column-header.tsx b/packages/components/table/src/table-column-header.tsx index a77ba55385..b02d5b28c8 100644 --- a/packages/components/table/src/table-column-header.tsx +++ b/packages/components/table/src/table-column-header.tsx @@ -1,7 +1,9 @@ +import type {ReactNode, ReactElement} from "react"; import type {GridNode} from "@react-types/grid"; import type {HTMLHeroUIProps} from "@heroui/system"; import type {ValuesType} from "./use-table"; +import {cloneElement, isValidElement} from "react"; import {forwardRef} from "@heroui/system"; import {useDOMRef, filterDOMProps} from "@heroui/react-utils"; import {clsx, dataAttr, mergeProps} from "@heroui/shared-utils"; @@ -12,10 +14,21 @@ import {VisuallyHidden} from "@react-aria/visually-hidden"; import {useHover} from "@react-aria/interactions"; // @internal +export type SortIconProps = { + "aria-hidden"?: boolean; + "data-direction"?: "ascending" | "descending"; + "data-visible"?: boolean | "true" | "false"; + className?: string; +}; + export interface TableColumnHeaderProps extends HTMLHeroUIProps<"th"> { slots: ValuesType["slots"]; state: ValuesType["state"]; classNames?: ValuesType["classNames"]; + /** + * Custom Icon to be displayed in the table header - overrides the default chevron one + */ + sortIcon?: ReactNode | ((props: SortIconProps) => ReactNode); /** * The table node to render. */ @@ -23,7 +36,7 @@ export interface TableColumnHeaderProps extends HTMLHeroUIProps<"th" } const TableColumnHeader = forwardRef<"th", TableColumnHeaderProps>((props, ref) => { - const {as, className, state, node, slots, classNames, ...otherProps} = props; + const {as, className, state, node, slots, classNames, sortIcon, ...otherProps} = props; const Component = as || "th"; const shouldFilterDOMProps = typeof Component === "string"; @@ -40,6 +53,18 @@ const TableColumnHeader = forwardRef<"th", TableColumnHeaderProps>((props, ref) const allowsSorting = columnProps.allowsSorting; + const sortIconProps = { + "aria-hidden": true, + "data-direction": state.sortDescriptor?.direction, + "data-visible": dataAttr(state.sortDescriptor?.column === node.key), + className: slots.sortIcon?.({class: classNames?.sortIcon}), + }; + + const customSortIcon = + typeof sortIcon === "function" + ? sortIcon(sortIconProps) + : isValidElement(sortIcon) && cloneElement(sortIcon as ReactElement, sortIconProps); + return ( ((props, ref) className={slots.th?.({align, class: thStyles})} > {hideHeader ? {node.rendered} : node.rendered} - {allowsSorting && ( - ); }); diff --git a/packages/components/table/src/table.tsx b/packages/components/table/src/table.tsx index bdef3bc981..783afdff32 100644 --- a/packages/components/table/src/table.tsx +++ b/packages/components/table/src/table.tsx @@ -30,6 +30,7 @@ const Table = forwardRef<"table", TableProps>((props, ref) => { bottomContentPlacement, bottomContent, removeWrapper, + sortIcon, getBaseProps, getWrapperProps, getTableProps, @@ -103,6 +104,7 @@ const Table = forwardRef<"table", TableProps>((props, ref) => { classNames={values.classNames} node={column} slots={values.slots} + sortIcon={sortIcon} state={values.state} /> ), diff --git a/packages/components/table/src/use-table.ts b/packages/components/table/src/use-table.ts index 3f83e93ef6..0aea62ed9c 100644 --- a/packages/components/table/src/use-table.ts +++ b/packages/components/table/src/use-table.ts @@ -91,6 +91,10 @@ interface Props extends HTMLHeroUIProps<"table"> { * Props to be passed to the checkboxes. */ checkboxesProps?: CheckboxProps; + /** + * Custom Icon to be displayed in the table header - overrides the default chevron one + */ + sortIcon?: ReactNode | ((props: any) => ReactNode); /** Handler that is called when a user performs an action on the row. */ onRowAction?: (key: Key) => void; /** Handler that is called when a user performs an action on the cell. */ @@ -166,6 +170,7 @@ export function useTable(originalProps: UseTableProps) { checkboxesProps, topContent, bottomContent, + sortIcon, onRowAction, onCellAction, ...otherProps @@ -294,6 +299,7 @@ export function useTable(originalProps: UseTableProps) { removeWrapper, topContentPlacement, bottomContentPlacement, + sortIcon, getBaseProps, getWrapperProps, getTableProps, diff --git a/packages/components/table/stories/table.stories.tsx b/packages/components/table/stories/table.stories.tsx index c6445394e1..369a91c3b5 100644 --- a/packages/components/table/stories/table.stories.tsx +++ b/packages/components/table/stories/table.stories.tsx @@ -10,7 +10,7 @@ import {Button} from "@heroui/button"; import {Spinner} from "@heroui/spinner"; import {Pagination} from "@heroui/pagination"; import {Tooltip} from "@heroui/tooltip"; -import {EditIcon, DeleteIcon, EyeIcon} from "@heroui/shared-icons"; +import {EditIcon, DeleteIcon, EyeIcon, SortIcon} from "@heroui/shared-icons"; import {useInfiniteScroll} from "@heroui/use-infinite-scroll"; import {useAsyncList} from "@react-stately/data"; import useSWR from "swr"; @@ -1059,6 +1059,15 @@ export const Sortable = { }, }; +export const CustomSortIcon = { + render: SortableTemplate, + + args: { + ...defaultProps, + sortIcon: SortIcon, + }, +}; + export const LoadMore = { render: LoadMoreTemplate, diff --git a/packages/utilities/shared-icons/src/index.ts b/packages/utilities/shared-icons/src/index.ts index 7c2b70eadf..0ceeda79a8 100644 --- a/packages/utilities/shared-icons/src/index.ts +++ b/packages/utilities/shared-icons/src/index.ts @@ -13,6 +13,7 @@ export * from "./ellipsis"; export * from "./forward"; export * from "./sun"; export * from "./sun-filled"; +export * from "./sort"; export * from "./mail"; export * from "./mail-filled"; export * from "./moon"; diff --git a/packages/utilities/shared-icons/src/sort.tsx b/packages/utilities/shared-icons/src/sort.tsx new file mode 100644 index 0000000000..0a2a4505ab --- /dev/null +++ b/packages/utilities/shared-icons/src/sort.tsx @@ -0,0 +1,37 @@ +import type {IconSvgProps} from "./types"; + +export const SortIcon = (props: IconSvgProps) => ( + + + + + + +);