diff --git a/src/components/Board/Board.tsx b/src/components/Board/Board.tsx index 6bc9f821f1..a09846d164 100644 --- a/src/components/Board/Board.tsx +++ b/src/components/Board/Board.tsx @@ -5,12 +5,13 @@ import {MenuBars} from "components/MenuBars"; import {InfoBar} from "components/Infobar"; import {BoardHeader} from "components/BoardHeader"; import {HotkeyAnchor} from "components/HotkeyAnchor"; -import "./Board.scss"; import {useDndMonitor} from "@dnd-kit/core"; import classNames from "classnames"; import {useStripeOffset} from "utils/hooks/useStripeOffset"; import {Toast} from "utils/Toast"; import {useTranslation} from "react-i18next"; +import {useIsTouchingSides} from "utils/hooks/useIsTouchingSides"; +import "./Board.scss"; export interface BoardProps { children: React.ReactElement | React.ReactElement[]; @@ -19,30 +20,8 @@ export interface BoardProps { locked: boolean; } -export interface BoardState { - showNextButton: boolean; - showPreviousButton: boolean; -} - -export interface ColumnState { - firstVisibleColumnIndex: number; - lastVisibleColumnIndex: number; -} - export const BoardComponent = ({children, currentUserIsModerator, moderating, locked}: BoardProps) => { const {t} = useTranslation(); - const [state, setState] = useState({ - firstVisibleColumnIndex: 0, - lastVisibleColumnIndex: React.Children.count(children), - showNextButton: false, - showPreviousButton: false, - }); - - const [columnState, setColumnState] = useState({ - firstVisibleColumnIndex: 0, - lastVisibleColumnIndex: React.Children.count(children), - }); - const [dragActive, setDragActive] = useState(false); useDndMonitor({ onDragStart() { @@ -57,7 +36,6 @@ export const BoardComponent = ({children, currentUserIsModerator, moderating, lo }); const boardRef = useRef(null); - const columnVisibilityStatesRef = useRef([]); const columnsCount = React.Children.count(children); @@ -65,6 +43,8 @@ export const BoardComponent = ({children, currentUserIsModerator, moderating, lo const leftSpacerOffset = useStripeOffset({gradientLength: 40, gradientAngle: 45}); const rightSpacerOffset = useStripeOffset({gradientLength: 40, gradientAngle: 45}); + const {isTouchingLeftSide, isTouchingRightSide} = useIsTouchingSides(boardRef); + useEffect(() => { leftSpacerOffset.updateOffset(); rightSpacerOffset.updateOffset(); @@ -72,73 +52,6 @@ export const BoardComponent = ({children, currentUserIsModerator, moderating, lo // eslint-disable-next-line react-hooks/exhaustive-deps }, [children]); - useEffect(() => { - const board = boardRef.current; - - if (board) { - // initialize column visibility states - columnVisibilityStatesRef.current = new Array(React.Children.count(children)); - const columnVisibilityStates = columnVisibilityStatesRef.current; - columnVisibilityStates.fill(false); - - // initialize intersection observer - const observerOptions = { - root: board, - rootMargin: "0px", - threshold: 1.0, - }; - const observerCallback: IntersectionObserverCallback = (entries) => { - entries.forEach((entry) => { - const index = Array.prototype.indexOf.call(board.children, entry.target) - 1; - columnVisibilityStates[index] = entry.isIntersecting; - }); - - const firstVisibleColumnIndex = columnVisibilityStates.findIndex((value) => value); - const lastVisibleColumnIndex = columnVisibilityStates.lastIndexOf(true); - - setColumnState({ - firstVisibleColumnIndex, - lastVisibleColumnIndex, - }); - }; - const observer = new IntersectionObserver(observerCallback, observerOptions); - - // observe children - const domChildren = board.children; - for (let i = 1; i < domChildren.length - 1; i += 1) { - observer.observe(domChildren[i]); - } - - // return callback handler that will disconnect the observer on unmount - return () => { - observer.disconnect(); - }; - } - return undefined; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [children]); - - useEffect(() => { - let firstVisibleColumnIndex; - let lastVisibleColumnIndex; - - if (columnState.firstVisibleColumnIndex === -1 && columnState.lastVisibleColumnIndex === -1) { - firstVisibleColumnIndex = state.firstVisibleColumnIndex; - lastVisibleColumnIndex = state.firstVisibleColumnIndex - 1; - } else { - firstVisibleColumnIndex = columnState.firstVisibleColumnIndex; - lastVisibleColumnIndex = columnState.lastVisibleColumnIndex; - } - - setState({ - firstVisibleColumnIndex, - lastVisibleColumnIndex, - showNextButton: lastVisibleColumnIndex < columnsCount - 1, - showPreviousButton: firstVisibleColumnIndex > 0, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [columnState]); - useEffect(() => { if (locked) { Toast.info({title: t("Toast.lockedBoard"), autoClose: 10_000}); @@ -163,18 +76,13 @@ export const BoardComponent = ({children, currentUserIsModerator, moderating, lo ); } - const {firstVisibleColumnIndex, lastVisibleColumnIndex} = state; const columnColors = React.Children.map(children, (child) => child.props.color); - const previousColumnIndex = firstVisibleColumnIndex > 0 ? firstVisibleColumnIndex - 1 : columnsCount - 1; - const nextColumnIndex = lastVisibleColumnIndex === columnsCount - 1 ? 0 : firstVisibleColumnIndex + 1; - - const handlePreviousClick = () => { - boardRef.current!.children[previousColumnIndex + 1].scrollIntoView({inline: "start", behavior: "smooth"}); - }; - - const handleNextClick = () => { - boardRef.current!.children[nextColumnIndex + 1].scrollIntoView({inline: "start", behavior: "smooth"}); + const scrollBoard = (direction: "left" | "right") => { + const boardWidth = boardRef.current?.scrollWidth ?? 0; + const columnWidth = boardWidth / columnsCount; + const scrollValue = columnWidth * (direction === "left" ? -1 : 1); + boardRef.current?.scrollBy({left: scrollValue, behavior: "smooth"}); }; return ( @@ -182,7 +90,12 @@ export const BoardComponent = ({children, currentUserIsModerator, moderating, lo - + scrollBoard("left")} + onNextColumn={() => scrollBoard("right")} + />
{ }; describe("basic", () => { - beforeEach(() => { - window.IntersectionObserver = jest.fn( - () => - ({ - observe: jest.fn(), - disconnect: jest.fn(), - }) as unknown as IntersectionObserver - ); - }); - test("show empty board", () => { const {container} = render(createBoardWithColumns()); expect(container.firstChild).toHaveClass("board--empty"); @@ -89,103 +78,11 @@ describe("basic", () => { }); test("side-panels have correct accent color with single column", () => { - const {container} = render(createBoardWithColumns("lean-lilac")); + const {container} = render(createBoardWithColumns("value-violet")); const board = container.querySelector(".board"); - expect(board?.childNodes[1]).toHaveClass("accent-color__lean-lilac"); - expect(board?.lastChild).toHaveClass("accent-color__lean-lilac"); - }); - }); - }); -}); - -describe("navigation", () => { - beforeEach(() => { - window.IntersectionObserver = jest.fn( - () => - ({ - observe: jest.fn(), - disconnect: jest.fn(), - }) as unknown as IntersectionObserver - ); - - const root = global.document.createElement("div"); - root.setAttribute("id", "root"); - global.document.querySelector("body")!.appendChild(root); - }); - - let intersectionObserver: IntersectionObserver; - - beforeEach(() => { - intersectionObserver = { - observe: jest.fn(), - disconnect: jest.fn(), - } as unknown as IntersectionObserver; - window.IntersectionObserver = jest.fn(() => intersectionObserver); - }); - - test("intersection observer is registered on mount", () => { - render(createBoardWithColumns("lean-lilac", "backlog-blue")); - expect(window.IntersectionObserver).toHaveBeenCalled(); - expect(intersectionObserver.observe).toHaveBeenCalledTimes(2); - }); - - test("intersection observer is disconnected on unmount", () => { - render(createBoardWithColumns("planning-pink")).unmount(); - expect(intersectionObserver.disconnect).toHaveBeenCalledTimes(1); - }); - - test("intersection observer is re-initialized on change of children", () => { - const {rerender} = render(createBoardWithColumns("planning-pink")); - - expect(window.IntersectionObserver).toHaveBeenCalledTimes(1); - expect(intersectionObserver.disconnect).toHaveBeenCalledTimes(0); - - rerender(createBoardWithColumns("planning-pink", "backlog-blue")); - - expect(window.IntersectionObserver).toHaveBeenCalledTimes(2); - expect(intersectionObserver.disconnect).toHaveBeenCalledTimes(1); - }); - - describe("buttons visibility and functionality", () => { - let container: HTMLElement; - - beforeEach(() => { - container = render(createBoardWithColumns("planning-pink", "backlog-blue", "poker-purple")).container; - }); - - const showColumns = (first: boolean, second: boolean, third: boolean) => { - const columns = container.querySelectorAll(".column"); - act(() => { - const firstMethodCall = 0; - const firstMethodParameter = 0; - - const intersectionObserverCallback = (window.IntersectionObserver as unknown as IntersectionObserver).mock.calls[firstMethodCall][firstMethodParameter]; - intersectionObserverCallback([ - {isIntersecting: first, target: columns[0]}, - {isIntersecting: second, target: columns[1]}, - {isIntersecting: third, target: columns[2]}, - ]); + expect(board?.childNodes[1]).toHaveClass("accent-color__value-violet"); + expect(board?.lastChild).toHaveClass("accent-color__value-violet"); }); - return columns; - }; - - test("correct scroll of previous button", () => { - const columns = showColumns(false, true, false); - const scrollIntoView = jest.fn(); - columns[0].scrollIntoView = scrollIntoView; - fireEvent.click(container.querySelectorAll(".menu-bars__navigation")[0] as HTMLElement); - - expect(scrollIntoView).toHaveBeenCalled(); - }); - - test("correct scroll of next button", () => { - const columns = showColumns(false, true, false); - - const scrollIntoView = jest.fn(); - columns[2].scrollIntoView = scrollIntoView; - fireEvent.click(container.querySelectorAll(".menu-bars__navigation")[1] as HTMLElement); - - expect(scrollIntoView).toHaveBeenCalled(); }); }); }); diff --git a/src/utils/hooks/useIsTouchingSides.ts b/src/utils/hooks/useIsTouchingSides.ts new file mode 100644 index 0000000000..433102d0c5 --- /dev/null +++ b/src/utils/hooks/useIsTouchingSides.ts @@ -0,0 +1,34 @@ +import {useEffect, useState, RefObject, useCallback} from "react"; +import {useSize} from "./useSize"; +import {useIsScrolling} from "./useIsScrolling"; + +/** + * returns if the container is scrolled to the very left or very right of the viewport. + * @param ref the container ref which is checked + */ +export const useIsTouchingSides = (ref: RefObject) => { + // margin of error, because different browsers treat the scroll width differently + const EPSILON = 1; + + const [isTouchingLeftSide, setIsTouchingLeftSide] = useState(false); + const [isTouchingRightSide, setIsTouchingRightSide] = useState(false); + const size = useSize(ref); + const isScrolling = useIsScrolling(ref, 30); + + const checkTouchingSides = useCallback(() => { + if (!ref.current || !size) return; + + const {scrollLeft: currentScrollLeft, scrollWidth: currentScrollWidth, clientWidth: currentClientWidth} = ref.current; + const touchingLeft = currentScrollLeft <= EPSILON; + const touchingRight = currentScrollLeft + currentClientWidth >= currentScrollWidth - EPSILON; + + setIsTouchingLeftSide(touchingLeft); + setIsTouchingRightSide(touchingRight); + }, [ref, size]); + + useEffect(() => { + checkTouchingSides(); + }, [size, isScrolling, checkTouchingSides]); + + return {isTouchingLeftSide, isTouchingRightSide}; +};