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