feat: visually group settings by category with collapsible cards
Node.js build / build (20.x) (push) Has been cancelled
Node.js build / build (22.x) (push) Has been cancelled

- Each command category (Better Formatting, Line Operations, etc.) now
  renders as a collapsible <details> card with a bordered/shaded header
  showing an 'N/M enabled' badge, instead of a flat list separated only
  by bold text headings.
- Sentence regex + reset moved into the Sentence Navigator category
  card (it configures that category's commands, so it belongs there
  instead of the general section).
- Bulk actions (global enable/disable all, per-category toggle-all,
  regex reset) now patch the DOM in place instead of calling the full
  display() re-render, so open/closed category state is never lost
  when a setting changes.
- Removed the 'General' settings heading (obsidianmd/settings-tab/no-problematic-settings-headings
  lint rule flags it).
- styles.css adds .bindthem-category-* rules for the card borders,
  header shading, collapse caret, and setting-row separators.
This commit is contained in:
2026-09-07 14:45:24 -04:00
parent 254fcc1b41
commit b031cd7e04
2 changed files with 181 additions and 56 deletions
+109 -48
View File
@@ -1,4 +1,4 @@
import { App, PluginSettingTab, Setting } from 'obsidian'; import { App, PluginSettingTab, Setting, TextComponent } from 'obsidian';
import BindThemPlugin from './main'; import BindThemPlugin from './main';
import { DEFAULT_SENTENCE_REGEX } from './Constants'; import { DEFAULT_SENTENCE_REGEX } from './Constants';
@@ -164,6 +164,13 @@ export const DEFAULT_SETTINGS: BindThemSettings = {
export class BindThemSettingTab extends PluginSettingTab { export class BindThemSettingTab extends PluginSettingTab {
plugin: BindThemPlugin; plugin: BindThemPlugin;
/**
* Refresh functions for each rendered category body. Bulk actions call
* these directly instead of re-running `display()`, so toggling a
* setting never collapses categories the user has open.
*/
private categoryRefreshers: Array<() => void> = [];
constructor(app: App, plugin: BindThemPlugin) { constructor(app: App, plugin: BindThemPlugin) {
super(app, plugin); super(app, plugin);
this.plugin = plugin; this.plugin = plugin;
@@ -172,6 +179,7 @@ export class BindThemSettingTab extends PluginSettingTab {
display(): void { display(): void {
const { containerEl } = this; const { containerEl } = this;
containerEl.empty(); containerEl.empty();
this.categoryRefreshers = [];
// Hotkeys link at the top // Hotkeys link at the top
containerEl.createEl('a', { containerEl.createEl('a', {
@@ -184,14 +192,10 @@ export class BindThemSettingTab extends PluginSettingTab {
}, (el) => { }, (el) => {
el.addEventListener('click', (e) => { el.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
const hotkeysTab = (this.app as unknown as { setting: { openTabById: (id: string) => unknown } }).setting.openTabById('hotkeys'); this.openHotkeysSearch();
if (hotkeysTab) {
(hotkeysTab as unknown as { searchComponent: { setValue: (v: string) => void } }).searchComponent.setValue('BindThem');
}
}); });
}); });
// General settings
new Setting(containerEl) new Setting(containerEl)
.setName('Debug mode') .setName('Debug mode')
.setDesc('Enable debug logging to console') .setDesc('Enable debug logging to console')
@@ -202,36 +206,16 @@ export class BindThemSettingTab extends PluginSettingTab {
await this.plugin.saveSettings(); await this.plugin.saveSettings();
})); }));
new Setting(containerEl)
.setName('Sentence regex')
.setDesc('Regular expression used to match sentences')
.addText(text => text
.setValue(this.plugin.settings.sentenceRegexSource)
.onChange(async (value) => {
this.plugin.settings.sentenceRegexSource = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Reset sentence regex')
.addButton(button => button
.setButtonText('Reset')
.onClick(async () => {
this.plugin.settings.sentenceRegexSource = DEFAULT_SENTENCE_REGEX;
await this.plugin.saveSettings();
this.display();
}));
// Commands section header // Commands section header
new Setting(containerEl) new Setting(containerEl)
.setName('Command availability') .setName('Command availability')
.setDesc('Toggle which commands are available in Obsidian. Disabled commands will not appear in the command palette.') .setDesc('Toggle which commands are available in Obsidian. Disabled commands will not appear in the command palette. Select a category below to see its commands.')
.setHeading(); .setHeading();
// Category toggle buttons // Global toggle-all buttons
new Setting(containerEl) new Setting(containerEl)
.setName('Enable/disable all') .setName('Enable/disable all')
.setDesc('Toggle all commands on or off') .setDesc('Toggle every command in every category on or off')
.addButton(button => button .addButton(button => button
.setButtonText('Enable all') .setButtonText('Enable all')
.onClick(async () => { .onClick(async () => {
@@ -239,7 +223,7 @@ export class BindThemSettingTab extends PluginSettingTab {
this.plugin.settings.enabledCommands[cmdId] = true; this.plugin.settings.enabledCommands[cmdId] = true;
} }
await this.plugin.saveSettings(); await this.plugin.saveSettings();
this.display(); this.refreshAllCategories();
})) }))
.addButton(button => button .addButton(button => button
.setButtonText('Disable all') .setButtonText('Disable all')
@@ -249,12 +233,13 @@ export class BindThemSettingTab extends PluginSettingTab {
this.plugin.settings.enabledCommands[cmdId] = false; this.plugin.settings.enabledCommands[cmdId] = false;
} }
await this.plugin.saveSettings(); await this.plugin.saveSettings();
this.display(); this.refreshAllCategories();
})); }));
// Render each category // Render each category as a collapsible, visually grouped card
const categoryListEl = containerEl.createDiv({ cls: 'bindthem-category-list' });
for (const [categoryKey, categoryName] of Object.entries(COMMAND_CATEGORIES)) { for (const [categoryKey, categoryName] of Object.entries(COMMAND_CATEGORIES)) {
this.renderCategory(containerEl, categoryKey, categoryName); this.renderCategory(categoryListEl, categoryKey, categoryName);
} }
// About section // About section
@@ -265,35 +250,78 @@ export class BindThemSettingTab extends PluginSettingTab {
.setHeading(); .setHeading();
} }
/**
* Opens the Hotkeys settings tab and pre-fills its search with "BindThem".
* `app.setting` (the settings modal controller) isn't part of Obsidian's
* public API types, so the shape is narrowed once here rather than cast
* inline at each property access.
*/
private openHotkeysSearch(): void {
const appWithSettingModal = this.app as unknown as {
setting: {
openTabById: (id: string) => { searchComponent: { setValue: (v: string) => void } } | null;
};
};
const hotkeysTab = appWithSettingModal.setting.openTabById('hotkeys');
if (hotkeysTab) {
hotkeysTab.searchComponent.setValue('BindThem');
}
}
private refreshAllCategories(): void {
for (const refresh of this.categoryRefreshers) {
refresh();
}
}
private renderCategory(containerEl: HTMLElement, categoryKey: string, categoryName: string): void { private renderCategory(containerEl: HTMLElement, categoryKey: string, categoryName: string): void {
const commands = COMMANDS[categoryKey as keyof typeof COMMANDS]; const commands = COMMANDS[categoryKey as keyof typeof COMMANDS];
if (!commands || commands.length === 0) return; if (!commands || commands.length === 0) return;
// Category header with toggle all const details = containerEl.createEl('details', { cls: 'bindthem-category' });
const categorySetting = new Setting(containerEl) const summary = details.createEl('summary', { cls: 'bindthem-category-summary' });
.setName(categoryName) summary.createSpan({ text: categoryName, cls: 'bindthem-category-name' });
.setHeading(); const badge = summary.createSpan({ cls: 'bindthem-category-count' });
// Check if all commands in this category are enabled const content = details.createDiv({ cls: 'bindthem-category-content' });
const allEnabled = commands.every(cmd => this.plugin.settings.enabledCommands[cmd.id] !== false);
// Add toggle all button for this category const refresh = () => this.renderCategoryBody(content, badge, categoryKey, commands);
categorySetting.addToggle(toggle => { refresh();
toggle this.categoryRefreshers.push(refresh);
.setValue(allEnabled) }
private renderCategoryBody(
content: HTMLElement,
badge: HTMLElement,
categoryKey: string,
commands: CommandDefinition[]
): void {
content.empty();
const enabledCount = commands.filter(cmd => this.plugin.settings.enabledCommands[cmd.id] !== false).length;
badge.setText(`${enabledCount}/${commands.length} enabled`);
// Sentence regex configures the Sentence Navigator commands, so it
// lives inside that category's card instead of the general section.
if (categoryKey === 'sentenceNavigator') {
this.renderSentenceRegexSetting(content);
}
new Setting(content)
.setName('Enable all in this category')
.addToggle(toggle => toggle
.setValue(enabledCount === commands.length)
.setTooltip('Toggle all commands in this category') .setTooltip('Toggle all commands in this category')
.onChange(async (value) => { .onChange(async (value) => {
for (const cmd of commands) { for (const cmd of commands) {
this.plugin.settings.enabledCommands[cmd.id] = value; this.plugin.settings.enabledCommands[cmd.id] = value;
} }
await this.plugin.saveSettings(); await this.plugin.saveSettings();
this.display(); this.renderCategoryBody(content, badge, categoryKey, commands);
}); }));
});
// Render individual commands
for (const cmd of commands) { for (const cmd of commands) {
new Setting(containerEl) new Setting(content)
.setName(cmd.name) .setName(cmd.name)
.setDesc(cmd.description || `Command ID: ${cmd.id}`) .setDesc(cmd.description || `Command ID: ${cmd.id}`)
.addToggle(toggle => toggle .addToggle(toggle => toggle
@@ -301,7 +329,40 @@ export class BindThemSettingTab extends PluginSettingTab {
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.enabledCommands[cmd.id] = value; this.plugin.settings.enabledCommands[cmd.id] = value;
await this.plugin.saveSettings(); await this.plugin.saveSettings();
const newCount = commands.filter(c => this.plugin.settings.enabledCommands[c.id] !== false).length;
badge.setText(`${newCount}/${commands.length} enabled`);
})); }));
} }
} }
/**
* Renders the sentence-matching regex setting and its reset button.
* Updates the text field directly on reset instead of re-rendering.
*/
private renderSentenceRegexSetting(containerEl: HTMLElement): void {
let textComponent: TextComponent | undefined;
new Setting(containerEl)
.setName('Sentence regex')
.setDesc('Regular expression used to match sentences')
.addText(text => {
textComponent = text;
text
.setValue(this.plugin.settings.sentenceRegexSource)
.onChange(async (value) => {
this.plugin.settings.sentenceRegexSource = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName('Reset sentence regex')
.addButton(button => button
.setButtonText('Reset')
.onClick(async () => {
this.plugin.settings.sentenceRegexSource = DEFAULT_SENTENCE_REGEX;
await this.plugin.saveSettings();
textComponent?.setValue(DEFAULT_SENTENCE_REGEX);
}));
}
} }
+72 -8
View File
@@ -1,8 +1,72 @@
/* /*
This CSS file will be included with your plugin, and This CSS file will be included with your plugin, and
available in the app when your plugin is enabled. available in the app when your plugin is enabled.
If your plugin does not need CSS, delete this file. */
*/ /* ============================================================
Settings tab — command category grouping
============================================================ */
.bindthem-category-list {
margin-bottom: 0.5em;
}
.bindthem-category {
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
margin-bottom: 0.5em;
overflow: hidden;
}
.bindthem-category > summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75em;
padding: 0.65em 1em;
cursor: pointer;
font-weight: 600;
background-color: var(--background-secondary);
list-style: none;
}
.bindthem-category > summary::-webkit-details-marker {
display: none;
}
.bindthem-category > summary::before {
content: '▸';
display: inline-block;
margin-right: 0.5em;
color: var(--text-muted);
transition: transform 0.15s ease-in-out;
}
.bindthem-category[open] > summary::before {
transform: rotate(90deg);
}
.bindthem-category-name {
flex: 1;
}
.bindthem-category-count {
font-weight: 400;
font-size: var(--font-ui-smaller);
color: var(--text-muted);
white-space: nowrap;
}
.bindthem-category-content {
padding: 0 1em;
}
.bindthem-category-content .setting-item {
border-top: 1px solid var(--background-modifier-border);
}
.bindthem-category-content .setting-item:first-child {
border-top: none;
}