From 1a5465dc340aa007fa0b423420d2bf54b82278ad Mon Sep 17 00:00:00 2001
From: DaneEveritt <dane@daneeveritt.com>
Date: Sun, 5 Jun 2022 14:56:42 -0400
Subject: [PATCH] Update react, add some V2 components for V1 usage

---
 package.json                                  |  12 +-
 .../components/elements/button/Button.tsx     |  36 ++++++
 .../components/elements/button/index.ts       |   2 +
 .../elements/button/style.module.css          |  30 +++++
 .../components/elements/dialog/Dialog.tsx     | 112 ++++++++++++++++++
 .../components/elements/dialog/index.ts       |   2 +
 .../elements/dialog/style.module.css          |  20 ++++
 .../components/elements/dropdown/Dropdown.tsx |  62 ++++++++++
 .../elements/dropdown/DropdownButton.tsx      |  24 ++++
 .../elements/dropdown/DropdownItem.tsx        |  43 +++++++
 .../components/elements/dropdown/index.ts     |   2 +
 .../elements/dropdown/style.module.css        |  58 +++++++++
 .../components/elements/inputs/Checkbox.tsx   |  11 ++
 .../components/elements/inputs/InputField.tsx |  11 ++
 .../components/elements/inputs/index.ts       |   3 +
 .../elements/inputs/styles.module.css         |  21 ++++
 .../elements/transitions/FadeTransition.tsx   |  31 +++++
 .../components/elements/transitions/index.ts  |   8 ++
 resources/scripts/globals.d.ts                |   1 +
 resources/scripts/macros.d.ts                 |  43 +++----
 yarn.lock                                     |  75 +++++++++---
 21 files changed, 564 insertions(+), 43 deletions(-)
 create mode 100644 resources/scripts/components/elements/button/Button.tsx
 create mode 100644 resources/scripts/components/elements/button/index.ts
 create mode 100644 resources/scripts/components/elements/button/style.module.css
 create mode 100644 resources/scripts/components/elements/dialog/Dialog.tsx
 create mode 100644 resources/scripts/components/elements/dialog/index.ts
 create mode 100644 resources/scripts/components/elements/dialog/style.module.css
 create mode 100644 resources/scripts/components/elements/dropdown/Dropdown.tsx
 create mode 100644 resources/scripts/components/elements/dropdown/DropdownButton.tsx
 create mode 100644 resources/scripts/components/elements/dropdown/DropdownItem.tsx
 create mode 100644 resources/scripts/components/elements/dropdown/index.ts
 create mode 100644 resources/scripts/components/elements/dropdown/style.module.css
 create mode 100644 resources/scripts/components/elements/inputs/Checkbox.tsx
 create mode 100644 resources/scripts/components/elements/inputs/InputField.tsx
 create mode 100644 resources/scripts/components/elements/inputs/index.ts
 create mode 100644 resources/scripts/components/elements/inputs/styles.module.css
 create mode 100644 resources/scripts/components/elements/transitions/FadeTransition.tsx
 create mode 100644 resources/scripts/components/elements/transitions/index.ts

diff --git a/package.json b/package.json
index 0d80e4a7..f4d8bf6d 100644
--- a/package.json
+++ b/package.json
@@ -4,9 +4,13 @@
         "@fortawesome/fontawesome-svg-core": "^1.2.32",
         "@fortawesome/free-solid-svg-icons": "^5.15.1",
         "@fortawesome/react-fontawesome": "^0.1.11",
+        "@headlessui/react": "^1.6.4",
+        "@heroicons/react": "^1.0.6",
+        "@hot-loader/react-dom": "^16.14.0",
         "@tailwindcss/forms": "^0.5.2",
         "axios": "^0.21.1",
         "chart.js": "^2.8.0",
+        "classnames": "^2.3.1",
         "codemirror": "^5.57.0",
         "date-fns": "^2.16.1",
         "debounce": "^1.2.0",
@@ -20,7 +24,7 @@
         "i18next-xhr-backend": "^3.2.2",
         "qrcode.react": "^1.0.1",
         "query-string": "^6.7.0",
-        "react": "^16.13.1",
+        "react": "^16.14.0",
         "react-copy-to-clipboard": "^5.0.2",
         "react-dom": "npm:@hot-loader/react-dom",
         "react-fast-compare": "^3.2.0",
@@ -64,9 +68,9 @@
         "@types/node": "^14.11.10",
         "@types/qrcode.react": "^1.0.1",
         "@types/query-string": "^6.3.0",
