rallly/apps/web/src/components/poll/desktop-poll.tsx
2024-10-31 08:52:38 +00:00

392 lines
13 KiB
TypeScript

import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import { Card, CardHeader, CardTitle } from "@rallly/ui/card";
import { Icon } from "@rallly/ui/icon";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import {
ArrowLeftIcon,
ArrowRightIcon,
ExpandIcon,
PlusIcon,
ShrinkIcon,
Users2Icon,
} from "lucide-react";
import { Trans } from "next-i18next";
import * as React from "react";
import { RemoveScroll } from "react-remove-scroll";
import { useMeasure, useScroll } from "react-use";
import {
EmptyState,
EmptyStateDescription,
EmptyStateIcon,
EmptyStateTitle,
} from "@/app/components/empty-state";
import { TimesShownIn } from "@/components/clock";
import { useVotingForm } from "@/components/poll/voting-form";
import { usePermissions } from "@/contexts/permissions";
import { usePoll } from "@/contexts/poll";
import { useTranslation } from "@/i18n/client";
import {
useParticipants,
useVisibleParticipants,
} from "../participants-provider";
import ParticipantRow from "./desktop-poll/participant-row";
import ParticipantRowForm from "./desktop-poll/participant-row-form";
import PollHeader from "./desktop-poll/poll-header";
function EscapeListener({ onEscape }: { onEscape: () => void }) {
React.useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onEscape();
}
};
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("keydown", handleEscape);
};
}, [onEscape]);
return null;
}
const useIsOverflowing = <E extends Element | null>(
ref: React.RefObject<E>,
) => {
const [isOverflowing, setIsOverflowing] = React.useState(false);
React.useEffect(() => {
const checkOverflow = () => {
if (ref.current) {
const element = ref.current;
const overflowX = element.scrollWidth > element.clientWidth;
const overflowY = element.scrollHeight > element.clientHeight;
setIsOverflowing(overflowX || overflowY);
}
};
if (ref.current) {
const resizeObserver = new ResizeObserver(checkOverflow);
resizeObserver.observe(ref.current);
// Initial check
checkOverflow();
return () => {
resizeObserver.disconnect();
};
}
}, [ref]);
return isOverflowing;
};
const DesktopPoll: React.FunctionComponent = () => {
const poll = usePoll();
const [measureRef, { height }] = useMeasure<HTMLDivElement>();
const goToNextPage = () => {
if (scrollRef.current) {
scrollRef.current.scrollLeft += 240;
}
};
const { canAddNewParticipant } = usePermissions();
const [expanded, setExpanded] = React.useState(false);
const expand = () => {
setExpanded(true);
};
const collapse = () => {
// enable scrolling on body
document.body.style.overflow = "";
setExpanded(false);
};
const goToPreviousPage = () => {
if (scrollRef.current) {
scrollRef.current.scrollLeft -= 240;
}
};
const { t } = useTranslation();
const votingForm = useVotingForm();
const mode = votingForm.watch("mode");
const { participants } = useParticipants();
const visibleParticipants = useVisibleParticipants();
const scrollRef = React.useRef<HTMLDivElement>(null);
const isOverflowing = useIsOverflowing(scrollRef);
const { x } = useScroll(scrollRef);
function TableControls() {
return (
<div className="flex items-center gap-4">
<div className="text-muted-foreground text-sm">
<Trans
i18nKey="optionCount"
values={{ count: poll.options.length }}
/>
</div>
<div className="flex gap-x-1">
{isOverflowing ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={x === 0}
onClick={goToPreviousPage}
>
<Icon>
<ArrowLeftIcon />
</Icon>
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey="scrollLeft" defaults="Scroll Left" />
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={Boolean(
scrollRef.current &&
x + scrollRef.current.offsetWidth >=
scrollRef.current.scrollWidth,
)}
onClick={() => {
goToNextPage();
}}
>
<Icon>
<ArrowRightIcon />
</Icon>
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey="scrollRight" defaults="Scroll Right" />
</TooltipContent>
</Tooltip>
</>
) : null}
{expanded ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
collapse();
}}
>
<Icon>
<ShrinkIcon />
</Icon>
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey="shrink" defaults="Shrink" />
</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
expand();
}}
>
<Icon>
<ExpandIcon />
</Icon>
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey="expand" defaults="Expand" />
</TooltipContent>
</Tooltip>
)}
{expanded ? <EscapeListener onEscape={collapse} /> : null}
</div>
</div>
);
}
return (
<Card>
<div ref={measureRef} style={{ height: expanded ? height : undefined }}>
<div
className={cn(
expanded
? "fixed left-0 top-0 z-50 flex h-full w-full items-center justify-center bg-gray-900/25 p-8"
: "",
)}
>
<div
className={cn(
"flex max-h-full max-w-7xl flex-col overflow-hidden rounded-md bg-white",
{
"shadow-huge": expanded,
},
)}
>
<CardHeader className="flex items-center justify-between gap-4">
<div className="flex items-center gap-x-2.5">
<CardTitle>
<Trans i18nKey="participants" />
</CardTitle>
<Badge>{participants.length}</Badge>
{canAddNewParticipant && mode !== "new" ? (
<Button
className="ml-2"
size="sm"
data-testid="add-participant-button"
onClick={() => {
votingForm.newParticipant();
}}
>
<Icon>
<PlusIcon />
</Icon>
</Button>
) : null}
</div>
<TableControls />
</CardHeader>
{poll.options[0]?.duration !== 0 && poll.timeZone ? (
<div className="border-b bg-gray-50 px-4 py-3">
<TimesShownIn />
</div>
) : null}
{participants.length > 0 || mode !== "view" ? (
<div className="relative flex min-h-0 flex-col">
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute bottom-0 left-[240px] top-0 z-30 w-4 border-l bg-gradient-to-r from-gray-800/5 via-transparent to-transparent transition-opacity",
x > 0 ? "opacity-100" : "opacity-0",
)}
/>
<RemoveScroll
enabled={expanded}
ref={scrollRef}
className={cn(
"scrollbar-thin hover:scrollbar-thumb-gray-400 scrollbar-thumb-gray-300 scrollbar-track-gray-100 relative z-10 flex-grow overflow-auto scroll-smooth",
)}
>
<table className="w-full table-auto border-separate border-spacing-0 bg-gray-50 ">
<thead>
<PollHeader />
</thead>
<tbody>
{mode === "new" ? (
<ParticipantRowForm isNew={true} />
) : null}
{visibleParticipants.length > 0
? visibleParticipants.map((participant, i) => {
return (
<ParticipantRow
key={i}
participant={{
id: participant.id,
name: participant.name,
userId: participant.userId ?? undefined,
email: participant.email ?? undefined,
votes: participant.votes,
}}
editMode={
votingForm.watch("mode") === "edit" &&
votingForm.watch("participantId") ===
participant.id
}
className={
i === visibleParticipants.length - 1
? "last-row"
: ""
}
onChangeEditMode={(isEditing) => {
if (isEditing) {
votingForm.setEditingParticipantId(
participant.id,
);
}
}}
/>
);
})
: null}
</tbody>
</table>
{mode === "new" ? (
<div className="sticky left-[240px] flex w-[calc(100%-240px)] items-center justify-between gap-4 border-l border-t bg-gray-50 p-3">
<Button
onClick={() => {
votingForm.cancel();
}}
>
<Trans i18nKey="cancel" />
</Button>
<p className="hidden min-w-0 truncate text-sm md:block">
<Trans
i18nKey="saveInstruction"
values={{
action: mode === "new" ? t("continue") : t("save"),
}}
components={{
b: <strong className="font-semibold" />,
}}
/>
</p>
<Button
type="submit"
variant="primary"
form="voting-form"
>
<Trans i18nKey="continue" />
</Button>
</div>
) : null}
</RemoveScroll>
</div>
) : (
<EmptyState className="p-16">
<EmptyStateIcon>
<Users2Icon />
</EmptyStateIcon>
<EmptyStateTitle>
<Trans i18nKey="noParticipants" defaults="No participants" />
</EmptyStateTitle>
<EmptyStateDescription>
<Trans
i18nKey="noParticipantsDescription"
components={{ b: <strong className="font-semibold" /> }}
defaults="Click <b>Share</b> to invite participants"
/>
</EmptyStateDescription>
</EmptyState>
)}
</div>
</div>
</div>
</Card>
);
};
export default DesktopPoll;