diff --git a/apps/docs/__tests__/ImageGallery_.test.res b/apps/docs/__tests__/ImageGallery_.test.res new file mode 100644 index 000000000..3366b6da8 --- /dev/null +++ b/apps/docs/__tests__/ImageGallery_.test.res @@ -0,0 +1,81 @@ +open Vitest + +let images = ["/lp/community-3.avif", "/lp/community-2.avif", "/lp/community-1.avif"] + +test( + "gallery selectors show their photo and the next control wraps after the last photo", + async () => { + let screen = await render() + let first = await screen->getByLabelText("Show community photo 1") + let third = await screen->getByLabelText("Show community photo 3") + let next = await screen->getByLabelText("Next community photo") + + await element(first)->toHaveAttribute("aria-pressed", "true") + await third->click + await element(third)->toHaveAttribute("aria-pressed", "true") + let lastImage = await screen->getByAltText("ReScript community photo 3") + await element(lastImage)->toHaveAttribute("src", "/lp/community-1.avif") + await next->click + + await element(first)->toHaveAttribute("aria-pressed", "true") + await element(third)->toHaveAttribute("aria-pressed", "false") + let firstImage = await screen->getByAltText("ReScript community photo 1") + await element(firstImage)->toHaveAttribute("src", "/lp/community-3.avif") + }, +) + +test("gallery renders no controls for an empty image list", async () => { + let screen = await render() + expect(screen->container->textContent->Nullable.toOption)->toEqual(Some("")) + let next = await screen->getByLabelText("Next community photo") + await element(next)->notToBeInTheDocument +}) + +test("gallery returns to the first image when the selected image is removed", async () => { + let screen = await render() + let third = await screen->getByLabelText("Show community photo 3") + await third->click + + await screen->rerender() + + let firstImage = await screen->getByAltText("ReScript community photo 1") + await element(firstImage)->toHaveAttribute("src", "/lp/community-3.avif") + let next = await screen->getByLabelText("Next community photo") + await next->click + await element(firstImage)->toHaveAttribute("src", "/lp/community-3.avif") +}) + +test("gallery keeps the first image selected when a shortened list grows again", async () => { + let screen = await render() + let third = await screen->getByLabelText("Show community photo 3") + await third->click + + await screen->rerender() + let first = await screen->getByLabelText("Show community photo 1") + await element(first)->toHaveAttribute("aria-pressed", "true") + + await screen->rerender() + + await element(first)->toHaveAttribute("aria-pressed", "true") + await element(third)->toHaveAttribute("aria-pressed", "false") + let firstImage = await screen->getByAltText("ReScript community photo 1") + await element(firstImage)->toHaveAttribute("src", "/lp/community-3.avif") +}) + +test("gallery keeps the first image selected after an empty list is restored", async () => { + let screen = await render() + let third = await screen->getByLabelText("Show community photo 3") + await third->click + + await screen->rerender() + let next = await screen->getByLabelText("Next community photo") + await element(next)->notToBeInTheDocument + + await screen->rerender() + + let first = await screen->getByLabelText("Show community photo 1") + await element(first)->toHaveAttribute("aria-pressed", "true") + await element(third)->toHaveAttribute("aria-pressed", "false") + let firstImage = await screen->getByAltText("ReScript community photo 1") + await element(firstImage)->toHaveAttribute("src", "/lp/community-3.avif") +}) diff --git a/apps/docs/__tests__/LandingPageCopyButton_.test.res b/apps/docs/__tests__/LandingPageCopyButton_.test.res new file mode 100644 index 000000000..77d2d6ddb --- /dev/null +++ b/apps/docs/__tests__/LandingPageCopyButton_.test.res @@ -0,0 +1,64 @@ +open Vitest + +test( + "copy button writes its command and clears success feedback before copying again", + async () => { + let copiedCommand = ref(None) + let writeClipboard = async code => { + copiedCommand := Some(code) + Ok() + } + let screen = await render() + let button = await screen->getByLabelText("Copy npm install rescript command") + + await button->click + + let feedback = await screen->getByText("Copied!") + await element(feedback)->toBeVisible + expect(copiedCommand.contents)->toEqual(Some("npm install rescript")) + await element(button)->toBeDisabled + await element(button)->notToBeDisabled + await element(feedback)->notToBeInTheDocument + await button->click + await element(feedback)->toBeVisible + }, +) + +test("copy button exposes clipboard failure and allows retry", async () => { + let shouldFail = ref(true) + let writeClipboard = async _ => { + if shouldFail.contents { + shouldFail := false + Error(Clipboard.WriteFailed) + } else { + Ok() + } + } + let screen = await render() + let button = await screen->getByLabelText("Copy npm install rescript command") + + await button->click + + let feedback = await screen->getByText("Could not copy. Try again.") + await element(feedback)->toBeVisible + await element(button)->notToBeDisabled + await button->click + + let copied = await screen->getByText("Copied!") + await element(copied)->toBeVisible + await element(feedback)->notToBeInTheDocument +}) + +test("copy button disables duplicate writes until the clipboard operation settles", async () => { + let complete = ref(_ => ()) + let writeClipboard = _ => Promise.make((resolve, _) => complete := resolve) + let screen = await render() + let button = await screen->getByLabelText("Copy npm install rescript command") + + await button->click + await element(button)->toBeDisabled + complete.contents(Ok()) + + let feedback = await screen->getByText("Copied!") + await element(feedback)->toBeVisible +}) diff --git a/apps/docs/__tests__/visual/LandingPage_.test.res b/apps/docs/__tests__/visual/LandingPage_.test.res index fd8d8aabc..3dc6b6e44 100644 --- a/apps/docs/__tests__/visual/LandingPage_.test.res +++ b/apps/docs/__tests__/visual/LandingPage_.test.res @@ -20,10 +20,6 @@ let snapshotSection = async (~width, ~height, ~sectionTestId, ~screenshotName) = if sectionTestId == "landing-other-selling-points" { let sourceSelector = `[data-testid="${sectionTestId}"]` await TestUtils.waitForImages(sourceSelector) - // Headless UI's appear transition mutates classes after first render. Since - // these tests snapshot a cloned outerHTML string, wait for the live section - // to settle so the clone does not preserve a transient opacity-0 state. - await TestUtils.sleep(1100) } let sandboxTestId = `${sectionTestId}-snapshot` diff --git a/apps/docs/e2e/bindings/Cypress.res b/apps/docs/e2e/bindings/Cypress.res index ce22fada1..cf6730b36 100644 --- a/apps/docs/e2e/bindings/Cypress.res +++ b/apps/docs/e2e/bindings/Cypress.res @@ -7,7 +7,6 @@ type rec window = {document: Dom.document, console: console, navigator: {clipboa and clipboard type response = {status: int, body: string} type automation = {command: string, params?: {permissions: array, origin: string}} -type clickOptions = {scrollBehavior: string} type request = {url: string, resourceType: string} type routeMatcher = {resourceType?: string, pathname?: string} type url @@ -60,6 +59,7 @@ external shouldCss: (chain, @as("have.css") _, string, string) => chai "should" @send external shouldCssProperty: (chain, @as("have.css") _, string) => chain = "should" +@send external shouldInt: (chain<'a>, string, int) => chain<'a> = "should" @send external shouldEqual: (chain<'a>, @as("equal") _, 'a) => chain<'a> = "should" @send external shouldDeepEqual: (chain<'a>, @as("deep.equal") _, 'a) => chain<'a> = "should" @send external shouldMatch: (chain<'a>, @as("match") _, RegExp.t) => chain<'a> = "should" @@ -72,8 +72,9 @@ external shouldAttribute: (chain, @as("have.attr") _, string, string) @send external propertyInt: (chain<'a>, string) => chain = "its" @send external shouldSatisfy: (chain<'a>, 'a => unit) => chain<'a> = "should" @send external click: chain => chain = "click" -@send -external realClick: (chain, clickOptions) => chain = "realClick" +@send external focusElement: chain => chain = "focus" +@send external realClick: chain => chain = "realClick" +@send external realPress: (chain, string) => chain = "realPress" @send external scrollIntoView: chain => chain = "scrollIntoView" @send external each: (chain, elements => unit) => chain = "each" @send external as_: (chain<'a>, string) => chain<'a> = "as" diff --git a/apps/docs/e2e/homepage/Homepage.cy.res b/apps/docs/e2e/homepage/Homepage.cy.res index 07b5ba42a..b283c84d1 100644 --- a/apps/docs/e2e/homepage/Homepage.cy.res +++ b/apps/docs/e2e/homepage/Homepage.cy.res @@ -1,8 +1,7 @@ open Cypress open HomepageHelpers -it("homepage hydrates with working links and copy feedback", () => { - grantClipboardPermissions() +it("homepage hydrates with working links and images", () => { visit("/") containsIn("h1", headline)->should("be.visible")->ignore containsIn("a", "Get started")->shouldAttribute("href", "/docs/manual/installation")->ignore @@ -10,11 +9,6 @@ it("homepage hydrates with working links and copy feedback", () => { ->attribute("href") ->shouldMatch(/\/try\?code=.+/) ->ignore - get(`button[aria-label="Copy npm install rescript command"]`) - ->realClick({scrollBehavior: "center"}) - ->ignore - contains("Copied!")->should("be.visible")->ignore - readClipboard()->shouldEqual("npm install rescript")->ignore get("img") ->each(image => { wrap(image) diff --git a/apps/docs/e2e/homepage/HomepageHelpers.res b/apps/docs/e2e/homepage/HomepageHelpers.res index e016dbb5f..37a569db1 100644 --- a/apps/docs/e2e/homepage/HomepageHelpers.res +++ b/apps/docs/e2e/homepage/HomepageHelpers.res @@ -14,6 +14,15 @@ let grantClipboardPermissions = () => { )->ignore } +let denyClipboardPermissions = () => { + run(() => + automate({ + command: "Browser.grantPermissions", + params: {permissions: [], origin: baseUrl()}, + }) + )->ignore +} + let readClipboard = () => cyWindow()->thenPromise(window => window.navigator.clipboard->readText) let homepageDocument = callback => { diff --git a/apps/docs/e2e/homepage/HomepageInteractions.cy.res b/apps/docs/e2e/homepage/HomepageInteractions.cy.res new file mode 100644 index 000000000..a5e242d8e --- /dev/null +++ b/apps/docs/e2e/homepage/HomepageInteractions.cy.res @@ -0,0 +1,44 @@ +open Cypress +open HomepageHelpers + +it("community gallery supports keyboard selection and wraps to the first photo", () => { + visit("/") + containsIn("h1", headline)->realClick->ignore + let first = `button[aria-label="Show community photo 1"]` + let third = `button[aria-label="Show community photo 3"]` + let next = `button[aria-label="Next community photo"]` + + get(first)->shouldAttribute("aria-pressed", "true")->ignore + get(third)->scrollIntoView->focusElement->realPress("Enter")->ignore + get(third)->shouldAttribute("aria-pressed", "true")->should("be.focused")->ignore + get(`img[alt="ReScript community photo 3"]`)->should("be.visible")->ignore + get(next)->focusElement->realPress("Space")->ignore + get(first)->shouldAttribute("aria-pressed", "true")->ignore + get(next)->should("be.focused")->ignore + get(`img[alt="ReScript community photo 1"]`)->should("be.visible")->ignore +}) + +it("clipboard denial can recover and both install commands can be copied repeatedly", () => { + denyClipboardPermissions() + visit("/") + let first = `button[aria-label="Copy npm install rescript command"]` + get(`[role="status"]`)->shouldInt("have.length", 2)->ignore + get(`button [role="status"]`)->should("not.exist")->ignore + get(first)->realClick->ignore + containsIn(`[role="status"]`, "Could not copy. Try again.")->should("be.visible")->ignore + get(first)->should("be.enabled")->ignore + grantClipboardPermissions() + + ["npm install rescript", "npx create-rescript-app"]->Array.forEach(command => { + let button = `button[aria-label="Copy ${command} command"]` + get(button)->realClick->ignore + containsIn(`[role="status"]`, "Copied!")->should("be.visible")->ignore + get(button)->should("be.disabled")->ignore + readClipboard()->shouldEqual(command)->ignore + get(button)->should("be.enabled")->ignore + contains("Copied!")->should("not.exist")->ignore + get(button)->realClick->ignore + contains("Copied!")->should("be.visible")->ignore + get(button)->should("be.enabled")->ignore + }) +}) diff --git a/apps/docs/src/common/Clipboard.res b/apps/docs/src/common/Clipboard.res new file mode 100644 index 000000000..ea9b53b51 --- /dev/null +++ b/apps/docs/src/common/Clipboard.res @@ -0,0 +1,10 @@ +type error = WriteFailed + +let writeText = async text => { + try { + await navigator.clipboard->WebAPI.Clipboard.writeText(text) + Ok() + } catch { + | _ => Error(WriteFailed) + } +} diff --git a/apps/docs/src/components/ImageGallery.res b/apps/docs/src/components/ImageGallery.res index 3fc0c5125..c74b5f7f3 100644 --- a/apps/docs/src/components/ImageGallery.res +++ b/apps/docs/src/components/ImageGallery.res @@ -1,86 +1,46 @@ -type mode = - | NoAuto - | AutoFadeTransition(int) //milliseconds - @react.component -let make = ( - ~className="", - ~imgClassName="", - ~imgSrcs: array, - ~imgLoading=?, - ~mode=NoAuto, -) => { - let (index, setIndex) = React.useState(_ => 0) - - React.useEffect(() => { - switch mode { - | NoAuto => None - | AutoFadeTransition(ms) => - let timerId = setInterval2(~handler=() => { - setIndex( - prev => { - if prev === imgSrcs->Array.length - 1 { - 0 - } else { - prev + 1 - } - }, - ) - }, ~timeout=ms) - - Some( - () => { - clearInterval(timerId) - }, - ) - } - }, []) - - let src = imgSrcs->Belt.Array.getExn(index) - - let lineEls = imgSrcs->Array.mapWithIndex((src, i) => { - let bgColor = if i === index { - "bg-gray-40" - } else { - "bg-gray-70" - } - let onClick = evt => { - ReactEvent.Mouse.preventDefault(evt) +let make = (~className="", ~imgClassName="", ~imgSrcs: array, ~imgLoading=?) => { + let (selected, setSelected) = React.useState(_ => 0) + let count = Array.length(imgSrcs) + let index = selected < count ? selected : 0 - setIndex(_ => i) - } -
-
-
- }) - - let onClick = evt => { - ReactEvent.Mouse.preventDefault(evt) - - setIndex(prev => { - if prev === imgSrcs->Array.length - 1 { - 0 - } else { - prev + 1 - } - }) + if selected !== index { + setSelected(_ => index) } -
-
- Array.get(index) { + | None => React.null + | Some(src) => +
+ +
+ {imgSrcs + ->Array.mapWithIndex((src, i) => { + let color = i === index ? "text-gray-40" : "text-gray-70" +
-
{lineEls->React.array}
-
+ } } diff --git a/apps/docs/src/components/LandingPageCopyButton.res b/apps/docs/src/components/LandingPageCopyButton.res new file mode 100644 index 000000000..a65b87e35 --- /dev/null +++ b/apps/docs/src/components/LandingPageCopyButton.res @@ -0,0 +1,56 @@ +type state = + | Idle + | Pending + | Copied + | Failed + +@react.component +let make = (~code, ~writeClipboard=Clipboard.writeText) => { + let (state, setState) = React.useState(_ => Idle) + + let feedbackRef = React.useCallback(_ => { + let timer = setTimeout(~handler=() => setState(_ => Idle), ~timeout=2000) + Some(() => clearTimeout(timer)) + }, []) + + let copy = async () => { + setState(_ => Pending) + let result = await writeClipboard(code) + setState(_ => { + switch result { + | Ok() => Copied + | Error(Clipboard.WriteFailed) => Failed + } + }) + } + + let feedback = switch state { + | Idle | Pending => React.null + | Copied => + + {React.string("Copied!")} + + | Failed => + + {React.string("Could not copy. Try again.")} + + } + + <> + + feedback + +} diff --git a/apps/docs/src/components/LandingPageCopyButton.resi b/apps/docs/src/components/LandingPageCopyButton.resi new file mode 100644 index 000000000..2f0b04179 --- /dev/null +++ b/apps/docs/src/components/LandingPageCopyButton.resi @@ -0,0 +1,5 @@ +@react.component +let make: ( + ~code: string, + ~writeClipboard: string => promise>=?, +) => React.element diff --git a/apps/docs/src/components/LandingPageInstallInstructions.res b/apps/docs/src/components/LandingPageInstallInstructions.res new file mode 100644 index 000000000..f3e4d0562 --- /dev/null +++ b/apps/docs/src/components/LandingPageInstallInstructions.res @@ -0,0 +1,25 @@ +let copyBox = text => { +
+ {React.string(text)} + +
+} + +@react.component +let make = (~className="") => { +
+

{React.string("Quick Install")}

+
+ {React.string( + "You can quickly add ReScript to your existing JavaScript codebase via npm / yarn:", + )} +
+ {copyBox("npm install rescript")} +
+ {React.string("Or generate a new project from the official template with npx:")} +
+ {copyBox("npx create-rescript-app")} +
+} diff --git a/apps/docs/src/components/LandingPageInstallInstructions.resi b/apps/docs/src/components/LandingPageInstallInstructions.resi new file mode 100644 index 000000000..193c81cf3 --- /dev/null +++ b/apps/docs/src/components/LandingPageInstallInstructions.resi @@ -0,0 +1,2 @@ +@react.component +let make: (~className: string=?) => React.element diff --git a/apps/docs/src/components/LandingPageQuickInstall.res b/apps/docs/src/components/LandingPageQuickInstall.res index b24d8f6f9..bb263356a 100644 --- a/apps/docs/src/components/LandingPageQuickInstall.res +++ b/apps/docs/src/components/LandingPageQuickInstall.res @@ -1,122 +1,3 @@ -module CopyButton = { - let copyToClipboard: string => bool = %raw(` - function(str) { - try { - const el = document.createElement('textarea'); - el.value = str; - el.setAttribute('readonly', ''); - el.style.position = 'absolute'; - el.style.left = '-9999px'; - document.body.appendChild(el); - const selected = - document.getSelection().rangeCount > 0 ? document.getSelection().getRangeAt(0) : false; - el.select(); - document.execCommand('copy'); - document.body.removeChild(el); - if (selected) { - document.getSelection().removeAllRanges(); - document.getSelection().addRange(selected); - } - return true; - } catch(e) { - return false; - } - } - `) - - type state = - | Init - | Copied - | Failed - - @react.component - let make = (~code) => { - let (state, setState) = React.useState(_ => Init) - let buttonRef = React.useRef(Nullable.null) - - let onClick = evt => { - ReactEvent.Mouse.preventDefault(evt) - if copyToClipboard(code) { - setState(_ => Copied) - } else { - setState(_ => Failed) - } - } - - React.useEffect(() => { - switch state { - | Copied => - let buttonEl = Nullable.toOption(buttonRef.current)->Option.getOrThrow - - let bannerEl = WebAPI.Document.createElement(document, "div") - bannerEl.className = "foobar opacity-0 absolute top-0 mt-4 -mr-1 px-2 rounded right-0 - bg-turtle text-gray-80-tr body-sm - transition-all duration-500 ease-in-out " - let textNode = WebAPI.Document.createTextNode(document, "Copied!") - - WebAPI.Element.appendChild(bannerEl, textNode)->ignore - WebAPI.Element.appendChild(buttonEl, bannerEl)->ignore - - let nextFrameId = WebAPI.Window.requestAnimationFrame(window, _ => { - WebAPI.DOMTokenList.toggle(bannerEl.classList, ~token="opacity-0")->ignore - WebAPI.DOMTokenList.toggle(bannerEl.classList, ~token="opacity-100")->ignore - }) - - let timeoutId = setTimeout(~handler=() => { - buttonEl->WebAPI.Element.removeChild(bannerEl)->ignore - setState(_ => Init) - }, ~timeout=2000) - - Some( - () => { - cancelAnimationFrame(nextFrameId) - clearTimeout(timeoutId) - }, - ) - | _ => None - } - }, [state]) - - - } -} - -module Instructions = { - let copyBox = text => { -
- {React.string(text)} - -
- } - - @react.component - let make = (~className: string="") => { -
-

{React.string("Quick Install")}

-
- {React.string( - "You can quickly add ReScript to your existing JavaScript codebase via npm / yarn:", - )} -
- {copyBox("npm install rescript")} -
- {React.string("Or generate a new project from the official template with npx:")} -
- {copyBox("npx create-rescript-app")} -
- } -} - @react.component let make = () => {
@@ -135,7 +16,7 @@ let make = () => { > {React.string(`ReScript is used to ship and maintain mission-critical products with good UI and UX.`)}

- +
diff --git a/apps/docs/src/components/LandingPageQuickInstall.resi b/apps/docs/src/components/LandingPageQuickInstall.resi index 1ca44ce26..9d792611d 100644 --- a/apps/docs/src/components/LandingPageQuickInstall.resi +++ b/apps/docs/src/components/LandingPageQuickInstall.resi @@ -1,2 +1,3 @@ +/** Presentational install section with event-driven clipboard controls. */ @react.component let make: unit => React.element diff --git a/apps/docs/styles/main.css b/apps/docs/styles/main.css index ac10e3e5c..b538e8db8 100644 --- a/apps/docs/styles/main.css +++ b/apps/docs/styles/main.css @@ -569,6 +569,29 @@ display: inline-block; } +.gallery-selector::after { + content: ""; + inline-size: 100%; + block-size: 1px; + background-color: currentColor; +} + +@media (prefers-reduced-motion: no-preference) { + .gallery-photo { + animation: gallery-fade-in 1s ease-in-out; + } +} + +@keyframes gallery-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + .version-popover[popover] { inset: unset; width: 100%; diff --git a/apps/docs/vitest.config.mjs b/apps/docs/vitest.config.mjs index 7bd863a6a..c39aeb9fa 100644 --- a/apps/docs/vitest.config.mjs +++ b/apps/docs/vitest.config.mjs @@ -46,6 +46,7 @@ export default defineConfig({ provider: playwright({ contextOptions: { deviceScaleFactor: 1, + permissions: ["clipboard-read", "clipboard-write"], }, }), ui: false, diff --git a/packages/shared/src/Vitest.res b/packages/shared/src/Vitest.res index 2555d707b..1135b0bd2 100644 --- a/packages/shared/src/Vitest.res +++ b/packages/shared/src/Vitest.res @@ -33,6 +33,9 @@ external render: Jsx.element => promise = "render" @send external unmount: element => promise = "unmount" +@send +external rerender: (element, Jsx.element) => promise = "rerender" + @module("vitest") @scope("expect") external element: 'a => element = "element" @@ -51,6 +54,9 @@ external getByTextWithOptions: (element, string, {"exact": bool}) => promise promise = "getByLabelText" +@send +external getByAltText: (element, string) => promise = "getByAltText" + @send external getAllByLabelText: (element, string) => promise> = "getAllByLabelText" @@ -84,6 +90,9 @@ external toBeVisible: element => promise = "toBeVisible" @send @scope("not") external notToBeVisible: element => promise = "toBeVisible" +@send @scope("not") +external notToBeInTheDocument: element => promise = "toBeInTheDocument" + @send external toBeDisabled: element => promise = "toBeDisabled" @@ -99,6 +108,9 @@ external toHaveTextContent: (element, string) => promise = "toHaveTextCont @send external toHaveClass: (element, string) => promise = "toHaveClass" +@send +external toHaveAttribute: (element, string, string) => promise = "toHaveAttribute" + @send external toMatchScreenshot: (element, string) => promise = "toMatchScreenshot"