Repository: inovex/scrumlr.io
Pull request #3403 (2023-10-03 15:06:17)

feat: seamless stripe transition between columns and stripes on board spacers
<!--
Please make sure the title of your PR starts with a semantic prefix:

feat: A new feature.
fix: A bug fix.
improvement: Implemented enhancements to existing functionality.
dependency: Updates or modifications to project dependencies or external libraries.
localization: Changes related to localization or internationalization of the codebase.
accessibility: Improvements made to enhance the accessibility of the application or website.
docs: Documentation only changes.
style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc).
refactor: A code change that neither fixes a bug nor adds a feature.
perf: A code change that improves performance.
test: Adding missing tests or correcting existing tests.
build: Changes that affect the build system.
chore: Other changes that don't modify src or test files.
revert: Reverts a previous commit.
-->

## Description
<!-- Explain the changes you’ve made. It doesn’t need to be fancy and you don’t have to get too technical. -->
- create custom hook to offset and align the stripes on every column. 
  - This removes the "break" in the stripes when two adjacend columns are hidden
- add stripes to the spacers on the side of the boards to implement #3263

## Changelog
<!-- Please provide a detailed list of the changes you have made to the codebase. -->
- create the `useStripeOffset` hook
  - calculates the gradient offset so that every gradient using this "connects" 
  - updates the gradient offset whenever the element size changes using a ResizeObserver
- add the `useStripeOffset` to the column as well as the two spacers
- add a useEffect to update the stripe offset whenever a column is added or removed
- move the gradient declaration to the `constants/style.scss` file as `$column-stripes--light` and `$column-stripes--dark`
- add css rules to check for hidden columns next to the spacers and apply the same stripe-gradient background
  - ⚠️ this uses an `:has()` rule, which isn't supported by firefox yet but will be (#3048)
- add a mock-`ResizeObserver` to the Board and Column tests
- update the Column snapshot to include the `--stripe-offset` style

## Checklist

- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] The light- and dark-theme are both supported and tested
- [x] The design was implemented and is responsive for all devices and screen sizes
- [x] The application was tested in the most commonly used browsers (e.g. Chrome, Firefox, Safari)

