FN-7713: collapse mobile Settings search row behind an expand/hide toggle

Mobile Settings modal now hides the search row by default and exposes a toggle icon to expand/hide it, keeping desktop layout unchanged.

- Add mobile-only searchRowExpanded state and toggle affordance in SettingsModal.tsx
- Add toggle icon styling in SettingsModal.css
- Update dashboard-guide.md docs to describe the new mobile behavior
- Add/extend tests covering the collapsed-by-default state and toggle interaction
- Add changeset (feature, no release wording) for @runfusion/fusion

Files changed:
 .../fn-7713-mobile-settings-search-toggle.md       |   7 ++
 docs/dashboard-guide.md                            |   3 +
 .../dashboard/app/components/SettingsModal.css     |  31 ++++++
 .../dashboard/app/components/SettingsModal.tsx     | 112 ++++++++++++++-------
 .../__tests__/SettingsModal.general.test.tsx       |   7 ++
 .../components/__tests__/settings-mobile.test.tsx  |  69 +++++++++++++
 6 files changed, 192 insertions(+), 37 deletions(-)

Fusion-Task-Id: FN-7713

Fusion-Task-Lineage: 4ce90317-28ce-45b4-a1d7-8fd662a922ae

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-09 10:35:05 -07:00
parent e4a59f7269
commit 21d1201de1
6 changed files with 192 additions and 37 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Mobile Settings search row now collapses by default with a show/hide toggle.
category: feature
dev: SettingsModal mobile-only searchRowExpanded state + toggle icon; desktop unchanged.

View File

@@ -13,6 +13,9 @@ When Fusion detects a newer `@runfusion/fusion` release, the Settings modal foot
<!-- FNXC:SettingsSearchDocs 2026-07-04-00:00: Settings search is section-discovery, not a global command palette. Document that it filters visible Settings sections by section names and setting keywords while preserving feature-gated hidden sections. --> <!-- FNXC:SettingsSearchDocs 2026-07-04-00:00: Settings search is section-discovery, not a global command palette. Document that it filters visible Settings sections by section names and setting keywords while preserving feature-gated hidden sections. -->
Use **Search settings** at the top of Settings to find the section that contains a setting by name or keyword. The same search works in the Settings modal and embedded Settings page, filters both the desktop section list and mobile section picker, and only searches sections currently visible for enabled feature flags. Use **Search settings** at the top of Settings to find the section that contains a setting by name or keyword. The same search works in the Settings modal and embedded Settings page, filters both the desktop section list and mobile section picker, and only searches sections currently visible for enabled feature flags.
<!-- FNXC:Settings 2026-07-09-00:00: FN-7713 collapses the mobile search row by default behind a toggle icon to reclaim vertical space above the section picker; desktop/tablet keep the row always visible with no toggle. -->
On mobile, the search row starts collapsed behind a compact toggle icon (top of the Settings navigation) to save vertical space; tap it to reveal the search input and tap again to hide it. An in-progress search query is preserved across collapse/expand. Desktop and tablet always show the search row with no toggle.
<!-- FNXC:SettingsDefaults 2026-07-04-00:00: FN-7505 requires every user-editable setting's help text to state its own default value, so operators reading a field's description know what it defaults to without checking the reference doc. --> <!-- FNXC:SettingsDefaults 2026-07-04-00:00: FN-7505 requires every user-editable setting's help text to state its own default value, so operators reading a field's description know what it defaults to without checking the reference doc. -->
Every user-editable setting's help text (the `.settings-description`/`<small>` hint under a field) states its own default value — for example “Default: 3.”, “Default: enabled.”, or “No default — unset (inherits the global setting).” for values that fall back to another scope. Canonical default values come from `DEFAULT_GLOBAL_SETTINGS` / `DEFAULT_PROJECT_SETTINGS` in `packages/core/src/settings-schema.ts`; the dashboard copy never invents a number. A guard test (`settings-default-descriptions.test.tsx`) enforces that every surfaced setting states its default and that every `DEFAULT_SETTINGS` key is either documented or explicitly allowlisted as not surfaced in the Settings UI. Every user-editable setting's help text (the `.settings-description`/`<small>` hint under a field) states its own default value — for example “Default: 3.”, “Default: enabled.”, or “No default — unset (inherits the global setting).” for values that fall back to another scope. Canonical default values come from `DEFAULT_GLOBAL_SETTINGS` / `DEFAULT_PROJECT_SETTINGS` in `packages/core/src/settings-schema.ts`; the dashboard copy never invents a number. A guard test (`settings-default-descriptions.test.tsx`) enforces that every surfaced setting states its default and that every `DEFAULT_SETTINGS` key is either documented or explicitly allowlisted as not surfaced in the Settings UI.

View File

