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";
|
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);
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user