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
5 changes: 5 additions & 0 deletions .changeset/datagrid-sticky-footer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@easypost/easy-ui": minor
---

Adds a sticky footer to `<DataGrid />` through `renderFooter`, along with `<DataGrid.Footer />`, `<DataGrid.Pagination />`, and `<DataGrid.RowsPerPage />`. Adds a numbered pagination scheme to `<Pagination />` through `<Pagination.Pages />` and a rows per page menu through `<Pagination.RowsPerPage />`, both in two sizes through `size`. Also lightens `<DataGrid />`'s sticky shadows, which affects the sticky header and frozen column shadows
5 changes: 4 additions & 1 deletion easy-ui-react/src/DataGrid/ColumnHeader.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@
}

.shadowBottom [data-ezui-data-grid-shadow="bottom"] {
@include DataGrid.shadow(bottom, $backdrop: true);
@include DataGrid.shadow(
bottom,
$opacity: component-token("data-grid", "header-shadow-opacity")
);
}

.shadowRight [data-ezui-data-grid-shadow="side"] {
Expand Down
61 changes: 61 additions & 0 deletions easy-ui-react/src/DataGrid/DataGrid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,69 @@ In the example below, to make the status text green or red depending on whether

<Controls of={DataGridStories.LoadingState} />

## Footer

`DataGrid` supports rendering a footer through the `renderFooter` prop. The footer sticks to the bottom of the data grid the same way the column header sticks to the top, staying in place as rows scroll underneath and casting a shadow over them once there's anything to scroll. It stays mounted through the empty and loading states.

`renderFooter` controls what goes inside. `<DataGrid.Footer />` lays that content out into `start`, `center`, and `end` regions, which are positional rather than named after what they hold β€” the footer carries no assumptions about how a consumer pages through the grid.

`<DataGrid.Pagination />` and `<DataGrid.RowsPerPage />` cover the common case. Both are fully controlled.

```tsx
const [page, setPage] = useState(1);
const [rowsPerPage, setRowsPerPage] = useState(50);

<DataGrid
aria-label="Data grid with a footer"
columns={columns}
rows={rows}
renderColumnCell={(column) => <span>{String(column.name)}</span>}
renderRowCell={(item) => <span>{String(item)}</span>}
renderFooter={() => (
<DataGrid.Footer
center={<DataGrid.Pagination page={page} count={10} onChange={setPage} />}
end={
<DataGrid.RowsPerPage
rowsPerPage={rowsPerPage}
options={[25, 50, 100]}
onChange={setRowsPerPage}
/>
}
/>
)}
/>;
```

<Canvas of={DataGridStories.WithFooter} />

<Controls of={DataGridStories.WithFooter} />

Unlike the rest of the data grid, the footer's height is fixed and doesn't scale with `size`. `maxRows` still counts body rows, so adding a footer grows the data grid rather than eating into the rows it shows.

### With a Custom Footer

The regions accept anything, so a footer needn't paginate at all.

<Canvas of={DataGridStories.WithCustomFooter} />

### Footer with an Empty State

<Canvas of={DataGridStories.FooterWithEmptyState} />

## Properties

### DataGrid

<ArgTypes of={DataGrid} />

### DataGrid.Footer

<ArgTypes of={DataGrid.Footer} />

### DataGrid.Pagination

<ArgTypes of={DataGrid.Pagination} />

### DataGrid.RowsPerPage

<ArgTypes of={DataGrid.RowsPerPage} />
34 changes: 32 additions & 2 deletions easy-ui-react/src/DataGrid/DataGrid.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@
);
@include component-token("data-grid", "header-row-height", 48px);
@include component-token("data-grid", "body-row-height", 48px);
// Zero until a footer is rendered, at which point `.hasFooter` gives it the
// footer's height so `max-rows` still counts visible body rows
@include component-token("data-grid", "footer-row-height", 0px);
@include component-token("data-grid", "footer-width", 100%);
@include component-token(
"data-grid",
"footer-bg",
design-token("color.neutral.000")
);
@include component-token("data-grid", "expand-btn-size", 24px);
@include component-token(
"data-grid",
Expand All @@ -35,11 +44,20 @@
design-token("color.neutral.100")
);
@include component-token("data-grid", "sticky-shadow-size", 8px);
@include component-token("data-grid", "sticky-shadow-opacity", 0.5);
// The header and frozen columns can't carry the `shadow.stuck-from-*` tokens:
// they're cast by table cells, and box shadows don't paint on those once the
// table's borders are collapsed. Their gradient band mimics the token instead,
// which is `color.neutral.300` at a quarter, so every sticky edge of the grid
// reads at the weight the footer's real shadow does.
@include component-token("data-grid", "sticky-shadow-opacity", 0.25);
// A dark header is a hard edge against the rows, which swamps a shadow this
// light, so the header carries its own weight. `.headerSecondary` is light
// enough not to need it and drops back to the weight of the other edges.
@include component-token("data-grid", "header-shadow-opacity", 0.65);
@include component-token(
"data-grid",
"sticky-shadow-color",
design-token("color.neutral.500")
design-token("color.neutral.300")
);
@include component-token(
"data-grid",
Expand All @@ -56,6 +74,7 @@
@include component-token("data-grid", "expanded-row-z-index", 3);
@include component-token("data-grid", "column-z-index", 4);
@include component-token("data-grid", "column-stuck-z-index", 5);
@include component-token("data-grid", "footer-z-index", 6);
@include component-token("data-grid", "sticky-left-offset", -16px);
@include component-token("data-grid", "sticky-right-offset", -12px);
@include component-token("data-grid", "action-cell-width", 44px);
Expand All @@ -68,6 +87,7 @@
max-height: calc(
(component-token("data-grid", "border-width") * 2) +
component-token("data-grid", "header-row-height") +
component-token("data-grid", "footer-row-height") +
(
component-token("data-grid", "body-row-height") *
component-token("data-grid", "max-rows")
Expand All @@ -82,6 +102,11 @@
}
}

// Per design, the footer's height is fixed and doesn't scale with `size`
.hasFooter {
@include component-token("data-grid", "footer-row-height", 54px);
}

.sizeSm {
@include component-token("data-grid", "header-row-height", 32px);
@include component-token("data-grid", "body-row-height", 32px);
Expand Down Expand Up @@ -109,6 +134,11 @@
}

.headerSecondary {
@include component-token(
"data-grid",
"header-shadow-opacity",
component-token("data-grid", "sticky-shadow-opacity")
);
@include component-token(
"data-grid",
"header-bg",
Expand Down
70 changes: 69 additions & 1 deletion easy-ui-react/src/DataGrid/DataGrid.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import CheckCircleIcon from "@easypost/easy-ui-icons/CheckCircle";
import ErrorIcon from "@easypost/easy-ui-icons/Error";
import { action } from "storybook/actions";
import { Meta, StoryObj } from "@storybook/react-vite";
import React from "react";
import React, { useState } from "react";
import { Key } from "react-aria";
import { useAsyncList } from "react-stately";
import { Icon } from "../Icon";
import { Menu } from "../Menu";
import { Text } from "../Text";
import {
PlaceholderBox,
createNaiveSortingFunction,
Expand Down Expand Up @@ -324,6 +325,41 @@ export const LoadingState: Story = {
},
};

export const WithFooter: Story = {
render: WithFooterTemplate.bind({}),
args: {
"aria-label": "Example data grid with a footer",
maxRows: 4,
},
parameters: {
controls: {
include: ["maxRows", "size"],
},
},
};

export const WithCustomFooter: Story = {
render: Template.bind({}),
args: {
"aria-label": "Example data grid with a custom footer",
maxRows: 4,
renderFooter: () => (
<DataGrid.Footer
start={<Text variant="body2">6 results</Text>}
end={<Text variant="body2">Updated just now</Text>}
/>
),
},
};

export const FooterWithEmptyState: Story = {
render: WithFooterTemplate.bind({}),
args: {
"aria-label": "Example data grid with a footer and no data",
rows: [],
},
};

function WithSortTemplate(args: Partial<DataGridProps>) {
// https://react-spectrum.adobe.com/react-stately/useAsyncList.html
const list = useAsyncList({
Expand Down Expand Up @@ -351,3 +387,35 @@ function WithSortTemplate(args: Partial<DataGridProps>) {
/>
);
}

function WithFooterTemplate(args: Partial<DataGridProps>) {
const [page, setPage] = useState(1);
const [rowsPerPage, setRowsPerPage] = useState(50);
return (
<DataGrid
columns={columns}
rows={rows}
renderColumnCell={(column) => (
<span style={{ whiteSpace: "nowrap" }}>{String(column.name)}</span>
)}
renderRowCell={(item) => (
<span style={{ whiteSpace: "nowrap" }}>{String(item)}</span>
)}
renderFooter={() => (
<DataGrid.Footer
center={
<DataGrid.Pagination page={page} count={10} onChange={setPage} />
}
end={
<DataGrid.RowsPerPage
rowsPerPage={rowsPerPage}
options={[25, 50, 100]}
onChange={setRowsPerPage}
/>
}
/>
)}
{...args}
/>
);
}
121 changes: 121 additions & 0 deletions easy-ui-react/src/DataGrid/DataGrid.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,121 @@ describe("<DataGrid />", () => {
screen.getByRole("status", { name: /loading/i }),
).toBeInTheDocument();
});

it("should render a footer", () => {
render(
createDataGrid({
renderFooter: () => (
<DataGrid.Footer
start={<span>Start</span>}
center={<span>Center</span>}
end={<span>End</span>}
/>
),
}),
);
expect(screen.getByText("Start")).toBeInTheDocument();
expect(screen.getByText("Center")).toBeInTheDocument();
expect(screen.getByText("End")).toBeInTheDocument();
expect(getOuterContainer()).toHaveAttribute(
"class",
expect.stringContaining("hasFooter"),
);
});

it("should render the footer outside of the grid", () => {
render(
createDataGrid({
renderFooter: () => <DataGrid.Footer center={<span>Center</span>} />,
}),
);
// Interactive footer content must sit outside the grid so it doesn't
// interfere with the grid's own focus management
expect(getFooter()).toBeInTheDocument();
expect(screen.getByRole("grid")).not.toContainElement(getFooter());
});

it("should keep the footer mounted through the empty state", () => {
render(
createDataGrid({
rows: [],
renderFooter: () => <DataGrid.Footer center={<span>Center</span>} />,
}),
);
expect(screen.getByText("Center")).toBeInTheDocument();
});

it("should keep the footer mounted through the loading state", () => {
render(
createDataGrid({
isLoading: true,
renderFooter: () => <DataGrid.Footer center={<span>Center</span>} />,
}),
);
expect(screen.getByText("Center")).toBeInTheDocument();
});

it("should not render a footer without renderFooter", () => {
render(createDataGrid());
expect(getOuterContainer()).not.toHaveAttribute(
"class",
expect.stringContaining("hasFooter"),
);
});

it("should support pagination in the footer", async () => {
const handleChange = vi.fn();
const { user } = render(
createDataGrid({
renderFooter: () => (
<DataGrid.Footer
center={
<DataGrid.Pagination
page={1}
count={10}
onChange={handleChange}
/>
}
/>
),
}),
);
await userClick(user, screen.getByRole("button", { name: "Page 3 of 10" }));
expect(handleChange).toHaveBeenCalledWith(3);

// The first page is current, so there's nowhere earlier to go
expect(screen.getByRole("button", { name: /first/i })).toBeDisabled();
expect(screen.getByRole("button", { name: /previous/i })).toBeDisabled();
expect(screen.getByRole("button", { name: /next/i })).toBeEnabled();
expect(screen.getByRole("button", { name: /last/i })).toBeEnabled();
});

it("should support a rows per page menu in the footer", async () => {
const handleChange = vi.fn();
const { user } = render(
createDataGrid({
renderFooter: () => (
<DataGrid.Footer
end={
<DataGrid.RowsPerPage
rowsPerPage={50}
options={[25, 50, 100]}
onChange={handleChange}
/>
}
/>
),
}),
);
const trigger = screen.getByRole("button", {
name: /rows per page: 50/i,
});
await userClick(user, trigger);
expect(screen.getAllByRole("menuitemradio").length).toBe(3);

await userClick(user, screen.getByRole("menuitemradio", { name: "100" }));
expect(handleChange).toHaveBeenCalledWith(100);
});
});

const columns = [
Expand Down Expand Up @@ -277,6 +392,12 @@ function getInnerContainer() {
return screen.getByRole("grid").parentElement as HTMLElement;
}

function getFooter() {
return getInnerContainer().querySelector(
"[data-ezui-data-grid-footer]",
) as HTMLElement;
}

function getHead() {
const [head] = screen.getAllByRole("rowgroup");
return head;
Expand Down
Loading
Loading