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.
|
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 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 agentOnboardingEnabled = experimentalFeatures.agentOnboarding === true;
|
||||||
const agentsEnabled = true;
|
const agentsEnabled = true;
|
||||||
|
|
||||||
@@ -2136,15 +2137,16 @@ function AppInner() {
|
|||||||
currentProject={currentProject}
|
currentProject={currentProject}
|
||||||
onSelectProject={handleSelectProject}
|
onSelectProject={handleSelectProject}
|
||||||
onViewAllProjects={handleViewAllProjects}
|
onViewAllProjects={handleViewAllProjects}
|
||||||
|
footerVisible={executorFooterVisible}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div
|
<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()}
|
{renderMainContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{viewMode === "project" && currentProject && (
|
{executorFooterVisible && currentProject && (
|
||||||
<ExecutorStatusBar
|
<ExecutorStatusBar
|
||||||
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
|
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
|
||||||
projectId={currentProject.id}
|
projectId={currentProject.id}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ The experimental sidebar is a persistent desktop/tablet navigation replacement f
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
width: var(--left-sidebar-nav-width);
|
width: var(--left-sidebar-nav-width);
|
||||||
min-width: var(--left-sidebar-nav-width);
|
min-width: var(--left-sidebar-nav-width);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -16,6 +17,14 @@ The experimental sidebar is a persistent desktop/tablet navigation replacement f
|
|||||||
color: var(--text);
|
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 {
|
.left-sidebar-nav__collapse-toggle {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export interface LeftSidebarNavProps {
|
|||||||
currentProject?: ProjectInfo | null;
|
currentProject?: ProjectInfo | null;
|
||||||
onSelectProject?: (project: ProjectInfo) => void;
|
onSelectProject?: (project: ProjectInfo) => void;
|
||||||
onViewAllProjects?: () => void;
|
onViewAllProjects?: () => void;
|
||||||
|
footerVisible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCount(count: number): string {
|
function formatCount(count: number): string {
|
||||||
@@ -160,6 +161,7 @@ export function LeftSidebarNav({
|
|||||||
pluginDashboardViews = [],
|
pluginDashboardViews = [],
|
||||||
showAgentsTab = false,
|
showAgentsTab = false,
|
||||||
showSkillsTab = false,
|
showSkillsTab = false,
|
||||||
|
footerVisible = false,
|
||||||
}: LeftSidebarNavProps) {
|
}: LeftSidebarNavProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(readStoredSidebarWidth);
|
const [sidebarWidth, setSidebarWidth] = useState(readStoredSidebarWidth);
|
||||||
@@ -390,7 +392,7 @@ export function LeftSidebarNav({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<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"
|
data-testid="left-sidebar-nav"
|
||||||
aria-label={t("nav.sidebarAriaLabel", "Sidebar navigation")}
|
aria-label={t("nav.sidebarAriaLabel", "Sidebar navigation")}
|
||||||
style={isCollapsed ? undefined : { width: sidebarWidth, minWidth: sidebarWidth }}
|
style={isCollapsed ? undefined : { width: sidebarWidth, minWidth: sidebarWidth }}
|
||||||
|
|||||||
@@ -580,6 +580,7 @@
|
|||||||
* On mobile the token is overridden to 32px to match the shorter footer.
|
* On mobile the token is overridden to 32px to match the shorter footer.
|
||||||
*/
|
*/
|
||||||
.dashboard-project-shell {
|
.dashboard-project-shell {
|
||||||
|
--executor-footer-height: 36px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -602,7 +603,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project-content--with-footer {
|
.project-content--with-footer {
|
||||||
--executor-footer-height: 36px;
|
|
||||||
padding-bottom: var(--executor-footer-height);
|
padding-bottom: var(--executor-footer-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ export function ProjectSelector({
|
|||||||
viewAllLabel,
|
viewAllLabel,
|
||||||
}: ProjectSelectorProps) {
|
}: ProjectSelectorProps) {
|
||||||
const { t } = useTranslation("app");
|
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 [isOpen, setIsOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||||
@@ -389,12 +394,12 @@ export function ProjectSelector({
|
|||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-label={t("projectSelector.ariaLabel", "Select project")}
|
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"
|
data-testid="project-selector-trigger"
|
||||||
>
|
>
|
||||||
<Folder size={16} className="project-selector__trigger-icon" />
|
<Folder size={16} className="project-selector__trigger-icon" />
|
||||||
<span className="project-selector__trigger-text">
|
<span className="project-selector__trigger-text">
|
||||||
{currentProject?.name || t("projectSelector.projects")}
|
{currentProject?.name || projectsLabel}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={14}
|
size={14}
|
||||||
@@ -407,7 +412,7 @@ export function ProjectSelector({
|
|||||||
<div
|
<div
|
||||||
className="project-selector__dropdown"
|
className="project-selector__dropdown"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label={t("projectSelector.projects")}
|
aria-label={projectsLabel}
|
||||||
onKeyDown={handleDropdownKeyDown}
|
onKeyDown={handleDropdownKeyDown}
|
||||||
data-testid="project-selector-dropdown"
|
data-testid="project-selector-dropdown"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ function expectNoSidebarBrandOrProjectAffordances(container: HTMLElement) {
|
|||||||
expect(container.querySelector(".left-sidebar-nav__wordmark")).toBeNull();
|
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>> = {}) {
|
function renderSidebar(overrides: Partial<ComponentProps<typeof LeftSidebarNav>> = {}) {
|
||||||
const onChangeView = vi.fn();
|
const onChangeView = vi.fn();
|
||||||
const props: ComponentProps<typeof LeftSidebarNav> = {
|
const props: ComponentProps<typeof LeftSidebarNav> = {
|
||||||
@@ -127,6 +133,36 @@ describe("LeftSidebarNav", () => {
|
|||||||
expect(sidebarButtons.at(-1)).toBe(screen.getByTestId("sidebar-nav-settings"));
|
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", () => {
|
it("gates optional destinations on their matching feature flags and props while preserving bottom settings", () => {
|
||||||
renderSidebar({
|
renderSidebar({
|
||||||
showAgentsTab: false,
|
showAgentsTab: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user