From baed1455cff2746d064e22f46a6d5224aaa21136 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 6 Aug 2026 08:26:49 -0700 Subject: [PATCH] feat(site/src/pages/TemplateBuilder): scroll to module when clicking sidebar row (#27351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Clicking a selected module in the right-hand `SelectionSummary` sidebar now jumps to the module settings step and scrolls that module's card into view. Addresses [DEVEX-587](https://linear.app/codercom/issue/DEVEX-587). This is an isolated slice of #27077 (which bundles several unrelated changes); only the "click a module to scroll to it" behavior is included here. ## Changes - `SelectionSummary`: gains an optional `onNavigateModule` prop. When provided, each selected module row renders as an accessible ` ))} ); diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx index fa3767778e..53fbacfb47 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useReducer, + useRef, } from "react"; import { useQuery } from "react-query"; @@ -179,6 +180,69 @@ export const TemplateBuilderPageView: FC = ({ }); }; + // Maps module id -> its config section node, populated by + // ModuleSettingsStep via callback refs. Used to scroll a module into + // view without relying on DOM ids. + const moduleRefs = useRef(new Map()); + + const registerModuleRef = useCallback( + (moduleId: string, node: HTMLDivElement | null) => { + if (node) { + moduleRefs.current.set(moduleId, node); + } else { + moduleRefs.current.delete(moduleId); + } + }, + [], + ); + + // Holds the module a sidebar click wants to scroll to, so the scroll can + // happen after the module-settings step has rendered. + const pendingModuleScrollRef = useRef(null); + + const scrollModuleIntoView = (moduleId: string) => { + moduleRefs.current.get(moduleId)?.scrollIntoView({ behavior: "smooth" }); + }; + + // Sidebar module rows call this to jump to a module's configuration. + const navigateToModule = (moduleId: string) => { + const settingsIndex = WIZARD_STEPS.findIndex( + (s) => s.id === "module-settings", + ); + const settingsVisible = + settingsIndex >= 0 && !WIZARD_STEPS[settingsIndex].shouldSkip(state); + + // If module-settings is skipped (no configurable vars) there is no + // card to scroll to, so the click is a no-op. + if (!settingsVisible) { + return; + } + + if (currentStep.id === "module-settings") { + scrollModuleIntoView(moduleId); + return; + } + // Remember the target and scroll once the step has rendered. + pendingModuleScrollRef.current = moduleId; + navigateToStep(settingsIndex); + }; + + // Runs after the scroll-reset effect above (declared earlier, so it fires + // first). Scrolls the requested module into view once module-settings + // has rendered. + // biome-ignore lint/correctness/useExhaustiveDependencies: run on step change + useEffect(() => { + if (currentStep.id !== "module-settings") { + return; + } + const moduleId = pendingModuleScrollRef.current; + if (!moduleId) { + return; + } + pendingModuleScrollRef.current = null; + requestAnimationFrame(() => scrollModuleIntoView(moduleId)); + }, [currentStep.id]); + if (isCreating) { return ; } @@ -213,6 +277,7 @@ export const TemplateBuilderPageView: FC = ({ createError, handleProvisionerStatusChange, handleDeselectModule, + registerModuleRef, )} @@ -237,6 +302,7 @@ export const TemplateBuilderPageView: FC = ({
void, onRemoveModule: (moduleId: string) => void, + registerModuleRef: (moduleId: string, node: HTMLDivElement | null) => void, ): ReactNode { switch (stepId) { case "base-infra": @@ -311,6 +378,7 @@ function renderStepContent( }) } onRemoveModule={onRemoveModule} + registerModuleRef={registerModuleRef} /> ); case "customizations":