@@ -558,6 +558,20 @@ The embedded title reads like other embedded-view titles (Planning modal-header-
border-bottom: var(--btn-border-width) solid var(--border); border-bottom: var(--btn-border-width) solid var(--border);
} }
/* FNXC:Settings 2026-07-09-00:00: the row wraps label + input + results; on desktop it always
renders (see .settings-search-toggle below, hidden here). On mobile the toggle in SettingsModal.tsx
controls whether this wrapper mounts at all. */
.settings-search-row {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
/* Desktop/tablet: search row is always visible, so the toggle icon never renders here. */
.settings-search-toggle {
display: none;
}
.settings-search-label { .settings-search-label {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.75rem; font-size: 0.75rem;
@@ -2179,6 +2193,23 @@ The header row wraps so the badge drops below the heading on narrow widths inste
padding: var(--space-md) var(--space-lg) var(--space-sm); padding: var(--space-md) var(--space-lg) var(--space-sm);
} }
/* FNXC:Settings 2026-07-09-00:00: mobile-only collapse-by-default toggle. The row starts
collapsed (only the icon button renders) and expands to the full search row on tap. */
.settings-search-toggle {
--settings-search-toggle-touch-target: calc(var(--space-lg) + var(--space-lg) + var(--space-xs));
display: inline-flex;
align-items: center;
justify-content: center;
align-self: flex-start;
width: var(--settings-search-toggle-touch-target);
height: var(--settings-search-toggle-touch-target);
flex-shrink: 0;
}
.settings-search-row {
margin-top: var(--space-xs);
}
.settings-search-input-wrap { .settings-search-input-wrap {
align-items: stretch; align-items: stretch;
} }

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties, type MouseEvent } from "react"; import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties, type MouseEvent } from "react";
import { Globe, Folder, RefreshCw, Star, HelpCircle, Settings as SettingsIcon } from "lucide-react"; import { Globe, Folder, RefreshCw, Star, HelpCircle, Settings as SettingsIcon, Search, X as SearchToggleCloseIcon } from "lucide-react";
import { import {
getErrorMessage, getErrorMessage,
resolveGitlabConfig, resolveGitlabConfig,
@@ -973,6 +973,18 @@ export function SettingsModal({
: false, : false,
); );
const [settingsSearchQuery, setSettingsSearchQuery] = useState(""); const [settingsSearchQuery, setSettingsSearchQuery] = useState("");
/*
* FNXC:Settings 2026-07-09-00:00:
* Mobile Settings previously always rendered the `.settings-search` row (label + input + result
* count) above the section picker, eating vertical space even when the user was not searching.
* On mobile only, the row now starts COLLAPSED behind a compact icon toggle; tapping it reveals
* the input, tapping again hides it. Desktop/tablet is untouched — the row is always visible
* there and the toggle never renders (see `isMobileSettingsSearch` below). Decision on the
* active-query edge case: `settingsSearchQuery` state is never cleared by collapsing, so
* re-expanding always restores the exact query and result count the user had before collapsing
* (no forced auto-expand — the toggle stays user-controlled).
*/
const [mobileSearchRowExpanded, setMobileSearchRowExpanded] = useState(false);
const [appVersion, setAppVersion] = useState<string | null>(null); const [appVersion, setAppVersion] = useState<string | null>(null);
const [updateCheckLoading, setUpdateCheckLoading] = useState(false); const [updateCheckLoading, setUpdateCheckLoading] = useState(false);
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(null); const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(null);
@@ -1053,6 +1065,10 @@ export function SettingsModal({
const searchableSectionOptions = searchMatchedSections.filter((section) => !section.isGroupHeader); const searchableSectionOptions = searchMatchedSections.filter((section) => !section.isGroupHeader);
const hasSettingsSearchQuery = normalizedSettingsSearchQuery.length > 0; const hasSettingsSearchQuery = normalizedSettingsSearchQuery.length > 0;
const hasSettingsSearchResults = searchableSectionOptions.length > 0; const hasSettingsSearchResults = searchableSectionOptions.length > 0;
// FNXC:Settings 2026-07-09-00:00: desktop/tablet always show the search row and never render the
// toggle; mobile starts collapsed (`mobileSearchRowExpanded === false`) until the user taps it.
const isMobileSettingsSearch = viewportMode === "mobile";
const settingsSearchRowVisible = !isMobileSettingsSearch || mobileSearchRowExpanded;
const firstSearchMatchedSectionId = resolveFirstSelectableSettingsSection(searchMatchedSections, firstVisibleSectionId); const firstSearchMatchedSectionId = resolveFirstSelectableSettingsSection(searchMatchedSections, firstVisibleSectionId);
/** Get the scope of the currently active section */ /** Get the scope of the currently active section */
@@ -3636,42 +3652,64 @@ export function SettingsModal({
<div className="settings-layout"> <div className="settings-layout">
<aside className="settings-navigation" aria-label={t("settings.search.navigationLabel", "Settings navigation")}> <aside className="settings-navigation" aria-label={t("settings.search.navigationLabel", "Settings navigation")}>
<div className="settings-search" data-testid="settings-search"> <div className="settings-search" data-testid="settings-search">
<label className="settings-search-label" htmlFor="settings-search-input"> {isMobileSettingsSearch && (
{t("settings.search.label", "Search settings")} // FNXC:Settings 2026-07-09-00:00: mobile-only compact icon toggle; hidden entirely on
</label> // desktop/tablet via `isMobileSettingsSearch`, which always shows the row instead.
<div className="settings-search-input-wrap"> <button
<input type="button"
id="settings-search-input" className="btn btn-sm btn-icon settings-search-toggle"
data-testid="settings-search-input" onClick={() => setMobileSearchRowExpanded((expanded) => !expanded)}
className="input settings-search-input" aria-expanded={settingsSearchRowVisible}
type="search" aria-controls="settings-search-row-region"
value={settingsSearchQuery} aria-label={
onChange={(event) => setSettingsSearchQuery(event.target.value)} settingsSearchRowVisible
onKeyDown={(event) => { ? t("settings.search.toggleHide", "Hide search")
if (event.key === "Escape" && hasSettingsSearchQuery) { : t("settings.search.toggleShow", "Show search")
event.stopPropagation(); }
setSettingsSearchQuery(""); >
} {settingsSearchRowVisible ? <SearchToggleCloseIcon size={16} /> : <Search size={16} />}
}} </button>
placeholder={t("settings.search.placeholder", "Search by setting or section")} )}
aria-describedby="settings-search-results" {settingsSearchRowVisible && (
/> <div id="settings-search-row-region" className="settings-search-row">
{hasSettingsSearchQuery && ( <label className="settings-search-label" htmlFor="settings-search-input">
<button {t("settings.search.label", "Search settings")}
type="button" </label>
className="btn btn-sm settings-search-clear" <div className="settings-search-input-wrap">
onClick={() => setSettingsSearchQuery("")} <input
aria-label={t("settings.search.clear", "Clear settings search")} id="settings-search-input"
> data-testid="settings-search-input"
{t("actions.clear", "Clear")} className="input settings-search-input"
</button> type="search"
)} value={settingsSearchQuery}
</div> onChange={(event) => setSettingsSearchQuery(event.target.value)}
<div id="settings-search-results" className="settings-search-results" aria-live="polite"> onKeyDown={(event) => {
{hasSettingsSearchQuery if (event.key === "Escape" && hasSettingsSearchQuery) {
? t("settings.search.resultCount", "{{count}} matching sections", { count: searchableSectionOptions.length }) event.stopPropagation();
: t("settings.search.allSections", "Showing all settings sections")} setSettingsSearchQuery("");
</div> }
}}
placeholder={t("settings.search.placeholder", "Search by setting or section")}
aria-describedby="settings-search-results"
/>
{hasSettingsSearchQuery && (
<button
type="button"
className="btn btn-sm settings-search-clear"
onClick={() => setSettingsSearchQuery("")}
aria-label={t("settings.search.clear", "Clear settings search")}
>
{t("actions.clear", "Clear")}
</button>
)}
</div>
<div id="settings-search-results" className="settings-search-results" aria-live="polite">
{hasSettingsSearchQuery
? t("settings.search.resultCount", "{{count}} matching sections", { count: searchableSectionOptions.length })
: t("settings.search.allSections", "Showing all settings sections")}
</div>
</div>
)}
</div> </div>
{showMobileSectionPicker && ( {showMobileSectionPicker && (
<div className="settings-mobile-section-picker"> <div className="settings-mobile-section-picker">

View File

@@ -240,6 +240,9 @@ describe("SettingsModal", () => {
renderModal(); renderModal();
await waitForSettingsModalReady(); await waitForSettingsModalReady();
// FN-7713: this file mocks useViewportMode to "mobile", so the search row starts collapsed
// behind the toggle — expand it before interacting with the search input.
await settingsModalUser.click(screen.getByLabelText("Show search"));
const search = screen.getByTestId("settings-search-input"); const search = screen.getByTestId("settings-search-input");
expect(screen.getByRole("button", { name: /^General$/ })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /^General$/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^Project General$/ })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /^Project General$/ })).toBeInTheDocument();
@@ -275,6 +278,8 @@ describe("SettingsModal", () => {
renderModal(); renderModal();
await waitForSettingsModalReady(); await waitForSettingsModalReady();
// FN-7713: search row is collapsed by default under the "mobile" viewport mock — expand it first.
await settingsModalUser.click(screen.getByLabelText("Show search"));
const search = screen.getByTestId("settings-search-input"); const search = screen.getByTestId("settings-search-input");
await settingsModalUser.type(search, "mcp"); await settingsModalUser.type(search, "mcp");
@@ -345,6 +350,8 @@ describe("SettingsModal", () => {
renderModal({ presentation: "embedded", onClose }); renderModal({ presentation: "embedded", onClose });
await waitForSettingsModalReady(); await waitForSettingsModalReady();
// FN-7713: embedded mobile-mocked viewport starts the search row collapsed — expand it first.
await settingsModalUser.click(screen.getByLabelText("Show search"));
const search = screen.getByTestId("settings-search-input"); const search = screen.getByTestId("settings-search-input");
await settingsModalUser.type(search, "model pricing"); await settingsModalUser.type(search, "model pricing");
expect(screen.getByRole("button", { name: /^Models$/ })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /^Models$/ })).toBeInTheDocument();

View File

@@ -308,6 +308,8 @@ describe("SettingsModal mobile adaptations", () => {
const { getByLabelText, getByTestId, queryByLabelText, getByText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />); const { getByLabelText, getByTestId, queryByLabelText, getByText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// FN-7713: search row is collapsed by default on mobile — expand it first.
await user.click(getByLabelText("Show search"));
const search = getByTestId("settings-search-input"); const search = getByTestId("settings-search-input");
await user.type(search, "mcp"); await user.type(search, "mcp");
@@ -588,4 +590,71 @@ describe("SettingsModal mobile adaptations", () => {
expectBaseRule(css, ".settings-section-heading", "margin: 0;"); expectBaseRule(css, ".settings-section-heading", "margin: 0;");
expect(css).not.toMatch(/\.settings-section-heading\s*\{[^}]*border-bottom:\s*1px solid var\(--border\);/); expect(css).not.toMatch(/\.settings-section-heading\s*\{[^}]*border-bottom:\s*1px solid var\(--border\);/);
}); });
// FN-7713: mobile Settings search row collapses by default behind an icon toggle; desktop keeps
// the row always visible with no toggle.
describe("mobile settings search collapse toggle (FN-7713)", () => {
it("hides the search input by default on mobile and reveals it via the toggle", async () => {
mockSettingsViewport(true);
const user = userEvent.setup();
const { getByLabelText, queryByTestId, findByLabelText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Collapsed by default: no search input, toggle present and labeled "Show search".
expect(queryByTestId("settings-search-input")).toBeNull();
const toggle = getByLabelText("Show search");
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(toggle.getAttribute("aria-controls")).toBe("settings-search-row-region");
await user.click(toggle);
expect(await findByLabelText("Hide search")).toBeTruthy();
expect(getByLabelText("Hide search").getAttribute("aria-expanded")).toBe("true");
expect(document.getElementById("settings-search-row-region")).toBeTruthy();
await user.click(getByLabelText("Hide search"));
await waitFor(() => expect(queryByTestId("settings-search-input")).toBeNull());
expect(getByLabelText("Show search").getAttribute("aria-expanded")).toBe("false");
// No leftover shell for the results region while collapsed.
expect(document.getElementById("settings-search-row-region")).toBeNull();
expect(document.getElementById("settings-search-results")).toBeNull();
});
it("preserves an active search query across collapse/expand cycles", async () => {
mockSettingsViewport(true);
const user = userEvent.setup();
const { getByLabelText, getByTestId, queryByTestId } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
await user.click(getByLabelText("Show search"));
const search = getByTestId("settings-search-input");
await user.type(search, "mcp");
expect((getByTestId("settings-search-input") as HTMLInputElement).value).toBe("mcp");
// Collapse while a query is active: state is preserved, no forced auto-expand.
await user.click(getByLabelText("Hide search"));
expect(queryByTestId("settings-search-input")).toBeNull();
// Re-expanding restores the exact query and its results region.
await user.click(getByLabelText("Show search"));
expect((getByTestId("settings-search-input") as HTMLInputElement).value).toBe("mcp");
expect(document.getElementById("settings-search-results")?.textContent).toContain("matching sections");
});
it("keeps the desktop search row always visible with no toggle rendered", async () => {
mockSettingsViewport(false);
const { queryByLabelText, getByTestId } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(getByTestId("settings-search-input")).toBeTruthy();
expect(queryByLabelText("Show search")).toBeNull();
expect(queryByLabelText("Hide search")).toBeNull();
});
it("contains the mobile-only toggle CSS override and hides it on desktop", () => {
const css = loadAllAppCss();
expectMobileRule(css, ".settings-search-toggle", "display: inline-flex;");
expectBaseRule(css, ".settings-search-toggle", "display: none;");
});
});
}); });