## (Optional) Visual Changes
<!-- If available, please provide a before and after screenshot of your UI related changes. -->
![grafik](https://github.com/inovex/scrumlr.io/assets/71148001/ba9c6535-4f7c-4f41-97c3-296803e93072)
stripes not aligned, no stripes on spacer

![grafik](https://github.com/inovex/scrumlr.io/assets/71148001/bf31c934-0817-491e-b3c7-5c88cce81373)
stripes aligned, added stripes on spacer

![grafik](https://github.com/inovex/scrumlr.io/assets/71148001/b20c3091-3501-4350-a49c-863229a99aae)
full page view


Patch:
diff --git a/src/components/Board/Board.scss b/src/components/Board/Board.scss
index d62d6367..5ab108ca 100644
--- a/src/components/Board/Board.scss
+++ b/src/components/Board/Board.scss
@@ -54,6 +54,11 @@
   background-color: $color-white-one;
 }
 
+.column--hidden + .board__spacer-right,
+.board__spacer-left:has(+ .column--hidden) {
+  background-image: $column-stripes--light;
+}
+
 [theme="dark"] {
   .board__spacer-left:nth-child(odd),
   .board__spacer-right:nth-child(odd) {
@@ -64,6 +69,11 @@
   .board__spacer-right:nth-child(even) {
     background-color: $color-dark-two;
   }
+
+  .column--hidden + .board__spacer-right,
+  .board__spacer-left:has(+ .column--hidden) {
+    background-image: $column-stripes--dark;
+  }
 }
 
 @media screen and (max-width: $column__min-width+2*$board__side-panel-width) {
diff --git a/src/components/Board/Board.tsx b/src/components/Board/Board.tsx
index a921f64b..a46ab8d6 100644
--- a/src/components/Board/Board.tsx
+++ b/src/components/Board/Board.tsx
@@ -8,6 +8,7 @@ import {HotkeyAnchor} from "components/HotkeyAnchor";
 import "./Board.scss";
 import {useDndMonitor} from "@dnd-kit/core";
 import classNames from "classnames";
+import {useStripeOffset} from "utils/hooks/useStripeOffset";
 
 export interface BoardProps {
   children: React.ReactElement<ColumnProps> | React.ReactElement<ColumnProps>[];
@@ -56,6 +57,17 @@ export const BoardComponent = ({children, currentUserIsModerator, moderating}: B
 
   const columnsCount = React.Children.count(children);
 
+  // stripe offset for spacer divs
+  const leftSpacerOffset = useStripeOffset<HTMLDivElement>({gradientLength: 40, gradientAngle: 45});
+  const rightSpacerOffset = useStripeOffset<HTMLDivElement>({gradientLength: 40, gradientAngle: 45});
+
+  useEffect(() => {
+    leftSpacerOffset.updateOffset();
+    rightSpacerOffset.updateOffset();
+
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [children]);
+
   useEffect(() => {
     const board = boardRef.current;
 
@@ -134,8 +146,8 @@ export const BoardComponent = ({children, currentUserIsModerator, moderating}: B
         <HotkeyAnchor />
         <main className="board" ref={boardRef}>
           {/* Fixed color - can also be dynamic */}
-          <div className={`board__spacer-left ${getColorClassName("backlog-blue")}`} />
-          <div className={`board__spacer-right ${getColorClassName("backlog-blue")}`} />
+          <div className={`board__spacer-left ${getColorClassName("backlog-blue")}`} {...leftSpacerOffset.bindings} />
+          <div className={`board__spacer-right ${getColorClassName("backlog-blue")}`} {...rightSpacerOffset.bindings} />
         </main>
       </div>
     );
@@ -163,9 +175,15 @@ export const BoardComponent = ({children, currentUserIsModerator, moderating}: B
       <MenuBars showPreviousColumn={state.showPreviousButton} showNextColumn={state.showNextButton} onPreviousColumn={handlePreviousClick} onNextColumn={handleNextClick} />
       <HotkeyAnchor />
       <main className={classNames("board", dragActive && "board--dragging")} ref={boardRef}>
-        <div className={`board__spacer-left ${currentUserIsModerator && moderating ? "accent-color__goal-green" : getColorClassName(columnColors[0])}`} />
+        <div
+          className={`board__spacer-left ${currentUserIsModerator && moderating ? "accent-color__goal-green" : getColorClassName(columnColors[0])}`}
+          {...leftSpacerOffset.bindings}
+        />
         {children}
-        <div className={`board__spacer-right ${currentUserIsModerator && moderating ? "accent-color__goal-green" : getColorClassName(columnColors[columnColors.length - 1])}`} />
+        <div
+          className={`board__spacer-right ${currentUserIsModerator && moderating ? "accent-color__goal-green" : getColorClassName(columnColors[columnColors.length - 1])}`}
+          {...rightSpacerOffset.bindings}
+        />
       </main>
     </>
   );
diff --git a/src/components/Column/Column.scss b/src/components/Column/Column.scss
index f8ba085b..846714b2 100644
--- a/src/components/Column/Column.scss
+++ b/src/components/Column/Column.scss
@@ -28,17 +28,11 @@
   }
 }
 .column--hidden {
-  background-image: repeating-linear-gradient(
-    45deg,
-    $color-white-one,
-    $color-white-one 20px,
-    var(--accent-color--background-light) 20px,
-    var(--accent-color--background-light) 40px
-  );
+  background-image: $column-stripes--light;
 }
 [theme="dark"] {
   .column--hidden {
-    background-image: repeating-linear-gradient(45deg, $color-dark-one, $color-dark-one 20px, var(--accent-color--background-dark) 20px, var(--accent-color--background-dark) 40px);
+    background-image: $column-stripes--dark;
   }
 }
 
diff --git a/src/components/Column/Column.tsx b/src/components/Column/Column.tsx
index 37a7f070..cb8b57e1 100644
--- a/src/components/Column/Column.tsx
+++ b/src/components/Column/Column.tsx
@@ -14,6 +14,7 @@ import {useDispatch} from "react-redux";
 import {useTranslation} from "react-i18next";
 import {hotkeyMap} from "constants/hotkeys";
 import {Droppable} from "components/DragAndDrop/Droppable";
+import {useStripeOffset} from "utils/hooks/useStripeOffset";
 import {Note} from "../Note";
 import {ColumnSettings} from "./ColumnSettings";
 
@@ -51,7 +52,7 @@ export const Column = ({id, name, color, visible, index}: ColumnProps) => {
   const [isTemporary, setIsTemporary] = useState(id === "TEMP_ID");
 
   const inputRef = useRef<HTMLInputElement>();
-  const columnRef = useRef<HTMLDivElement>(null);
+  const columnRef = useRef<HTMLElement | null>(null);
 
   const toggleVisibilityHandler = () => {
     dispatch(Actions.editColumn(id, {name, color, index, visible: !visible}));
@@ -162,8 +163,28 @@ export const Column = ({id, name, color, visible, index}: ColumnProps) => {
     </>
   );
 
+  const {
+    bindings: {ref: stripeRef, style: stripeStyle},
+    updateOffset,
+  } = useStripeOffset({gradientLength: 40, gradientAngle: 45});
+
+  const columnCount = useAppSelector((state) => state.columns.length);
+
+  useEffect(() => {
+    updateOffset();
+
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [columnCount]);
+
   return (
-    <section className={classNames("column", {"column--hidden": !visible}, {"column__moderation-isActive": isModerator && moderating}, colorClassName)} ref={columnRef}>
+    <section
+      className={classNames("column", {"column--hidden": !visible}, {"column__moderation-isActive": isModerator && moderating}, colorClassName)}
+      style={stripeStyle}
+      ref={(r) => {
+        columnRef.current = r;
+        stripeRef.current = r;
+      }}
+    >
       <div className="column__content">
         <div className="column__header">
           <div className="column__header-title">
diff --git a/src/constants/style.scss b/src/constants/style.scss
index 605a1442..bedac88b 100644
--- a/src/constants/style.scss
+++ b/src/constants/style.scss
@@ -180,6 +180,23 @@ $menu-desktop: "screen and (min-width: 1344px)";
 $box-shadow--light: 0px 5px 15px 0px rgba(0, 0, 0, 0.2);
 $box-shadow--dark: 0px 5px 15px 0px rgba(0, 0, 0, 0.35);
 
+// gradients
+$column-stripes--light: repeating-linear-gradient(
+  45deg,
+  $color-white-one calc(0px + var(--stripe-offset, 0px)),
+  $color-white-one calc(20px + var(--stripe-offset, 0px)),
+  var(--accent-color--background-light) calc(20px + var(--stripe-offset, 0px)),
+  var(--accent-color--background-light) calc(40px + var(--stripe-offset, 0px))
+);
+
+$column-stripes--dark: repeating-linear-gradient(
+  45deg,
+  $color-dark-one calc(0px + var(--stripe-offset, 0px)),
+  $color-dark-one calc(20px + var(--stripe-offset, 0px)),
+  var(--accent-color--background-dark) calc(20px + var(--stripe-offset, 0px)),
+  var(--accent-color--background-dark) calc(40px + var(--stripe-offset, 0px))
+);
+
 // helper functions
 @function inset-border($left: false, $right: false, $top: false, $bottom: false, $color: var(--accent-color)) {
   $props: ();
diff --git a/src/utils/hooks/useStripeOffset.ts b/src/utils/hooks/useStripeOffset.ts
new file mode 100644
index 00000000..c2c0e8d2
--- /dev/null
+++ b/src/utils/hooks/useStripeOffset.ts
@@ -0,0 +1,105 @@
+import {DetailedHTMLProps, HTMLProps, useCallback, useEffect, useMemo, useRef, useState} from "react";
+
+/**
+ * this function sets the css offset variable for a repeating css gradient so that the gradient is uniform across multiple elements
+ * we can't use the css `background-offset` property because it will cause a break in the gradient.
+ *
+ * instead shift the gradient start point with a css variable like this:
+ * ```css
+ * .striped {
+ *   background-image: repeating-linear-gradient(
+ *    45deg,
+ *    transparent calc(0px  + var(--stripe-offset, 0px)),
+ *    transparent calc(20px + var(--stripe-offset, 0px)),
+ *    #fff        calc(20px + var(--stripe-offset, 0px)),
+ *    #fff        calc(40px + var(--stripe-offset, 0px))
+ *   );
+ * }
+ * ```
+ *
+ * the hook takes care of updating the offset when the element size changes but not when the dom changes, since it would need to observe the whole tree
+ * if the position of the striped element changes, you will need to call `updateOffset` manually
+ *
+ * @param gradientLength the length of the gradient **in pixels** until it repeats as specified in the css
+ * @param gradientAngle the angle of the gradient **in degrees** as specified in the css
+ * @param phaseOffset optional phase offset of the stripe, one phase = 1
+ * @param cssVariableName the name of the css offset variable
+ *
+ */
+export function useStripeOffset<RefElement extends HTMLElement>({
+  gradientLength = 40,
+  gradientAngle = 45,
+  phaseOffset = 0,
+  cssVariableName = "stripe-offset",
+}: {
+  gradientLength?: number;
+  gradientAngle?: number;
+  phaseOffset?: number;
+  cssVariableName?: string;
+} = {}) {
+  // convert degrees to radians because Math.sin and Math.cos expect radians
+  const gradientAngleRad = (gradientAngle / 180) * Math.PI;
+
+  const stripedElementRef = useRef<RefElement | null>(null);
+
+  const [offset, setOffset] = useState(0);
+
+  const updateOffset = useCallback(() => {
+    if (!stripedElementRef.current || typeof window === undefined) return;
+
+    // find the origin of the gradient on the page, independent of scroll of parent elements
+    let el: HTMLElement | null = stripedElementRef.current;
+
+    let stripeOriginX = 0;
+    let stripeOriginY = 0;
+
+    do {
+      stripeOriginX += el.offsetLeft;
+      stripeOriginY += el.offsetTop;
+
+      // loop over every parent element to sum up the offset
+      el = el.offsetParent as HTMLElement | null;
+    } while (el !== null);
+
+    // depending on the angle, the gradient starts on a different edge of the element
+    if (Math.sin(gradientAngleRad) < 0) stripeOriginX += stripedElementRef.current.getBoundingClientRect().width; // gradient starts on the right edge
+    if (Math.cos(gradientAngleRad) > 0) stripeOriginY += stripedElementRef.current.getBoundingClientRect().height; // gradient starts on the bottom edge
+
+    // calculate the distance from the gradient origin to the "zero-line"
+    // the zero-line is a line at (0/0) with the same direction as the gradient stripes
+    const distanceToLine = -stripeOriginX * Math.cos(gradientAngleRad - 0.5 * Math.PI) + stripeOriginY * Math.sin(gradientAngleRad - 0.5 * Math.PI);
+
+    // take the offset modulo the length to lower the offset amount
+    // add in the phase offset to shift the gradient
+    setOffset((distanceToLine + phaseOffset * gradientLength) % gradientLength);
+  }, [gradientAngleRad, gradientLength, phaseOffset]);
+
+  // update the offset when the element size changes
+  // this also updates on mount since the ResizeObserver fires on `observe()` call
+  useEffect(() => {
+    if (!stripedElementRef.current) return undefined;
+
+    const resizeObserver = new ResizeObserver(() => {
+      updateOffset();
+    });
+
+    resizeObserver.observe(stripedElementRef.current);
+
+    return () => {
+      resizeObserver.disconnect();
+    };
+  }, [updateOffset]);
+
+  return useMemo(
+    () => ({
+      bindings: {
+        ref: stripedElementRef,
+        style: {
+          [`--${cssVariableName}`]: `${offset}px`,
+        },
+      } satisfies DetailedHTMLProps<HTMLProps<RefElement>, RefElement>,
+      updateOffset,
+    }),
+    [cssVariableName, offset, updateOffset]
+  );
+}


Test patch:
diff --git a/src/components/Board/__tests__/Board.test.tsx b/src/components/Board/__tests__/Board.test.tsx
index b1250f2c..2394a16c 100644
--- a/src/components/Board/__tests__/Board.test.tsx
+++ b/src/components/Board/__tests__/Board.test.tsx
@@ -28,7 +28,15 @@ describe("basic", () => {
         ({
           observe: jest.fn(),
           disconnect: jest.fn(),
-        } as unknown as IntersectionObserver)
+        }) as unknown as IntersectionObserver
+    );
+
+    window.ResizeObserver = jest.fn(
+      () =>
+        ({
+          observe: jest.fn(),
+          disconnect: jest.fn(),
+        }) as unknown as ResizeObserver
     );
   });
 
@@ -101,7 +109,15 @@ describe("navigation", () => {
         ({
           observe: jest.fn(),
           disconnect: jest.fn(),
-        } as unknown as IntersectionObserver)
+        }) as unknown as IntersectionObserver
+    );
+
+    window.ResizeObserver = jest.fn(
+      () =>
+        ({
+          observe: jest.fn(),
+          disconnect: jest.fn(),
+        }) as unknown as ResizeObserver
     );
 
     const root = global.document.createElement("div");
diff --git a/src/components/Column/__tests__/Column.test.tsx b/src/components/Column/__tests__/Column.test.tsx
index 621f0c34..7e9a7e88 100644
--- a/src/components/Column/__tests__/Column.test.tsx
+++ b/src/components/Column/__tests__/Column.test.tsx
@@ -26,6 +26,16 @@ const createEmptyColumn = (overwrite?: Partial<ApplicationState>) => {
 };
 
 describe("Column", () => {
+  beforeEach(() => {
+    window.ResizeObserver = jest.fn(
+      () =>
+        ({
+          observe: jest.fn(),
+          disconnect: jest.fn(),
+        }) as unknown as ResizeObserver
+    );
+  });
+
   describe("should render correctly", () => {
     test("column has correct accent-color", () => {
       const {container} = render(createColumn());
diff --git a/src/components/Column/__tests__/__snapshots__/Column.test.tsx.snap b/src/components/Column/__tests__/__snapshots__/Column.test.tsx.snap
index f6036624..f6c6f88e 100644
--- a/src/components/Column/__tests__/__snapshots__/Column.test.tsx.snap
+++ b/src/components/Column/__tests__/__snapshots__/Column.test.tsx.snap
@@ -3,6 +3,7 @@
 exports[`Column should have correct style show column with correct style 1`] = `
 <section
   class="column column--hidden accent-color__planning-pink"
+  style="--stripe-offset: 0px;"
 >
   <div
     class="column__content"