FN-6819: keep sidebar settings above footer
Keep the experimental left sidebar Settings control clear of the fixed executor footer while preserving readable project selector fallbacks. - Share the executor footer visibility state with the left sidebar and project content. - Reserve executor-footer height on the sidebar when the status footer is visible so Settings stays clickable in expanded and collapsed modes. - Add project-selector translation fallbacks for incomplete locale fixtures. - Cover footer-clearance behavior in left sidebar tests and add a patch changeset. Files changed: .changeset/fn-6819-sidebar-footer-clearance.md | 5 +++ packages/dashboard/app/App.tsx | 8 +++-- .../dashboard/app/components/LeftSidebarNav.css | 9 ++++++ .../dashboard/app/components/LeftSidebarNav.tsx | 4 ++- .../dashboard/app/components/ProjectSelector.css | 2 +- .../dashboard/app/components/ProjectSelector.tsx | 11 +++++-- .../components/__tests__/LeftSidebarNav.test.tsx | 36 ++++++++++++++++++++++ 7 files changed, 67 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-6819 Fusion-Task-Lineage: 950da852-8dcf-4379-a91b-0188b12d1a6c
This commit is contained in:
5
.changeset/fn-6819-sidebar-footer-clearance.md
Normal file
5
.changeset/fn-6819-sidebar-footer-clearance.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix the experimental left sidebar Settings button so it remains clear of the fixed executor status footer, and keep project-selector fallback labels readable when translations are incomplete.
|
||||
@@ -1036,7 +1036,8 @@ function AppInner() {
|
||||
Experimental left sidebar navigation replaces the Header view shortcuts with a persistent sidebar on non-mobile project screens, while mobile continues to use the bottom navigation bar as the only primary navigation surface.
|
||||
*/
|
||||
const leftSidebarNavEnabled = experimentalFeatures.leftSidebarNav === true;
|
||||
const sidebarActive = leftSidebarNavEnabled && !isMobile && viewMode === "project" && !!currentProject;
|
||||
const executorFooterVisible = viewMode === "project" && !!currentProject;
|
||||
const sidebarActive = leftSidebarNavEnabled && !isMobile && executorFooterVisible;
|
||||
const agentOnboardingEnabled = experimentalFeatures.agentOnboarding === true;
|
||||
const agentsEnabled = true;
|
||||
|
||||
@@ -2136,15 +2137,16 @@ function AppInner() {
|
||||
currentProject={currentProject}
|
||||
onSelectProject={handleSelectProject}
|
||||
onViewAllProjects={handleViewAllProjects}
|
||||
footerVisible={executorFooterVisible}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`project-content${viewMode === "project" && currentProject && (!isMobile || !mobileKeyboardOpen) ? " project-content--with-footer" : ""}${isMobile && !mobileKeyboardOpen ? " project-content--with-mobile-nav" : ""}`}
|
||||
className={`project-content${executorFooterVisible && (!isMobile || !mobileKeyboardOpen) ? " project-content--with-footer" : ""}${isMobile && !mobileKeyboardOpen ? " project-content--with-mobile-nav" : ""}`}
|
||||
>
|
||||
{renderMainContent()}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "project" && currentProject && (
|
||||
{executorFooterVisible && currentProject && (
|
||||
<ExecutorStatusBar
|
||||
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
|
||||
projectId={currentProject.id}
|
||||
|
||||
@@ -8,6 +8,7 @@ The experimental sidebar is a persistent desktop/tablet navigation replacement f
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
width: var(--left-sidebar-nav-width);
|
||||
min-width: var(--left-sidebar-nav-width);
|
||||
min-height: 0;
|
||||
@@ -16,6 +17,14 @@ The experimental sidebar is a persistent desktop/tablet navigation replacement f
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Navigation 2026-06-20-00:00:
|
||||
The left sidebar is a sibling of project-content, so it does not inherit project-content footer padding. When the fixed executor status bar is rendered, reserve the shared --executor-footer-height on the aside so the bottom Settings button remains visible and clickable above the footer.
|
||||
*/
|
||||
.left-sidebar-nav--with-footer {
|
||||
padding-bottom: var(--executor-footer-height);
|
||||
}
|
||||
|
||||
.left-sidebar-nav__collapse-toggle {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ export interface LeftSidebarNavProps {
|
||||
currentProject?: ProjectInfo | null;
|
||||
onSelectProject?: (project: ProjectInfo) => void;
|
||||
onViewAllProjects?: () => void;
|
||||
footerVisible?: boolean;
|
||||
}
|
||||
|
||||
function formatCount(count: number): string {
|
||||
@@ -160,6 +161,7 @@ export function LeftSidebarNav({
|
||||
pluginDashboardViews = [],
|
||||
showAgentsTab = false,
|
||||
showSkillsTab = false,
|
||||
footerVisible = false,
|
||||
}: LeftSidebarNavProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [sidebarWidth, setSidebarWidth] = useState(readStoredSidebarWidth);
|
||||
@@ -390,7 +392,7 @@ export function LeftSidebarNav({
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`left-sidebar-nav${isCollapsed ? " left-sidebar-nav--collapsed" : ""}`}
|
||||
className={`left-sidebar-nav${isCollapsed ? " left-sidebar-nav--collapsed" : ""}${footerVisible ? " left-sidebar-nav--with-footer" : ""}`}
|
||||
data-testid="left-sidebar-nav"
|
||||
aria-label={t("nav.sidebarAriaLabel", "Sidebar navigation")}
|
||||
style={isCollapsed ? undefined : { width: sidebarWidth, minWidth: sidebarWidth }}
|
||||
|
||||
@@ -580,6 +580,7 @@
|
||||
* On mobile the token is overridden to 32px to match the shorter footer.
|
||||
*/
|
||||
.dashboard-project-shell {
|
||||
--executor-footer-height: 36px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -602,7 +603,6 @@
|
||||
}
|
||||
|
||||
.project-content--with-footer {
|
||||
--executor-footer-height: 36px;
|
||||
padding-bottom: var(--executor-footer-height);
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,11 @@ export function ProjectSelector({
|
||||
viewAllLabel,
|
||||
}: ProjectSelectorProps) {
|
||||
const { t } = useTranslation("app");
|
||||
/*
|
||||
* FNXC:ProjectSelector 2026-06-20-20:51:
|
||||
* The trigger fallback must remain readable when translation fixtures omit the optional project-selector key; provide the English default at the component seam instead of leaking the i18n key into the header.
|
||||
*/
|
||||
const projectsLabel = t("projectSelector.projects", "Projects");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
@@ -389,12 +394,12 @@ export function ProjectSelector({
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={t("projectSelector.ariaLabel", "Select project")}
|
||||
title={currentProject?.name ? t("projectSelector.switchProjectTitle", "Switch project (current: {{name}})", { name: currentProject.name }) : t("projectSelector.projectsTitle")}
|
||||
title={currentProject?.name ? t("projectSelector.switchProjectTitle", "Switch project (current: {{name}})", { name: currentProject.name }) : t("projectSelector.projectsTitle", "Projects")}
|
||||
data-testid="project-selector-trigger"
|
||||
>
|
||||
<Folder size={16} className="project-selector__trigger-icon" />
|
||||
<span className="project-selector__trigger-text">
|
||||
{currentProject?.name || t("projectSelector.projects")}
|
||||
{currentProject?.name || projectsLabel}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
@@ -407,7 +412,7 @@ export function ProjectSelector({
|
||||
<div
|
||||
className="project-selector__dropdown"
|
||||
role="listbox"
|
||||
aria-label={t("projectSelector.projects")}
|
||||
aria-label={projectsLabel}
|
||||
onKeyDown={handleDropdownKeyDown}
|
||||
data-testid="project-selector-dropdown"
|
||||
>
|
||||
|
||||
@@ -57,6 +57,12 @@ function expectNoSidebarBrandOrProjectAffordances(container: HTMLElement) {
|
||||
expect(container.querySelector(".left-sidebar-nav__wordmark")).toBeNull();
|
||||
}
|
||||
|
||||
function expectSettingsLastInFooter() {
|
||||
const footer = screen.getByTestId("sidebar-nav-settings").closest(".left-sidebar-nav__footer");
|
||||
expect(footer).not.toBeNull();
|
||||
expect(footer?.lastElementChild).toBe(screen.getByTestId("sidebar-nav-settings"));
|
||||
}
|
||||
|
||||
function renderSidebar(overrides: Partial<ComponentProps<typeof LeftSidebarNav>> = {}) {
|
||||
const onChangeView = vi.fn();
|
||||
const props: ComponentProps<typeof LeftSidebarNav> = {
|
||||
@@ -127,6 +133,36 @@ describe("LeftSidebarNav", () => {
|
||||
expect(sidebarButtons.at(-1)).toBe(screen.getByTestId("sidebar-nav-settings"));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["expanded", false],
|
||||
["collapsed", true],
|
||||
])("applies footer clearance only when the executor footer is visible in %s mode", (_label, collapsed) => {
|
||||
if (collapsed) {
|
||||
window.localStorage.setItem("fusion:left-sidebar-collapsed", "true");
|
||||
}
|
||||
|
||||
const withFooter = renderSidebar({ footerVisible: true });
|
||||
const sidebarWithFooter = screen.getByTestId("left-sidebar-nav");
|
||||
expect(sidebarWithFooter).toHaveClass("left-sidebar-nav--with-footer");
|
||||
if (collapsed) {
|
||||
expect(sidebarWithFooter).toHaveClass("left-sidebar-nav--collapsed");
|
||||
}
|
||||
expectSettingsLastInFooter();
|
||||
|
||||
withFooter.unmount();
|
||||
if (collapsed) {
|
||||
window.localStorage.setItem("fusion:left-sidebar-collapsed", "true");
|
||||
}
|
||||
|
||||
renderSidebar();
|
||||
const sidebarWithoutFooter = screen.getByTestId("left-sidebar-nav");
|
||||
expect(sidebarWithoutFooter).not.toHaveClass("left-sidebar-nav--with-footer");
|
||||
if (collapsed) {
|
||||
expect(sidebarWithoutFooter).toHaveClass("left-sidebar-nav--collapsed");
|
||||
}
|
||||
expectSettingsLastInFooter();
|
||||
});
|
||||
|
||||
it("gates optional destinations on their matching feature flags and props while preserving bottom settings", () => {
|
||||
renderSidebar({
|
||||
showAgentsTab: false,
|
||||
|
||||
Reference in New Issue
Block a user