-        "@types/react": "^16.9.41",
+        "@types/react": "^16.14.0",
         "@types/react-copy-to-clipboard": "^4.3.0",
-        "@types/react-dom": "^16.9.8",
+        "@types/react-dom": "^16.9.16",
         "@types/react-helmet": "^6.0.0",
         "@types/react-redux": "^7.1.1",
         "@types/react-router": "^5.1.3",
@@ -103,7 +107,7 @@
         "terser-webpack-plugin": "^4.2.3",
         "ts-essentials": "^9.1.2",
         "twin.macro": "^2.8.2",
-        "typescript": "^4.2.4",
+        "typescript": "^4.7.3",
         "webpack": "^4.43.0",
         "webpack-assets-manifest": "^3.1.1",
         "webpack-bundle-analyzer": "^3.8.0",
diff --git a/resources/scripts/components/elements/button/Button.tsx b/resources/scripts/components/elements/button/Button.tsx
new file mode 100644
index 00000000..55c8ca81
--- /dev/null
+++ b/resources/scripts/components/elements/button/Button.tsx
@@ -0,0 +1,36 @@
+import React, { forwardRef } from 'react';
+import classNames from 'classnames';
+import styles from './style.module.css';
+
+export type ButtonProps = JSX.IntrinsicElements['button'] & {
+    square?: boolean;
+    small?: boolean;
+}
+
+const Button = forwardRef<HTMLButtonElement, ButtonProps>(
+    ({ children, square, small, className, ...rest }, ref) => {
+        return (
+            <button
+                ref={ref}
+                className={classNames(styles.button, { [styles.square]: square, [styles.small]: small }, className)}
+                {...rest}
+            >
+                {children}
+            </button>
+        );
+    },
+);
+
+const TextButton = forwardRef<HTMLButtonElement, ButtonProps>(({ className, ...props }, ref) => (
+    // @ts-expect-error
+    <Button ref={ref} className={classNames(styles.text, className)} {...props} />
+));
+
+const DangerButton = forwardRef<HTMLButtonElement, ButtonProps>(({ className, ...props }, ref) => (
+    // @ts-expect-error
+    <Button ref={ref} className={classNames(styles.danger, className)} {...props} />
+));
+
+const _Button = Object.assign(Button, { Text: TextButton, Danger: DangerButton });
+
+export default _Button;
diff --git a/resources/scripts/components/elements/button/index.ts b/resources/scripts/components/elements/button/index.ts
new file mode 100644
index 00000000..f40f42db
--- /dev/null
+++ b/resources/scripts/components/elements/button/index.ts
@@ -0,0 +1,2 @@
+export { default as Button } from './Button';
+export { default as styles } from './style.module.css';
diff --git a/resources/scripts/components/elements/button/style.module.css b/resources/scripts/components/elements/button/style.module.css
new file mode 100644
index 00000000..6956422a
--- /dev/null
+++ b/resources/scripts/components/elements/button/style.module.css
@@ -0,0 +1,30 @@
+.button {
+    @apply px-4 py-2 inline-flex items-center justify-center;
+    @apply bg-blue-600 rounded text-base font-semibold text-blue-50 transition-all duration-100;
+    @apply hover:bg-blue-500 active:bg-blue-500;
+
+    &.square {
+        @apply p-2;
+    }
+
+    &:focus {
+        @apply ring-[3px] ring-blue-500 ring-offset-2 ring-offset-neutral-700;
+    }
+
+    /* Sizing Controls */
+    &.small {
+        @apply px-3 py-1 font-normal focus:ring-2;
+
+        &.square {
+            @apply p-1;
+        }
+    }
+}
+
+.text {
+    @apply bg-transparent focus:ring-neutral-300 focus:ring-opacity-50 hover:bg-neutral-500 active:bg-neutral-500;
+}
+
+.danger {
+    @apply bg-red-600 hover:bg-red-500 active:bg-red-500 focus:ring-red-500 text-red-50;
+}
diff --git a/resources/scripts/components/elements/dialog/Dialog.tsx b/resources/scripts/components/elements/dialog/Dialog.tsx
new file mode 100644
index 00000000..89180630
--- /dev/null
+++ b/resources/scripts/components/elements/dialog/Dialog.tsx
@@ -0,0 +1,112 @@
+import React, { Fragment } from 'react';
+import { Dialog as HeadlessDialog, Transition } from '@headlessui/react';
+import { Button } from '@/components/elements/button/index';
+import styles from './style.module.css';
+import { XIcon } from '@heroicons/react/solid';
+import { CheckIcon, ExclamationIcon, InformationCircleIcon, ShieldExclamationIcon } from '@heroicons/react/outline';
+import classNames from 'classnames';
+
+interface Props {
+    visible: boolean;
+    onDismissed: () => void;
+    title?: string;
+    children?: React.ReactNode;
+}
+
+interface DialogIconProps {
+    type: 'danger' | 'info' | 'success' | 'warning';
+    className?: string;
+}
+
+const DialogIcon = ({ type, className }: DialogIconProps) => {
+    const [ Component, styles ] = (function (): [(props: React.ComponentProps<'svg'>) => JSX.Element, string] {
+        switch (type) {
+            case 'danger':
+                return [ ShieldExclamationIcon, 'bg-red-500 text-red-50' ];
+            case 'warning':
+                return [ ExclamationIcon, 'bg-yellow-600 text-yellow-50' ];
+            case 'success':
+                return [ CheckIcon, 'bg-green-600 text-green-50' ];
+            case 'info':
+                return [ InformationCircleIcon, 'bg-primary-500 text-primary-50' ];
+        }
+    })();
+
+    return (
+        <div className={classNames('flex items-center justify-center w-10 h-10 rounded-full', styles, className)}>
+            <Component className={'w-6 h-6'} />
+        </div>
+    );
+};
+
+const DialogButtons = ({ children }: { children: React.ReactNode }) => (
+    <>{children}</>
+);
+
+const Dialog = ({ visible, title, onDismissed, children }: Props) => {
+    const items = React.Children.toArray(children || []);
+    const [ buttons, icon, content ] = [
+        // @ts-expect-error
+        items.find(child => child.type === DialogButtons),
+        // @ts-expect-error
+        items.find(child => child.type === DialogIcon),
+        // @ts-expect-error
+        items.filter(child => ![ DialogIcon, DialogButtons ].includes(child.type)),
+    ];
+
+    return (
+        <Transition show={visible} as={Fragment}>
+            <HeadlessDialog onClose={() => onDismissed()} className={styles.wrapper}>
+                <div className={'flex items-center justify-center min-h-screen'}>
+                    <Transition.Child
+                        as={Fragment}
+                        enter={'ease-out duration-200'}
+                        enterFrom={'opacity-0'}
+                        enterTo={'opacity-100'}
+                        leave={'ease-in duration-100'}
+                        leaveFrom={'opacity-100'}
+                        leaveTo={'opacity-0'}
+                    >
+                        <HeadlessDialog.Overlay className={styles.overlay}/>
+                    </Transition.Child>
+                    <Transition.Child
+                        as={Fragment}
+                        enter={'ease-out duration-200'}
+                        enterFrom={'opacity-0 scale-95'}
+                        enterTo={'opacity-100 scale-100'}
+                        leave={'ease-in duration-100'}
+                        leaveFrom={'opacity-100 scale-100'}
+                        leaveTo={'opacity-0 scale-95'}
+                    >
+                        <div className={styles.container}>
+                            <div className={'flex p-6'}>
+                                {icon && <div className={'mr-4'}>{icon}</div>}
+                                <div className={'flex-1'}>
+                                    {title &&
+                                    <HeadlessDialog.Title className={styles.title}>
+                                        {title}
+                                    </HeadlessDialog.Title>
+                                    }
+                                    <HeadlessDialog.Description className={'pr-4'}>
+                                        {content}
+                                    </HeadlessDialog.Description>
+                                </div>
+                            </div>
+                            {buttons && <div className={styles.button_bar}>{buttons}</div>}
+                            {/* Keep this below the other buttons so that it isn't the default focus if they're present. */}
+                            <div className={'absolute right-0 top-0 m-4'}>
+                                <Button.Text square small onClick={() => onDismissed()} className={'hover:rotate-90'}>
+                                    <XIcon className={'w-5 h-5'}/>
+                                </Button.Text>
+                            </div>
+                        </div>
+                    </Transition.Child>
+                </div>
+            </HeadlessDialog>
+        </Transition>
+    );
+};
+
+const _Dialog = Object.assign(Dialog, { Buttons: DialogButtons, Icon: DialogIcon });
+
+export default _Dialog;
diff --git a/resources/scripts/components/elements/dialog/index.ts b/resources/scripts/components/elements/dialog/index.ts
new file mode 100644
index 00000000..96085ec9
--- /dev/null
+++ b/resources/scripts/components/elements/dialog/index.ts
@@ -0,0 +1,2 @@
+export { default as Dialog } from './Dialog';
+export { default as styles } from './style.module.css';
diff --git a/resources/scripts/components/elements/dialog/style.module.css b/resources/scripts/components/elements/dialog/style.module.css
new file mode 100644
index 00000000..94959ee7
--- /dev/null
+++ b/resources/scripts/components/elements/dialog/style.module.css
@@ -0,0 +1,20 @@
+.wrapper {
+  @apply fixed z-10 inset-0 overflow-y-auto;
+}
+
+.overlay {
+  @apply fixed inset-0 bg-gray-900 opacity-50;
+}
+
+.container {
+  @apply relative bg-gray-600 rounded max-w-xl w-full mx-auto shadow-lg;
+  @apply ring-4 ring-gray-800 ring-opacity-80;
+
+  & .title {
+    @apply font-header text-xl font-medium mb-2 text-white pr-4;
+  }
+
+  & > .button_bar {
+    @apply px-6 py-3 bg-gray-700 flex items-center justify-end space-x-3 rounded-b;
+  }
+}
diff --git a/resources/scripts/components/elements/dropdown/Dropdown.tsx b/resources/scripts/components/elements/dropdown/Dropdown.tsx
new file mode 100644
index 00000000..e9378046
--- /dev/null
+++ b/resources/scripts/components/elements/dropdown/Dropdown.tsx
@@ -0,0 +1,62 @@
+import React, { ElementType, forwardRef, useMemo } from 'react';
+import { Menu, Transition } from '@headlessui/react';
+import styles from './style.module.css';
+import classNames from 'classnames';
+import DropdownItem from '@/components/elements/dropdown/DropdownItem';
+import DropdownButton from '@/components/elements/dropdown/DropdownButton';
+
+interface Props {
+    as?: ElementType;
+    children: React.ReactNode;
+}
+
+const DropdownGap = ({ invisible }: { invisible?: boolean }) => (
+    <div className={classNames('border m-2', { 'border-neutral-700': !invisible, 'border-transparent': invisible })}/>
+);
+
+type TypedChild = (React.ReactChild | React.ReactFragment | React.ReactPortal) & {
+    type?: JSX.Element;
+}
+
+const Dropdown = forwardRef<typeof Menu, Props>(({ as, children }, ref) => {
+    const [ Button, items ] = useMemo(() => {
+        const list = React.Children.toArray(children) as unknown as TypedChild[];
+
+        return [
+            list.filter(child => child.type === DropdownButton),
+            list.filter(child => child.type !== DropdownButton),
+        ];
+    }, [ children ]);
+
+    if (!Button) {
+        throw new Error('Cannot mount <Dropdown /> component without a child <Dropdown.Button />.');
+    }
+
+    return (
+        <Menu as={as || 'div'} className={styles.menu} ref={ref}>
+            {Button}
+            <Transition
+                enter={'transition duration-100 ease-out'}
+                enterFrom={'transition scale-95 opacity-0'}
+                enterTo={'transform scale-100 opacity-100'}
+                leave={'transition duration-75 ease-out'}
+                leaveFrom={'transform scale-100 opacity-100'}
+                leaveTo={'transform scale-95 opacity-0'}
+            >
+                <Menu.Items className={classNames(styles.items_container, 'w-56')}>
+                    <div className={'px-1 py-1'}>
+                        {items}
+                    </div>
+                </Menu.Items>
+            </Transition>
+        </Menu>
+    );
+});
+
+const _Dropdown = Object.assign(Dropdown, {
+    Button: DropdownButton,
+    Item: DropdownItem,
+    Gap: DropdownGap,
+});
+
+export { _Dropdown as default };
diff --git a/resources/scripts/components/elements/dropdown/DropdownButton.tsx b/resources/scripts/components/elements/dropdown/DropdownButton.tsx
new file mode 100644
index 00000000..32863d26
--- /dev/null
+++ b/resources/scripts/components/elements/dropdown/DropdownButton.tsx
@@ -0,0 +1,24 @@
+import classNames from 'classnames';
+import styles from '@/components/elements/dropdown/style.module.css';
+import { ChevronDownIcon } from '@heroicons/react/solid';
+import { Menu } from '@headlessui/react';
+import React from 'react';
+
+interface Props {
+    className?: string;
+    animate?: boolean;
+    children: React.ReactNode;
+}
+
+export default ({ className, animate = true, children }: Props) => (
+    <Menu.Button className={classNames(styles.button, className || 'px-4')}>
+        {typeof children === 'string' ?
+            <>
+                <span className={'mr-2'}>{children}</span>
+                <ChevronDownIcon aria-hidden={'true'} data-animated={animate.toString()}/>
+            </>
+            :
+            children
+        }
+    </Menu.Button>
+);
diff --git a/resources/scripts/components/elements/dropdown/DropdownItem.tsx b/resources/scripts/components/elements/dropdown/DropdownItem.tsx
new file mode 100644
index 00000000..86e5acb1
--- /dev/null
+++ b/resources/scripts/components/elements/dropdown/DropdownItem.tsx
@@ -0,0 +1,43 @@
+import React, { forwardRef } from 'react';
+import { Menu } from '@headlessui/react';
+import styles from './style.module.css';
+import classNames from 'classnames';
+
+interface Props {
+    children: React.ReactNode | ((opts: { active: boolean; disabled: boolean }) => JSX.Element);
+    danger?: boolean;
+    disabled?: boolean;
+    className?: string;
+    icon?: JSX.Element;
+    onClick?: (e: React.MouseEvent) => void;
+}
+
+const DropdownItem = forwardRef<HTMLAnchorElement, Props>(({
+    disabled,
+    danger,
+    className,
+    onClick,
+    children,
+    icon: IconComponent,
+}, ref) => {
+    return (
+        <Menu.Item disabled={disabled}>
+            {({ disabled, active }) => (
+                <a
+                    ref={ref}
+                    href={'#'}
+                    className={classNames(styles.menu_item, {
+                        [styles.danger]: danger,
+                        [styles.disabled]: disabled,
+                    }, className)}
+                    onClick={onClick}
+                >
+                    {IconComponent}
+                    {typeof children === 'function' ? children({ disabled, active }) : children}
+                </a>
+            )}
+        </Menu.Item>
+    );
+});
+
+export default DropdownItem;
diff --git a/resources/scripts/components/elements/dropdown/index.ts b/resources/scripts/components/elements/dropdown/index.ts
new file mode 100644
index 00000000..95a1a07d
--- /dev/null
+++ b/resources/scripts/components/elements/dropdown/index.ts
@@ -0,0 +1,2 @@
+export { default as Dropdown } from './Dropdown';
+export * as styles from './style.module.css';
diff --git a/resources/scripts/components/elements/dropdown/style.module.css b/resources/scripts/components/elements/dropdown/style.module.css
new file mode 100644
index 00000000..0b98e342
--- /dev/null
+++ b/resources/scripts/components/elements/dropdown/style.module.css
@@ -0,0 +1,58 @@
+.menu {
+  @apply relative inline-block text-left;
+
+  & .button {
+    @apply inline-flex justify-center items-center w-full py-2 text-neutral-100 rounded-md;
+    @apply transition-all duration-100;
+
+    &:hover, &[aria-expanded="true"] {
+      @apply bg-neutral-600 text-white;
+    }
+
+    &:focus, &:focus-within, &:active {
+      @apply ring-2 ring-opacity-50 ring-neutral-300 text-white;
+    }
+
+    & svg {
+      @apply w-5 h-5 transition-transform duration-75;
+    }
+
+    &[aria-expanded="true"] svg[data-animated="true"] {
+      @apply rotate-180;
+    }
+  }
+
+  & .items_container {
+    @apply absolute right-0 mt-2 origin-top-right bg-neutral-900 rounded z-10;
+  }
+}
+
+.menu_item {
+  @apply flex items-center rounded w-full px-2 py-2;
+
+  & svg {
+    @apply w-4 h-4 mr-4 text-neutral-300;
+  }
+
+  &:hover, &:focus {
+    @apply bg-blue-500 text-blue-50;
+
+    & svg {
+      @apply text-blue-50;
+    }
+  }
+
+  &.danger {
+    &:hover, &:focus {
+      @apply bg-red-500 text-red-50;
+
+      & svg {
+        @apply text-red-50;
+      }
+    }
+  }
+
+  &.disabled {
+    @apply cursor-not-allowed hover:bg-neutral-800 opacity-30 focus:bg-transparent focus:hover:bg-neutral-800;
+  }
+}
diff --git a/resources/scripts/components/elements/inputs/Checkbox.tsx b/resources/scripts/components/elements/inputs/Checkbox.tsx
new file mode 100644
index 00000000..da84d962
--- /dev/null
+++ b/resources/scripts/components/elements/inputs/Checkbox.tsx
@@ -0,0 +1,11 @@
+import React, { forwardRef } from 'react';
+import classNames from 'classnames';
+import styles from './styles.module.css';
+
+export default forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(({ className, ...props }, ref) => (
+    <input
+        ref={ref}
+        className={classNames('form-input', styles.text_input, className)}
+        {...props}
+    />
+));
diff --git a/resources/scripts/components/elements/inputs/InputField.tsx b/resources/scripts/components/elements/inputs/InputField.tsx
new file mode 100644
index 00000000..da84d962
--- /dev/null
+++ b/resources/scripts/components/elements/inputs/InputField.tsx
@@ -0,0 +1,11 @@
+import React, { forwardRef } from 'react';
+import classNames from 'classnames';
+import styles from './styles.module.css';
+
+export default forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(({ className, ...props }, ref) => (
+    <input
+        ref={ref}
+        className={classNames('form-input', styles.text_input, className)}
+        {...props}
+    />
+));
diff --git a/resources/scripts/components/elements/inputs/index.ts b/resources/scripts/components/elements/inputs/index.ts
new file mode 100644
index 00000000..e2e4443b
--- /dev/null
+++ b/resources/scripts/components/elements/inputs/index.ts
@@ -0,0 +1,3 @@
+export { default as Checkbox } from './Checkbox';
+export { default as InputField } from './InputField';
+export { default as styles } from './styles.module.css';
diff --git a/resources/scripts/components/elements/inputs/styles.module.css b/resources/scripts/components/elements/inputs/styles.module.css
new file mode 100644
index 00000000..c57cb0bb
--- /dev/null
+++ b/resources/scripts/components/elements/inputs/styles.module.css
@@ -0,0 +1,21 @@
+.checkbox {
+    @apply w-4 h-4 rounded-sm border-neutral-500 bg-neutral-600 text-primary-500;
+
+    &:focus, &:active {
+        @apply ring-2 ring-primary-500 ring-offset-2 ring-offset-neutral-700;
+    }
+
+    &.indeterminate:checked {
+        @apply text-primary-500/50 border border-primary-500;
+        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='white'%3E%3Cpath fill-rule='evenodd' d='M5 10a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1z' clip-rule='evenodd' /%3E%3C/svg%3E");
+    }
+}
+
+.text_input {
+    @apply transition-all duration-75;
+    @apply bg-neutral-800 border-neutral-600 rounded px-4 py-2 outline-none;
+
+    &:focus {
+        @apply border-blue-600 ring-2 ring-blue-500;
+    }
+}
diff --git a/resources/scripts/components/elements/transitions/FadeTransition.tsx b/resources/scripts/components/elements/transitions/FadeTransition.tsx
new file mode 100644
index 00000000..d53acc47
--- /dev/null
+++ b/resources/scripts/components/elements/transitions/FadeTransition.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { Transition } from '@headlessui/react';
+
+type Duration = `duration-${number}`;
+
+interface Props {
+    as?: React.ElementType;
+    duration?: Duration | [ Duration, Duration ];
+    show: boolean;
+    children: React.ReactNode;
+}
+
+export default ({ children, duration, ...props }: Props) => {
+    const [ enterDuration, exitDuration ] = Array.isArray(duration)
+        ? duration
+        : (!duration ? [ 'duration-200', 'duration-100' ] : [ duration, duration ]);
+
+    return (
+        <Transition
+            {...props}
+            enter={`ease-out ${enterDuration}`}
+            enterFrom={'opacity-0'}
+            enterTo={'opacity-100'}
+            leave={`ease-in ${exitDuration}`}
+            leaveFrom={'opacity-100'}
+            leaveTo={'opacity-0'}
+        >
+            {children}
+        </Transition>
+    );
+};
diff --git a/resources/scripts/components/elements/transitions/index.ts b/resources/scripts/components/elements/transitions/index.ts
new file mode 100644
index 00000000..98060223
--- /dev/null
+++ b/resources/scripts/components/elements/transitions/index.ts
@@ -0,0 +1,8 @@
+import { Transition as TransitionComponent } from '@headlessui/react';
+import FadeTransition from '@/components/elements/transitions/FadeTransition';
+
+const Transition = Object.assign(TransitionComponent, {
+    Fade: FadeTransition,
+});
+
+export { Transition };
diff --git a/resources/scripts/globals.d.ts b/resources/scripts/globals.d.ts
index bb38b742..b08b7271 100644
--- a/resources/scripts/globals.d.ts
+++ b/resources/scripts/globals.d.ts
@@ -1,3 +1,4 @@
 declare module '*.jpg';
 declare module '*.png';
 declare module '*.svg';
