-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
80c5680
commit 53c4249
Showing
9 changed files
with
466 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
import { Fragment } from "react"; | ||
import { Listbox, Transition } from "@headlessui/react"; | ||
import { ChevronUpDownIcon } from "@heroicons/react/24/outline"; | ||
|
||
export type DropdownProps = { | ||
items: (string | number)[]; | ||
selected: string | number; | ||
width?: string; | ||
onChange(newValue: string | number): void; | ||
}; | ||
|
||
const DEFAULT_WIDTH = "w-32"; | ||
|
||
export const Dropdown: React.FC<DropdownProps> = function ({ | ||
items, | ||
selected, | ||
width, | ||
onChange, | ||
}) { | ||
return ( | ||
<Listbox value={selected} onChange={onChange}> | ||
<div className="relative"> | ||
<Listbox.Button | ||
className={`relative h-9 ${width ?? DEFAULT_WIDTH | ||
} cursor-pointer rounded-lg border border-transparent bg-controlBackground-light pl-2 pr-8 text-left text-sm shadow-md hover:border hover:border-controlBackground-light focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white active:border-controlBorderHighlight-dark ui-open:border-controlActive-light dark:bg-controlBackground-dark dark:hover:border-controlBorderHighlight-dark dark:ui-open:border-controlActive-dark`} | ||
> | ||
<span className="block truncate align-middle font-normal"> | ||
{selected} | ||
</span> | ||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> | ||
<ChevronUpDownIcon | ||
className="h-5 w-5 text-icon-light dark:text-icon-dark" | ||
aria-hidden="true" | ||
/> | ||
</span> | ||
</Listbox.Button> | ||
<Transition | ||
as={Fragment} | ||
leave="transition ease-in duration-100" | ||
leaveFrom="opacity-100" | ||
leaveTo="opacity-0" | ||
> | ||
<Listbox.Options className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-controlBackground-light py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-controlBackground-dark sm:text-sm"> | ||
{items.map((item, personIdx) => ( | ||
<Listbox.Option | ||
key={personIdx} | ||
className={({ active }) => | ||
`relative cursor-pointer select-none py-2 pl-4 pr-4 font-normal ${active | ||
? "bg-controlActive-light dark:bg-controlActive-dark dark:text-content-dark" | ||
: "text-contentSecondary-light dark:text-contentSecondary-dark" | ||
}` | ||
} | ||
value={item} | ||
> | ||
{({ selected }) => ( | ||
<> | ||
<span | ||
className={`block truncate text-sm ${selected ? "font-bold" : "" | ||
}`} | ||
> | ||
{item} | ||
</span> | ||
</> | ||
)} | ||
</Listbox.Option> | ||
))} | ||
</Listbox.Options> | ||
</Transition> | ||
</div> | ||
</Listbox> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import type { FC, ReactNode } from "react"; | ||
|
||
export const Header: FC<{ children: ReactNode }> = function ({ children }) { | ||
return <div className="truncate text-2xl font-bold">{children}</div>; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
import { Fragment, useCallback } from "react"; | ||
import type { FC, ReactNode } from "react"; | ||
import { useRouter } from "next/router"; | ||
|
||
import { Card } from "../Cards/Card"; | ||
import { Dropdown } from "../Dropdown"; | ||
import type { DropdownProps } from "../Dropdown"; | ||
import { Pagination } from "../Pagination"; | ||
import type { PaginationProps } from "../Pagination"; | ||
import { Header } from "./Header"; | ||
|
||
export type PaginatedListLayoutProps = { | ||
header?: ReactNode; | ||
title?: ReactNode; | ||
items?: ReactNode[]; | ||
totalItems?: number; | ||
page: number; | ||
pageSize: number; | ||
itemSkeleton: ReactNode; | ||
emptyState?: ReactNode; | ||
}; | ||
|
||
const PAGE_SIZES = [10, 25, 50, 100]; | ||
|
||
export const PaginatedListLayout: FC<PaginatedListLayoutProps> = function ({ | ||
header, | ||
title, | ||
items, | ||
totalItems, | ||
page, | ||
pageSize, | ||
itemSkeleton, | ||
emptyState = "No items", | ||
}) { | ||
const router = useRouter(); | ||
const pages = | ||
totalItems !== undefined | ||
? totalItems === 0 | ||
? 1 | ||
: Math.ceil(totalItems / pageSize) | ||
: undefined; | ||
const hasItems = !items || items.length; | ||
|
||
const handlePageSizeSelection = useCallback<DropdownProps["onChange"]>( | ||
(newPageSize: number) => | ||
void router.push({ | ||
pathname: router.pathname, | ||
query: { | ||
...router.query, | ||
/** | ||
* Update the selected page to a lower value if we require less pages to show the | ||
* new amount of elements per page. | ||
*/ | ||
p: Math.min(Math.ceil(totalItems ?? 0 / newPageSize), page), | ||
ps: newPageSize, | ||
}, | ||
}), | ||
[page, totalItems, router] | ||
); | ||
|
||
const handlePageSelection = useCallback<PaginationProps["onChange"]>( | ||
(newPage) => | ||
void router.push({ | ||
pathname: router.pathname, | ||
query: { | ||
...router.query, | ||
p: newPage, | ||
ps: pageSize, | ||
}, | ||
}), | ||
[pageSize, router] | ||
); | ||
|
||
return ( | ||
<> | ||
<Header>{header}</Header> | ||
<Card | ||
header={ | ||
hasItems ? ( | ||
<div | ||
className={`flex flex-col ${title ? "justify-between" : "justify-end" | ||
} md:flex-row`} | ||
> | ||
{title && <div>{title}</div>} | ||
<div className="w-full self-center sm:w-auto"> | ||
<Pagination | ||
selected={page} | ||
pages={pages} | ||
onChange={handlePageSelection} | ||
/> | ||
</div> | ||
</div> | ||
) : undefined | ||
} | ||
emptyState={emptyState} | ||
> | ||
{hasItems ? ( | ||
<div className="flex flex-col gap-6"> | ||
<div className="space-y-4"> | ||
{!items | ||
? Array.from({ length: 4 }).map((_, i) => ( | ||
<Fragment key={i}>{itemSkeleton}</Fragment> | ||
)) | ||
: (items ?? []).map((item, i) => ( | ||
<Fragment key={i}>{item}</Fragment> | ||
))} | ||
</div> | ||
<div className="flex w-full flex-col items-center gap-3 text-sm md:flex-row md:justify-between"> | ||
<div className="flex items-center justify-start gap-2"> | ||
Displayed items: | ||
<Dropdown | ||
items={PAGE_SIZES} | ||
selected={pageSize} | ||
width="w-full" | ||
onChange={handlePageSizeSelection} | ||
/> | ||
</div> | ||
<div className="w-full sm:w-auto"> | ||
<Pagination | ||
selected={page} | ||
pages={pages} | ||
inverseCompact | ||
onChange={handlePageSelection} | ||
/> | ||
</div> | ||
</div> | ||
</div> | ||
) : undefined} | ||
</Card> | ||
</> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,161 @@ | ||
import { useEffect, useState } from "react"; | ||
import type { FC } from "react"; | ||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid"; | ||
|
||
import "react-loading-skeleton/dist/skeleton.css"; | ||
import Skeleton from "react-loading-skeleton"; | ||
|
||
import { Button } from "./Button"; | ||
import { Input } from "./Input"; | ||
|
||
type NavigationButton = { | ||
disabled?: boolean; | ||
onChange(page: number): void; | ||
}; | ||
|
||
export type PaginationProps = { | ||
pages?: number; | ||
selected: number; | ||
inverseCompact?: boolean; | ||
onChange(page: number): void; | ||
}; | ||
|
||
// eslint-disable-next-line @typescript-eslint/no-empty-function | ||
const NOOP = () => { }; | ||
|
||
const FirstButton: FC<NavigationButton> = function ({ disabled, onChange }) { | ||
return ( | ||
<Button | ||
disabled={disabled} | ||
variant="outline" | ||
size="sm" | ||
label="First" | ||
className="w-full" | ||
onClick={() => { | ||
onChange(1); | ||
}} | ||
/> | ||
); | ||
}; | ||
|
||
const LastButton: FC<NavigationButton & { lastPage: number }> = function ({ | ||
disabled, | ||
onChange, | ||
lastPage, | ||
}) { | ||
return ( | ||
<Button | ||
className="w-full" | ||
disabled={disabled} | ||
variant="outline" | ||
size="sm" | ||
label="Last" | ||
onClick={() => onChange(lastPage)} | ||
/> | ||
); | ||
}; | ||
|
||
export const Pagination: FC<PaginationProps> = function ({ | ||
pages, | ||
selected, | ||
onChange, | ||
inverseCompact = false, | ||
}) { | ||
const [pageInput, setPageInput] = useState(selected); | ||
const isUndefined = pages === undefined; | ||
const disableFirst = selected === 1 || isUndefined; | ||
const disableLast = selected === pages || isUndefined; | ||
|
||
// Keep inner page input value in sync | ||
useEffect(() => { | ||
setPageInput(selected); | ||
}, [selected]); | ||
|
||
return ( | ||
<form | ||
onSubmit={(e) => { | ||
e.preventDefault(); | ||
onChange(pageInput); | ||
}} | ||
> | ||
<nav | ||
className={`flex gap-2 ${inverseCompact ? "flex-col-reverse" : "flex-col" | ||
}`} | ||
> | ||
<div className="block sm:hidden"> | ||
<div className="flex justify-between gap-2"> | ||
<FirstButton disabled={disableFirst} onChange={onChange} /> | ||
<LastButton | ||
disabled={disableLast} | ||
lastPage={pages ?? 0} | ||
onChange={onChange} | ||
/> | ||
</div> | ||
</div> | ||
<div className="flex w-full justify-between gap-2 align-middle"> | ||
<div className="hidden sm:block"> | ||
<FirstButton disabled={disableFirst} onChange={onChange} /> | ||
</div> | ||
<Button | ||
disabled={disableFirst} | ||
variant="outline" | ||
size="sm" | ||
icon={ | ||
<div> | ||
<span className="sr-only">Previous</span> | ||
<ChevronLeftIcon className="h-5 w-5" aria-hidden="true" /> | ||
</div> | ||
} | ||
onClick={() => onChange(Math.max(1, selected - 1))} | ||
/> | ||
<div className="flex items-center gap-2 text-sm text-contentSecondary-light dark:text-contentSecondary-dark"> | ||
<div className="w-20 font-light"> | ||
<Input | ||
disabled={isUndefined} | ||
className="text-sm" | ||
type="number" | ||
min={1} | ||
max={pages} | ||
step={1} | ||
value={pageInput} | ||
onChange={(e) => setPageInput(Number(e.target.value))} | ||
/> | ||
</div> | ||
<div className="self-center font-normal"> | ||
{" "} | ||
of{" "} | ||
{isUndefined ? ( | ||
<span> | ||
<Skeleton width={33} /> | ||
</span> | ||
) : ( | ||
pages | ||
)} | ||
</div> | ||
</div> | ||
<Button | ||
disabled={disableLast} | ||
variant="outline" | ||
size="sm" | ||
icon={ | ||
<div> | ||
<span className="sr-only">Next</span> | ||
<ChevronRightIcon className="h-5 w-5" aria-hidden="true" /> | ||
</div> | ||
} | ||
onClick={ | ||
pages ? () => onChange(Math.min(pages, selected + 1)) : NOOP | ||
} | ||
/> | ||
<div className="hidden sm:block"> | ||
<LastButton | ||
disabled={disableLast} | ||
lastPage={pages ?? 0} | ||
onChange={onChange} | ||
/> | ||
</div> | ||
</div> | ||
</nav> | ||
</form> | ||
); | ||
}; |
Oops, something went wrong.