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:
7
.changeset/fn-7713-mobile-settings-search-toggle.md
Normal file
7
.changeset/fn-7713-mobile-settings-search-toggle.md
Normal 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.
|
||||
@@ -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. -->
|
||||
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. -->
|
||||
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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
color: var(--text-muted);
|
||||
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);
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
getErrorMessage,
|
||||
resolveGitlabConfig,
|
||||
@@ -973,6 +973,18 @@ export function SettingsModal({
|
||||
: false,
|
||||
);
|
||||
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 [updateCheckLoading, setUpdateCheckLoading] = useState(false);
|
||||
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(null);
|
||||
@@ -1053,6 +1065,10 @@ export function SettingsModal({
|
||||
const searchableSectionOptions = searchMatchedSections.filter((section) => !section.isGroupHeader);
|
||||
const hasSettingsSearchQuery = normalizedSettingsSearchQuery.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);
|
||||
|
||||
/** Get the scope of the currently active section */
|
||||
@@ -3636,42 +3652,64 @@ export function SettingsModal({
|
||||
<div className="settings-layout">
|
||||
<aside className="settings-navigation" aria-label={t("settings.search.navigationLabel", "Settings navigation")}>
|
||||
<div className="settings-search" data-testid="settings-search">
|
||||
<label className="settings-search-label" htmlFor="settings-search-input">
|
||||
{t("settings.search.label", "Search settings")}
|
||||
</label>
|
||||
<div className="settings-search-input-wrap">
|
||||
<input
|
||||
id="settings-search-input"
|
||||
data-testid="settings-search-input"
|
||||
className="input settings-search-input"
|
||||
type="search"
|
||||
value={settingsSearchQuery}
|
||||
onChange={(event) => setSettingsSearchQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && hasSettingsSearchQuery) {
|
||||
event.stopPropagation();
|
||||
setSettingsSearchQuery("");
|
||||
}
|
||||
}}
|
||||
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>
|
||||
{isMobileSettingsSearch && (
|
||||
// FNXC:Settings 2026-07-09-00:00: mobile-only compact icon toggle; hidden entirely on
|
||||
// desktop/tablet via `isMobileSettingsSearch`, which always shows the row instead.
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-icon settings-search-toggle"
|
||||
onClick={() => setMobileSearchRowExpanded((expanded) => !expanded)}
|
||||
aria-expanded={settingsSearchRowVisible}
|
||||
aria-controls="settings-search-row-region"
|
||||
aria-label={
|
||||
settingsSearchRowVisible
|
||||
? t("settings.search.toggleHide", "Hide search")
|
||||
: t("settings.search.toggleShow", "Show search")
|
||||
}
|
||||
>
|
||||
{settingsSearchRowVisible ? <SearchToggleCloseIcon size={16} /> : <Search size={16} />}
|
||||
</button>
|
||||
)}
|
||||
{settingsSearchRowVisible && (
|
||||
<div id="settings-search-row-region" className="settings-search-row">
|
||||
<label className="settings-search-label" htmlFor="settings-search-input">
|
||||
{t("settings.search.label", "Search settings")}
|
||||
</label>
|
||||
<div className="settings-search-input-wrap">
|
||||
<input
|
||||
id="settings-search-input"
|
||||
data-testid="settings-search-input"
|
||||
className="input settings-search-input"
|
||||
type="search"
|
||||
value={settingsSearchQuery}
|
||||
onChange={(event) => setSettingsSearchQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && hasSettingsSearchQuery) {
|
||||
event.stopPropagation();
|
||||
setSettingsSearchQuery("");
|
||||
}
|
||||
}}
|
||||
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>
|
||||
{showMobileSectionPicker && (
|
||||
<div className="settings-mobile-section-picker">
|
||||
|
||||
@@ -240,6 +240,9 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
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");
|
||||
expect(screen.getByRole("button", { name: /^General$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^Project General$/ })).toBeInTheDocument();
|
||||
@@ -275,6 +278,8 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
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");
|
||||
await settingsModalUser.type(search, "mcp");
|
||||
|
||||
@@ -345,6 +350,8 @@ describe("SettingsModal", () => {
|
||||
renderModal({ presentation: "embedded", onClose });
|
||||
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");
|
||||
await settingsModalUser.type(search, "model pricing");
|
||||
expect(screen.getByRole("button", { name: /^Models$/ })).toBeInTheDocument();
|
||||
|
||||
@@ -308,6 +308,8 @@ describe("SettingsModal mobile adaptations", () => {
|
||||
const { getByLabelText, getByTestId, queryByLabelText, getByText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
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");
|
||||
await user.type(search, "mcp");
|
||||
|
||||
@@ -588,4 +590,71 @@ describe("SettingsModal mobile adaptations", () => {
|
||||
expectBaseRule(css, ".settings-section-heading", "margin: 0;");
|
||||
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;");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user