Repository: homebound-team/beam
Pull request #1141: feat: Add scroll and fixed icons to RightSideBar component
Author: login: kbesingeryeh
Labels: name: released
Created: 2025-05-08T21:39:34Z | Merged: 2025-05-08T22:55:55Z

[SC-70171](https://app.shortcut.com/homebound-team/story/70171/fe-cost-summary-sidebar-investigate-independent-scrolling)

LOOM VIDEO: https://www.loom.com/share/6004d1f2ba2a47fe9a8d988befaf84ac


Diff:
diff --git a/src/components/Layout/FormPageLayout.tsx b/src/components/Layout/FormPageLayout.tsx
index 175428531..337c97063 100644
--- a/src/components/Layout/FormPageLayout.tsx
+++ b/src/components/Layout/FormPageLayout.tsx
@@ -73,7 +73,7 @@ function FormPageLayoutComponent<F>(props: FormPageLayoutProps<F>) {
         <FormSections sectionsWithRefs={sectionsWithRefs} formState={formState} {...tids} />
         {rightSideBar && (
           <aside css={Css.gr(2).gc("3 / 4").sticky.topPx(headerHeightPx).$}>
-            <RightSidebar content={rightSideBar} />
+            <RightSidebar content={rightSideBar} headerHeightPx={headerHeightPx} />
           </aside>
         )}
       </div>
diff --git a/src/components/RightSidebar.test.tsx b/src/components/RightSidebar.test.tsx
index 488009ec6..03b295228 100644
--- a/src/components/RightSidebar.test.tsx
+++ b/src/components/RightSidebar.test.tsx
@@ -1,3 +1,4 @@
+import { waitFor } from "@homebound/rtl-utils";
 import { click, render } from "src/utils/rtl";
 import { RightSidebar, SidebarContentProps } from "./RightSidebar";
 
@@ -9,7 +10,7 @@ describe("RightSidebar", () => {
     ] as SidebarContentProps[];
 
     // Given a right sidebar component with content
-    const r = await render(<RightSidebar content={content} />);
+    const r = await render(<RightSidebar content={content} headerHeightPx={120} />);
 
     // Expect the sidebar to render the icons and not render content initially
     expect(r.comment).toBeInTheDocument();
@@ -21,6 +22,11 @@ describe("RightSidebar", () => {
     expect(r.rightSidebar_content).toHaveTextContent("Comments");
     expect(r.rightSidebar_content).not.toHaveTextContent("History");
 
+    // wait for transition to finish
+    await waitFor(() => {
+      expect(r.queryAllByTestId("time")).toHaveLength(1);
+    });
+
     click(r.time);
     expect(r.rightSidebar_content).toHaveTextContent("History");
     expect(r.rightSidebar_content).not.toHaveTextContent("Comments");
diff --git a/src/components/RightSidebar.tsx b/src/components/RightSidebar.tsx
index 5d54b5335..b8cd8bb23 100644
--- a/src/components/RightSidebar.tsx
+++ b/src/components/RightSidebar.tsx
@@ -12,13 +12,14 @@ export type SidebarContentProps = {
 
 export type RightSidebarProps = {
   content: SidebarContentProps[];
+  headerHeightPx: number;
 };
 
 /** Exporting this value allows layout components to coordinate responsive column sizing
  * while avoiding layout shift when the sidebar is opened */
 export const RIGHT_SIDEBAR_MIN_WIDTH = "250px";
 
-export function RightSidebar({ content }: RightSidebarProps) {
+export function RightSidebar({ content, headerHeightPx }: RightSidebarProps) {
   const [selectedIcon, setSelectedIcon] = useState<IconKey | undefined>(undefined);
   const tid = useTestIds({}, "rightSidebar");
 
@@ -48,32 +49,36 @@ export function RightSidebar({ content }: RightSidebarProps) {
             animate={{ x: 0, opacity: 1 }}
             transition={{ delay: 0.2, ease: [0.51, 0.92, 0.24, 1], duration: 0.3 }}
             exit={{ transition: { ease: "linear", duration: 0.2 }, x: "100%" }}
-            css={Css.w100.mw(RIGHT_SIDEBAR_MIN_WIDTH).z0.$}
+            css={Css.w100.mw(RIGHT_SIDEBAR_MIN_WIDTH).z0.maxh(`calc(100vh - ${headerHeightPx}px)`).oya.pl4.pr3.$}
           >
-            <div css={Css.relative.z0.px3.$}>
-              {/* Close button */}
-              <div css={Css.absolute.leftPx(-24).top0.$}>
-                <IconButton
-                  bgColor={Palette.White}
-                  circle
-                  onClick={() => setSelectedIcon(undefined)}
-                  icon="x"
-                  inc={3.5}
-                />
-                {/* vertical line */}
-                <div css={Css.absolute.topPx(48).leftPx(23).h("calc(100vh - 168px)").wPx(1).bgGray300.$} />
-              </div>
-
-              {/* Horizontal icons when opened */}
-              <div css={Css.df.aic.jcfe.gap2.mb3.$}>
-                <IconButtonList content={content} selectedIcon={selectedIcon} onIconClick={setSelectedIcon} />
+            <>
+              {/* Sticky header section */}
+              <div css={Css.sticky.top0.bgWhite.$}>
+                {/* Close button */}
+                <div css={Css.absolute.leftPx(-24).top0.df.fdc.aic.$}>
+                  <IconButton
+                    bgColor={Palette.White}
+                    circle
+                    onClick={() => setSelectedIcon(undefined)}
+                    icon="x"
+                    inc={3.5}
+                  />
+                  {/* vertical line */}
+                  <div css={Css.absolute.topPx(48).h("calc(100vh - 168px)").wPx(1).bgGray300.$} />
+                </div>
+                {/* Horizontal icons when opened */}
+                <div css={Css.df.aic.jcfe.gap2.mb3.$}>
+                  <IconButtonList content={content} selectedIcon={selectedIcon} onIconClick={setSelectedIcon} />
+                </div>
               </div>
 
               {/* Content area */}
               {selectedIcon && (
-                <div {...tid.content}>{content.find((sidebar) => sidebar.icon === selectedIcon)?.render()}</div>
+                <div {...tid.content} css={Css.pl3.$}>
+                  {content.find((sidebar) => sidebar.icon === selectedIcon)?.render()}
+                </div>
               )}
-            </div>
+            </>
           </motion.div>
         )}
       </AnimatePresence>


Reviews:
user: login: kbesingeryeh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T21:44:59Z
user: login: kbesingeryeh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T22:22:32Z
user: login: blambillotte
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T22:30:43Z
user: login: blambillotte
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T22:31:55Z
user: login: blambillotte
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T22:39:45Z
user: login: blambillotte
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T22:40:46Z

Review comments:
diff_hunk: @@ -48,32 +48,36 @@ export function RightSidebar({ content }: RightSidebarProps) {
             animate={{ x: 0, opacity: 1 }}
             transition={{ delay: 0.2, ease: [0.51, 0.92, 0.24, 1], duration: 0.3 }}
             exit={{ transition: { ease: "linear", duration: 0.2 }, x: "100%" }}
-            css={Css.w100.mw(RIGHT_SIDEBAR_MIN_WIDTH).z0.$}
+            css={Css.w100.mw(RIGHT_SIDEBAR_MIN_WIDTH).z0.maxh("calc(100vh - 120px)").oya.pl4.pr3.$}
           >
-            <div css={Css.relative.z0.px3.$}>
-              {/* Close button */}
-              <div css={Css.absolute.leftPx(-24).top0.$}>
-                <IconButton
-                  bgColor={Palette.White}
-                  circle
-                  onClick={() => setSelectedIcon(undefined)}
-                  icon="x"
-                  inc={3.5}
-                />
-                {/* vertical line */}
-                <div css={Css.absolute.topPx(48).leftPx(23).h("calc(100vh - 168px)").wPx(1).bgGray300.$} />
-              </div>
-
-              {/* Horizontal icons when opened */}
-              <div css={Css.df.aic.jcfe.gap2.mb3.$}>
-                <IconButtonList content={content} selectedIcon={selectedIcon} onIconClick={setSelectedIcon} />
+            <>
+              {/* Sticky header section */}
+              <div css={Css.sticky.top0.bgWhite.z999.$}>
path: src/components/RightSidebar.tsx
user: login: kbesingeryeh
body: I had some difficulty getting all the `icons` to align horizontally and make only the left one sticky, so I ended up just putting them in the same `div` and making them both sticky. I think it should be ok, plus it looks nicer visually to see both stick
created_at: 2025-05-08T21:44:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,6 +22,11 @@ describe("RightSidebar", () => {
     expect(r.rightSidebar_content).toHaveTextContent("Comments");
     expect(r.rightSidebar_content).not.toHaveTextContent("History");
 
+    // wait for transition to finish
+    await waitFor(() => {
path: src/components/RightSidebar.test.tsx
user: login: kbesingeryeh
body: there was a scenario in which the animation was rendering two `time` buttons in the DOM when switching icons, so I added this `waitFor` to ensure we actually only have one
created_at: 2025-05-08T22:22:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,32 +49,36 @@ export function RightSidebar({ content }: RightSidebarProps) {
             animate={{ x: 0, opacity: 1 }}
             transition={{ delay: 0.2, ease: [0.51, 0.92, 0.24, 1], duration: 0.3 }}
             exit={{ transition: { ease: "linear", duration: 0.2 }, x: "100%" }}
-            css={Css.w100.mw(RIGHT_SIDEBAR_MIN_WIDTH).z0.$}
+            css={Css.w100.mw(RIGHT_SIDEBAR_MIN_WIDTH).z0.maxh(`calc(100vh - ${headerHeightPx}px)`).oya.pl4.pr3.$}
           >
-            <div css={Css.relative.z0.px3.$}>
-              {/* Close button */}
-              <div css={Css.absolute.leftPx(-24).top0.$}>
-                <IconButton
-                  bgColor={Palette.White}
-                  circle
-                  onClick={() => setSelectedIcon(undefined)}
-                  icon="x"
-                  inc={3.5}
-                />
-                {/* vertical line */}
-                <div css={Css.absolute.topPx(48).leftPx(23).h("calc(100vh - 168px)").wPx(1).bgGray300.$} />
-              </div>
-
-              {/* Horizontal icons when opened */}
-              <div css={Css.df.aic.jcfe.gap2.mb3.$}>
-                <IconButtonList content={content} selectedIcon={selectedIcon} onIconClick={setSelectedIcon} />
+            <>
+              {/* Sticky header section */}
+              <div css={Css.sticky.top0.bgWhite.$}>
path: src/components/RightSidebar.tsx
user: login: blambillotte
body: thought: Rather than having to do another level of sticky headers within a sticky container, what do you think about just setting the max height on the content container and letting the top icon container just sit on top like a normal div 🤔 

<img width="1750" alt="image" src="https://github.com/user-attachments/assets/b2cfecd3-2006-4c37-a81b-1e9bd10b525e" />

I think there's also an added benefit to scrollbar more correctly aligning to the scrollable content vs. extending beyond the top of the content as it does in your loom video
created_at: 2025-05-08T22:30:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,32 +49,36 @@ export function RightSidebar({ content }: RightSidebarProps) {
             animate={{ x: 0, opacity: 1 }}
             transition={{ delay: 0.2, ease: [0.51, 0.92, 0.24, 1], duration: 0.3 }}
             exit={{ transition: { ease: "linear", duration: 0.2 }, x: "100%" }}
-            css={Css.w100.mw(RIGHT_SIDEBAR_MIN_WIDTH).z0.$}
+            css={Css.w100.mw(RIGHT_SIDEBAR_MIN_WIDTH).z0.maxh(`calc(100vh - ${headerHeightPx}px)`).oya.pl4.pr3.$}
           >
-            <div css={Css.relative.z0.px3.$}>
-              {/* Close button */}
-              <div css={Css.absolute.leftPx(-24).top0.$}>
-                <IconButton
-                  bgColor={Palette.White}
-                  circle
-                  onClick={() => setSelectedIcon(undefined)}
-                  icon="x"
-                  inc={3.5}
-                />
-                {/* vertical line */}
-                <div css={Css.absolute.topPx(48).leftPx(23).h("calc(100vh - 168px)").wPx(1).bgGray300.$} />
-              </div>
-
-              {/* Horizontal icons when opened */}
-              <div css={Css.df.aic.jcfe.gap2.mb3.$}>
-                <IconButtonList content={content} selectedIcon={selectedIcon} onIconClick={setSelectedIcon} />
+            <>
+              {/* Sticky header section */}
+              <div css={Css.sticky.top0.bgWhite.$}>
path: src/components/RightSidebar.tsx
user: login: blambillotte
body: Nevermind the `scrollbar-gutter` property in my example, I was just playing with something (as obviously the padding needs to be adjusted in my example)
created_at: 2025-05-08T22:31:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,32 +49,36 @@ export function RightSidebar({ content }: RightSidebarProps) {
             animate={{ x: 0, opacity: 1 }}
             transition={{ delay: 0.2, ease: [0.51, 0.92, 0.24, 1], duration: 0.3 }}
             exit={{ transition: { ease: "linear", duration: 0.2 }, x: "100%" }}
-            css={Css.w100.mw(RIGHT_SIDEBAR_MIN_WIDTH).z0.$}
+            css={Css.w100.mw(RIGHT_SIDEBAR_MIN_WIDTH).z0.maxh(`calc(100vh - ${headerHeightPx}px)`).oya.pl4.pr3.$}
           >
-            <div css={Css.relative.z0.px3.$}>
-              {/* Close button */}
-              <div css={Css.absolute.leftPx(-24).top0.$}>
-                <IconButton
-                  bgColor={Palette.White}
-                  circle
-                  onClick={() => setSelectedIcon(undefined)}
-                  icon="x"
-                  inc={3.5}
-                />
-                {/* vertical line */}
-                <div css={Css.absolute.topPx(48).leftPx(23).h("calc(100vh - 168px)").wPx(1).bgGray300.$} />
-              </div>
-
-              {/* Horizontal icons when opened */}
-              <div css={Css.df.aic.jcfe.gap2.mb3.$}>
-                <IconButtonList content={content} selectedIcon={selectedIcon} onIconClick={setSelectedIcon} />
+            <>
+              {/* Sticky header section */}
+              <div css={Css.sticky.top0.bgWhite.$}>
path: src/components/RightSidebar.tsx
user: login: blambillotte
body: Though I guess each is a compromise, downside of my approach is the icons aren't aligned on the right with the content 🤔 
I guess maybe not worth it, something we can't spend too much time on right now ha
created_at: 2025-05-08T22:39:44Z
author_association: CONTRIBUTOR

PR comments:
user: login: homebound-team-bot
created_at: 2025-05-08T22:58:46Z
body: :tada: This PR is included in version 2.394.0 :tada:

The release is available on:
- [npm package (@latest dist-tag)](https://www.npmjs.com/package/@homebound/beam/v/2.394.0)
- [GitHub release](https://github.com/homebound-team/beam/releases/tag/v2.394.0)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: NONE