♻️ 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": {
"@auth/prisma-adapter": "^1.0.3",
"@floating-ui/react-dom-interactions": "^0.13.3",
"@headlessui/react": "^1.7.7",
"@hookform/resolvers": "^3.3.1",
"@next/bundle-analyzer": "^12.3.4",
"@radix-ui/react-slot": "^1.0.1",

View file

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

View file

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

View file

@ -2,6 +2,14 @@ import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@rallly/ui/card";
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 { Label } from "@rallly/ui/label";
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 { getBrowserTimeZone } from "../../../utils/date-time-utils";
import { useModal } from "../../modal";
import { NewEventData } from "../types";
import MonthCalendar from "./month-calendar";
import { DateTimeOption } from "./types";
@ -73,38 +80,17 @@ const PollOptionsForm = ({
options.length === 0 ||
options.some((option) => option.type !== "timeSlot");
const [dateOrTimeRangeModal, openDateOrTimeRangeModal] = useModal({
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", "");
},
});
const dateOrTimeRangeDialog = useDialog();
React.useEffect(() => {
if (watchOptions.length > 1) {
const optionType = watchOptions[0].type;
// all options needs to be the same type
if (watchOptions.some((option) => option.type !== optionType)) {
openDateOrTimeRangeModal();
dateOrTimeRangeDialog.trigger();
}
}
}, [watchOptions, openDateOrTimeRangeModal]);
}, [watchOptions, dateOrTimeRangeDialog]);
const watchNavigationDate = watch("navigationDate");
const navigationDate = new Date(watchNavigationDate ?? Date.now());
@ -146,7 +132,47 @@ const PollOptionsForm = ({
</div>
</div>
</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>
<FormField
control={form.control}

View file

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

View file

@ -1,128 +1,66 @@
import { Dialog } from "@headlessui/react";
import { AnimatePresence, m } from "framer-motion";
import { XIcon } from "lucide-react";
import { Button, ButtonProps } from "@rallly/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@rallly/ui/dialog";
import * as React from "react";
import { ButtonProps, LegacyButton } from "../button";
export interface ModalProps {
description?: React.ReactNode;
title?: React.ReactNode;
okText?: string;
cancelText?: string;
okButtonProps?: ButtonProps;
onOk?: () => void;
onCancel?: () => void;
footer?: React.ReactNode;
content?: React.ReactNode;
overlayClosable?: boolean;
visible?: boolean;
showClose?: boolean;
}
const Modal: React.FunctionComponent<ModalProps> = ({
description,
title,
okText,
cancelText,
okButtonProps,
footer,
content,
overlayClosable,
onCancel,
cancelText,
onOk,
okButtonProps,
visible,
showClose,
}) => {
const initialFocusRef = React.useRef<HTMLButtonElement>(null);
return (
<AnimatePresence>
{visible ? (
<Dialog
open={visible}
className="fixed inset-0 z-50 overflow-y-auto"
initialFocus={initialFocusRef}
onClose={() => {
if (overlayClosable) onCancel?.();
}}
>
<m.div
transition={{ duration: 0.5 }}
className="flex min-h-screen items-center justify-center"
>
<Dialog.Overlay
as={m.div}
transition={{ duration: 0.5 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
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={() => {
onOpenChange={(open) => {
if (!open) {
onCancel?.();
}
}}
>
{cancelText}
</LegacyButton>
) : null}
{okText ? (
<LegacyButton
ref={initialFocusRef}
type="primary"
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<p className="text-sm">{content}</p>
<DialogFooter>
<DialogClose>
<Button>{cancelText}</Button>
</DialogClose>
<Button
variant="primary"
onClick={() => {
onOk?.();
}}
{...okButtonProps}
>
{okText}
</LegacyButton>
) : null}
</div>
) : null}
</div>
</m.div>
</m.div>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : null}
</AnimatePresence>
);
};

View file

@ -25,12 +25,3 @@ export const useModal = (
);
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();
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 expect(page.locator('text="Are you sure?"')).toBeVisible();
await page.click("text='Delete'");

View file

@ -1969,13 +1969,6 @@
dependencies:
"@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":
version "1.0.6"
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"
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"
resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz"
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==