Add horizontal scroller ui component

This commit is contained in:
Kuba Orlik 2024-07-02 17:33:03 +02:00
parent ae6f87e815
commit 636ac56070
7 changed files with 300 additions and 0 deletions

View File

@ -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;
}
}
}

View File

@ -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" });
}
}

View File

@ -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<FlatTemplatable>;
}) {
const id = make_id().next().value;
const scroller_id = `horizontal-scroller-${id}`;
const scroller = (
<div
class="horizontal-scroller__element-container"
data-horizontal-scroller-target="scroller"
>
{elements.map((e, index) => (
<div
id={scroller_id + "__element--number-" + index}
class={"horizontal-scroller__element"}
data-horizontal-scroller-target="element"
data-index={index}
>
<span>{e}</span>
</div>
))}
</div>
);
const markers = (
<div class="horizontal-scroller__markers">
{elements.map(() => (
<div
class="horizontal-scroller__marker"
data-horizontal-scroller-target="marker"
></div>
))}
</div>
);
return (
<div
id={scroller_id}
class={["horizontal-scroller", ...classes]}
data-controller="horizontal-scroller"
>
{render({ scroller, markers })}
</div>
);
}

View File

@ -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;
}
}

View File

@ -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",
<div>
{horizontalScroller({
elements: [1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13].map((n) => (
<div class="bignum">{n}</div>
)),
render: async ({ scroller, markers }) => (
<div>
<div>
<button
class="prev-button"
data-action="horizontal-scroller#scrollLeft"
>
{" "}
{" "}
</button>
<button
class="next-button"
data-action="horizontal-scroller#scrollRight"
>
{" "}
{" "}
</button>
</div>
{scroller}
{markers}
</div>
),
})}
</div>
);
}
})();

View File

@ -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);
});

View File

@ -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"; import { default as MapWithPins } from "./../back/jdd-components/map-with-pins/map-with-pins.stimulus.js";
application.register("map-with-pins", MapWithPins); 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"; import { default as ComponentDebugger } from "./../back/routes/component-preview/component-debugger.stimulus.js";
application.register("component-debugger", ComponentDebugger); application.register("component-debugger", ComponentDebugger);