+declare module '*.css';
diff --git a/resources/scripts/macros.d.ts b/resources/scripts/macros.d.ts
index 720aa9d2..2277519c 100644
--- a/resources/scripts/macros.d.ts
+++ b/resources/scripts/macros.d.ts
@@ -1,28 +1,29 @@
-// This allows the use of css={} on JSX elements.
-//
-// @see https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31245
-//
-// This is just the contents of the @types/styled-components/cssprop.d.ts file
-// since using the other method of just importing the one file did not work
-// correctly for some reason.
-// noinspection ES6UnusedImports
-import {} from 'react';
+import { ComponentType, ReactElement } from 'react';
 // eslint-disable-next-line no-restricted-imports
-import { CSSProp } from 'styled-components';
+import styledImport, { css as cssImport, CSSProp, StyledComponentProps } from 'styled-components';
 
 declare module 'react' {
     interface Attributes {
-        // NOTE: unlike the plain javascript version, it is not possible to get access
-        // to the element's own attributes inside function interpolations.
-        // Only theme will be accessible, and only with the DefaultTheme due to the global
-        // nature of this declaration.
-        // If you are writing this inline you already have access to all the attributes anyway,
-        // no need for the extra indirection.
-        /**
-         * If present, this React element will be converted by
-         * `babel-plugin-styled-components` into a styled component
-         * with the given css as its styles.
-         */
         css?: CSSProp;
     }
 }
