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 <noreply@anthropic.com>
This commit is contained in:
parent
ec3f1bc404
commit
404b33edac
1 changed files with 508 additions and 0 deletions
508
src/ui/field-renderers.ts
Normal file
508
src/ui/field-renderers.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue