`<Button>`-component (#480)

-`<Button/>` component
- Uses `next/link` + `<a/>` if you supply a `href` otherwise `<button/>`
- Add UI sandbox
- Change the `event-types/index` to use a query param for deciding if modal is open or not
This commit is contained in:
Alex Johansson 2021-08-23 14:45:25 +02:00 committed by GitHub
parent ce64080160
commit a77a15056a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 251 additions and 83 deletions

View File

@ -1,32 +1,37 @@
import React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
export function Dialog({ children, ...props }) {
type DialogProps = React.ComponentProps<typeof DialogPrimitive["Root"]>;
export function Dialog(props: DialogProps) {
const { children, ...other } = props;
return (
<DialogPrimitive.Root {...props}>
<DialogPrimitive.Root {...other}>
<DialogPrimitive.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
{children}
</DialogPrimitive.Root>
);
}
type DialogContentProps = React.ComponentProps<typeof DialogPrimitive["Content"]>;
export const DialogContent = React.forwardRef(({ children, ...props }, forwardedRef) => (
<DialogPrimitive.Content
{...props}
className="fixed bg-white min-w-[360px] rounded top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-left overflow-hidden shadow-xl sm:align-middle sm:max-w-lg sm:w-full p-6"
ref={forwardedRef}>
{children}
</DialogPrimitive.Content>
));
export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
({ children, ...props }, forwardedRef) => (
<DialogPrimitive.Content
{...props}
className="min-w-[360px] fixed left-1/2 top-1/2 p-6 text-left bg-white rounded shadow-xl overflow-hidden -translate-x-1/2 -translate-y-1/2 sm:align-middle sm:w-full sm:max-w-lg"
ref={forwardedRef}>
{children}
</DialogPrimitive.Content>
)
);
export function DialogHeader({ title, subtitle }: { title: string; subtitle: string }) {
return (
<div className="mb-8">
<h3 className="text-lg leading-6 font-bold text-gray-900" id="modal-title">
<h3 className="text-gray-900 text-lg font-bold leading-6" id="modal-title">
{title}
</h3>
<div>
<p className="text-sm text-gray-400">{subtitle}</p>
<p className="text-gray-400 text-sm">{subtitle}</p>
</div>
</div>
);

View File

@ -1,26 +1,111 @@
export default function Button(props) {
return (
<button type="submit" className="btn btn-primary dark:btn-white">
{!props.loading && props.children}
{props.loading && (
<svg
className="animate-spin mx-4 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
import classNames from "@lib/classNames";
import Link, { LinkProps } from "next/link";
import React from "react";
type HTMLAnchorProps = React.AnchorHTMLAttributes<HTMLAnchorElement>;
type HTMLButtonProps = React.ButtonHTMLAttributes<HTMLButtonProps>;
type SVGComponent = React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
export type ButtonProps = {
color?: "primary" | "secondary" | "minimal";
size?: "base" | "sm" | "lg";
loading?: boolean;
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
StartIcon?: SVGComponent;
EndIcon?: SVGComponent;
} & ((Omit<HTMLAnchorProps, "href"> & { href: LinkProps["href"] }) | (HTMLButtonProps & { href?: never }));
export const Button = function Button(props: ButtonProps) {
const {
loading = false,
color = "primary",
size = "base",
StartIcon,
EndIcon,
// attributes propagated from `HTMLAnchorProps` or `HTMLButtonProps`
...passThroughProps
} = props;
// Buttons are **always** disabled if we're in a `loading` state
const disabled = props.disabled || loading;
// If pass an `href`-attr is passed it's `<a>`, otherwise it's a `<button />`
const isLink = typeof props.href !== "undefined";
const elementType = isLink ? "a" : "button";
const element = React.createElement(
elementType,
{
...passThroughProps,
disabled,
className: classNames(
// base styles independent what type of button it is
"inline-flex items-center relative",
// different styles depending on size
size === "sm" && "px-3 py-2 text-sm leading-4 font-medium rounded-sm",
size === "base" && "px-3 py-2 text-sm font-medium rounded-sm",
size === "lg" && "px-4 py-2 text-base font-medium rounded-sm",
// different styles depending on color
color === "primary" &&
(disabled
? "border border-transparent bg-gray-400 text-white"
: "border border-transparent text-white bg-neutral-900 hover:bg-neutral-800 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900"),
color === "secondary" &&
(disabled
? "border border-gray-200 text-gray-400 bg-white"
: "border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 hover:text-gray-900 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900"),
color === "minimal" &&
(disabled
? "text-gray-400 bg-transparent"
: "text-gray-700 bg-transparent hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-gray-100 focus:ring-neutral-500"),
// set not-allowed cursor if disabled
disabled && "cursor-not-allowed",
props.className
),
// if we click a disabled button, we prevent going through the click handler
onClick: disabled
? (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.preventDefault();
}
: props.onClick,
},
<>
{StartIcon && <StartIcon className="inline w-5 h-5 mr-2 -ml-1" />}
{props.children}
{loading && (
<div className="absolute transform -translate-x-1/2 -translate-y-1/2 top-1/2 left-1/2">
<svg
className={classNames(
"w-5 h-5 mx-4 animate-spin",
color === "primary" ? "text-white" : "text-black"
)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
)}
</button>
{EndIcon && <EndIcon className="inline w-5 h-5 ml-2 -mr-1" />}
</>
);
}
return props.href ? (
<Link passHref href={props.href}>
{element}
</Link>
) : (
element
);
};

View File

@ -1,3 +1,3 @@
export default function classNames(...classes) {
export default function classNames(...classes: unknown[]) {
return classes.filter(Boolean).join(" ");
}

View File

@ -40,7 +40,7 @@
"lodash.debounce": "^4.0.8",
"lodash.merge": "^4.6.2",
"lodash.throttle": "^4.1.1",
"next": "^10.2.0",
"next": "^10.2.3",
"next-auth": "^3.28.0",
"next-transpile-modules": "^8.0.0",
"nodemailer": "^6.6.3",

View File

@ -1,6 +1,7 @@
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@components/Dialog";
import { Tooltip } from "@components/Tooltip";
import { Dialog, DialogClose, DialogContent } from "@components/Dialog";
import Loader from "@components/Loader";
import { Tooltip } from "@components/Tooltip";
import { Button } from "@components/ui/Button";
import { Menu, Transition } from "@headlessui/react";
import {
ClockIcon,
@ -12,6 +13,7 @@ import {
UserIcon,
} from "@heroicons/react/solid";
import classNames from "@lib/classNames";
import showToast from "@lib/notification";
import { getSession, useSession } from "next-auth/client";
import Head from "next/head";
import Link from "next/link";
@ -19,7 +21,6 @@ import { useRouter } from "next/router";
import React, { Fragment, useRef } from "react";
import Shell from "../../components/Shell";
import prisma from "../../lib/prisma";
import showToast from "@lib/notification";
export default function Availability({ user, types }) {
const [session, loading] = useSession();
@ -30,6 +31,8 @@ export default function Availability({ user, types }) {
const descriptionRef = useRef<HTMLTextAreaElement>();
const lengthRef = useRef<HTMLInputElement>();
const dialogOpen = router.query.new === "1";
async function createEventTypeHandler(event) {
event.preventDefault();
@ -69,14 +72,23 @@ export default function Availability({ user, types }) {
}
const CreateNewEventDialog = () => (
<Dialog>
<DialogTrigger className="py-2 px-4 mt-6 border border-transparent rounded-sm shadow-sm text-sm font-medium text-white bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
<PlusIcon className="w-5 h-5 mr-1 inline" />
<Dialog
open={dialogOpen}
onOpenChange={(isOpen) => {
const newQuery = {
...router.query,
};
delete newQuery["new"];
if (!isOpen) {
router.push({ pathname: router.pathname, query: newQuery });
}
}}>
<Button className="mt-2" StartIcon={PlusIcon} href={{ query: { ...router.query, new: "1" } }}>
New event type
</DialogTrigger>
</Button>
<DialogContent>
<div className="mb-8">
<h3 className="text-lg leading-6 font-bold text-gray-900" id="modal-title">
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
Add a new event type
</h3>
<div>
@ -97,7 +109,7 @@ export default function Availability({ user, types }) {
name="title"
id="title"
required
className="shadow-sm focus:ring-neutral-900 focus:border-neutral-900 block w-full sm:text-sm border-gray-300 rounded-sm"
className="block w-full border-gray-300 rounded-sm shadow-sm focus:border-neutral-900 focus:ring-neutral-900 sm:text-sm"
placeholder="Quick Chat"
/>
</div>
@ -108,7 +120,7 @@ export default function Availability({ user, types }) {
</label>
<div className="mt-1">
<div className="flex rounded-sm shadow-sm">
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 sm:text-sm">
<span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 bg-gray-50 rounded-l-md sm:text-sm">
{location.hostname}/{user.username}/
</span>
<input
@ -117,7 +129,7 @@ export default function Availability({ user, types }) {
name="slug"
id="slug"
required
className="flex-1 block w-full focus:ring-neutral-900 focus:border-neutral-900 min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"
className="flex-1 block w-full min-w-0 border-gray-300 rounded-none focus:border-neutral-900 rounded-r-md focus:ring-neutral-900 sm:text-sm"
/>
</div>
</div>
@ -131,7 +143,7 @@ export default function Availability({ user, types }) {
ref={descriptionRef}
name="description"
id="description"
className="shadow-sm focus:ring-neutral-900 focus:border-neutral-900 block w-full sm:text-sm border-gray-300 rounded-sm"
className="block w-full border-gray-300 rounded-sm shadow-sm focus:border-neutral-900 focus:ring-neutral-900 sm:text-sm"
placeholder="A quick video meeting."></textarea>
</div>
</div>
@ -139,17 +151,17 @@ export default function Availability({ user, types }) {
<label htmlFor="length" className="block text-sm font-medium text-gray-700">
Length
</label>
<div className="mt-1 relative rounded-sm shadow-sm">
<div className="relative mt-1 rounded-sm shadow-sm">
<input
ref={lengthRef}
type="number"
name="length"
id="length"
required
className="focus:ring-neutral-900 focus:border-neutral-900 block w-full pr-20 sm:text-sm border-gray-300 rounded-sm"
className="block w-full pr-20 border-gray-300 rounded-sm focus:border-neutral-900 focus:ring-neutral-900 sm:text-sm"
placeholder="15"
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 text-sm">
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-gray-400">
minutes
</div>
</div>
@ -159,7 +171,7 @@ export default function Availability({ user, types }) {
<button type="submit" className="btn btn-primary">
Continue
</button>
<DialogClose as="button" className="btn btn-white mx-2">
<DialogClose as="button" className="mx-2 btn btn-white">
Cancel
</DialogClose>
</div>
@ -178,44 +190,44 @@ export default function Availability({ user, types }) {
heading="Event Types"
subtitle="Create events to share for people to book on your calendar."
CTA={types.length !== 0 && <CreateNewEventDialog />}>
<div className="bg-white border border-gray-200 rounded-sm overflow-hidden -mx-4 sm:mx-0">
<div className="-mx-4 overflow-hidden bg-white border border-gray-200 rounded-sm sm:mx-0">
<ul className="divide-y divide-neutral-200">
{types.map((type) => (
<li key={type.id}>
<div className="hover:bg-neutral-50">
<div className="px-4 py-4 flex items-center sm:px-6">
<div className="flex items-center px-4 py-4 sm:px-6">
<Link href={"/event-types/" + type.id}>
<a className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
<span className="truncate ">
<a className="flex-1 min-w-0 sm:flex sm:items-center sm:justify-between">
<span className="truncate">
<div className="flex text-sm">
<p className="font-medium text-neutral-900 truncate">{type.title}</p>
<p className="font-medium truncate text-neutral-900">{type.title}</p>
{type.hidden && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
<span className="inline-flex items-center ml-2 px-1.5 py-0.5 text-yellow-800 text-xs font-medium bg-yellow-100 rounded-sm">
Hidden
</span>
)}
</div>
<div className="mt-2 flex space-x-4">
<div className="flex mt-2 space-x-4">
<div className="flex items-center text-sm text-neutral-500">
<ClockIcon
className="flex-shrink-0 mr-1.5 h-4 w-4 text-neutral-400"
className="flex-shrink-0 mr-1.5 w-4 h-4 text-neutral-400"
aria-hidden="true"
/>
<p>{type.length}m</p>
</div>
<div className="flex items-center text-sm text-neutral-500">
<UserIcon
className="flex-shrink-0 mr-1.5 h-4 w-4 text-neutral-400"
className="flex-shrink-0 mr-1.5 w-4 h-4 text-neutral-400"
aria-hidden="true"
/>
<p>1-on-1</p>
</div>
<div className="flex items-center text-sm text-neutral-500">
<InformationCircleIcon
className="flex-shrink-0 mr-1.5 h-4 w-4 text-neutral-400"
className="flex-shrink-0 mr-1.5 w-4 h-4 text-neutral-400"
aria-hidden="true"
/>
<div className="max-w-32 sm:max-w-full truncate">
<div className="truncate max-w-32 sm:max-w-full">
{type.description.substring(0, 100)}
</div>
</div>
@ -224,15 +236,15 @@ export default function Availability({ user, types }) {
</a>
</Link>
<div className="hidden sm:flex mt-4 flex-shrink-0 sm:mt-0 sm:ml-5">
<div className="flex overflow-hidden space-x-5">
<div className="flex-shrink-0 hidden mt-4 sm:flex sm:ml-5 sm:mt-0">
<div className="flex space-x-5 overflow-hidden">
<Tooltip content="Preview">
<a
href={"/" + session.user.username + "/" + type.slug}
target="_blank"
rel="noreferrer"
className="group cursor-pointer text-neutral-400 p-2 border border-transparent hover:border-gray-200">
<ExternalLinkIcon className="group-hover:text-black w-5 h-5" />
className="p-2 border border-transparent cursor-pointer group text-neutral-400 hover:border-gray-200">
<ExternalLinkIcon className="w-5 h-5 group-hover:text-black" />
</a>
</Tooltip>
@ -244,20 +256,20 @@ export default function Availability({ user, types }) {
window.location.hostname + "/" + session.user.username + "/" + type.slug
);
}}
className="group text-neutral-400 p-2 border border-transparent hover:border-gray-200">
<LinkIcon className="group-hover:text-black w-5 h-5" />
className="p-2 border border-transparent group text-neutral-400 hover:border-gray-200">
<LinkIcon className="w-5 h-5 group-hover:text-black" />
</button>
</Tooltip>
</div>
</div>
<div className="flex sm:hidden ml-5 flex-shrink-0">
<div className="flex flex-shrink-0 ml-5 sm:hidden">
<Menu as="div" className="inline-block text-left">
{({ open }) => (
<>
<div>
<Menu.Button className="text-neutral-400 mt-1 p-2 border border-transparent hover:border-gray-200">
<Menu.Button className="p-2 mt-1 border border-transparent text-neutral-400 hover:border-gray-200">
<span className="sr-only">Open options</span>
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
<DotsHorizontalIcon className="w-5 h-5" aria-hidden="true" />
</Menu.Button>
</div>
@ -272,7 +284,7 @@ export default function Availability({ user, types }) {
leaveTo="transform opacity-0 scale-95">
<Menu.Items
static
className="origin-top-right absolute right-0 mt-2 w-56 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none divide-y divide-neutral-100">
className="absolute right-0 w-56 mt-2 origin-top-right bg-white divide-y rounded-sm shadow-lg focus:outline-none divide-neutral-100 ring-1 ring-black ring-opacity-5">
<div className="py-1">
<Menu.Item>
{({ active }) => (
@ -285,7 +297,7 @@ export default function Availability({ user, types }) {
"group flex items-center px-4 py-2 text-sm font-medium"
)}>
<ExternalLinkIcon
className="mr-3 h-4 w-4 text-neutral-400 group-hover:text-neutral-500"
className="w-4 h-4 mr-3 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
Preview
@ -307,10 +319,10 @@ export default function Availability({ user, types }) {
}}
className={classNames(
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
"group flex items-center px-4 py-2 text-sm w-full font-medium"
"group flex items-center px-4 py-2 w-full text-sm font-medium"
)}>
<LinkIcon
className="mr-3 h-4 w-4 text-neutral-400 group-hover:text-neutral-500"
className="w-4 h-4 mr-3 text-neutral-400 group-hover:text-neutral-500"
aria-hidden="true"
/>
Copy link to event
@ -326,7 +338,7 @@ export default function Availability({ user, types }) {
{/* "group flex items-center px-4 py-2 text-sm font-medium"*/}
{/* )}>*/}
{/* <DuplicateIcon*/}
{/* className="mr-3 h-4 w-4 text-neutral-400 group-hover:text-neutral-500"*/}
{/* className="w-4 h-4 mr-3 text-neutral-400 group-hover:text-neutral-500"*/}
{/* aria-hidden="true"*/}
{/* />*/}
{/* Duplicate*/}
@ -344,7 +356,7 @@ export default function Availability({ user, types }) {
{/* "group flex items-center px-4 py-2 text-sm font-medium"*/}
{/* )}>*/}
{/* <TrashIcon*/}
{/* className="mr-3 h-5 w-5 text-red-400 group-hover:text-red-700"*/}
{/* className="w-5 h-5 mr-3 text-red-400 group-hover:text-red-700"*/}
{/* aria-hidden="true"*/}
{/* />*/}
{/* Delete*/}
@ -367,7 +379,7 @@ export default function Availability({ user, types }) {
{types.length === 0 && (
<div className="md:py-20">
<svg
className="w-1/2 md:w-32 mx-auto block mb-4"
className="block w-1/2 mx-auto mb-4 md:w-32"
viewBox="0 0 132 132"
fill="none"
xmlns="http://www.w3.org/2000/svg">
@ -604,7 +616,7 @@ export default function Availability({ user, types }) {
</clipPath>
</defs>
</svg>
<div className="text-center block md:max-w-screen-sm mx-auto">
<div className="block mx-auto text-center md:max-w-screen-sm">
<h3 className="mt-2 text-xl font-bold text-neutral-900">Create your first event type</h3>
<p className="mt-1 text-md text-neutral-600">
Event types enable you to share links that show available times on your calendar and allow

66
pages/sandbox/Button.tsx Normal file
View File

@ -0,0 +1,66 @@
import { Button, ButtonProps } from "@components/ui/Button";
import { PlusIcon } from "@heroicons/react/solid";
import Head from "next/head";
import React from "react";
export default function ButtonPage() {
const list: ButtonProps[] = [
// primary
{ color: "primary" },
{ color: "primary", disabled: true },
{ color: "primary", disabled: true, loading: true },
// secondary
{ color: "secondary" },
{ color: "secondary", disabled: true },
{ color: "secondary", disabled: true, loading: true },
// minimal
{ color: "minimal" },
{ color: "minimal", disabled: true },
{ color: "minimal", disabled: true, loading: true },
// sizes
{ color: "primary", size: "sm" },
{ color: "primary", size: "base" },
{ color: "primary", size: "lg" },
// href
{ href: "/staging" },
{ href: "/staging", disabled: true },
{ StartIcon: PlusIcon },
{ EndIcon: PlusIcon },
];
return (
<>
<Head>
<meta name="googlebot" content="noindex" />
</Head>
<div className="p-4 bg-gray-200">
<h1>Button component</h1>
<div className="flex flex-col">
{list.map((props, index) => (
<div key={index} className="p-2 m-2 bg-white">
<h3>
<code>
{JSON.stringify(
props,
(key, value) => {
if (key.includes("Icon")) {
return "..";
}
return value;
},
2
)}
</code>
</h3>
<Button {...props}>Button text</Button>
</div>
))}
</div>
</div>
</>
);
}

View File

@ -4804,7 +4804,7 @@ next-transpile-modules@^8.0.0:
enhanced-resolve "^5.7.0"
escalade "^3.1.1"
next@^10.2.0:
next@^10.2.3:
version "10.2.3"
resolved "https://registry.npmjs.org/next/-/next-10.2.3.tgz#5aa058a63626338cea91c198fda8f2715c058394"
integrity sha512-dkM1mIfnORtGyzw/Yme8RdqNxlCMZyi4Lqj56F01/yHbe1ZtOaJ0cyqqRB4RGiPhjGGh0319f8ddjDyO1605Ow==