From 404b33edac6ea322ca6ec0129aead7880badb43d Mon Sep 17 00:00:00 2001 From: tolvitty Date: Fri, 13 Feb 2026 13:21:54 +0100 Subject: [PATCH] feat: add field renderers for all 10 field types Implements renderField() with per-type renderers for text, textarea, number, toggle, date, dropdown, tags, note-link, folder-picker, and rating. Each renderer returns getValue/setValue/setError accessors. Co-Authored-By: Claude Opus 4.6 --- src/ui/field-renderers.ts | 508 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 508 insertions(+) create mode 100644 src/ui/field-renderers.ts diff --git a/src/ui/field-renderers.ts b/src/ui/field-renderers.ts new file mode 100644 index 0000000..c974fee --- /dev/null +++ b/src/ui/field-renderers.ts @@ -0,0 +1,508 @@ +import { App, TFolder } from 'obsidian'; +import { FormField } from '../types'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export type FieldValue = string | number | boolean | string[]; + +export interface RenderedField { + getValue: () => FieldValue; + setValue: (value: FieldValue) => void; + setError: (message: string | null) => void; +} + +// --------------------------------------------------------------------------- +// Wrapper helper +// --------------------------------------------------------------------------- + +interface FieldWrapper { + wrapper: HTMLDivElement; + controlEl: HTMLDivElement; + errorEl: HTMLDivElement; +} + +function createFieldWrapper( + container: HTMLElement, + field: FormField, +): FieldWrapper { + const wrapper = container.createDiv({ cls: 'ff-field' }); + const labelEl = wrapper.createEl('label', { + cls: 'ff-field-label', + text: field.label, + }); + if (field.required) { + labelEl.createSpan({ cls: 'ff-field-required', text: ' *' }); + } + const controlEl = wrapper.createDiv({ cls: 'ff-field-control' }); + const errorEl = wrapper.createDiv({ cls: 'ff-field-error' }); + errorEl.style.display = 'none'; + return { wrapper, controlEl, errorEl }; +} + +function makeErrorAccessor( + wrapper: HTMLDivElement, + errorEl: HTMLDivElement, +): (message: string | null) => void { + return (message: string | null) => { + if (message) { + errorEl.textContent = message; + errorEl.style.display = ''; + wrapper.addClass('ff-field--error'); + } else { + errorEl.textContent = ''; + errorEl.style.display = 'none'; + wrapper.removeClass('ff-field--error'); + } + }; +} + +// --------------------------------------------------------------------------- +// Per-type renderers +// --------------------------------------------------------------------------- + +function renderText( + controlEl: HTMLDivElement, + field: FormField, + initialValue?: FieldValue, +): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { + const input = controlEl.createEl('input', { + cls: 'ff-input', + type: 'text', + placeholder: field.placeholder ?? '', + }); + if (initialValue !== undefined) input.value = String(initialValue); + + return { + getValue: () => input.value, + setValue: (v) => { + input.value = String(v); + }, + }; +} + +function renderTextarea( + controlEl: HTMLDivElement, + field: FormField, + initialValue?: FieldValue, +): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { + const textarea = controlEl.createEl('textarea', { + cls: 'ff-textarea', + placeholder: field.placeholder ?? '', + }); + textarea.rows = 4; + if (initialValue !== undefined) textarea.value = String(initialValue); + + return { + getValue: () => textarea.value, + setValue: (v) => { + textarea.value = String(v); + }, + }; +} + +function renderNumber( + controlEl: HTMLDivElement, + field: FormField, + initialValue?: FieldValue, +): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { + const input = controlEl.createEl('input', { + cls: 'ff-input', + type: 'number', + placeholder: field.placeholder ?? '', + }); + if (initialValue !== undefined) input.value = String(initialValue); + + return { + getValue: () => { + const v = input.value; + return v === '' ? '' : Number(v); + }, + setValue: (v) => { + input.value = String(v); + }, + }; +} + +function renderToggle( + controlEl: HTMLDivElement, + _field: FormField, + initialValue?: FieldValue, +): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { + let enabled = initialValue === true || initialValue === 'true'; + + const toggle = controlEl.createDiv({ cls: 'checkbox-container' }); + if (enabled) toggle.addClass('is-enabled'); + + toggle.addEventListener('click', () => { + enabled = !enabled; + toggle.toggleClass('is-enabled', enabled); + }); + + return { + getValue: () => enabled, + setValue: (v) => { + enabled = v === true || v === 'true'; + toggle.toggleClass('is-enabled', enabled); + }, + }; +} + +function renderDate( + controlEl: HTMLDivElement, + field: FormField, + initialValue?: FieldValue, +): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { + const input = controlEl.createEl('input', { + cls: 'ff-input', + type: 'date', + placeholder: field.placeholder ?? '', + }); + const today = new Date().toISOString().slice(0, 10); + input.value = initialValue !== undefined ? String(initialValue) : today; + + return { + getValue: () => input.value, + setValue: (v) => { + input.value = String(v); + }, + }; +} + +function renderDropdown( + controlEl: HTMLDivElement, + field: FormField, + initialValue?: FieldValue, +): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { + const select = controlEl.createEl('select', { cls: 'dropdown ff-dropdown' }); + // Empty first option + select.createEl('option', { value: '', text: '' }); + for (const opt of field.options ?? []) { + select.createEl('option', { value: opt, text: opt }); + } + if (initialValue !== undefined) select.value = String(initialValue); + + return { + getValue: () => select.value, + setValue: (v) => { + select.value = String(v); + }, + }; +} + +function renderTags( + controlEl: HTMLDivElement, + field: FormField, + initialValue?: FieldValue, +): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { + let tags: string[] = []; + if (Array.isArray(initialValue)) { + tags = [...initialValue]; + } else if (typeof initialValue === 'string' && initialValue) { + tags = initialValue.split(',').map((t) => t.trim()).filter(Boolean); + } + + const container = controlEl.createDiv({ cls: 'ff-tags-container' }); + const chipsEl = container.createDiv({ cls: 'ff-tags-chips' }); + const input = container.createEl('input', { + cls: 'ff-tags-input', + type: 'text', + placeholder: field.placeholder ?? 'Type and press Enter...', + }); + + function renderChips(): void { + chipsEl.empty(); + for (const tag of tags) { + const chip = chipsEl.createSpan({ cls: 'ff-tag-chip' }); + chip.createSpan({ cls: 'ff-tag-text', text: tag }); + const removeBtn = chip.createSpan({ cls: 'ff-tag-remove', text: '\u00D7' }); + removeBtn.addEventListener('click', () => { + tags = tags.filter((t) => t !== tag); + renderChips(); + }); + } + } + + function addTag(value: string): void { + const trimmed = value.trim(); + if (trimmed && !tags.includes(trimmed)) { + tags.push(trimmed); + renderChips(); + } + input.value = ''; + } + + input.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + addTag(input.value); + } + // Backspace on empty input removes last tag + if (e.key === 'Backspace' && input.value === '' && tags.length > 0) { + tags.pop(); + renderChips(); + } + }); + + renderChips(); + + return { + getValue: () => [...tags], + setValue: (v) => { + if (Array.isArray(v)) { + tags = [...v]; + } else if (typeof v === 'string') { + tags = v.split(',').map((t) => t.trim()).filter(Boolean); + } else { + tags = []; + } + renderChips(); + }, + }; +} + +function renderNoteLink( + app: App, + controlEl: HTMLDivElement, + field: FormField, + initialValue?: FieldValue, +): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { + const wrapper = controlEl.createDiv({ cls: 'ff-suggest-wrapper' }); + const input = wrapper.createEl('input', { + cls: 'ff-input', + type: 'text', + placeholder: field.placeholder ?? 'Search notes...', + }); + const suggestList = wrapper.createDiv({ cls: 'ff-suggest-list' }); + suggestList.style.display = 'none'; + + if (initialValue !== undefined) input.value = String(initialValue); + + function getFiles(): string[] { + let files = app.vault.getMarkdownFiles(); + if (field.folder) { + const prefix = field.folder.endsWith('/') + ? field.folder + : field.folder + '/'; + files = files.filter((f) => f.path.startsWith(prefix)); + } + return files.map((f) => f.path); + } + + function showSuggestions(): void { + const query = input.value.toLowerCase(); + const matches = getFiles() + .filter((p) => p.toLowerCase().includes(query)) + .slice(0, 10); + + suggestList.empty(); + if (matches.length === 0) { + suggestList.style.display = 'none'; + return; + } + + for (const match of matches) { + const item = suggestList.createDiv({ cls: 'ff-suggest-item' }); + item.textContent = match; + item.addEventListener('mousedown', (e: MouseEvent) => { + e.preventDefault(); + input.value = match; + suggestList.style.display = 'none'; + }); + } + suggestList.style.display = ''; + } + + input.addEventListener('input', showSuggestions); + input.addEventListener('focus', showSuggestions); + input.addEventListener('blur', () => { + setTimeout(() => { + suggestList.style.display = 'none'; + }, 200); + }); + + return { + getValue: () => input.value, + setValue: (v) => { + input.value = String(v); + }, + }; +} + +function renderFolderPicker( + app: App, + controlEl: HTMLDivElement, + field: FormField, + initialValue?: FieldValue, +): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { + const wrapper = controlEl.createDiv({ cls: 'ff-suggest-wrapper' }); + const input = wrapper.createEl('input', { + cls: 'ff-input', + type: 'text', + placeholder: field.placeholder ?? 'Search folders...', + }); + const suggestList = wrapper.createDiv({ cls: 'ff-suggest-list' }); + suggestList.style.display = 'none'; + + if (initialValue !== undefined) input.value = String(initialValue); + + function getFolders(): string[] { + return app.vault + .getAllLoadedFiles() + .filter((f): f is TFolder => f instanceof TFolder) + .map((f) => f.path); + } + + function showSuggestions(): void { + const query = input.value.toLowerCase(); + const matches = getFolders() + .filter((p) => p.toLowerCase().includes(query)) + .slice(0, 10); + + suggestList.empty(); + if (matches.length === 0) { + suggestList.style.display = 'none'; + return; + } + + for (const match of matches) { + const item = suggestList.createDiv({ cls: 'ff-suggest-item' }); + item.textContent = match; + item.addEventListener('mousedown', (e: MouseEvent) => { + e.preventDefault(); + input.value = match; + suggestList.style.display = 'none'; + }); + } + suggestList.style.display = ''; + } + + input.addEventListener('input', showSuggestions); + input.addEventListener('focus', showSuggestions); + input.addEventListener('blur', () => { + setTimeout(() => { + suggestList.style.display = 'none'; + }, 200); + }); + + return { + getValue: () => input.value, + setValue: (v) => { + input.value = String(v); + }, + }; +} + +function renderRating( + controlEl: HTMLDivElement, + _field: FormField, + initialValue?: FieldValue, +): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { + let rating = + initialValue !== undefined && initialValue !== '' ? Number(initialValue) : 0; + + const ratingEl = controlEl.createDiv({ cls: 'ff-rating' }); + const stars: HTMLSpanElement[] = []; + + function updateStars(): void { + for (let i = 0; i < 5; i++) { + stars[i].textContent = i < rating ? '\u2605' : '\u2606'; + stars[i].toggleClass('ff-star-active', i < rating); + } + } + + for (let i = 0; i < 5; i++) { + const star = ratingEl.createSpan({ cls: 'ff-star' }); + star.textContent = '\u2606'; + + star.addEventListener('click', () => { + rating = i + 1; + updateStars(); + }); + + star.addEventListener('mouseenter', () => { + for (let j = 0; j < 5; j++) { + stars[j].textContent = j <= i ? '\u2605' : '\u2606'; + stars[j].toggleClass('ff-star-hover', j <= i); + } + }); + + stars.push(star); + } + + ratingEl.addEventListener('mouseleave', () => { + for (let j = 0; j < 5; j++) { + stars[j].removeClass('ff-star-hover'); + } + updateStars(); + }); + + updateStars(); + + return { + getValue: () => rating, + setValue: (v) => { + rating = Number(v) || 0; + updateStars(); + }, + }; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +export function renderField( + app: App, + container: HTMLElement, + field: FormField, + initialValue?: FieldValue, +): RenderedField { + const { wrapper, controlEl, errorEl } = createFieldWrapper(container, field); + const setError = makeErrorAccessor(wrapper, errorEl); + + let accessor: { getValue: () => FieldValue; setValue: (v: FieldValue) => void }; + + switch (field.type) { + case 'text': + accessor = renderText(controlEl, field, initialValue); + break; + case 'textarea': + accessor = renderTextarea(controlEl, field, initialValue); + break; + case 'number': + accessor = renderNumber(controlEl, field, initialValue); + break; + case 'toggle': + accessor = renderToggle(controlEl, field, initialValue); + break; + case 'date': + accessor = renderDate(controlEl, field, initialValue); + break; + case 'dropdown': + accessor = renderDropdown(controlEl, field, initialValue); + break; + case 'tags': + accessor = renderTags(controlEl, field, initialValue); + break; + case 'note-link': + accessor = renderNoteLink(app, controlEl, field, initialValue); + break; + case 'folder-picker': + accessor = renderFolderPicker(app, controlEl, field, initialValue); + break; + case 'rating': + accessor = renderRating(controlEl, field, initialValue); + break; + default: + accessor = renderText(controlEl, field, initialValue); + break; + } + + return { + getValue: accessor.getValue, + setValue: accessor.setValue, + setError, + }; +}