From d9d0a008a6e6a9fa6ee06ec2234715fb7373eba2 Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 20 Jun 2026 10:57:38 +0900 Subject: [PATCH] Refine settings helper rows --- src/settings/helper-section.tsx | 18 +--- src/settings/hook-section.tsx | 17 ++-- src/settings/setting-components.tsx | 87 +++++------------ src/shared/ui/components.tsx | 140 +++++++++++++++++++++++++++- tests/mocks/obsidian.ts | 16 ++-- 5 files changed, 181 insertions(+), 97 deletions(-) diff --git a/src/settings/helper-section.tsx b/src/settings/helper-section.tsx index 250be51f..528b8a6d 100644 --- a/src/settings/helper-section.tsx +++ b/src/settings/helper-section.tsx @@ -59,25 +59,15 @@ function ModelEffortSetting({ onChange={(value) => { onModelChange(value === CODEX_DEFAULT_VALUE ? null : value); }} - > - {modelSelectOptions(models, modelValue).map((option) => ( - - ))} - + options={modelSelectOptions(models, modelValue)} + /> { onEffortChange(value === CODEX_DEFAULT_VALUE ? null : value); }} - > - {reasoningEffortSelectOptions(efforts, effortValue).map((option) => ( - - ))} - + options={reasoningEffortSelectOptions(efforts, effortValue)} + /> ); } diff --git a/src/settings/hook-section.tsx b/src/settings/hook-section.tsx index ffbf5694..80dc916f 100644 --- a/src/settings/hook-section.tsx +++ b/src/settings/hook-section.tsx @@ -1,6 +1,7 @@ import type { ComponentChild as UiNode } from "preact"; import type { HookItem } from "../domain/catalog/metadata"; +import { ObsidianButton } from "../shared/ui/components"; import type { HookSectionState } from "./section-state"; import { SettingRow, SettingsHeading } from "./setting-components"; @@ -53,24 +54,20 @@ function HookRow({ hook, state }: { hook: HookItem; state: HookSectionState }): desc={`${hook.eventName} · ${hook.matcher ?? "(no matcher)"} · ${hook.trustStatus} · ${hook.enabled ? "enabled" : "disabled"}`} extraInfo={
{hook.currentHash}
} > - - + /> ); } diff --git a/src/settings/setting-components.tsx b/src/settings/setting-components.tsx index fe1966ed..fa6741e9 100644 --- a/src/settings/setting-components.tsx +++ b/src/settings/setting-components.tsx @@ -1,6 +1,14 @@ -import type { ComponentChild as UiNode, TargetedMouseEvent, TargetedPointerEvent } from "preact"; +import type { ComponentChild as UiNode } from "preact"; -import { IconButton } from "../shared/ui/components"; +import { + ObsidianDropdown, + type ObsidianDropdownOption, + ObsidianExtraButton, + ObsidianTextInput, + ObsidianToggle, +} from "../shared/ui/components"; + +export type SelectControlOption = ObsidianDropdownOption; export function SettingsHeading({ name, desc, dynamic = false }: { name: string; desc?: string; dynamic?: boolean }): UiNode { return ( @@ -8,10 +16,8 @@ export function SettingsHeading({ name, desc, dynamic = false }: { name: string; className={`${dynamic ? "codex-panel-settings__dynamic-section-heading" : "codex-panel-settings__section-heading"} setting-item setting-item-heading`} >
-
-
{name}
- {desc ?? null} -
+
{name}
+ {desc ?
{desc}
: null}
@@ -34,11 +40,9 @@ export function SettingRow({ return (
-
-
{name}
- {desc} - {extraInfo} -
+
{name}
+
{desc}
+ {extraInfo}
{children}
@@ -56,43 +60,19 @@ export function SettingsIconButton({ className: string; onClick: () => void; }): UiNode { - return ( - ) => { - event.stopPropagation(); - }} - onClick={(event: TargetedMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onClick(); - }} - /> - ); + return ; } export function SelectControl({ value, onChange, - children, + options, }: { value: string; onChange: (value: string) => void; - children: UiNode; + options: readonly SelectControlOption[]; }): UiNode { - return ( - - ); + return ; } export function TextControl({ @@ -104,34 +84,9 @@ export function TextControl({ placeholder: string; onChange: (value: string) => void; }): UiNode { - return ( - { - onChange(event.currentTarget.value); - }} - /> - ); + return ; } export function ToggleControl({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }): UiNode { - return ( -
{ - if (event.target !== event.currentTarget) return; - onChange(!checked); - }} - > - { - onChange(event.currentTarget.checked); - }} - /> -
- ); + return ; } diff --git a/src/shared/ui/components.tsx b/src/shared/ui/components.tsx index 7ef73984..f72166b4 100644 --- a/src/shared/ui/components.tsx +++ b/src/shared/ui/components.tsx @@ -1,4 +1,4 @@ -import { setIcon } from "obsidian"; +import { ButtonComponent, DropdownComponent, ExtraButtonComponent, TextComponent, ToggleComponent, setIcon } from "obsidian"; import type { ButtonHTMLAttributes, ComponentChild as UiNode, Ref } from "preact"; import { useLayoutEffect, useRef } from "preact/hooks"; @@ -7,6 +7,11 @@ interface ObsidianIconProps { className?: string; } +export interface ObsidianDropdownOption { + value: string; + label: string; +} + function ObsidianIcon({ icon, className }: ObsidianIconProps): UiNode { const ref = useRef(null); useLayoutEffect(() => { @@ -54,3 +59,136 @@ export function IconButton({ icon, label, buttonRef, className, children, ...pro ); } + +export function ObsidianDropdown({ + value, + options, + onChange, +}: { + value: string; + options: readonly ObsidianDropdownOption[]; + onChange: (value: string) => void; +}): UiNode { + const ref = useRef(null); + useLayoutEffect(() => { + const container = ref.current; + if (!container) return; + container.empty(); + const dropdown = new DropdownComponent(container); + for (const option of options) { + dropdown.addOption(option.value, option.label); + } + dropdown.setValue(value).onChange((selected) => { + onChange(selected); + }); + return () => { + container.empty(); + }; + }, [onChange, options, value]); + + return ; +} + +export function ObsidianTextInput({ + value, + placeholder, + onChange, +}: { + value: string; + placeholder: string; + onChange: (value: string) => void; +}): UiNode { + const ref = useRef(null); + useLayoutEffect(() => { + const container = ref.current; + if (!container) return; + container.empty(); + const text = new TextComponent(container); + text + .setPlaceholder(placeholder) + .setValue(value) + .onChange((nextValue) => { + onChange(nextValue); + }); + return () => { + container.empty(); + }; + }, [onChange, placeholder, value]); + + return ; +} + +export function ObsidianToggle({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }): UiNode { + const ref = useRef(null); + useLayoutEffect(() => { + const container = ref.current; + if (!container) return; + container.empty(); + const toggle = new ToggleComponent(container); + toggle.setValue(checked).onChange((nextValue) => { + onChange(nextValue); + }); + return () => { + container.empty(); + }; + }, [checked, onChange]); + + return ; +} + +export function ObsidianExtraButton({ + icon, + label, + className, + onClick, +}: { + icon: string; + label: string; + className?: string; + onClick: () => void; +}): UiNode { + const ref = useRef(null); + useLayoutEffect(() => { + const container = ref.current; + if (!container) return; + container.empty(); + const button = new ExtraButtonComponent(container).setIcon(icon).setTooltip(label).onClick(onClick); + button.extraSettingsEl.ariaLabel = label; + const stopPointerDown = (event: PointerEvent): void => { + event.stopPropagation(); + }; + button.extraSettingsEl.addEventListener("pointerdown", stopPointerDown); + if (className) { + for (const classPart of className.split(" ").filter(Boolean)) { + button.extraSettingsEl.addClass(classPart); + } + } + return () => { + button.extraSettingsEl.removeEventListener("pointerdown", stopPointerDown); + container.empty(); + }; + }, [className, icon, label, onClick]); + + return ; +} + +export function ObsidianButton({ text, disabled, onClick }: { text: string; disabled?: boolean; onClick: () => void }): UiNode { + const ref = useRef(null); + useLayoutEffect(() => { + const container = ref.current; + if (!container) return; + container.empty(); + const button = new ButtonComponent(container) + .setButtonText(text) + .setDisabled(disabled ?? false) + .onClick(() => { + onClick(); + }); + button.buttonEl.type = "button"; + return () => { + container.empty(); + }; + }, [disabled, onClick, text]); + + return ; +} diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 4a582afa..12655a81 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -306,7 +306,7 @@ export class Setting { } } -class ButtonComponent { +export class ButtonComponent { readonly buttonEl: HTMLButtonElement; constructor(parent: HTMLElement) { @@ -329,7 +329,7 @@ class ButtonComponent { } } -class ExtraButtonComponent { +export class ExtraButtonComponent { readonly extraSettingsEl: HTMLButtonElement; constructor(parent: HTMLElement) { @@ -357,11 +357,12 @@ class ExtraButtonComponent { } } -class DropdownComponent { +export class DropdownComponent { readonly selectEl: HTMLSelectElement; constructor(parent: HTMLElement) { this.selectEl = parent.createEl("select"); + this.selectEl.addClass("dropdown"); } addOption(value: string, label: string): this { @@ -385,7 +386,7 @@ class DropdownComponent { } } -class TextComponent { +export class TextComponent { readonly inputEl: HTMLInputElement; constructor(parent: HTMLElement) { @@ -410,15 +411,18 @@ class TextComponent { } } -class ToggleComponent { +export class ToggleComponent { readonly toggleEl: HTMLInputElement; + private readonly containerEl: HTMLDivElement; constructor(parent: HTMLElement) { - this.toggleEl = parent.createEl("input", { attr: { type: "checkbox" } }); + this.containerEl = parent.createDiv({ cls: "checkbox-container" }); + this.toggleEl = this.containerEl.createEl("input", { attr: { type: "checkbox" } }); } setValue(value: boolean): this { this.toggleEl.checked = value; + this.containerEl.classList.toggle("is-enabled", value); return this; }