+
+declare module 'styled-components' {
+    interface StyledComponentBase<
+        C extends string | ComponentType<any>,
+        // eslint-disable-next-line @typescript-eslint/ban-types
+        T extends object,
+        // eslint-disable-next-line @typescript-eslint/ban-types
+        O extends object = {},
+        A extends keyof any = never
+        > extends ForwardRefExoticBase<StyledComponentProps<C, T, O, A>> {
+        (props: StyledComponentProps<C, T, O, A> & { as?: Element | string; forwardedAs?: never | undefined }): ReactElement<
+            StyledComponentProps<C, T, O, A>
+            >;
+    }
+}
+
+declare module 'twin.macro' {
+    const css: typeof cssImport;
+    const styled: typeof styledImport;
+}
diff --git a/yarn.lock b/yarn.lock
index 03d6e2f8..9b5b1c2a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1272,6 +1272,26 @@
   resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"
   integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==
 
+"@headlessui/react@^1.6.4":
+  version "1.6.4"
+  resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.6.4.tgz#c73084e23386bef5fb86cd16da3352c3a844bb4c"
+  integrity sha512-0yqz1scwbFtwljmbbKjXsSGl5ABEYNICVHZnMCWo0UtOZodo2Tpu94uOVgCRjRZ77l2WcTi2S0uidINDvG7lsA==
+
+"@heroicons/react@^1.0.6":
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-1.0.6.tgz#35dd26987228b39ef2316db3b1245c42eb19e324"
+  integrity sha512-JJCXydOFWMDpCP4q13iEplA503MQO3xLoZiKum+955ZCtHINWnx26CUxVxxFQu/uLb4LW3ge15ZpzIkXKkJ8oQ==
+
+"@hot-loader/react-dom@^16.14.0":
+  version "16.14.0"
+  resolved "https://registry.yarnpkg.com/@hot-loader/react-dom/-/react-dom-16.14.0.tgz#3cfc64e40bb78fa623e59b582b8f09dcdaad648a"
+  integrity sha512-EN9czvcLsMYmSDo5yRKZOAq3ZGRlDpad1gPtX0NdMMomJXcPE3yFSeFzE94X/NjOaiSVimB7LuqPYpkWVaIi4Q==
+  dependencies:
+    loose-envify "^1.1.0"
+    object-assign "^4.1.1"
+    prop-types "^15.6.2"
+    scheduler "^0.19.1"
+
 "@jridgewell/gen-mapping@^0.3.0":
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.1.tgz#cf92a983c83466b8c0ce9124fadeaf09f7c66ea9"
@@ -1471,12 +1491,12 @@
   dependencies:
     "@types/react" "*"
 
