diff --git a/packages/website/app/modules/dom/portal.tsx b/packages/website/app/modules/dom/portal.tsx new file mode 100644 index 0000000..ce96a61 --- /dev/null +++ b/packages/website/app/modules/dom/portal.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react" +import { useEffect, useRef } from "react" +import { createPortal } from "react-dom" + +export function Portal({ children }: { children: ReactNode }) { + const containerRef = useRef() + + if (!containerRef.current && typeof document !== "undefined") { + containerRef.current = document.createElement("react-portal") + document.body.append(containerRef.current) + } + + useEffect(() => () => containerRef.current!.remove(), []) + + return containerRef.current ? ( + createPortal(children, containerRef.current) + ) : ( + <>{children} + ) +} diff --git a/packages/website/app/modules/landing/landing-animation.tsx b/packages/website/app/modules/landing/landing-animation.tsx new file mode 100644 index 0000000..d4bd70b --- /dev/null +++ b/packages/website/app/modules/landing/landing-animation.tsx @@ -0,0 +1,197 @@ +import clsx from "clsx" +import { useEffect, useRef, useState } from "react" +import blobComfyUrl from "~/assets/blob-comfy.png" +import cursorIbeamUrl from "~/assets/cursor-ibeam.png" +import cursorUrl from "~/assets/cursor.png" + +const defaultState = { + chatInputText: "", + chatInputCursorVisible: true, + messageVisible: false, + count: 0, + cursorLeft: "25%", + cursorBottom: "-15px", +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +const animationFrame = () => + new Promise((resolve) => requestAnimationFrame(resolve)) + +export function LandingAnimation() { + const [state, setState] = useState(defaultState) + const chatInputRef = useRef(null) + const addRef = useRef(null) + const deleteRef = useRef(null) + const cursorRef = useRef(null) + + useEffect(() => { + const animateClick = (element: HTMLElement) => + element.animate( + [{ transform: `translateY(2px)` }, { transform: `translateY(0px)` }], + 300, + ) + + let running = true + + void (async () => { + await delay(1000) + + while (running) { + setState(defaultState) + await delay(1000) + + for (const letter of "/counter") { + setState((state) => ({ + ...state, + chatInputText: state.chatInputText + letter, + })) + await delay(100) + } + + await delay(1000) + + setState((state) => ({ + ...state, + messageVisible: true, + chatInputText: "", + })) + await delay(1000) + + setState((state) => ({ + ...state, + cursorLeft: "70px", + cursorBottom: "40px", + })) + await delay(1500) + + for (let i = 0; i < 3; i++) { + setState((state) => ({ + ...state, + count: state.count + 1, + chatInputCursorVisible: false, + })) + animateClick(addRef.current!) + await delay(700) + } + + await delay(500) + + setState((state) => ({ + ...state, + cursorLeft: "140px", + })) + await delay(1000) + + animateClick(deleteRef.current!) + setState((state) => ({ ...state, messageVisible: false })) + await delay(1000) + + setState(() => ({ + ...defaultState, + chatInputCursorVisible: false, + })) + await delay(500) + } + })() + + return () => { + running = false + } + }, []) + + useEffect(() => { + let running = true + + void (async () => { + while (running) { + // check if the cursor is in the input + const cursorRect = cursorRef.current!.getBoundingClientRect() + const chatInputRect = chatInputRef.current!.getBoundingClientRect() + + const isOverInput = + cursorRef.current && + chatInputRef.current && + cursorRect.top + cursorRect.height / 2 > chatInputRect.top + + cursorRef.current!.src = isOverInput ? cursorIbeamUrl : cursorUrl + + await animationFrame() + } + })() + + return () => { + running = false + } + }) + + return ( +
+
+
+
+ +
+
+

comfybot

+

this button was clicked {state.count} times

