From f02eb28aff3004270b3ea2833af1f593278f2e15 Mon Sep 17 00:00:00 2001 From: zumm Date: Wed, 1 Jun 2022 17:17:38 +0700 Subject: [PATCH 1/4] fix: controls styling in demo --- src/demo/Control.vue | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/demo/Control.vue b/src/demo/Control.vue index 9083a60..dceef69 100644 --- a/src/demo/Control.vue +++ b/src/demo/Control.vue @@ -80,8 +80,8 @@ /> -
-

Scroll Behavior:

+
+

Scroll Behavior:

-
@@ -140,11 +176,11 @@ export default defineComponent({ display: grid; grid-template: - "length pageProvider" auto - "pageSize pageProvider" auto - "scrollTo pageProvider" auto - "scrollBehavior pageProvider" auto - / 2fr 1fr; + "length scrollMode pageProvider" auto + "pageSize scrollMode pageProvider" auto + "scrollTo scrollMode pageProvider" auto + "scrollBehavior scrollMode pageProvider" auto + / 2fr 1fr 1fr; place-items: center stretch; grid-gap: 1.5rem; } @@ -165,6 +201,14 @@ export default defineComponent({ justify-content: flex-start; } +.scrollMode { + grid-area: scrollMode; + place-self: stretch; + display: flex; + flex-flow: column nowrap; + justify-content: flex-start; +} + .scrollTo { grid-area: scrollTo; } @@ -222,8 +266,8 @@ export default defineComponent({ @media (min-width: 760px) { .root { grid-template: - "length pageSize pageProvider scrollTo scrollBehavior" auto - / 2fr 2fr 2fr 1fr 1fr; + "length pageSize pageProvider scrollMode scrollTo scrollBehavior" auto + / 2fr 2fr 2fr 2fr 1fr 1fr; } .category { diff --git a/src/demo/Header.vue b/src/demo/Header.vue index d37ee4c..09e867a 100644 --- a/src/demo/Header.vue +++ b/src/demo/Header.vue @@ -40,7 +40,6 @@ export default defineComponent({ .root { background-color: var(--color-rice); padding: 1.5rem; - margin-bottom: 1rem; display: grid; grid-template: "title title" auto diff --git a/src/demo/store.ts b/src/demo/store.ts index 272ce7c..a52c497 100644 --- a/src/demo/store.ts +++ b/src/demo/store.ts @@ -12,6 +12,9 @@ export const scrollBehavior = ref("smooth"); export type Collection = "" | "all-mens" | "womens-view-all"; export const collection = ref(""); +export type ScrollMode = "vertical" | "horizontal"; +export const scrollMode = ref("vertical"); + export const generalPageProvider = curry( ( collection: Collection, diff --git a/src/pipeline.test.ts b/src/pipeline.test.ts index c29c371..e2079a0 100644 --- a/src/pipeline.test.ts +++ b/src/pipeline.test.ts @@ -2,9 +2,9 @@ import { accumulateAllItems, accumulateBuffer, callPageProvider, - computeHeightAboveWindowOf, + computeSpaceBehindWindowOf, getBufferMeta, - getContentHeight, + getContentSize, getGridMeasurement, getObservableOfVisiblePageNumbers, getResizeMeasurement, @@ -12,16 +12,16 @@ import { } from "./pipeline"; import { TestScheduler } from "rxjs/testing"; -describe("computeHeightAboveWindowOf", () => { +describe("computeSpaceBehindWindowOf", () => { // Mock getBoundingClientRect() for jsdom as it always returns: // { bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0 } - function createMockDiv(top: number): HTMLElement { + function createMockDiv(left: number = 0, top: number = 0): HTMLElement { const div = document.createElement("div"); div.getBoundingClientRect = () => ({ width: 0, height: 0, top, - left: 0, + left, right: 0, bottom: 0, x: 0, @@ -32,48 +32,72 @@ describe("computeHeightAboveWindowOf", () => { return div; } - it("returns 0 when the element is below window top", () => { - const el = createMockDiv(100); - const height = computeHeightAboveWindowOf(el); + it("returns 0 space when the element is after window", () => { + const el = createMockDiv(100, 200); + const space = computeSpaceBehindWindowOf(el); - expect(height).toBe(0); + expect(space).toEqual({ width: 0, height: 0 }); }); - it("returns the height above window when the element is above window top", () => { - const el = createMockDiv(-100); - const height = computeHeightAboveWindowOf(el); + it("returns the space behind window when the element is before window", () => { + const el = createMockDiv(-100, -200); + const space = computeSpaceBehindWindowOf(el); - expect(height).toBe(100); + expect(space).toEqual({ width: 100, height: 200 }); }); }); function createGridRoot( rowGap: string = "10px", columnGap: string = "20px", - gridTemplateColumns: string = "30px 30px 30px" + gridAutoFlow: string = "row", + gridTemplateColumns: string = "30px 30px 30px", + gridTemplateRows: string = "30px 30px 30px" ): HTMLElement { const el = document.createElement("div"); - Object.assign(el.style, { rowGap, columnGap, gridTemplateColumns }); + Object.assign(el.style, { + rowGap, + columnGap, + gridAutoFlow, + gridTemplateColumns, + gridTemplateRows, + }); return el; } describe("getGridMeasurement", () => { it("returns correct grid measurement in numbers", () => { - const el = createGridRoot("10px", "20px", "30px 30px 30px"); + const el = createGridRoot("10px", "20px", "row", "30px 30px 30px", "30px"); const measurement = getGridMeasurement(el); expect(measurement).toEqual({ rowGap: 10, colGap: 20, + flow: "row", columns: 3, + rows: 1, }); }); + + it("returns correct grid flow when flow is dense", () => { + const el = createGridRoot("10px", "20px", "dense"); + const { flow } = getGridMeasurement(el); + + expect(flow).toBe("row"); + }); + + it("returns correct grid flow when flow contains two words", () => { + const el = createGridRoot("10px", "20px", "column dense"); + const { flow } = getGridMeasurement(el); + + expect(flow).toBe("column"); + }); }); describe("getResizeMeasurement", () => { it("returns correct grid measurement in numbers", () => { - const el = createGridRoot("10px", "20px", "30px 30px 30px"); + const el = createGridRoot("10px", "20px", "column", "30px", "20px 20px"); const measurement = getResizeMeasurement(el, { width: 10, @@ -89,7 +113,10 @@ describe("getResizeMeasurement", () => { expect(measurement).toEqual({ rowGap: 10, - columns: 3, + colGap: 20, + flow: "column", + columns: 1, + rows: 2, itemHeightWithGap: 30, itemWidthWithGap: 30, }); @@ -97,27 +124,57 @@ describe("getResizeMeasurement", () => { }); describe("getBufferMeta", () => { - it("returns correct buffer meta data when heightAboveWindow is 0", () => { - const meta = getBufferMeta(1000)(0, { - columns: 3, + function createMockResizeMeasurement(flow: "row" | "column") { + return { + colGap: 20, rowGap: 10, + flow, + columns: 3, + rows: 2, itemHeightWithGap: 50, itemWidthWithGap: 50, - }); + }; + } + + it("returns correct buffer meta data when flow is row and space is 0", () => { + const space = { width: 0, height: 0 }; + const meta = getBufferMeta(1000, 1000)( + space, + createMockResizeMeasurement("row") + ); expect(meta).toEqual({ bufferedOffset: 0, bufferedLength: 132 }); }); - it("returns correct buffer meta data when heightAboveWindow is greater than 0", () => { - const meta = getBufferMeta(1000)(5000, { - columns: 3, - rowGap: 10, - itemHeightWithGap: 50, - itemWidthWithGap: 50, - }); + it("returns correct buffer meta data when flow is column and space is 0", () => { + const space = { width: 0, height: 0 }; + const meta = getBufferMeta(1000, 1000)( + space, + createMockResizeMeasurement("column") + ); + + expect(meta).toEqual({ bufferedOffset: 0, bufferedLength: 88 }); + }); + + it("returns correct buffer meta data when flow is row and space is greater than 0", () => { + const space = { width: 5000, height: 5000 }; + const meta = getBufferMeta(1000, 1000)( + space, + createMockResizeMeasurement("row") + ); expect(meta).toEqual({ bufferedOffset: 267, bufferedLength: 132 }); }); + + it("returns correct buffer meta data when flow is column and space is greater than 0", () => { + const space = { width: 5000, height: 5000 }; + const meta = getBufferMeta(1000, 1000)( + space, + createMockResizeMeasurement("column") + ); + + expect(meta).toEqual({ bufferedOffset: 178, bufferedLength: 88 }); + }); }); describe("getObservableOfVisiblePageNumbers", () => { @@ -225,12 +282,15 @@ describe("accumulateAllItems", () => { }); describe("getVisibleItems", () => { - it("returns correct visible items", () => { + it("returns correct visible items when flow is row", () => { const visibleItems = getVisibleItems( { bufferedOffset: 2, bufferedLength: 2 }, { - columns: 2, + colGap: 10, rowGap: 10, + flow: "row", + columns: 2, + rows: 2, itemHeightWithGap: 50, itemWidthWithGap: 60, }, @@ -256,6 +316,8 @@ describe("getVisibleItems", () => { }, ]); }); + + // TODO: test getVisibleItems() when flow is column }); describe("accumulateBuffer", () => { @@ -335,19 +397,31 @@ describe("accumulateBuffer", () => { }); }); -describe("getContentHeight", () => { +describe("getContentSize", () => { + function createMockResizeMeasurement(flow: "row" | "column") { + return { + colGap: 10, + rowGap: 10, + flow: flow, + columns: 5, + rows: 5, + itemHeightWithGap: 100, + itemWidthWithGap: 100, + }; + } + + it("returns correct content width", () => { + const measurement = createMockResizeMeasurement("column"); + const contentSize = getContentSize(measurement, 1000); + + expect(contentSize).toEqual({ width: 19_990 }); + }); + it("returns correct content height", () => { - const contentHeight = getContentHeight( - { - columns: 5, - rowGap: 10, - itemHeightWithGap: 100, - itemWidthWithGap: 100, - }, - 1000 - ); + const measurement = createMockResizeMeasurement("row"); + const contentSize = getContentSize(measurement, 1000); - expect(contentHeight).toBe(19_990); + expect(contentSize).toEqual({ height: 19_990 }); }); }); diff --git a/src/pipeline.ts b/src/pipeline.ts index 9eeed9b..b99b0b2 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -34,18 +34,28 @@ import { without, zip, } from "ramda"; -import { getVerticalScrollParent } from "./utilites"; +import { getScrollParents } from "./utilites"; -export function computeHeightAboveWindowOf(el: Element): number { - const top = el.getBoundingClientRect().top; +interface SpaceBehindWindow { + width: number; + height: number; +} + +export function computeSpaceBehindWindowOf(el: Element): SpaceBehindWindow { + const { left, top } = el.getBoundingClientRect(); - return Math.abs(Math.min(top, 0)); + return { + width: Math.abs(Math.min(left, 0)), + height: Math.abs(Math.min(top, 0)), + }; } interface GridMeasurement { colGap: number; rowGap: number; + flow: "row" | "column"; columns: number; + rows: number; } export function getGridMeasurement(rootEl: Element): GridMeasurement { @@ -54,14 +64,17 @@ export function getGridMeasurement(rootEl: Element): GridMeasurement { return { rowGap: parseInt(computedStyle.getPropertyValue("row-gap")) || 0, colGap: parseInt(computedStyle.getPropertyValue("column-gap")) || 0, + flow: computedStyle.getPropertyValue("grid-auto-flow").startsWith("column") + ? "column" + : "row", columns: computedStyle.getPropertyValue("grid-template-columns").split(" ") .length, + rows: computedStyle.getPropertyValue("grid-template-rows").split(" ") + .length, }; } -interface ResizeMeasurement { - rowGap: number; - columns: number; +interface ResizeMeasurement extends GridMeasurement { itemHeightWithGap: number; itemWidthWithGap: number; } @@ -70,11 +83,14 @@ export function getResizeMeasurement( rootEl: Element, { height, width }: DOMRectReadOnly ): ResizeMeasurement { - const { rowGap, colGap, columns } = getGridMeasurement(rootEl); + const { colGap, rowGap, flow, columns, rows } = getGridMeasurement(rootEl); return { + colGap, rowGap, + flow, columns, + rows, itemHeightWithGap: height + rowGap, itemWidthWithGap: width + colGap, }; @@ -86,20 +102,50 @@ interface BufferMeta { } export const getBufferMeta = - (windowInnerHeight: number = window.innerHeight) => ( - heightAboveWindow: number, - { columns, rowGap, itemHeightWithGap }: ResizeMeasurement + windowInnerWidth: number = window.innerWidth, + windowInnerHeight: number = window.innerHeight + ) => + ( + { width: widthBehindWindow, height: heightBehindWindow }: SpaceBehindWindow, + { + colGap, + rowGap, + flow, + columns, + rows, + itemHeightWithGap, + itemWidthWithGap, + }: ResizeMeasurement ): BufferMeta => { - const rowsInView = - itemHeightWithGap && - Math.ceil((windowInnerHeight + rowGap) / itemHeightWithGap) + 1; - const length = rowsInView * columns; - - const rowsBeforeView = - itemHeightWithGap && - Math.floor((heightAboveWindow + rowGap) / itemHeightWithGap); - const offset = rowsBeforeView * columns; + let crosswiseLines; + let gap; + let itemSizeWithGap; + let windowInnerSize; + let spaceBehindWindow; + if (flow === "row") { + crosswiseLines = columns; + gap = rowGap; + itemSizeWithGap = itemHeightWithGap; + windowInnerSize = windowInnerHeight; + spaceBehindWindow = heightBehindWindow; + } else { + crosswiseLines = rows; + gap = colGap; + itemSizeWithGap = itemWidthWithGap; + windowInnerSize = windowInnerWidth; + spaceBehindWindow = widthBehindWindow; + } + + const linesInView = + itemSizeWithGap && + Math.ceil((windowInnerSize + gap) / itemSizeWithGap) + 1; + const length = linesInView * crosswiseLines; + + const linesBeforeView = + itemSizeWithGap && + Math.floor((spaceBehindWindow + gap) / itemSizeWithGap); + const offset = linesBeforeView * crosswiseLines; const bufferedOffset = Math.max(offset - Math.floor(length / 2), 0); const bufferedLength = length * 2; @@ -166,6 +212,34 @@ export function accumulateAllItems( )(allItems); } +interface ItemOffset { + x: number; + y: number; +} + +export function getItemOffsetByIndex( + index: number, + { + flow, + columns, + rows, + itemWidthWithGap, + itemHeightWithGap, + }: ResizeMeasurement +): ItemOffset { + let x; + let y; + if (flow === "row") { + x = (index % columns) * itemWidthWithGap; + y = Math.floor(index / columns) * itemHeightWithGap; + } else { + x = Math.floor(index / rows) * itemWidthWithGap; + y = (index % rows) * itemHeightWithGap; + } + + return { x, y }; +} + export interface InternalItem { index: number; value: unknown | undefined; @@ -174,15 +248,14 @@ export interface InternalItem { export function getVisibleItems( { bufferedOffset, bufferedLength }: BufferMeta, - { columns, itemWidthWithGap, itemHeightWithGap }: ResizeMeasurement, + resizeMeasurement: ResizeMeasurement, allItems: unknown[] ): InternalItem[] { return pipe( slice(bufferedOffset, bufferedOffset + bufferedLength), addIndex(ramdaMap)((value, localIndex) => { const index = bufferedOffset + localIndex; - const x = (index % columns) * itemWidthWithGap; - const y = Math.floor(index / columns) * itemHeightWithGap; + const { x, y } = getItemOffsetByIndex(index, resizeMeasurement); return { index, @@ -217,11 +290,26 @@ export function accumulateBuffer( )(buffer); } -export function getContentHeight( - { columns, rowGap, itemHeightWithGap }: ResizeMeasurement, +interface ContentSize { + width?: number; + height?: number; +} + +export function getContentSize( + { + colGap, + rowGap, + flow, + columns, + rows, + itemWidthWithGap, + itemHeightWithGap, + }: ResizeMeasurement, length: number -): number { - return itemHeightWithGap * Math.ceil(length / columns) - rowGap; +): ContentSize { + return flow === "row" + ? { height: itemHeightWithGap * Math.ceil(length / columns) - rowGap } + : { width: itemWidthWithGap * Math.ceil(length / rows) - colGap }; } interface PipelineInput { @@ -235,11 +323,19 @@ interface PipelineInput { scrollTo$: Observable; } -export type ScrollAction = [Element, number]; +interface ScrollOffset { + left?: number; + top?: number; +} + +export type ScrollAction = { + target: Element; + offset: ScrollOffset; +}; interface PipelineOutput { buffer$: Observable; - contentHeight$: Observable; + contentSize$: Observable; scrollAction$: Observable; } @@ -254,19 +350,19 @@ export function pipeline({ scrollTo$, }: PipelineInput): PipelineOutput { // region: measurements of the visual grid - const heightAboveWindow$: Observable = merge( + const spaceBehindWindow$: Observable = merge( rootResize$, scroll$ - ).pipe(map(computeHeightAboveWindowOf), distinctUntilChanged()); + ).pipe(map(computeSpaceBehindWindowOf), distinctUntilChanged()); const resizeMeasurement$: Observable = combineLatest( [rootResize$, itemRect$], getResizeMeasurement ).pipe(distinctUntilChanged(equals)); - const contentHeight$: Observable = combineLatest( + const contentSize$: Observable = combineLatest( [resizeMeasurement$, length$], - getContentHeight + getContentSize ); // endregion @@ -279,11 +375,11 @@ export function pipeline({ take(1) ) ), - map<[number, ResizeMeasurement, Element], ScrollAction>( - ([scrollTo, { columns, itemHeightWithGap }, rootEl]) => { - const verticalScrollEl = getVerticalScrollParent(rootEl); - - const computedStyle = window.getComputedStyle(rootEl); + map<[number, ResizeMeasurement, Element], ScrollAction[]>( + ([scrollTo, resizeMeasurement, rootEl]) => { + const { vertical: verticalScrollEl, horizontal: horizontalScrollEl } = + getScrollParents(rootEl); + const computedStyle = getComputedStyle(rootEl); const gridPaddingTop = parseInt( computedStyle.getPropertyValue("padding-top") @@ -292,30 +388,51 @@ export function pipeline({ computedStyle.getPropertyValue("border-top") ); + const gridPaddingLeft = parseInt( + computedStyle.getPropertyValue("padding-left") + ); + const gridBoarderLeft = parseInt( + computedStyle.getPropertyValue("border-left") + ); + + const leftToGridContainer = + rootEl instanceof HTMLElement && + horizontalScrollEl instanceof HTMLElement + ? rootEl.offsetLeft - horizontalScrollEl.offsetLeft + : 0; + const topToGridContainer = rootEl instanceof HTMLElement && verticalScrollEl instanceof HTMLElement ? rootEl.offsetTop - verticalScrollEl.offsetTop : 0; - // The offset within the scroll container + const { x, y } = getItemOffsetByIndex(scrollTo, resizeMeasurement); + + const scrollLeft = + x + leftToGridContainer + gridPaddingLeft + gridBoarderLeft; const scrollTop = - // row count * row height - Math.floor(scrollTo / columns) * itemHeightWithGap + - // top to the scroll container - topToGridContainer + - // the padding + boarder top of grid - gridPaddingTop + - gridBoarderTop; - return [verticalScrollEl, scrollTop]; + y + topToGridContainer + gridPaddingTop + gridBoarderTop; + + return [ + { + target: verticalScrollEl, + offset: { top: scrollTop }, + }, + { + target: horizontalScrollEl, + offset: { left: scrollLeft }, + }, + ]; } - ) + ), + mergeAll() ); // endregion // region: rendering buffer const bufferMeta$: Observable = combineLatest( - [heightAboveWindow$, resizeMeasurement$], + [spaceBehindWindow$, resizeMeasurement$], getBufferMeta() ).pipe(distinctUntilChanged(equals)); @@ -352,5 +469,5 @@ export function pipeline({ ).pipe(scan(accumulateBuffer, [])); // endregion - return { buffer$, contentHeight$, scrollAction$: scrollAction$ }; + return { buffer$, contentSize$, scrollAction$: scrollAction$ }; } diff --git a/src/utilites.ts b/src/utilites.ts index 403745d..e91b3c4 100644 --- a/src/utilites.ts +++ b/src/utilites.ts @@ -64,19 +64,31 @@ export function useObservable(observable: Observable): Readonly> { return valueRef as Readonly>; } -export function getVerticalScrollParent( +interface ScrollParents { + vertical: Element; + horizontal: Element; +} + +export function getScrollParents( element: Element, includeHidden: boolean = false -): Element { +): ScrollParents { const style = getComputedStyle(element); + + if (style.position === "fixed") { + return { + vertical: document.body, + horizontal: document.body, + }; + } + const excludeStaticParent = style.position === "absolute"; const overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/; - if (style.position === "fixed") { - return document.body; - } + let vertical; + let horizontal; for ( let parent: Element | null = element; @@ -85,13 +97,22 @@ export function getVerticalScrollParent( ) { const parentStyle = getComputedStyle(parent); - if (excludeStaticParent && parentStyle.position === "static") { - continue; + if (excludeStaticParent && parentStyle.position === "static") continue; + + if (!horizontal && overflowRegex.test(parentStyle.overflowX)) { + horizontal = parent; + if (vertical) return { vertical, horizontal }; } - if (overflowRegex.test(parentStyle.overflow + parentStyle.overflowY)) - return parent; + if (!vertical && overflowRegex.test(parentStyle.overflowY)) { + vertical = parent; + if (horizontal) return { vertical, horizontal }; + } } - return document.scrollingElement || document.documentElement; + const fallback = document.scrollingElement || document.documentElement; + return { + vertical: vertical ?? fallback, + horizontal: horizontal ?? fallback, + }; }