-"@types/react-dom@^16.9.8":
-  version "16.9.8"
-  resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.8.tgz#fe4c1e11dfc67155733dfa6aa65108b4971cb423"
-  integrity sha512-ykkPQ+5nFknnlU6lDd947WbQ6TE3NNzbQAkInC2EKY1qeYdTKp7onFusmYZb+ityzx2YviqT6BXSu+LyWWJwcA==
+"@types/react-dom@^16.9.16":
+  version "16.9.16"
+  resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.16.tgz#c591f2ed1c6f32e9759dfa6eb4abfd8041f29e39"
+  integrity sha512-Oqc0RY4fggGA3ltEgyPLc3IV9T73IGoWjkONbsyJ3ZBn+UPPCYpU2ec0i3cEbJuEdZtkqcCF2l1zf2pBdgUGSg==
   dependencies:
-    "@types/react" "*"
+    "@types/react" "^16"
 
 "@types/react-helmet@^6.0.0":
   version "6.0.0"
@@ -1523,13 +1543,19 @@
     "@types/prop-types" "*"
     csstype "^2.2.0"
 
-"@types/react@^16.9.41":
-  version "16.9.41"
-  resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.41.tgz#925137ee4d2ff406a0ecf29e8e9237390844002e"
-  integrity sha512-6cFei7F7L4wwuM+IND/Q2cV1koQUvJ8iSV+Gwn0c3kvABZ691g7sp3hfEQHOUBJtccl1gPi+EyNjMIl9nGA0ug==
+"@types/react@^16", "@types/react@^16.14.0":
+  version "16.14.26"
+  resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.26.tgz#82540a240ba7207ebe87d9579051bc19c9ef7605"
+  integrity sha512-c/5CYyciOO4XdFcNhZW1O2woVx86k4T+DO2RorHZL7EhitkNQgSD/SgpdZJAUJa/qjVgOmTM44gHkAdZSXeQuQ==
   dependencies:
     "@types/prop-types" "*"
