diff --git a/src/assets/icons/next.svg b/src/assets/icons/next.svg index 3bbcf645a983de4ca26f1a944b5dc3af2a9d085b..eb52ef71b30155ba1ab0940083caaf8d500782e2 100644 --- a/src/assets/icons/next.svg +++ b/src/assets/icons/next.svg @@ -1,3 +1,3 @@ -<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M5 14L11 8L5 2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> </svg> \ No newline at end of file diff --git a/src/components/Breadcrumb.tsx b/src/components/Breadcrumb.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b9b8831ab1a7bffe79a29dcb3a60cf0d05443c48 --- /dev/null +++ b/src/components/Breadcrumb.tsx @@ -0,0 +1,49 @@ +import { Breadcrumbs, BreadcrumbItem } from "@nextui-org/react"; +import { clsx } from "clsx"; +import Icon from "./Icon.tsx"; +import Typography from "./Typography.tsx"; +import { textColor } from "./utils/classes.ts"; +import { MenuComponent, MenuItemProps } from "./Menu.tsx"; + +type BreadcrumbProps = { + items: { name: string; href: string }[]; +}; + +const Breadcrumb = ({ items }: BreadcrumbProps) => { + if (items.length === 0) items = [{ name: "Home", href: "/" }]; + return ( + <Breadcrumbs + maxItems={5} + itemsBeforeCollapse={1} + itemsAfterCollapse={2} + separator={ + <Icon name="next" color="body" customClass="w-[7px] h-[7px]" /> + } + itemClasses={{ + item: clsx( + textColor("primary"), + "data-[current=true]:text-mediumGrey dark:data-[current=true]:text-baseWhite" + ), + }} + renderEllipsis={({ items, ellipsisIcon, separator }) => { + return ( + <div className="flex items-center"> + <MenuComponent + menuTrigger={ellipsisIcon} + items={items as MenuItemProps[]} + /> + {separator} + </div> + ); + }} + > + {items.map((item, idx) => ( + <BreadcrumbItem href={item.href} isCurrent={idx === items.length - 1}> + <Typography text={item.name} variant="note" customColor={true} /> + </BreadcrumbItem> + ))} + </Breadcrumbs> + ); +}; + +export default Breadcrumb; diff --git a/src/components/Button.tsx b/src/components/Button.tsx index df51ccb5c16c8a0450d5f07db83e2d16bf25d07e..0e625d1a682e7be846fc5c4e527fd6592ec37292 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -15,6 +15,9 @@ export type ButtonProps = { onClick?: () => void; }; +export const buttonBaseClass: string = + "px-[9px] h-[32px] min-w-fit max-w-fit py-[5px] text-baseWhite rounded-[4px] flex focus:!outline-0"; + /** * A simple button with optional icon * @@ -34,11 +37,6 @@ const Button = ({ }: ButtonProps) => { if (!label && !icon) throw Error("Button cannot be empty"); - const pxClass = (icon: boolean, label: boolean) => { - if (icon && label) return "px-[8px]"; - else if (icon) return "px-[9px]"; - else if (label) return "px-[10px]"; - }; const bgClass = (isDisabled: boolean) => isDisabled ? "bg-lightGrey !opacity-100" : bgColor(color); @@ -46,10 +44,9 @@ const Button = ({ <NextButton startContent={icon && <Icon name={icon} customClass={iconClass} />} className={clsx( - pxClass(!!icon, !!label), bgClass(isDisabled), cursorPointer(isDisabled), - "h-[32px] min-w-fit max-w-fit py-[5px] text-baseWhite rounded-[4px] flex focus:!outline-0" + buttonBaseClass )} isDisabled={isDisabled} type={type} diff --git a/src/components/DateInput.tsx b/src/components/DateInput.tsx index 0e7ee1efbbd10ac8ac398719cbd2d7a20aa52abb..7720a37a33f9ff5b6dcde0701b27dd51e228b277 100644 --- a/src/components/DateInput.tsx +++ b/src/components/DateInput.tsx @@ -1,5 +1,5 @@ -import { Dispatch, forwardRef, SetStateAction } from "react"; -import DatePicker from "react-datepicker"; +import { forwardRef } from "react"; +import DatePicker, { ReactDatePickerCustomHeaderProps } from "react-datepicker"; import { format } from "date-fns"; import { BaseInput } from "./utils/baseComponents.tsx"; @@ -8,11 +8,17 @@ import Typography from "./Typography.tsx"; import { textColor } from "./utils/classes.ts"; +export type DateNull = Date | null; +export type DateArray = [DateNull, DateNull]; + type DateInputProps = { isRequired?: boolean; label: string; - value?: Date; - onValueChange: Dispatch<SetStateAction<Date | undefined>>; + isRange?: boolean; + value?: DateNull; + startDate?: DateNull; + endDate?: DateNull; + onValueChange: (date: Date | DateArray) => void; isInvalid?: boolean; errorMessage?: string; }; @@ -21,10 +27,18 @@ const DateInput = ({ isRequired = false, label, value, + startDate, + endDate, onValueChange, isInvalid = false, errorMessage = "", + isRange = false, }: DateInputProps) => { + if (value && (startDate || endDate || isRange)) + throw Error( + "Supply `value` when `isRange` is false (default), OR supply `startDate` and `endDate` when `isRange` is true." + ); + const dateFormat = "dd/MM/yyyy"; type inputProps = { @@ -48,10 +62,11 @@ const DateInput = ({ endContent={ <Icon name="datepicker" + // Note: textColor("body") has a different grey, hence the below is used customClass="text-grey dark:text-baseWhite" /> } - placeholder="DD/MM/YYYY" + placeholder={isRange ? " " : "DD/MM/YYYY"} value={value} onValueChange={() => {}} isInvalid={isInvalid} @@ -89,44 +104,66 @@ const DateInput = ({ </div> ); + const datePickerBaseProps: object = { + dateFormat: dateFormat, + customInput: <CustomInput isRequired={isRequired} />, + showPopperArrow: false, + popperClassName: "!transform-none !relative", + calendarStartDay: 1, + formatWeekDay: (day: string) => <div>{day.charAt(0)}</div>, + weekDayClassName: () => + "bg-baseWhite dark:bg-mediumGrey text-grey w-[33px] h-[30px] pt-[2px] m-0 mb-[5px]", + calendarClassName: + "!bg-baseWhite dark:!bg-mediumGrey !font-body border-none rounded-[4px] shadow-3xl", + renderCustomHeader: ({ + date, + decreaseYear, + increaseYear, + decreaseMonth, + increaseMonth, + }: ReactDatePickerCustomHeaderProps) => ( + <div className="!bg-baseWhite dark:!bg-mediumGrey rounded-t-[4px]"> + <CustomPagination + text={format(date, "yyyy")} + onClickPrev={decreaseYear} + onClickNext={increaseYear} + /> + <CustomPagination + text={format(date, "MMMM")} + onClickPrev={decreaseMonth} + onClickNext={increaseMonth} + /> + </div> + ), + }; + + const singleDatePickerProps: object = { + openToDate: value || new Date(), + selected: value, + dayClassName: () => + "!text-foreground-body !bg-default aria-selected:!bg-lightBlue", + }; + + const rangeDatePickerProps: object = { + openToDate: startDate || new Date(), + selected: startDate, + startDate: startDate, + endDate: endDate, + dayClassName: () => + "!text-foreground-body bg-default hover:aria-[selected=false]:bg-default mx-0 w-8", + }; + + const datePickerProps = { + ...datePickerBaseProps, + ...(isRange ? rangeDatePickerProps : singleDatePickerProps), + }; + return ( <DatePicker - dateFormat={dateFormat} - openToDate={value || new Date()} - selected={value} + selectsRange={isRange} onSelect={(date) => onValueChange(date)} onChange={(date) => !!date && onValueChange(date)} - customInput={<CustomInput isRequired={isRequired} />} - showPopperArrow={false} - calendarStartDay={1} - formatWeekDay={(day) => <div>{day.charAt(0)}</div>} - weekDayClassName={() => - "bg-baseWhite dark:bg-mediumGrey text-grey w-[33px] h-[30px] pt-[2px] m-0 mb-[5px]" - } - calendarClassName="!bg-baseWhite dark:!bg-mediumGrey !font-body border-none rounded-[4px] shadow-3xl" - dayClassName={() => - "!text-foreground-body !bg-default aria-selected:!bg-lightBlue" - } - renderCustomHeader={({ - date, - decreaseYear, - increaseYear, - decreaseMonth, - increaseMonth, - }) => ( - <div className="!bg-baseWhite dark:!bg-mediumGrey rounded-t-[4px]"> - <CustomPagination - text={format(date, "yyyy")} - onClickPrev={decreaseYear} - onClickNext={increaseYear} - /> - <CustomPagination - text={format(date, "MMMM")} - onClickPrev={decreaseMonth} - onClickNext={increaseMonth} - /> - </div> - )} + {...datePickerProps} /> ); }; diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx index 0179b428da025dda7de5e484a237fd7ac8d025ea..a0e4796e15bbb711b34b3a92f584e532071cf820 100644 --- a/src/components/Dropdown.tsx +++ b/src/components/Dropdown.tsx @@ -3,6 +3,7 @@ import { Dispatch, SetStateAction } from "react"; import { clsx } from "clsx"; import { baseInputClassNames } from "./utils/baseComponents.tsx"; import Icon from "./Icon.tsx"; +import { textColor } from "./utils/classes.ts"; type DropdownProps = { isRequired?: boolean; @@ -41,8 +42,10 @@ const Dropdown = ({ selectorIcon={<Icon name="dropdown" />} isClearable={false} // so the "cross" button does not show classNames={{ - selectorButton: - "data-[hover=true]:bg-default text-foreground-body opacity-100", + selectorButton: clsx( + textColor("body"), + "data-[hover=true]:bg-default opacity-100" + ), popoverContent: "rounded-[4px]", }} > diff --git a/src/components/Menu.tsx b/src/components/Menu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..30aae9157fdb81245f85d754c47c186347da33f6 --- /dev/null +++ b/src/components/Menu.tsx @@ -0,0 +1,141 @@ +import { + Avatar, + Dropdown, + DropdownItem, + DropdownMenu, + DropdownTrigger, +} from "@nextui-org/react"; +import { ReactNode } from "react"; +import { Button as NextButton } from "@nextui-org/button"; +import { clsx } from "clsx"; +import { BreadcrumbItemProps } from "@react-types/breadcrumbs"; +import Icon, { IconName } from "./Icon.tsx"; +import { buttonBaseClass } from "./Button.tsx"; +import Typography from "./Typography.tsx"; +import { textColor } from "./utils/classes.ts"; + +type MenuComponentProps = { + menuTrigger: ReactNode; + onAction?: (key: string | number) => void; + items: MenuItemProps[]; +}; + +export interface MenuItemProps extends BreadcrumbItemProps { + iconName?: IconName | null; + key: string; + itemName: string; + href?: string; + danger?: boolean; +} + +export const MenuComponent = ({ + menuTrigger, + onAction, + items, +}: MenuComponentProps) => { + if (!items.length) throw Error("`items` must not be empty"); + + const MenuItem = ( + { iconName, key, itemName, href, danger = false, children }: MenuItemProps, + idx: number + ) => { + const itemContent = () => { + if (children) return children; + return ( + <> + {iconName && <Icon name={iconName} />} + <Typography text={itemName} variant="paragraph" customColor={true} /> + </> + ); + }; + return ( + <DropdownItem + key={key} + href={href || ""} + showDivider={idx < items.length - 1} + > + <div + className={clsx( + "flex flex-row gap-3 items-center pl-2", + danger ? textColor("negative") : textColor("body") + )} + > + {itemContent()} + </div> + </DropdownItem> + ); + }; + + const padding: string = "py-0.5 px-0"; + + return ( + <Dropdown classNames={{ content: padding }}> + <DropdownTrigger>{menuTrigger}</DropdownTrigger> + <DropdownMenu onAction={onAction} classNames={{ base: padding }}> + {items.map((item, idx) => MenuItem(item, idx))} + </DropdownMenu> + </Dropdown> + ); +}; + +type MenuProps = { + label: string; + items: MenuItemProps[]; + onAction?: (key: string | number) => void; +}; +export const ActionMenu = ({ label, items, onAction }: MenuProps) => { + return ( + <MenuComponent + items={items} + onAction={onAction} + menuTrigger={ + <NextButton + startContent={<Icon name="dropdown" />} + className={buttonBaseClass} // same as Button component (src/components/Button.tsx) + type="button" + color="primary" + > + {label && ( + <Typography text={label} variant="paragraph" customColor={true} /> + )} + </NextButton> + } + /> + ); +}; + +export const ProfileMenu = ({ label, items, onAction }: MenuProps) => { + const getInitials = (name: string) => { + const [firstName, lastName] = name.split(" "); + return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase(); + }; + return ( + <MenuComponent + items={items} + onAction={onAction} + menuTrigger={ + <div className="min-w-72 max-w-fit flex flex-row gap-2 items-center aria-expanded:scale-1 aria-expanded:opacity-100"> + <Typography + text={label} + variant="overtitle" + customColor={true} + customClass={textColor("body")} + /> + <Avatar + isFocusable + color="primary" + className="w-[28px] h-[28px] text-baseWhite" + fallback={ + <Typography + text={getInitials(label)} + variant="note" + customColor={true} + /> + } + /> + <Icon name="dropdown" color="primary" /> + </div> + } + /> + ); +}; diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index f2b47079d36558d2483651086c6cb09e37e129f7..d28cc3a9f9e2acc0b6b368820032f273c9299e62 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -5,9 +5,11 @@ import { ModalHeader, Modal as NextModal, } from "@nextui-org/react"; +import { clsx } from "clsx"; import Button, { ButtonProps } from "./Button.tsx"; import Icon from "./Icon.tsx"; import Typography from "./Typography.tsx"; +import { textColor } from "./utils/classes.ts"; type ModalProps = { isOpen: boolean; @@ -48,7 +50,7 @@ const Modal = ({ backdrop: "bg-darkBlue/90 dark:bg-baseBlack/90", base: "rounded-[8px] min-w-[500px] shadow-2xl p-[19px]", body: "p-0 pb-[10px] pr-[20px]", - closeButton: "text-foreground-heading mt-[17px] mr-[17px] p-0", + closeButton: clsx(textColor("heading"), "mt-[17px] mr-[17px] p-0"), footer: "flex flex-row justify-start gap-x-5 p-0 pt-[10px]", header: "flex flex-col gap-1 px-0 py-[10px]", }} diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..61e8c945b7b1085276eabc4eb25e603aa12a94bc --- /dev/null +++ b/src/components/ProgressBar.tsx @@ -0,0 +1,21 @@ +import { Progress } from "@nextui-org/react"; + +type ProgressBarProps = { + value?: number; +}; +const ProgressBar = ({ value }: ProgressBarProps) => { + return ( + <Progress + isIndeterminate={!value} + value={value as number} + aria-label="progress" + color="primary" + disableAnimation + classNames={{ + track: "bg-lightGrey dark:bg-mediumGrey", + }} + /> + ); +}; + +export default ProgressBar; diff --git a/src/stories/Badge.stories.ts b/src/stories/Badge.stories.ts index d005e1c154e14266e1c45d19e37cfdee10f476a0..5c6a5e09f9fe830671915d7571bdc578dc9f7d76 100644 --- a/src/stories/Badge.stories.ts +++ b/src/stories/Badge.stories.ts @@ -6,7 +6,6 @@ import { colorOptions } from "src/components/utils/colors.ts"; const meta = { title: "Components/Badge", component: Badge, - tags: ["autodocs"], argTypes: { color: { control: "radio", options: colorOptions }, backgroundColor: { control: "color" }, diff --git a/src/stories/Breadcrumb.stories.ts b/src/stories/Breadcrumb.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..c449b59acbaeada290986f486e4a1ec1b041e0b6 --- /dev/null +++ b/src/stories/Breadcrumb.stories.ts @@ -0,0 +1,38 @@ +import { Meta, StoryObj } from "@storybook/react"; +import Breadcrumb from "src/components/Breadcrumb.tsx"; + +const meta = { + title: "Components/Breadcrumb", + component: Breadcrumb, +} satisfies Meta; + +export default meta; + +export const OneLevel: StoryObj = { + args: { + items: [{ name: "Home", href: "/" }], + }, +}; + +export const MultipleLevels: StoryObj = { + args: { + items: [ + { name: "Home", href: "/" }, + { name: "Category", href: "/category" }, + { name: "Subcategory", href: "/category/subcategory" }, + { name: "This page", href: "/category/subcategory/this-page" }, + ], + }, +}; + +export const MoreThanFourLevels: StoryObj = { + args: { + items: [ + { name: "Home", href: "/" }, + { name: "Level 1", href: "/level-1" }, + { name: "Level 2", href: "/level-1/level-2" }, + { name: "Level 3", href: "/level-1/level-2/level-3" }, + { name: "This page", href: "/level-1/level-2/level-3/this-page" }, + ], + }, +}; diff --git a/src/stories/DateInput.stories.tsx b/src/stories/DateInput.stories.tsx index 2045ff5214bc2285912d3c0cec0734fd70756061..7c55d0bd76ea19c1bd2d0d1d74d68ac55be7a184 100644 --- a/src/stories/DateInput.stories.tsx +++ b/src/stories/DateInput.stories.tsx @@ -1,10 +1,9 @@ import { Meta } from "@storybook/react"; import { useState } from "react"; -import DateInput from "src/components/DateInput.tsx"; +import DateInput, { DateArray, DateNull } from "src/components/DateInput.tsx"; const meta = { title: "Components/Date Input", - component: DateInput, } satisfies Meta; export default meta; @@ -16,7 +15,30 @@ export const WithoutLimits = () => { <DateInput label="When's your birthday?" value={value} - onValueChange={setValue} + onValueChange={(date) => setValue(date as Date)} + /> + ); +}; + +export const DateRange = () => { + const [startDate, setStartDate] = useState<DateNull>(); + const [endDate, setEndDate] = useState<DateNull>(); + + const onChange = (dates: DateArray) => { + if (dates.length === 2) { + const [start, end] = dates; + setStartDate(start); + setEndDate(end); + } + }; + + return ( + <DateInput + label="When's your holiday?" + startDate={startDate} + endDate={endDate} + onValueChange={(dates) => onChange(dates as DateArray)} + isRange={true} /> ); }; diff --git a/src/stories/Menu.stories.tsx b/src/stories/Menu.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2a44ff1f7047b259f70a8d965868ebda07fdc300 --- /dev/null +++ b/src/stories/Menu.stories.tsx @@ -0,0 +1,76 @@ +import { Meta } from "@storybook/react"; +import { useState } from "react"; +import { + ActionMenu, + MenuItemProps, + ProfileMenu, +} from "src/components/Menu.tsx"; +import Typography from "src/components/Typography.tsx"; + +const meta = { + title: "Components/Menu", +} satisfies Meta; + +export default meta; + +const items = [ + { key: "expand", itemName: "Expand", iconName: "expand" }, + { key: "add", itemName: "Add", iconName: "plus" }, + { key: "remove", itemName: "Remove", iconName: "cross", danger: true }, +]; + +export const ActionMenuWithoutIcons = () => { + const [selectedKey, setSelectedKey] = useState(""); + return ( + <> + <Typography + text={`You selected: ${selectedKey || "nothing"}`} + variant="paragraph" + /> + <ActionMenu + label="Menu" + items={ + items.map((item: object): object => ({ + ...item, + iconName: null, + })) as MenuItemProps[] + } + onAction={(key) => setSelectedKey(key as string)} + /> + </> + ); +}; + +export const ActionMenuWithIcons = () => { + const [selectedKey, setSelectedKey] = useState(""); + return ( + <> + <Typography + text={`You selected: ${selectedKey || "nothing"}`} + variant="paragraph" + /> + <ActionMenu + label="Menu" + items={items as MenuItemProps[]} + onAction={(key) => setSelectedKey(key as string)} + /> + </> + ); +}; + +export const ProfileMenuWithIcons = () => { + const [selectedKey, setSelectedKey] = useState(""); + return ( + <> + <Typography + text={`You selected: ${selectedKey || "nothing"}`} + variant="paragraph" + /> + <ProfileMenu + label="Johnny Depp" + items={items as MenuItemProps[]} + onAction={(key) => setSelectedKey(key as string)} + /> + </> + ); +}; diff --git a/src/stories/ProgressBar.stories.tsx b/src/stories/ProgressBar.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..31bd47b0435e019f23a6067fe90b251e4306f07f --- /dev/null +++ b/src/stories/ProgressBar.stories.tsx @@ -0,0 +1,31 @@ +import { Meta } from "@storybook/react"; +import { useState } from "react"; +import ProgressBar from "src/components/ProgressBar.tsx"; + +const meta = { + title: "Components/Progress Bar", + component: ProgressBar, +} satisfies Meta; + +export default meta; + +/** The value of the progress bar can be set and controlled via state. */ +export const WithValue = () => { + const [value, setValue] = useState(0); + setTimeout(() => { + if (value < 100) setValue(value + 1); + else setValue(0); + }, 50); + + return ( + <div className="max-w-md"> + <ProgressBar value={value} /> + </div> + ); +}; + +export const IndeterminateValue = () => ( + <div className="max-w-md"> + <ProgressBar /> + </div> +); diff --git a/src/tailwind.css b/src/tailwind.css index f259af3517dfe812ad6e713f3de5b7ce4e001579..dfeb8148cf80856b816e22649abe553a04d10474 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -28,6 +28,39 @@ margin: 0; } -.react-datepicker__day--outside-month { +.dark .react-datepicker__day--outside-month { + color: #dadada !important; +} + +.light .react-datepicker__day--outside-month { color: #777777 !important; } + +.dark { + .react-datepicker__day--in-range, + .react-datepicker__day--in-selecting-range { + background-color: #777777; + border-radius: 0; + } +} + +.light { + .react-datepicker__day--in-range, + .react-datepicker__day--in-selecting-range { + background-color: #dadada; + border-radius: 0; + } +} + +.react-datepicker__day--range-start, +.react-datepicker__day--range-end, +.react-datepicker__day--selecting-range-start, +.react-datepicker__day--selecting-range-end, +.react-datepicker__day--selected { + background-color: #00adee !important; + border-radius: 0.3rem !important; +} + +.react-datepicker__day--keyboard-selected { + background-color: transparent; +}