Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/heavy-otters-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@heroui/shared-icons": patch
"@heroui/table": patch
---

support custom sort icon in Table (#5223)
96 changes: 96 additions & 0 deletions apps/docs/app/examples/table/sort-icon/page.tsx
Original file line number Diff line number Diff line change
@@ -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<SWCharacter>({
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 (
<div className="p-6">
<Table
aria-label="Example table with client side sorting"
classNames={{
table: "min-h-[400px]",
}}
sortDescriptor={list.sortDescriptor}
sortIcon={SortIcon}
onSortChange={list.sort}
>
<TableHeader>
<TableColumn key="name" allowsSorting>
Name
</TableColumn>
<TableColumn key="height" allowsSorting>
Height
</TableColumn>
<TableColumn key="mass" allowsSorting>
Mass
</TableColumn>
<TableColumn key="birth_year" allowsSorting>
Birth year
</TableColumn>
</TableHeader>
<TableBody
isLoading={isLoading}
items={list.items}
loadingContent={<Spinner label="Loading..." />}
>
{(item) => (
<TableRow key={item.name}>
{(columnKey) => <TableCell>{getKeyValue(item, columnKey)}</TableCell>}
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}
2 changes: 2 additions & 0 deletions apps/docs/content/components/table/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -40,6 +41,7 @@ export const tableContent = {
selectionBehavior,
rowActions,
sorting,
sortIcon,
loadMore,
paginated,
asyncPagination,
Expand Down
84 changes: 84 additions & 0 deletions apps/docs/content/components/table/sort-icon.raw.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Table
aria-label="Example table with client side sorting"
classNames={{
table: "min-h-[400px]",
}}
sortDescriptor={list.sortDescriptor}
sortIcon={SortIcon}
onSortChange={list.sort}
>
<TableHeader>
<TableColumn key="name" allowsSorting>
Name
</TableColumn>
<TableColumn key="height" allowsSorting>
Height
</TableColumn>
<TableColumn key="mass" allowsSorting>
Mass
</TableColumn>
<TableColumn key="birth_year" allowsSorting>
Birth year
</TableColumn>
</TableHeader>
<TableBody
isLoading={isLoading}
items={list.items}
loadingContent={<Spinner label="Loading..." />}
>
{(item) => (
<TableRow key={item.name}>
{(columnKey) => <TableCell>{getKeyValue(item, columnKey)}</TableCell>}
</TableRow>
)}
</TableBody>
</Table>
);
}
9 changes: 9 additions & 0 deletions apps/docs/content/components/table/sort-icon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import App from "./sort-icon.raw.jsx?raw";

const react = {
"/App.jsx": App,
};

export default {
...react,
};
20 changes: 20 additions & 0 deletions apps/docs/content/docs/components/table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

<CodeDemo
asIframe
title="Sort Icon"
resizeEnabled={false}
displayMode="visible"
files={tableContent.sortIcon}
previewHeight="520px"
iframeSrc="/examples/table/sort-icon"
/>

### Loading more data

Table allows you to add a custom component at the end of the table, on the example below we are
Expand Down Expand Up @@ -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",
Expand Down
37 changes: 27 additions & 10 deletions packages/components/table/src/table-column-header.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -12,18 +14,29 @@ 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<T = object> 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.
*/
node: GridNode<T>;
}

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";
Expand All @@ -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);

Comment thread
wingkwong marked this conversation as resolved.
return (
<Component
ref={domRef}
Expand All @@ -59,15 +84,7 @@ const TableColumnHeader = forwardRef<"th", TableColumnHeaderProps>((props, ref)
className={slots.th?.({align, class: thStyles})}
>
{hideHeader ? <VisuallyHidden>{node.rendered}</VisuallyHidden> : node.rendered}
{allowsSorting && (
<ChevronDownIcon
aria-hidden="true"
className={slots.sortIcon?.({class: classNames?.sortIcon})}
data-direction={state.sortDescriptor?.direction}
data-visible={dataAttr(state.sortDescriptor?.column === node.key)}
strokeWidth={3}
/>
)}
{allowsSorting && (customSortIcon || <ChevronDownIcon strokeWidth={3} {...sortIconProps} />)}
</Component>
);
});
Expand Down
2 changes: 2 additions & 0 deletions packages/components/table/src/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const Table = forwardRef<"table", TableProps>((props, ref) => {
bottomContentPlacement,
bottomContent,
removeWrapper,
sortIcon,
getBaseProps,
getWrapperProps,
getTableProps,
Expand Down Expand Up @@ -103,6 +104,7 @@ const Table = forwardRef<"table", TableProps>((props, ref) => {
classNames={values.classNames}
node={column}
slots={values.slots}
sortIcon={sortIcon}
state={values.state}
/>
),
Expand Down
6 changes: 6 additions & 0 deletions packages/components/table/src/use-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ interface Props<T> 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);
Comment thread
wingkwong marked this conversation as resolved.
/** 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. */
Expand Down Expand Up @@ -166,6 +170,7 @@ export function useTable<T extends object>(originalProps: UseTableProps<T>) {
checkboxesProps,
topContent,
bottomContent,
sortIcon,
onRowAction,
onCellAction,
...otherProps
Expand Down Expand Up @@ -294,6 +299,7 @@ export function useTable<T extends object>(originalProps: UseTableProps<T>) {
removeWrapper,
topContentPlacement,
bottomContentPlacement,
sortIcon,
getBaseProps,
getWrapperProps,
getTableProps,
Expand Down
11 changes: 10 additions & 1 deletion packages/components/table/stories/table.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1059,6 +1059,15 @@ export const Sortable = {
},
};

export const CustomSortIcon = {
render: SortableTemplate,

args: {
...defaultProps,
sortIcon: SortIcon,
},
};

export const LoadMore = {
render: LoadMoreTemplate,

Expand Down
Loading