From 636ac56070365d7b002c6d4e0009fcc010c92787 Mon Sep 17 00:00:00 2001 From: Kuba Orlik Date: Tue, 2 Jul 2024 17:33:03 +0200 Subject: [PATCH] Add horizontal scroller ui component --- .../horizontal-scroller.css | 39 +++++++++ .../horizontal-scroller.stimulus.ts | 80 +++++++++++++++++++ .../horizontal-scroller.tsx | 64 +++++++++++++++ src/back/routes/horizontal-scroller-demo.css | 24 ++++++ .../routes/horizontal-scroller-demo.page.tsx | 50 ++++++++++++ .../routes/horizontal-scroller-demo.test.ts | 40 ++++++++++ src/front/controllers.ts | 3 + 7 files changed, 300 insertions(+) create mode 100644 src/back/routes/common/horizontal-scroller/horizontal-scroller.css create mode 100644 src/back/routes/common/horizontal-scroller/horizontal-scroller.stimulus.ts create mode 100644 src/back/routes/common/horizontal-scroller/horizontal-scroller.tsx create mode 100644 src/back/routes/horizontal-scroller-demo.css create mode 100644 src/back/routes/horizontal-scroller-demo.page.tsx create mode 100644 src/back/routes/horizontal-scroller-demo.test.ts diff --git a/src/back/routes/common/horizontal-scroller/horizontal-scroller.css b/src/back/routes/common/horizontal-scroller/horizontal-scroller.css new file mode 100644 index 0000000..972651f --- /dev/null +++ b/src/back/routes/common/horizontal-scroller/horizontal-scroller.css @@ -0,0 +1,39 @@ +.horizontal-scroller { + .horizontal-scroller__element-container { + display: flex; + flex-flow: row; + gap: 24px; + overflow-x: auto; + scroll-snap-type: x mandatory; + padding-bottom: 12px; + + & > * { + scroll-snap-align: center; + } + + .horizontal-scroller__element { + width: 100%; + display: flex; + justify-content: center; + } + } + + .horizontal-scroller__markers { + display: flex; + flex-flow: row wrap; + justify-content: center; + gap: 8px; + margin-top: 8px; + } + + .horizontal-scroller__marker { + width: 10px; + height: 10px; + background-color: blue; + opacity: max(0.3, var(--visibility-value)); + + &.active { + opacity: 1; + } + } +} diff --git a/src/back/routes/common/horizontal-scroller/horizontal-scroller.stimulus.ts b/src/back/routes/common/horizontal-scroller/horizontal-scroller.stimulus.ts new file mode 100644 index 0000000..e1a5da5 --- /dev/null +++ b/src/back/routes/common/horizontal-scroller/horizontal-scroller.stimulus.ts @@ -0,0 +1,80 @@ +/* eslint-disable @typescript-eslint/consistent-type-assertions */ +import { Controller } from "stimulus"; + +export default class HorizontalScroller extends Controller { + currentIndex = 0; + interval_id: number; + + declare scrollerTarget: HTMLDivElement; + declare markerTargets: HTMLDivElement[]; + declare elementTargets: HTMLDivElement[]; + static targets = ["scroller", "marker", "element"]; + + visibility: number[]; + + connect() { + this.visibility = this.elementTargets.map(() => 0); + [0, 0.5, 0.9, 1].forEach((threshold) => { + const observer = new IntersectionObserver( + (events) => this.handleIntersection(events), + { + root: this.scrollerTarget, + rootMargin: "0px", + threshold, + } + ); + this.elementTargets.forEach((e) => observer.observe(e)); + }); + + this.refreshMarkers(); + } + + handleIntersection(events: IntersectionObserverEntry[]) { + events.forEach((event) => { + const element_index = this.elementTargets.indexOf( + event.target as HTMLDivElement + ); + this.visibility[element_index] = event.intersectionRatio; + }); + this.refreshMarkers(); + } + + refreshMarkers() { + this.visibility.forEach((value, index) => { + this.markerTargets[index].style.setProperty( + "--visibility-value", + value.toFixed(2) + ); + }); + this.element.classList.toggle("has-next", this.visibility.at(-1) < 1); + this.element.classList.toggle("has-prev", this.visibility.at(0) < 1); + } + + findNextNotFullyVisible(visibility: number[]) { + // taking it as an argument instead of using this.visibilty because sometimes we'd + // want to read it in reverse to get the previous instead of next + let current_index = 0; + while (visibility[current_index] !== 1 && current_index < visibility.length) { + current_index++; + } + while (visibility[current_index] == 1 && current_index < visibility.length) { + current_index++; + } + return current_index; + } + + scrollLeft() { + const index = Math.max( + 0, + this.visibility.length - + 1 - + this.findNextNotFullyVisible([...this.visibility].reverse()) + ); + this.elementTargets[index].scrollIntoView({ behavior: "smooth" }); + } + + scrollRight() { + const index = this.findNextNotFullyVisible(this.visibility); + this.elementTargets[index].scrollIntoView({ behavior: "smooth" }); + } +} diff --git a/src/back/routes/common/horizontal-scroller/horizontal-scroller.tsx b/src/back/routes/common/horizontal-scroller/horizontal-scroller.tsx new file mode 100644 index 0000000..77a8f40 --- /dev/null +++ b/src/back/routes/common/horizontal-scroller/horizontal-scroller.tsx @@ -0,0 +1,64 @@ +import type { FlatTemplatable } from "tempstream"; +import { tempstream, TempstreamJSX } from "tempstream"; + +const make_id = function* () { + let i = 0; + while (true) { + yield i++; + if (i == 999999999) { + i = 0; + } + } +}; + +export async function horizontalScroller({ + classes = [], + elements, + render = async ({ scroller, markers }) => tempstream`${scroller}${markers}`, +}: { + classes?: string[]; + elements: FlatTemplatable[]; + render?: (options: { + scroller: FlatTemplatable; + markers: FlatTemplatable; + }) => Promise; +}) { + const id = make_id().next().value; + const scroller_id = `horizontal-scroller-${id}`; + const scroller = ( +
+ {elements.map((e, index) => ( +
+ {e} +
+ ))} +
+ ); + const markers = ( +
+ {elements.map(() => ( +
+ ))} +
+ ); + return ( +
+ {render({ scroller, markers })} +
+ ); +} diff --git a/src/back/routes/horizontal-scroller-demo.css b/src/back/routes/horizontal-scroller-demo.css new file mode 100644 index 0000000..5070db1 --- /dev/null +++ b/src/back/routes/horizontal-scroller-demo.css @@ -0,0 +1,24 @@ +.bignum { + font-size: 60px; + background-color: aquamarine; + color: hsl(159.8, 100%, 39.9%); + width: 150px; + height: 200px; + display: flex; + align-items: center; + justify-content: center; +} + +.horizontal-scroller { + .next-button, + .prev-button { + pointer-events: none; + opacity: 0.5; + } + + &.has-next .next-button, + &.has-prev .prev-button { + pointer-events: all; + opacity: 1; + } +} diff --git a/src/back/routes/horizontal-scroller-demo.page.tsx b/src/back/routes/horizontal-scroller-demo.page.tsx new file mode 100644 index 0000000..f53ca95 --- /dev/null +++ b/src/back/routes/horizontal-scroller-demo.page.tsx @@ -0,0 +1,50 @@ +import type { Context } from "koa"; +import { TempstreamJSX } from "tempstream"; +import { Page } from "@sealcode/sealgen"; +import html from "../html.js"; +import { horizontalScroller } from "./common/horizontal-scroller/horizontal-scroller.js"; + +export const actionName = "HorizontalScrollerDemo"; + +export default new (class HorizontalScrollerDemoPage extends Page { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async canAccess(_: Context) { + return { canAccess: true, message: "" }; + } + + async render(ctx: Context) { + return html( + ctx, + "HorizontalScrollerDemo", +
+ {horizontalScroller({ + elements: [1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13].map((n) => ( +
{n}
+ )), + render: async ({ scroller, markers }) => ( +
+
+ + +
+ {scroller} + {markers} +
+ ), + })} +
+ ); + } +})(); diff --git a/src/back/routes/horizontal-scroller-demo.test.ts b/src/back/routes/horizontal-scroller-demo.test.ts new file mode 100644 index 0000000..b7a4266 --- /dev/null +++ b/src/back/routes/horizontal-scroller-demo.test.ts @@ -0,0 +1,40 @@ +import { withProdApp } from "../test_utils/with-prod-app.js"; +import { VERY_LONG_TEST_TIMEOUT, webhintURL } from "../test_utils/webhint.js"; +import { HorizontalScrollerDemoURL } from "./urls.js"; +import { getBrowser } from "../test_utils/browser-creator.js"; +import type { Browser, BrowserContext, Page } from "@playwright/test"; + +describe("HorizontalScrollerDemo webhint", () => { + it("doesn't crash", async function () { + return withProdApp(async ({ base_url, rest_api }) => { + await rest_api.get(HorizontalScrollerDemoURL); + await webhintURL(base_url + HorizontalScrollerDemoURL); + // alternatively you can use webhintHTML for faster but less precise scans + // or for scanning responses of requests that use some form of authorization: + // const response = await rest_api.get(HorizontalScrollerDemoURL); + // await webhintHTML(response); + }); + }).timeout(VERY_LONG_TEST_TIMEOUT); +}); + +describe("HorizontalScrollerDemo", () => { + let page: Page; + let browser: Browser; + let context: BrowserContext; + + beforeEach(async () => { + browser = await getBrowser(); + context = await browser.newContext(); + page = await context.newPage(); + }); + + afterEach(async () => { + await context.close(); + }); + + it("works as expected", async function () { + return withProdApp(async ({ base_url }) => { + await page.goto(base_url + HorizontalScrollerDemoURL); + }); + }).timeout(VERY_LONG_TEST_TIMEOUT); +}); diff --git a/src/front/controllers.ts b/src/front/controllers.ts index 07343c1..d8c96ef 100644 --- a/src/front/controllers.ts +++ b/src/front/controllers.ts @@ -16,6 +16,9 @@ application.register("autoscrolling-images", AutoscrollingImages); import { default as MapWithPins } from "./../back/jdd-components/map-with-pins/map-with-pins.stimulus.js"; application.register("map-with-pins", MapWithPins); +import { default as HorizontalScroller } from "./../back/routes/common/horizontal-scroller/horizontal-scroller.stimulus.js"; +application.register("horizontal-scroller", HorizontalScroller); + import { default as ComponentDebugger } from "./../back/routes/component-preview/component-debugger.stimulus.js"; application.register("component-debugger", ComponentDebugger);