♻️ Remove headlessui (#1124)

This commit is contained in:
Luke Vella 2024-05-29 15:01:40 +12:00 committed by GitHub
parent efa4f03353
commit e9fb86516d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 182 additions and 327 deletions

View file

@ -21,7 +21,6 @@
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^1.0.3", "@auth/prisma-adapter": "^1.0.3",
"@floating-ui/react-dom-interactions": "^0.13.3", "@floating-ui/react-dom-interactions": "^0.13.3",
"@headlessui/react": "^1.7.7",
"@hookform/resolvers": "^3.3.1", "@hookform/resolvers": "^3.3.1",
"@next/bundle-analyzer": "^12.3.4", "@next/bundle-analyzer": "^12.3.4",
"@radix-ui/react-slot": "^1.0.1", "@radix-ui/react-slot": "^1.0.1",

View file

@ -123,7 +123,7 @@ const Page = () => {
if (optionsToDeleteThatHaveVotes.length > 0) { if (optionsToDeleteThatHaveVotes.length > 0) {
modalContext.render({ modalContext.render({
title: t("areYouSure"), title: t("areYouSure"),
description: ( content: (
<Trans <Trans
i18nKey="deletingOptionsWarning" i18nKey="deletingOptionsWarning"
components={{ b: <strong /> }} components={{ b: <strong /> }}
@ -131,7 +131,7 @@ const Page = () => {
), ),
onOk, onOk,
okButtonProps: { okButtonProps: {
type: "danger", variant: "destructive",
}, },
okText: t("delete"), okText: t("delete"),
cancelText: t("cancel"), cancelText: t("cancel"),

View file

@ -1,29 +0,0 @@
import * as React from "react";
export interface CompactButtonProps {
icon?: React.ComponentType<{
className?: string;
style?: React.CSSProperties;
}>;
children?: React.ReactNode;
onClick?: () => void;
}
const CompactButton = React.forwardRef<HTMLButtonElement, CompactButtonProps>(
({ icon: Icon, children, onClick }, ref) => {
return (
<button
ref={ref}
type="button"
className="inline-flex size-5 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition-colors hover:bg-gray-200 active:bg-gray-300 active:text-gray-500"
onClick={onClick}
>
{Icon ? <Icon className="size-3" /> : children}
</button>
);
},
);
CompactButton.displayName = "CompactButton";
export default CompactButton;

View file

@ -4,8 +4,6 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu"; } from "@rallly/ui/dropdown-menu";
import { Icon } from "@rallly/ui/icon"; import { Icon } from "@rallly/ui/icon";
@ -14,6 +12,7 @@ import clsx from "clsx";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { import {
CalendarIcon, CalendarIcon,
CalendarXIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
MoreHorizontalIcon, MoreHorizontalIcon,
@ -34,7 +33,6 @@ import {
getDateProps, getDateProps,
removeAllOptionsForDay, removeAllOptionsForDay,
} from "../../../../utils/date-time-utils"; } from "../../../../utils/date-time-utils";
import CompactButton from "../../../compact-button";
import DateCard from "../../../date-card"; import DateCard from "../../../date-card";
import { useHeadlessDatePicker } from "../../../headless-date-picker"; import { useHeadlessDatePicker } from "../../../headless-date-picker";
import { DateTimeOption } from ".."; import { DateTimeOption } from "..";
@ -330,15 +328,20 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
); );
}} }}
/> />
<CompactButton <Button
icon={XIcon} size="sm"
variant="ghost"
onClick={() => { onClick={() => {
onChange([ onChange([
...options.slice(0, index), ...options.slice(0, index),
...options.slice(index + 1), ...options.slice(index + 1),
]); ]);
}} }}
/> >
<Icon>
<XIcon />
</Icon>
</Button>
</div> </div>
); );
})} })}
@ -380,15 +383,13 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild={true}> <DropdownMenuTrigger asChild={true}>
<button className="text-gray-500 hover:text-gray-800"> <Button variant="ghost" size="sm">
<MoreHorizontalIcon className="h4 w-4" /> <Icon>
</button> <MoreHorizontalIcon />
</Icon>
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">
<DropdownMenuLabel>
<Trans i18nKey="menu" defaults="Menu" />
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
const times = optionsForDay.map( const times = optionsForDay.map(
@ -430,7 +431,9 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
onChange(newOptions); onChange(newOptions);
}} }}
> >
<SparklesIcon className="mr-2 size-4" /> <Icon>
<SparklesIcon />
</Icon>
<Trans i18nKey="applyToAllDates" /> <Trans i18nKey="applyToAllDates" />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
@ -443,7 +446,9 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
); );
}} }}
> >
<XIcon className="mr-2 size-4" /> <Icon>
<CalendarXIcon />
</Icon>
<Trans i18nKey="deleteDate" /> <Trans i18nKey="deleteDate" />
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>

