Add horizontal scroller ui component
This commit is contained in:
parent
ae6f87e815
commit
636ac56070
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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" });
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
24
src/back/routes/horizontal-scroller-demo.css
Normal file
24
src/back/routes/horizontal-scroller-demo.css
Normal 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;
|
||||
}
|
||||
}
|
50
src/back/routes/horizontal-scroller-demo.page.tsx
Normal file
50
src/back/routes/horizontal-scroller-demo.page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
})();
|
40
src/back/routes/horizontal-scroller-demo.test.ts
Normal file
40
src/back/routes/horizontal-scroller-demo.test.ts
Normal 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);
|
||||
});
|
@ -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);
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user