-    csstype "^2.2.0"
+    "@types/scheduler" "*"
+    csstype "^3.0.2"
+
+"@types/scheduler@*":
+  version "0.16.2"
+  resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39"
+  integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==
 
 "@types/styled-components@^5.1.7":
   version "5.1.7"
@@ -2657,6 +2683,11 @@ class-utils@^0.3.5:
     isobject "^3.0.0"
     static-extend "^0.1.1"
 
+classnames@^2.3.1:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e"
+  integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
+
 clean-set@^1.1.1:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/clean-set/-/clean-set-1.1.2.tgz#76d8bf238c3e27827bfa73073ecdfdc767187070"
@@ -6994,10 +7025,10 @@ react-transition-group@^4.4.1:
     loose-envify "^1.4.0"
     prop-types "^15.6.2"
 
-react@^16.13.1:
-  version "16.13.1"
-  resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"
-  integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==
+react@^16.14.0:
+  version "16.14.0"
+  resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d"
+  integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==
   dependencies:
     loose-envify "^1.1.0"
     object-assign "^4.1.1"
@@ -7374,6 +7405,14 @@ scheduler@^0.17.0:
     loose-envify "^1.1.0"
     object-assign "^4.1.1"
 
+scheduler@^0.19.1:
+  version "0.19.1"
+  resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196"
+  integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==
+  dependencies:
+    loose-envify "^1.1.0"
+    object-assign "^4.1.1"
+
 schema-utils@2.7.0, schema-utils@^2.6.5:
   version "2.7.0"
   resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7"
@@ -8369,10 +8408,10 @@ typedarray@^0.0.6:
   version "0.0.6"
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
 
-typescript@^4.2.4:
-  version "4.2.4"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961"
-  integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==
+typescript@^4.7.3:
+  version "4.7.3"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.3.tgz#8364b502d5257b540f9de4c40be84c98e23a129d"
+  integrity sha512-WOkT3XYvrpXx4vMMqlD+8R8R37fZkjyLGlxavMc4iB8lrl8L0DeTcHbYgw/v0N/z9wAFsgBhcsF0ruoySS22mA==
 
 unbox-primitive@^1.0.1:
   version "1.0.1"