View file

@ -1,23 +1,18 @@
import { Button } from "@rallly/ui/button";
import { import {
flip, Select,
FloatingPortal, SelectContent,
offset, SelectItem,
size, SelectTrigger,
useFloating, SelectValue,
} from "@floating-ui/react-dom-interactions"; } from "@rallly/ui/select";
import { Listbox } from "@headlessui/react";
import clsx from "clsx";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ChevronDownIcon } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { getDuration } from "@/utils/date-time-utils"; import { getDuration } from "@/utils/date-time-utils";
import { stopPropagation } from "@/utils/stop-propagation";
import { styleMenuItem } from "../../../menu-styles";
export interface TimePickerProps { export interface TimePickerProps {
value: Date; value?: Date;
after?: Date; after?: Date;
className?: string; className?: string;
onChange?: (value: Date) => void; onChange?: (value: Date) => void;
@ -29,87 +24,69 @@ const TimePicker: React.FunctionComponent<TimePickerProps> = ({
className, className,
after, after,
}) => { }) => {
const { reference, floating, x, y, strategy, refs } = useFloating({ const [open, setOpen] = React.useState(false);
placement: "bottom-start", const getOptions = React.useCallback(() => {
strategy: "fixed", if (!open) {
middleware: [ return [dayjs(value).toISOString()];
offset(5),
flip(),
size({
apply: ({ rects }) => {
if (refs.floating.current) {
Object.assign(refs.floating.current.style, {
minWidth: `${rects.reference.width}px`,
});
}
},
}),
],
});
const renderOptions = () => {
const startFromDate = after ? dayjs(after) : dayjs(value).startOf("day");
const options: React.ReactNode[] = [];
for (let i = 1; i <= 96; i++) {
const optionValue = startFromDate.add(i * 15, "minutes");
options.push(
<Listbox.Option
key={i}
className={styleMenuItem}
value={optionValue.format("YYYY-MM-DDTHH:mm:ss")}
>
<div className="flex items-center gap-2">
<span>{optionValue.format("LT")}</span>
{after ? (
<span className="text-sm text-gray-500">
{getDuration(dayjs(after), optionValue)}
</span>
) : null}
</div>
</Listbox.Option>,
);
} }
return options; let cursor = after
}; ? dayjs(after).add(15, "minutes")
: dayjs(value).startOf("day");
const res: string[] = [];
while (cursor.isSame(value, "day")) {
res.push(cursor.toISOString());
cursor = cursor.add(15, "minutes");
}
return res;
}, [after, open, value]);
return ( return (
<Listbox <Select
value={dayjs(value).format("YYYY-MM-DDTHH:mm:ss")} value={value?.toISOString()}
onChange={(newValue) => { onValueChange={(newValue) => {
onChange?.(new Date(newValue)); if (newValue) {
onChange?.(new Date(newValue));
}
}} }}
open={open}
onOpenChange={setOpen}
> >
{(open) => ( <SelectTrigger asChild>
<> <Button className={className}>
<div ref={reference} className={clsx("relative", className)}> <SelectValue placeholder="Select time" />
<Listbox.Button className="btn-default text-left"> </Button>
<span className="grow truncate">{dayjs(value).format("LT")}</span> </SelectTrigger>
<span className="pointer-events-none ml-2 flex"> <SelectContent>
<ChevronDownIcon className="size-5" /> {open ? (
</span> getOptions().map((option) => {
</Listbox.Button> return (
</div> <SelectItem key={option} value={dayjs(option).toISOString()}>
<FloatingPortal> <div className="flex items-center gap-2">
{open ? ( <span>{dayjs(option).format("LT")}</span>
<Listbox.Options {after ? (
style={{ <span className="text-sm text-gray-500">
position: strategy, {getDuration(dayjs(after), dayjs(option))}
left: x ?? "", </span>
top: y ?? "", ) : null}
}} </div>
ref={floating} </SelectItem>
className="z-50 max-h-52 overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" );
onMouseDown={stopPropagation} })
> ) : (
{renderOptions()} <SelectItem value={dayjs(value).toISOString()}>
</Listbox.Options> <div className="flex items-center gap-2">
) : null} <span>{dayjs(value).format("LT")}</span>
</FloatingPortal> {after ? (
</> <span className="text-sm text-gray-500">
)} {getDuration(dayjs(after), dayjs(value))}
</Listbox> </span>
) : null}
</div>
</SelectItem>
)}
</SelectContent>
</Select>
); );
}; };

