Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 38 additions & 27 deletions frontend/src/components/SettingsPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export default function SettingsPanel({ settings, onSave, onClose }) {
minimal_mode: settings?.minimal_mode ?? false,
});

// SOLVES (#581): Persistent Collapse State Hooks
const [isExpanded, setIsExpanded] = useState(() => {
const savedState = localStorage.getItem("localmind_settings_expanded");
return savedState !== null ? JSON.parse(savedState) : true;
Expand Down Expand Up @@ -51,6 +50,19 @@ export default function SettingsPanel({ settings, onSave, onClose }) {
localStorage.setItem("localmind_settings_drafts", JSON.stringify(drafts));
}, [drafts]);

// FIXED (#578): Global keyboard listener listening for Escape key panel dismiss signals
useEffect(() => {
function handleKeyDown(e) {
if (e.key === "Escape") {
onClose();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);

const isEmptyState = !settings || Object.keys(settings).length === 0;

function set(key, val) {
setForm(p => ({ ...p, [key]: val }));
if (errors[key]) {
Expand Down Expand Up @@ -105,28 +117,30 @@ export default function SettingsPanel({ settings, onSave, onClose }) {
};

return (
/* FIXED (#580): Changed top wrapper from a generic <div> to a semantic section landmark region */
<section
aria-labelledby="settings-heading"
className="border-b border-gray-800 bg-gray-900 px-5 py-4 shrink-0 transition-all duration-300"
aria-label="Application Settings"
className="border-b border-gray-800 bg-gray-900 px-5 py-4 shrink-0 transition-all duration-300 outline-none"
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
{/* FIXED (#580): Transformed paragraph into a proper heading paired to the section landmark label */}
<h2 id="settings-heading" className="text-sm font-semibold text-white inline-flex items-center gap-1.5">
<SettingsIcon className="w-4 h-4" aria-hidden="true" />Settings
</h2>

{/* SOLVES (#581): Persistent Collapse Button Controller */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="text-[10px] bg-gray-800 hover:bg-gray-700 text-gray-400 hover:text-gray-200 px-2 py-0.5 rounded transition font-medium"
className="text-[10px] bg-gray-800 hover:bg-gray-700 text-gray-400 hover:text-gray-200 px-2 py-0.5 rounded transition font-medium focus:outline-none focus:ring-2 focus:ring-purple-500"
>
{isExpanded ? "Collapse ▵" : "Expand ▿"}
</button>
</div>
<button onClick={onClose} aria-label="Close settings panel" className="text-gray-500 hover:text-gray-300 text-lg leading-none">×</button>
<button
onClick={onClose}
aria-label="Close settings panel"
className="text-gray-500 hover:text-gray-300 text-lg leading-none rounded focus:outline-none focus:ring-2 focus:ring-purple-500 px-1"
>
×
</button>
</div>

{errors.global && (
Expand All @@ -135,49 +149,47 @@ export default function SettingsPanel({ settings, onSave, onClose }) {
</div>
)}

{/* SOLVES (#581): Controlled expanded layout rendering window wrapper */}
{isExpanded && (
<>
{/* FIXED (#580): Provided unique htmlId properties to bind labels explicitly to inputs */}
<div className="grid grid-cols-2 gap-x-6 gap-y-3 text-xs">
<Field label="Default Model" htmlId="model-dropdown" error={errors.default_model}>
<select id="model-dropdown" value={form.default_model} onChange={e=>set("default_model",e.target.value)} className={`sel ${errors.default_model ? "border-red-500" : ""}`}>
<select id="model-dropdown" value={form.default_model} onChange={e => set("default_model", e.target.value)} className="sel focus:ring-2 focus:ring-purple-500">
{MODELS.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</Field>

<Field label="Default Language" htmlId="lang-dropdown" error={errors.default_language}>
<select id="lang-dropdown" value={form.default_language} onChange={e => set("default_language", e.target.value)} className={`sel ${errors.default_language ? "border-red-500" : ""}`}>
<select id="lang-dropdown" value={form.default_language} onChange={e => set("default_language", e.target.value)} className="sel focus:ring-2 focus:ring-purple-500">
{LANGUAGES.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}
</select>
</Field>

<Field label={`Temperature: ${form.temperature}`} htmlId="temp-slider" error={errors.temperature}>
<input id="temp-slider" type="range" min="0" max="2" step="0.1" value={form.temperature}
onChange={e => set("temperature", parseFloat(e.target.value))}
className="w-full accent-purple-500" />
className="w-full accent-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded" />
</Field>

<Field label={`RAG Context Chunks: ${form.rag_top_k}`} htmlId="rag-slider" error={errors.rag_top_k}>
<input id="rag-slider" type="range" min="1" max="10" step="1" value={form.rag_top_k}
onChange={e => set("rag_top_k", parseInt(e.target.value))}
className="w-full accent-purple-500" />
className="w-full accent-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded" />
</Field>

<Field label={`RAG Chunk Overlap: ${form.rag_chunk_overlap}`} htmlId="overlap-slider" error={errors.rag_chunk_overlap}>
<input id="overlap-slider" type="range" min="0" max="200" step="10" value={form.rag_chunk_overlap}
onChange={e => set("rag_chunk_overlap", parseInt(e.target.value))}
className="w-full accent-purple-500" />
className="w-full accent-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded" />
</Field>

<Field label={`History Turns: ${form.max_history_turns}`} htmlId="history-slider" error={errors.max_history_turns}>
<input id="history-slider" type="range" min="2" max="20" step="2" value={form.max_history_turns}
onChange={e => set("max_history_turns", parseInt(e.target.value))}
className="w-full accent-purple-500" />
className="w-full accent-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded" />
</Field>

<Field label="Theme" htmlId="theme-dropdown" error={errors.theme}>
<select id="theme-dropdown" value={form.theme} onChange={e => set("theme", e.target.value)} className={`sel ${errors.theme ? "border-red-500" : ""}`}>
<select id="theme-dropdown" value={form.theme} onChange={e => set("theme", e.target.value)} className="sel focus:ring-2 focus:ring-purple-500">
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="high-contrast">High Contrast</option>
Expand All @@ -193,7 +205,7 @@ export default function SettingsPanel({ settings, onSave, onClose }) {
type="checkbox"
checked={form.minimal_mode}
onChange={e => set("minimal_mode", e.target.checked)}
className="accent-purple-500"
className="accent-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded"
/>
Low-bandwidth mode
</label>
Expand All @@ -209,13 +221,13 @@ export default function SettingsPanel({ settings, onSave, onClose }) {
value={newDraft}
onChange={e => setNewDraft(e.target.value)}
placeholder="Type a canned prompt snippet or scratchpad note..."
className="flex-1 bg-gray-800 text-gray-200 border border-gray-700 rounded-lg px-3 py-1 text-xs outline-none focus:border-purple-500 placeholder-gray-600"
className="flex-1 bg-gray-800 text-gray-200 border border-gray-700 rounded-lg px-3 py-1 text-xs outline-none focus:border-purple-500 placeholder-gray-600 focus:ring-2 focus:ring-purple-500"
onKeyDown={e => e.key === "Enter" && handleAddDraft()}
/>
<button
type="button"
onClick={handleAddDraft}
className="bg-purple-700/40 hover:bg-purple-700 border border-purple-500/30 hover:border-purple-500 text-purple-300 hover:text-white px-3 py-1 rounded-lg font-medium transition text-xs shrink-0"
className="bg-purple-700/40 hover:bg-purple-700 border border-purple-500/30 hover:border-purple-500 text-purple-300 hover:text-white px-3 py-1 rounded-lg font-medium transition text-xs shrink-0 focus:outline-none focus:ring-2 focus:ring-purple-500"
>
Add
</button>
Expand All @@ -231,7 +243,7 @@ export default function SettingsPanel({ settings, onSave, onClose }) {
<button
type="button"
onClick={() => handleDeleteDraft(d.id)}
className="text-gray-500 hover:text-red-400 transition font-bold px-1 text-sm leading-none"
className="text-gray-500 hover:text-red-400 transition font-bold px-1 text-sm leading-none focus:outline-none focus:ring-2 focus:ring-purple-500 rounded"
>
×
</button>
Expand All @@ -247,14 +259,14 @@ export default function SettingsPanel({ settings, onSave, onClose }) {
type="button"
onClick={handleSave}
disabled={isSaving}
className="text-xs bg-purple-700 hover:bg-purple-600 disabled:bg-gray-800 text-white px-4 py-1.5 rounded-lg transition font-medium shadow-md"
className="text-xs bg-purple-700 hover:bg-purple-600 disabled:bg-gray-800 text-white px-4 py-1.5 rounded-lg transition font-medium shadow-md focus:outline-none focus:ring-2 focus:ring-purple-500"
>
{isSaving ? "Saving..." : "Save Settings"}
</button>
<button
type="button"
onClick={onClose}
className="text-xs border border-gray-700 text-gray-400 hover:bg-gray-800 px-4 py-1.5 rounded-lg transition"
className="text-xs border border-gray-700 text-gray-400 hover:bg-gray-800 px-4 py-1.5 rounded-lg transition focus:outline-none focus:ring-2 focus:ring-purple-500"
>
Cancel
</button>
Expand All @@ -263,7 +275,7 @@ export default function SettingsPanel({ settings, onSave, onClose }) {
<button
type="button"
onClick={handleCopySummary}
className={`text-[11px] px-3 py-1.5 rounded-lg transition font-medium border flex items-center gap-1.5 duration-200
className={`text-[11px] px-3 py-1.5 rounded-lg transition font-medium border flex items-center gap-1.5 duration-200 focus:outline-none focus:ring-2 focus:ring-purple-500
${copied
? "bg-green-950/40 border-green-900/60 text-green-400"
: "border-gray-700 text-gray-400 hover:bg-gray-800"}`}
Expand All @@ -281,7 +293,6 @@ export default function SettingsPanel({ settings, onSave, onClose }) {
);
}

/* FIXED (#580): Mapped the incoming unique htmlId prop explicitly to the label's htmlFor target */
function Field({ label, htmlId, error, children }) {
return (
<div className="flex flex-col justify-between">
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/components/SettingsPanel.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ afterEach(() => {
cleanup();
});

describe("SettingsPanel Keyboard Navigation Suite (#578)", () => {
test("fires the onClose callback trigger instantly when the Escape key is pressed", () => {
const mockOnClose = vi.fn();
render(<SettingsPanel settings={mockSettings} onSave={vi.fn()} onClose={mockOnClose} />);

// Simulates standard window keyboard Escape sequence tracking
fireEvent.keyDown(window, { key: "Escape" });

expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});

describe("SettingsPanel Accessibility Landmarks & Validation Suite (#580)", () => {
test("renders with correct semantic section region and aria bindings", () => {
render(<SettingsPanel settings={mockSettings} onSave={vi.fn()} onClose={vi.fn()} />);
Expand All @@ -35,6 +47,7 @@ describe("SettingsPanel Accessibility Landmarks & Validation Suite (#580)", () =
expect(screen.getByLabelText(/temperature/i)).toBeDefined();
});


test("renders core settings form fields accurately with default prop values", () => {
render(<SettingsPanel settings={mockSettings} onSave={vi.fn()} onClose={vi.fn()} />);

Expand Down
Loading