From f7e28e5e2c548fcf49bccd5a86033ff715b43ae3 Mon Sep 17 00:00:00 2001 From: Gary Ritchie Date: Wed, 1 Apr 2026 19:55:02 -0600 Subject: [PATCH] Replace comma-separated textarea with pill-style input for multi-value properties (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Replace comma-separated textarea with pill-style input for tags Use Obsidian's native .multi-select-pill CSS classes for tags, aliases, and multitext property types. Pills are added via Enter, comma, or paste and removed via × button or Backspace. Dedup applies to tags/aliases only; multitext allows duplicates. IME composition is respected to avoid breaking CJK input. * Add x icon to pill remove button via setIcon * Fix paste merging and blur commit behavior for pill input Paste now merges clipboard text with existing input value at the cursor position before splitting on commas. The trailing segment after the last comma stays in the input for further editing. Blur only commits pending text when focus leaves the pill container entirely, not when clicking a remove button inside it. * Move pending-text commit to container focusout Replace the input-level blur handler with a container-level focusout listener that commits pending text only when focus leaves the pill container entirely. This covers the keyboard path where focus moves from a remove button to outside the container. * Strip border from pill remove button and reduce pill size * Add hover and focus-visible styles to pill remove button * Use span with role=button for pill remove to match Obsidian's DOM Obsidian's native multi-select pills use a span for the remove control, not a button element. The button element picks up Obsidian's base button styling (visible border) that can't be easily overridden. Switching to a span with role="button" and tabindex="0" matches the native DOM while preserving keyboard accessibility via an explicit keydown handler. * Match native button behavior: activate Space on keyup, not keydown * Validate tag names against Obsidian's naming rules Reject tags containing spaces, invalid characters, or only digits. Strip leading # from user input. Show a Notice with the specific reason when a tag is rejected. Validation applies only to the tags property type, not aliases or multitext. * Show notice when bare # is entered as a tag name * Restore focus to pill input after adding or removing a pill * Only refocus pill input from keyboard and paste, not blur commits * Fix duplicate pill on multitext by clearing input before addPill renderPills() detaches and reattaches the input element, which can fire a focusout event mid-operation. The focusout handler would then see the still-populated input and commit the same value again. For tags/aliases this was masked by dedup, but multitext allows duplicates so the double-add was visible. Fix by capturing and clearing the input value before calling addPill. --- src/bulk-edit-modal.ts | 175 +++++++++++++++++++++++++++++++++++++++-- styles.css | 56 +++++++++++++ 2 files changed, 223 insertions(+), 8 deletions(-) diff --git a/src/bulk-edit-modal.ts b/src/bulk-edit-modal.ts index 8d1949d..6a875b8 100644 --- a/src/bulk-edit-modal.ts +++ b/src/bulk-edit-modal.ts @@ -1,9 +1,27 @@ -import {App, Modal, Notice, Setting, TFile} from "obsidian"; +import {App, Modal, Notice, setIcon, Setting, TFile} from "obsidian"; import type BulkPropertiesPlugin from "./main"; import {getSelectedFiles} from "./files"; import {confirmEmptyValue} from "./confirm-modal"; import {withProgress} from "./progress"; +/** + * Validates a tag name against Obsidian's naming rules. + * Returns null if valid, or a reason string if invalid. + */ +function validateTag(tag: string): string | null { + if (/\s/.test(tag)) { + return "Tags can\u2019t contain spaces"; + } + if (/^\d+$/.test(tag)) { + return "Tags must contain at least one non-numerical character"; + } + // Allowed: word chars, hyphens, forward slashes, non-ASCII (emoji, symbols, etc.) + if (/[^\w\-/\u{0080}-\u{10FFFF}]/u.test(tag)) { + return "Tags can only contain letters, numbers, underscores, hyphens, and forward slashes"; + } + return null; +} + function coerceValue(raw: string, type: string): unknown { switch (type) { case "number": { @@ -298,14 +316,155 @@ export class BulkEditModal extends Modal { case "tags": case "aliases": - case "multitext": - setting.setDesc("Comma-separated values"); - setting.addTextArea(area => area - .setPlaceholder("Value1, value2, value3") - .onChange(value => { - this.rawValue = value; - })); + case "multitext": { + const pills: string[] = []; + const dedupTypes = new Set(["tags", "aliases"]); + const shouldDedup = dedupTypes.has(type); + + const syncRawValue = () => { + this.rawValue = pills.join(","); + }; + + const pillContainer = setting.controlEl.createDiv({ + cls: "bulk-properties-pill-container", + }); + const pillInput = pillContainer.createEl("input", { + cls: "bulk-properties-pill-input", + attr: {placeholder: "Type and press enter"}, + }); + + const renderPills = () => { + pillContainer + .querySelectorAll(".multi-select-pill") + .forEach(el => el.remove()); + for (let i = 0; i < pills.length; i++) { + const value = pills[i] ?? ""; + const pill = pillContainer.createSpan({ + cls: "multi-select-pill", + }); + pill.createSpan({ + cls: "multi-select-pill-content", + text: value, + }); + const removeBtn = pill.createSpan({ + cls: "multi-select-pill-remove-button", + attr: { + "aria-label": `Remove ${value}`, + "role": "button", + "tabindex": "0", + }, + }); + setIcon(removeBtn, "x"); + const idx = i; + const doRemove = () => { + pills.splice(idx, 1); + renderPills(); + syncRawValue(); + pillInput.focus(); + }; + removeBtn.addEventListener("click", doRemove); + removeBtn.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + doRemove(); + } else if (e.key === " ") { + e.preventDefault(); + } + }); + removeBtn.addEventListener("keyup", (e: KeyboardEvent) => { + if (e.key === " ") { + e.preventDefault(); + doRemove(); + } + }); + } + pillContainer.appendChild(pillInput); + }; + + const addPill = (text: string, refocus = false) => { + let trimmed = text.trim(); + if (trimmed === "") return; + if (type === "tags") { + trimmed = trimmed.replace(/^#/, ""); + if (trimmed === "") { + new Notice("Tag name can\u2019t be empty"); + return; + } + const reason = validateTag(trimmed); + if (reason !== null) { + new Notice(reason); + return; + } + } + if (shouldDedup && pills.includes(trimmed)) return; + pills.push(trimmed); + renderPills(); + syncRawValue(); + if (refocus) pillInput.focus(); + }; + + const removeLast = () => { + if (pills.length === 0) return; + pills.pop(); + renderPills(); + syncRawValue(); + pillInput.focus(); + }; + + pillInput.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.isComposing) return; + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + const val = pillInput.value; + pillInput.value = ""; + addPill(val, true); + } else if ( + e.key === "Backspace" && + pillInput.value === "" + ) { + removeLast(); + } + }); + + pillInput.addEventListener("paste", (e: ClipboardEvent) => { + e.preventDefault(); + const pasted = + e.clipboardData?.getData("text") ?? ""; + const start = pillInput.selectionStart ?? 0; + const end = pillInput.selectionEnd ?? 0; + const before = pillInput.value.slice(0, start); + const after = pillInput.value.slice(end); + const combined = before + pasted + after; + const parts = combined.split(","); + const trailing = parts.pop() ?? ""; + pillInput.value = ""; + for (const part of parts) { + addPill(part, true); + } + pillInput.value = trailing; + }); + + pillContainer.addEventListener("focusout", (e: FocusEvent) => { + const next = e.relatedTarget; + if ( + next instanceof Node && + pillContainer.contains(next) + ) { + return; + } + if (pillInput.value.trim() !== "") { + addPill(pillInput.value); + pillInput.value = ""; + } + }); + + pillContainer.addEventListener("click", (e: MouseEvent) => { + if (e.target === pillContainer) { + pillInput.focus(); + } + }); break; + } default: setting.addText(text => text diff --git a/styles.css b/styles.css index 6c50bad..8949308 100644 --- a/styles.css +++ b/styles.css @@ -36,3 +36,59 @@ .bulk-properties-cancel-btn { margin-left: var(--size-4-2); } + +.bulk-properties-pill-container { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--size-2-2); + min-height: var(--input-height); + padding: var(--size-4-1) var(--size-4-2); + background: var(--background-modifier-form-field); + border: 1px solid var(--background-modifier-border); + border-radius: var(--input-radius); + cursor: text; +} + +.bulk-properties-pill-container:focus-within { + border-color: var(--background-modifier-border-focus); + box-shadow: 0 0 0 2px var(--background-modifier-border-focus); +} + +.bulk-properties-pill-container .multi-select-pill { + font-size: var(--font-ui-smaller); + padding: var(--size-2-1) var(--size-2-3); +} + +.bulk-properties-pill-container .multi-select-pill-remove-button { + border: none; + background: none; + padding: var(--size-2-1); + border-radius: var(--radius-s); + cursor: pointer; + color: var(--text-muted); + display: flex; + align-items: center; +} + +.bulk-properties-pill-container .multi-select-pill-remove-button:hover { + color: var(--text-normal); + background: var(--background-modifier-hover); +} + +.bulk-properties-pill-container .multi-select-pill-remove-button:focus-visible { + color: var(--text-normal); + outline: 2px solid var(--background-modifier-border-focus); + outline-offset: -1px; +} + +.bulk-properties-pill-input { + flex: 1 1 80px; + min-width: 80px; + border: none; + background: transparent; + color: var(--text-normal); + font-size: var(--font-ui-medium); + outline: none; + padding: 0; +}