+
+
+ +1 +
+
+ 🗑 delete +
+
+
+
+
+
+ + {state.chatInputText || ( + + Message #showing-off-reacord + + )} + +
+ + +
+ ) +} diff --git a/packages/website/app/modules/landing/landing-code.mdx b/packages/website/app/modules/landing/landing-code.mdx new file mode 100644 index 0000000..d62396e --- /dev/null +++ b/packages/website/app/modules/landing/landing-code.mdx @@ -0,0 +1,26 @@ +{/* prettier-ignore */} +```tsx +import * as React from "react" +import { Button, useInstance } from "reacord" + +function Counter() { + const [count, setCount] = React.useState(0) + const instance = useInstance() + return ( + <> + this button was clicked {count} times + - - ) -} -``` diff --git a/packages/website/app/modules/ui/modal.tsx b/packages/website/app/modules/ui/modal.tsx new file mode 100644 index 0000000..d3a701d --- /dev/null +++ b/packages/website/app/modules/ui/modal.tsx @@ -0,0 +1,80 @@ +import { XIcon } from "@heroicons/react/outline" +import clsx from "clsx" +import type { ReactNode } from "react" +import { useEffect, useRef, useState } from "react" +import { FocusOn } from "react-focus-on" +import { Portal } from "~/modules/dom/portal" + +export function Modal({ + children, + visible, + onClose, +}: { + children: ReactNode + visible: boolean + onClose: () => void +}) { + const closeButtonRef = useRef(null) + + useEffect(() => { + if (visible) { + // trying to immediately focus doesn't work for whatever reason + // neither did requestAnimationFrame + setTimeout(() => { + closeButtonRef.current?.focus() + }, 50) + } + }, [visible]) + + return ( + +
+ + +
+ {children} +
+
+
+
+ ) +} + +export function ControlledModal({ + children, + button, +}: { + children: ReactNode + button: (buttonProps: { onClick: () => void }) => void +}) { + const [visible, setVisible] = useState(false) + return ( + <> + {button({ onClick: () => setVisible(true) })} + setVisible(false)}> + {children} + + + ) +} diff --git a/packages/website/app/routes/index.tsx b/packages/website/app/routes/index.tsx index 88e663b..023a91a 100644 --- a/packages/website/app/routes/index.tsx +++ b/packages/website/app/routes/index.tsx @@ -1,13 +1,11 @@ -import clsx from "clsx" -import { useEffect, useRef, useState } from "react" -import blobComfyUrl from "~/assets/blob-comfy.png" -import cursorIbeamUrl from "~/assets/cursor-ibeam.png" -import cursorUrl from "~/assets/cursor.png" import dotsBackgroundUrl from "~/assets/dots-background.svg" import { AppFooter } from "~/modules/app/app-footer" import { AppLogo } from "~/modules/app/app-logo" +import LandingCode from "~/modules/landing/landing-code.mdx" import { MainNavigation } from "~/modules/navigation/main-navigation" import { maxWidthContainer } from "~/modules/ui/components" +import { LandingAnimation } from "../modules/landing/landing-animation" +import { ControlledModal } from "../modules/ui/modal" export default function Landing() { return ( @@ -28,9 +26,21 @@ export default function Landing() {

Create interactive Discord messages with React.

- {/* */} + + ( + + )} + > +
+ +
+
@@ -39,195 +49,3 @@ export default function Landing() { ) } - -const defaultState = { - chatInputText: "", - chatInputCursorVisible: true, - messageVisible: false, - count: 0, - cursorLeft: "25%", - cursorBottom: "-15px", -} - -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -const animationFrame = () => - new Promise((resolve) => requestAnimationFrame(resolve)) - -function LandingAnimation() { - const [state, setState] = useState(defaultState) - const chatInputRef = useRef(null) - const addRef = useRef(null) - const deleteRef = useRef(null) - const cursorRef = useRef(null) - - useEffect(() => { - const animateClick = (element: HTMLElement) => - element.animate( - [{ transform: `translateY(2px)` }, { transform: `translateY(0px)` }], - 300, - ) - - let running = true - - void (async () => { - await delay(1000) - - while (running) { - setState(defaultState) - await delay(1000) - - for (const letter of "/counter") { - setState((state) => ({ - ...state, - chatInputText: state.chatInputText + letter, - })) - await delay(100) - } - - await delay(1000) - - setState((state) => ({ - ...state, - messageVisible: true, - chatInputText: "", - })) - await delay(1000) - - setState((state) => ({ - ...state, - cursorLeft: "70px", - cursorBottom: "40px", - })) - await delay(1500) - - for (let i = 0; i < 3; i++) { - setState((state) => ({ - ...state, - count: state.count + 1, - chatInputCursorVisible: false, - })) - animateClick(addRef.current!) - await delay(700) - } - - await delay(500) - - setState((state) => ({ - ...state, - cursorLeft: "140px", - })) - await delay(1000) - - animateClick(deleteRef.current!) - setState((state) => ({ ...state, messageVisible: false })) - await delay(1000) - - setState(() => ({ - ...defaultState, - chatInputCursorVisible: false, - })) - await delay(500) - } - })() - - return () => { - running = false - } - }, []) - - useEffect(() => { - let running = true - - void (async () => { - while (running) { - // check if the cursor is in the input - const cursorRect = cursorRef.current!.getBoundingClientRect() - const chatInputRect = chatInputRef.current!.getBoundingClientRect() - - const isOverInput = - cursorRef.current && - chatInputRef.current && - cursorRect.top + cursorRect.height / 2 > chatInputRect.top - - cursorRef.current!.src = isOverInput ? cursorIbeamUrl : cursorUrl - - await animationFrame() - } - })() - - return () => { - running = false - } - }) - - return ( -
-
-
-
- -
-
-

comfybot

-

this button was clicked {state.count} times

-
- - -
-
-
-
-
- - {state.chatInputText || ( - - Message #showing-off-reacord - - )} - -
- - -
- ) -} diff --git a/packages/website/package.json b/packages/website/package.json index 786787b..5837bf5 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -22,6 +22,7 @@ "reacord": "workspace:*", "react": "^17.0.2", "react-dom": "^17.0.2", + "react-focus-on": "^3.5.4", "remix": "^1.1.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28854ea..5efff37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,7 @@ importers: reacord: workspace:* react: ^17.0.2 react-dom: ^17.0.2 + react-focus-on: ^3.5.4 rehype-prism-plus: ^1.3.1 remix: ^1.1.1 tailwindcss: ^3.0.13 @@ -131,6 +132,7 @@ importers: reacord: link:../reacord react: 17.0.2 react-dom: 17.0.2_react@17.0.2 + react-focus-on: 3.5.4_b08e3c15324cbe90a6ff8fcd416c932c remix: 1.1.1 devDependencies: '@remix-run/dev': 1.1.1 @@ -1593,6 +1595,13 @@ packages: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: true + /aria-hidden/1.1.3: + resolution: {integrity: sha512-RhVWFtKH5BiGMycI72q2RAFMLQi8JP9bLuQXgR5a8Znp7P5KOIADSJeyfI8PCVxLEp067B2HbP5JIiI/PXIZeA==} + engines: {node: '>=8.5.0'} + dependencies: + tslib: 1.14.1 + dev: false + /aria-query/4.2.2: resolution: {integrity: sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==} engines: {node: '>=6.0'} @@ -2875,6 +2884,10 @@ packages: resolution: {integrity: sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=} dev: false + /detect-node-es/1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dev: false + /detective/5.2.0: resolution: {integrity: sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==} engines: {node: '>=0.8.0'} @@ -3943,6 +3956,13 @@ packages: resolution: {integrity: sha512-8/sOawo8tJ4QOBX8YlQBMxL8+RLZfxMQOif9o0KUKTNTjMYElWPE0r/m5VNFxTRd0NSw8qSy8dajrwX4RYI1Hw==} dev: true + /focus-lock/0.10.1: + resolution: {integrity: sha512-b9yUklCi4fTu2GXn7dnaVf4hiLVVBp7xTiZarAHMODV2To6Bitf6F/UI67RmKbdgJQeVwI1UO0d9HYNbXt3GkA==} + engines: {node: '>=10'} + dependencies: + tslib: 2.3.1 + dev: false + /follow-redirects/1.14.7: resolution: {integrity: sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==} engines: {node: '>=4.0'} @@ -4086,6 +4106,11 @@ packages: has: 1.0.3 has-symbols: 1.0.2 + /get-nonce/1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + dev: false + /get-package-type/0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} @@ -4607,6 +4632,12 @@ packages: engines: {node: '>= 0.10'} dev: true + /invariant/2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + dependencies: + loose-envify: 1.4.0 + dev: false + /ipaddr.js/1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -6781,7 +6812,6 @@ packages: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 - dev: true /property-information/6.1.1: resolution: {integrity: sha512-hrzC564QIl0r0vy4l6MvRLhafmUowhO/O3KgVSoXIbbA2Sz4j8HGpJc6T2cubRVwMwpdiG/vKGfhT4IixmKN9w==} @@ -6905,6 +6935,15 @@ packages: strip-json-comments: 2.0.1 dev: true + /react-clientside-effect/1.2.5_react@17.0.2: + resolution: {integrity: sha512-2bL8qFW1TGBHozGGbVeyvnggRpMjibeZM2536AKNENLECutp2yfs44IL8Hmpn8qjFQ2K7A9PnYf3vc7aQq/cPA==} + peerDependencies: + react: ^15.3.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@babel/runtime': 7.16.7 + react: 17.0.2 + dev: false + /react-dom/17.0.2_react@17.0.2: resolution: {integrity: sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==} peerDependencies: @@ -6916,9 +6955,45 @@ packages: scheduler: 0.20.2 dev: false + /react-focus-lock/2.7.1_b08e3c15324cbe90a6ff8fcd416c932c: + resolution: {integrity: sha512-ImSeVmcrLKNMqzUsIdqOkXwTVltj79OPu43oT8tVun7eIckA4VdM7UmYUFo3H/UC2nRVgagMZGFnAOQEDiDYcA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@babel/runtime': 7.16.7 + focus-lock: 0.10.1 + prop-types: 15.8.1 + react: 17.0.2 + react-clientside-effect: 1.2.5_react@17.0.2 + use-callback-ref: 1.2.5_b08e3c15324cbe90a6ff8fcd416c932c + use-sidecar: 1.0.5_react@17.0.2 + transitivePeerDependencies: + - '@types/react' + dev: false + + /react-focus-on/3.5.4_b08e3c15324cbe90a6ff8fcd416c932c: + resolution: {integrity: sha512-HnU0YGKhNSUsC4k6K8L+2wk8mC/qdg+CsS7A1bWLMgK7UuBphdECs2esnS6cLmBoVNjsFnCm/vMypeezKOdK3A==} + engines: {node: '>=8.5.0'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 17.0.38 + aria-hidden: 1.1.3 + react: 17.0.2 + react-focus-lock: 2.7.1_b08e3c15324cbe90a6ff8fcd416c932c + react-remove-scroll: 2.4.3_b08e3c15324cbe90a6ff8fcd416c932c + react-style-singleton: 2.1.1_b08e3c15324cbe90a6ff8fcd416c932c + tslib: 2.3.1 + use-callback-ref: 1.2.5_b08e3c15324cbe90a6ff8fcd416c932c + use-sidecar: 1.0.5_react@17.0.2 + dev: false + /react-is/16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - dev: true /react-is/17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -6936,6 +7011,41 @@ packages: scheduler: 0.20.2 dev: false + /react-remove-scroll-bar/2.2.0_b08e3c15324cbe90a6ff8fcd416c932c: + resolution: {integrity: sha512-UU9ZBP1wdMR8qoUs7owiVcpaPwsQxUDC2lypP6mmixaGlARZa7ZIBx1jcuObLdhMOvCsnZcvetOho0wzPa9PYg==} + engines: {node: '>=8.5.0'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 + react: ^16.8.0 || ^17.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 17.0.38 + react: 17.0.2 + react-style-singleton: 2.1.1_b08e3c15324cbe90a6ff8fcd416c932c + tslib: 1.14.1 + dev: false + + /react-remove-scroll/2.4.3_b08e3c15324cbe90a6ff8fcd416c932c: + resolution: {integrity: sha512-lGWYXfV6jykJwbFpsuPdexKKzp96f3RbvGapDSIdcyGvHb7/eqyn46C7/6h+rUzYar1j5mdU+XECITHXCKBk9Q==} + engines: {node: '>=8.5.0'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 + react: ^16.8.0 || ^17.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 17.0.38 + react: 17.0.2 + react-remove-scroll-bar: 2.2.0_b08e3c15324cbe90a6ff8fcd416c932c + react-style-singleton: 2.1.1_b08e3c15324cbe90a6ff8fcd416c932c + tslib: 1.14.1 + use-callback-ref: 1.2.5_b08e3c15324cbe90a6ff8fcd416c932c + use-sidecar: 1.0.5_react@17.0.2 + dev: false + /react-router-dom/6.2.1_react-dom@17.0.2+react@17.0.2: resolution: {integrity: sha512-I6Zax+/TH/cZMDpj3/4Fl2eaNdcvoxxHoH1tYOREsQ22OKDYofGebrNm6CTPUcvLvZm63NL/vzCYdjf9CUhqmA==} peerDependencies: @@ -6955,6 +7065,23 @@ packages: history: 5.2.0 react: 17.0.2 + /react-style-singleton/2.1.1_b08e3c15324cbe90a6ff8fcd416c932c: + resolution: {integrity: sha512-jNRp07Jza6CBqdRKNgGhT3u9umWvils1xsuMOjZlghBDH2MU0PL2WZor4PGYjXpnRCa9DQSlHMs/xnABWOwYbA==} + engines: {node: '>=8.5.0'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 + react: ^16.8.0 || ^17.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 17.0.38 + get-nonce: 1.0.1 + invariant: 2.2.4 + react: 17.0.2 + tslib: 1.14.1 + dev: false + /react/17.0.2: resolution: {integrity: sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==} engines: {node: '>=0.10.0'} @@ -8347,6 +8474,31 @@ packages: querystring: 0.2.0 dev: true + /use-callback-ref/1.2.5_b08e3c15324cbe90a6ff8fcd416c932c: + resolution: {integrity: sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg==} + engines: {node: '>=8.5.0'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 + react: ^16.8.0 || ^17.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 17.0.38 + react: 17.0.2 + dev: false + + /use-sidecar/1.0.5_react@17.0.2: + resolution: {integrity: sha512-k9jnrjYNwN6xYLj1iaGhonDghfvmeTmYjAiGvOr7clwKfPjMXJf4/HOr7oT5tJwYafgp2tG2l3eZEOfoELiMcA==} + engines: {node: '>=8.5.0'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 + dependencies: + detect-node-es: 1.1.0 + react: 17.0.2 + tslib: 1.14.1 + dev: false + /use/3.1.1: resolution: {integrity: sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==} engines: {node: '>=0.10.0'}