View file

@ -2,6 +2,14 @@ import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@rallly/ui/card"; import { Card, CardDescription, CardHeader, CardTitle } from "@rallly/ui/card";
import { CommandDialog } from "@rallly/ui/command"; import { CommandDialog } from "@rallly/ui/command";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
useDialog,
} from "@rallly/ui/dialog";
import { FormField, FormMessage } from "@rallly/ui/form"; import { FormField, FormMessage } from "@rallly/ui/form";
import { Label } from "@rallly/ui/label"; import { Label } from "@rallly/ui/label";
import { Switch } from "@rallly/ui/switch"; import { Switch } from "@rallly/ui/switch";
@ -15,7 +23,6 @@ import { useFormContext } from "react-hook-form";
import { TimeZoneCommand } from "@/components/time-zone-picker/time-zone-select"; import { TimeZoneCommand } from "@/components/time-zone-picker/time-zone-select";
import { getBrowserTimeZone } from "../../../utils/date-time-utils"; import { getBrowserTimeZone } from "../../../utils/date-time-utils";
import { useModal } from "../../modal";
import { NewEventData } from "../types"; import { NewEventData } from "../types";
import MonthCalendar from "./month-calendar"; import MonthCalendar from "./month-calendar";
import { DateTimeOption } from "./types"; import { DateTimeOption } from "./types";
@ -73,38 +80,17 @@ const PollOptionsForm = ({
options.length === 0 || options.length === 0 ||
options.some((option) => option.type !== "timeSlot"); options.some((option) => option.type !== "timeSlot");
const [dateOrTimeRangeModal, openDateOrTimeRangeModal] = useModal({ const dateOrTimeRangeDialog = useDialog();
title: t("mixedOptionsTitle"),
description: t("mixedOptionsDescription"),
okText: t("mixedOptionsKeepTimes"),
onOk: () => {
setValue(
"options",
watchOptions.filter((option) => option.type === "timeSlot"),
);
if (!watchTimeZone) {
setValue("timeZone", getBrowserTimeZone());
}
},
cancelText: t("mixedOptionsKeepDates"),
onCancel: () => {
setValue(
"options",
watchOptions.filter((option) => option.type === "date"),
);
setValue("timeZone", "");
},
});
React.useEffect(() => { React.useEffect(() => {
if (watchOptions.length > 1) { if (watchOptions.length > 1) {
const optionType = watchOptions[0].type; const optionType = watchOptions[0].type;
// all options needs to be the same type // all options needs to be the same type
if (watchOptions.some((option) => option.type !== optionType)) { if (watchOptions.some((option) => option.type !== optionType)) {
openDateOrTimeRangeModal(); dateOrTimeRangeDialog.trigger();
} }
} }
}, [watchOptions, openDateOrTimeRangeModal]); }, [watchOptions, dateOrTimeRangeDialog]);
const watchNavigationDate = watch("navigationDate"); const watchNavigationDate = watch("navigationDate");
const navigationDate = new Date(watchNavigationDate ?? Date.now()); const navigationDate = new Date(watchNavigationDate ?? Date.now());
@ -146,7 +132,47 @@ const PollOptionsForm = ({
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
{dateOrTimeRangeModal} <Dialog {...dateOrTimeRangeDialog.dialogProps}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey="mixedOptionsTitle" />
</DialogTitle>
</DialogHeader>
<p className="text-sm">
<Trans i18nKey="mixedOptionsDescription" />
</p>
<DialogFooter>
<Button
onClick={() => {
setValue(
"options",
watchOptions.filter((option) => option.type === "date"),
);
setValue("timeZone", "");
dateOrTimeRangeDialog.dismiss();
}}
>
<Trans i18nKey="mixedOptionsKeepDates" />
</Button>
<Button
onClick={() => {
setValue(
"options",
watchOptions.filter((option) => option.type === "timeSlot"),
);
if (!watchTimeZone) {
setValue("timeZone", getBrowserTimeZone());
}
dateOrTimeRangeDialog.dismiss();
}}
variant="primary"
>
<Trans i18nKey="mixedOptionsKeepTimes" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div> <div>
<FormField <FormField
control={form.control} control={form.control}

View file

@ -70,7 +70,6 @@ const ModalProvider: React.FunctionComponent<ModalProviderProps> = ({
visible={true} visible={true}
{...modal.config} {...modal.config}
content={modal.content} content={modal.content}
footer={null}
onCancel={() => { onCancel={() => {
remove(id); remove(id);
}} }}

View file

@ -1,128 +1,66 @@
import { Dialog } from "@headlessui/react"; import { Button, ButtonProps } from "@rallly/ui/button";
import { AnimatePresence, m } from "framer-motion"; import {
import { XIcon } from "lucide-react"; Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@rallly/ui/dialog";
import * as React from "react"; import * as React from "react";
import { ButtonProps, LegacyButton } from "../button";
export interface ModalProps { export interface ModalProps {
description?: React.ReactNode;
title?: React.ReactNode; title?: React.ReactNode;
okText?: string; okText?: string;
cancelText?: string; cancelText?: string;
okButtonProps?: ButtonProps; okButtonProps?: ButtonProps;
onOk?: () => void; onOk?: () => void;
onCancel?: () => void; onCancel?: () => void;
footer?: React.ReactNode;
content?: React.ReactNode; content?: React.ReactNode;
overlayClosable?: boolean; overlayClosable?: boolean;
visible?: boolean; visible?: boolean;
showClose?: boolean;
} }
const Modal: React.FunctionComponent<ModalProps> = ({ const Modal: React.FunctionComponent<ModalProps> = ({
description,
title, title,
okText, okText,
cancelText,
okButtonProps,
footer,
content, content,
overlayClosable,
onCancel, onCancel,
cancelText,
onOk, onOk,
okButtonProps,
visible, visible,
showClose,
}) => { }) => {
const initialFocusRef = React.useRef<HTMLButtonElement>(null);
return ( return (
<AnimatePresence> <Dialog
{visible ? ( open={visible}
<Dialog onOpenChange={(open) => {
open={visible} if (!open) {
className="fixed inset-0 z-50 overflow-y-auto" onCancel?.();
initialFocus={initialFocusRef} }
onClose={() => { }}
if (overlayClosable) onCancel?.(); >
}} <DialogContent>
> <DialogHeader>
<m.div <DialogTitle>{title}</DialogTitle>
transition={{ duration: 0.5 }} </DialogHeader>
className="flex min-h-screen items-center justify-center" <p className="text-sm">{content}</p>
<DialogFooter>
<DialogClose>
<Button>{cancelText}</Button>
</DialogClose>
<Button
variant="primary"
onClick={() => {
onOk?.();
}}
{...okButtonProps}
> >
<Dialog.Overlay {okText}
as={m.div} </Button>
transition={{ duration: 0.5 }} </DialogFooter>
initial={{ opacity: 0 }} </DialogContent>
animate={{ opacity: 1 }} </Dialog>
exit={{ opacity: 0 }}
className="fixed inset-0 z-0 bg-gray-900/25"
/>
<m.div
transition={{ duration: 0.1 }}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="relative z-50 m-3 inline-block max-w-full transform text-left align-middle sm:m-8"
>
<div
data-testid="modal"
className="shadow-huge max-w-full overflow-hidden rounded-md bg-white"
>
{showClose ? (
<button
role="button"
className="absolute right-2 top-2 z-10 inline-flex size-7 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-500/10 hover:text-gray-500 focus:ring-0 focus:ring-offset-0 active:bg-gray-500/20"
onClick={onCancel}
>
<XIcon className="h-4" />
</button>
) : null}
{content ?? (
<div className="max-w-md p-6">
{title ? (
<Dialog.Title className="mb-2 font-medium">
{title}
</Dialog.Title>
) : null}
{description ? (
<Dialog.Description className="m-0">
{description}
</Dialog.Description>
) : null}
</div>
)}
{footer === undefined ? (
<div className="flex h-14 items-center justify-end gap-3 rounded-br-lg border-t bg-gray-50 p-3">
{cancelText ? (
<LegacyButton
onClick={() => {
onCancel?.();
}}
>
{cancelText}
</LegacyButton>
) : null}
{okText ? (
<LegacyButton
ref={initialFocusRef}
type="primary"
onClick={() => {
onOk?.();
}}
{...okButtonProps}
>
{okText}
</LegacyButton>
) : null}
</div>
) : null}
</div>
</m.div>
</m.div>
</Dialog>
) : null}
</AnimatePresence>
); );
}; };

View file

@ -25,12 +25,3 @@ export const useModal = (
); );
return [modal, () => setVisible(true), () => setVisible(false)]; return [modal, () => setVisible(true), () => setVisible(false)];
}; };
export const useModalState = (): [boolean, OpenModalFn, CloseModalFn] => {
const [visible, setVisible] = React.useState(false);
const hide = React.useCallback(() => setVisible(false), []);
const show = React.useCallback(() => setVisible(true), []);
return [visible, show, hide];
};

View file

@ -1,45 +0,0 @@
import { Switch as HeadlessSwitch } from "@headlessui/react";
import clsx from "clsx";
import * as React from "react";
export interface SwitchProps {
checked?: boolean;
onChange: (checked: boolean) => void;
srDescription?: string;
}
const Switch: React.FunctionComponent<SwitchProps> = ({
checked = false,
onChange,
srDescription,
...rest
}) => {
return (
<HeadlessSwitch
checked={checked}
onChange={onChange}
className={clsx(
"relative inline-flex h-6 w-10 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out",
{
"bg-gray-200": !checked,
"bg-green-500": checked,
},
)}
{...rest}
>
{srDescription ? <span className="sr-only">{srDescription}</span> : null}
<span
aria-hidden="true"
className={clsx(
"pointer-events-none inline-block size-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out",
{
"translate-x-4": checked,
"translate-x-0": !checked,
},
)}
/>
</HeadlessSwitch>
);
};
export default Switch;

View file

@ -26,7 +26,8 @@ test.describe("edit options", () => {
editOptionsPage.switchToSpecifyTimes(); editOptionsPage.switchToSpecifyTimes();
await page.click("text='12:00 PM'"); await page.click("text='12:00 PM'");
await page.click("text='1:00 PM'"); const listbox = page.getByRole("listbox");
listbox.getByText("1:00 PM", { exact: true }).click();
await page.getByRole("button", { name: "Save" }).click(); await page.getByRole("button", { name: "Save" }).click();
await expect(page.locator('text="Are you sure?"')).toBeVisible(); await expect(page.locator('text="Are you sure?"')).toBeVisible();
await page.click("text='Delete'"); await page.click("text='Delete'");

View file

@ -1969,13 +1969,6 @@
dependencies: dependencies:
"@hapi/hoek" "^9.0.0" "@hapi/hoek" "^9.0.0"
"@headlessui/react@^1.7.7":
version "1.7.12"
resolved "https://registry.npmjs.org/@headlessui/react/-/react-1.7.12.tgz"
integrity sha512-FhSx5V+Qp0GvbTpaxyS+ymGDDNntCacClWsk/d8Upbr19g3AsPbjfPk4+m2CgJGcuCB5Dz7LpUIOAbvQTyjL2g==
dependencies:
client-only "^0.0.1"
"@heroicons/react@^1.0.6": "@heroicons/react@^1.0.6":
version "1.0.6" version "1.0.6"
resolved "https://registry.npmjs.org/@heroicons/react/-/react-1.0.6.tgz" resolved "https://registry.npmjs.org/@heroicons/react/-/react-1.0.6.tgz"
@ -5750,7 +5743,7 @@ cli-spinners@^2.5.0:
resolved "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.7.0.tgz" resolved "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.7.0.tgz"
integrity sha512-qu3pN8Y3qHNgE2AFweciB1IfMnmZ/fsNTEE+NOFjmGB2F/7rLhnhzppvpCnN4FovtP26k8lHyy9ptEbNwWFLzw== integrity sha512-qu3pN8Y3qHNgE2AFweciB1IfMnmZ/fsNTEE+NOFjmGB2F/7rLhnhzppvpCnN4FovtP26k8lHyy9ptEbNwWFLzw==
client-only@0.0.1, client-only@^0.0.1: client-only@0.0.1:
version "0.0.1" version "0.0.1"
resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz" resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz"
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==