From 34973dd7d74cbcef0502f5d9eadd3c26768cf3b5 Mon Sep 17 00:00:00 2001 From: murashit Date: Sun, 28 Jun 2026 07:46:32 +0900 Subject: [PATCH] Align settings controls with Obsidian components --- src/settings/tab-shell.tsx | 75 +++++---------------------- src/shared/ui/components.obsidian.tsx | 57 +++++++++++++++++--- tests/settings/settings-tab.test.ts | 7 ++- 3 files changed, 71 insertions(+), 68 deletions(-) diff --git a/src/settings/tab-shell.tsx b/src/settings/tab-shell.tsx index 380a16a6..41135859 100644 --- a/src/settings/tab-shell.tsx +++ b/src/settings/tab-shell.tsx @@ -1,8 +1,7 @@ -import type { TargetedEvent, TargetedKeyboardEvent, ComponentChild as UiNode } from "preact"; -import { useEffect, useState } from "preact/hooks"; +import type { ComponentChild as UiNode } from "preact"; import { DEFAULT_CODEX_PATH } from "../constants"; -import { IconButton } from "../shared/ui/components.obsidian"; +import { IconButton, ObsidianCommitTextInput, ObsidianDropdown, ObsidianToggle } from "../shared/ui/components.obsidian"; import type { SendShortcut } from "../shared/ui/keyboard"; import { ArchivedThreadSection } from "./archived-section"; import { HelperSettingsSection } from "./helper-section"; @@ -14,6 +13,10 @@ const SEND_SHORTCUT_LABELS = { enter: "Enter", "mod-enter": "Cmd/Ctrl+Enter", } as const; +const SEND_SHORTCUT_OPTIONS: { value: SendShortcut; label: string }[] = [ + { value: "enter", label: SEND_SHORTCUT_LABELS.enter }, + { value: "mod-enter", label: SEND_SHORTCUT_LABELS["mod-enter"] }, +]; interface SettingsTabPanelState { codexPath: string; @@ -75,10 +78,10 @@ function PanelPreferenceSections({ panel, actions }: { panel: SettingsTabPanelSt - + - + @@ -86,72 +89,22 @@ function PanelPreferenceSections({ panel, actions }: { panel: SettingsTabPanelSt - + options={SEND_SHORTCUT_OPTIONS} + /> - + ); } - -function CommitTextInput({ - value, - placeholder, - onCommit, -}: { - value: string; - placeholder: string; - onCommit: (value: string) => void; -}): UiNode { - const [draft, setDraft] = useState(value); - useEffect(() => { - setDraft(value); - }, [value]); - const commit = (nextValue = draft): void => { - onCommit(nextValue); - }; - return ( - { - setDraft(event.currentTarget.value); - }} - onBlur={(event) => { - commit(event.currentTarget.value); - }} - onKeyDown={(event: TargetedKeyboardEvent) => { - if (event.key !== "Enter") return; - event.preventDefault(); - commit(event.currentTarget.value); - }} - /> - ); -} - -function SettingsCheckbox({ checked, onChange }: { checked: boolean; onChange: (value: boolean) => void }): UiNode { - return ( - ) => { - onChange(event.currentTarget.checked); - }} - /> - ); -} diff --git a/src/shared/ui/components.obsidian.tsx b/src/shared/ui/components.obsidian.tsx index c9357b96..72967a6a 100644 --- a/src/shared/ui/components.obsidian.tsx +++ b/src/shared/ui/components.obsidian.tsx @@ -14,6 +14,12 @@ export interface ObsidianDropdownOption { label: string; } +function useLatestRef(value: T): { current: T } { + const ref = useRef(value); + ref.current = value; + return ref; +} + function ObsidianIcon({ icon, className }: ObsidianIconProps): UiNode { const ref = useRef(null); useLayoutEffect(() => { @@ -115,6 +121,7 @@ export function ObsidianDropdown({ onChange: (value: string) => void; }): UiNode { const ref = useRef(null); + const onChangeRef = useLatestRef(onChange); useLayoutEffect(() => { const container = ref.current; if (!container) return; @@ -124,12 +131,12 @@ export function ObsidianDropdown({ dropdown.addOption(option.value, option.label); } dropdown.setValue(value).onChange((selected) => { - onChange(selected); + onChangeRef.current(selected); }); return () => { container.empty(); }; - }, [onChange, options, value]); + }, [onChangeRef, options, value]); return ; } @@ -144,6 +151,7 @@ export function ObsidianTextInput({ onChange: (value: string) => void; }): UiNode { const ref = useRef(null); + const onChangeRef = useLatestRef(onChange); useLayoutEffect(() => { const container = ref.current; if (!container) return; @@ -153,30 +161,67 @@ export function ObsidianTextInput({ .setPlaceholder(placeholder) .setValue(value) .onChange((nextValue) => { - onChange(nextValue); + onChangeRef.current(nextValue); }); return () => { container.empty(); }; - }, [onChange, placeholder, value]); + }, [onChangeRef, placeholder, value]); + + return ; +} + +export function ObsidianCommitTextInput({ + value, + placeholder, + onCommit, +}: { + value: string; + placeholder: string; + onCommit: (value: string) => void; +}): UiNode { + const ref = useRef(null); + const onCommitRef = useLatestRef(onCommit); + useLayoutEffect(() => { + const container = ref.current; + if (!container) return; + container.empty(); + const text = new TextComponent(container); + text.setPlaceholder(placeholder).setValue(value); + const commit = () => { + onCommitRef.current(text.inputEl.value); + }; + return disposeDomListeners( + listenDomEvent(text.inputEl, "blur", commit), + listenDomEvent(text.inputEl, "keydown", (event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + commit(); + }), + () => { + container.empty(); + }, + ); + }, [onCommitRef, placeholder, value]); return ; } export function ObsidianToggle({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }): UiNode { const ref = useRef(null); + const onChangeRef = useLatestRef(onChange); useLayoutEffect(() => { const container = ref.current; if (!container) return; container.empty(); const toggle = new ToggleComponent(container); toggle.setValue(checked).onChange((nextValue) => { - onChange(nextValue); + onChangeRef.current(nextValue); }); return () => { container.empty(); }; - }, [checked, onChange]); + }, [checked, onChangeRef]); return ; } diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index d9c8d8ca..94ac375e 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -84,9 +84,12 @@ describe("settings tab", () => { const tab = newSettingsTab({ saveSettings }); tab.display(); - expect(inputForSetting(tab, "Codex executable")?.getAttribute("aria-label")).toBeNull(); + const codexPath = inputForSetting(tab, "Codex executable"); + expect(codexPath?.getAttribute("aria-label")).toBeNull(); + expect(codexPath?.type).toBe("text"); const shortcut = selectForSetting(tab, "Send shortcut"); if (!shortcut) throw new Error("Missing send shortcut dropdown"); + expect(shortcut.classList.contains("dropdown")).toBe(true); shortcut.value = "mod-enter"; shortcut.dispatchEvent(new Event("change")); @@ -105,6 +108,7 @@ describe("settings tab", () => { tab.display(); const toggle = inputForSetting(tab, "Show chat toolbar"); if (!toggle) throw new Error("Missing toolbar visibility toggle"); + expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true); toggle.checked = false; toggle.dispatchEvent(new Event("change")); @@ -122,6 +126,7 @@ describe("settings tab", () => { tab.display(); const toggle = inputForSetting(tab, "Scroll thread from composer line edges"); if (!toggle) throw new Error("Missing composer line edge scroll toggle"); + expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true); toggle.checked = true; toggle.dispatchEvent(new Event("change"));