obsidian-formfire/src/ui/field-renderers.ts
tolvitty ff6fecd39b feat: add keyboard accessibility to toggle and rating fields
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 14:54:49 +01:00

632 lines
17 KiB
TypeScript

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.setAttribute('tabindex', '0');
toggle.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
enabled = !enabled;
toggle.toggleClass('is-enabled', 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 renderSlider(
controlEl: HTMLDivElement,
field: FormField,
initialValue?: FieldValue,
): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } {
const min = field.min ?? 0;
const max = field.max ?? 100;
const step = field.step ?? 1;
const startVal = initialValue !== undefined ? Number(initialValue) : min;
const wrapper = controlEl.createDiv({ cls: 'ff-slider-wrapper' });
const input = wrapper.createEl('input', {
cls: 'ff-slider',
type: 'range',
});
input.min = String(min);
input.max = String(max);
input.step = String(step);
input.value = String(startVal);
const valueLabel = wrapper.createSpan({
cls: 'ff-slider-value',
text: String(startVal),
});
input.addEventListener('input', () => {
valueLabel.textContent = input.value;
});
return {
getValue: () => Number(input.value),
setValue: (v) => {
input.value = String(v);
valueLabel.textContent = String(v);
},
};
}
function renderColor(
controlEl: HTMLDivElement,
_field: FormField,
initialValue?: FieldValue,
): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } {
const startVal =
initialValue !== undefined ? String(initialValue) : '#000000';
const wrapper = controlEl.createDiv({ cls: 'ff-color-wrapper' });
const input = wrapper.createEl('input', {
cls: 'ff-color-input',
type: 'color',
});
input.value = startVal;
const hexLabel = wrapper.createSpan({
cls: 'ff-color-hex',
text: startVal,
});
input.addEventListener('input', () => {
hexLabel.textContent = input.value;
});
return {
getValue: () => input.value,
setValue: (v) => {
input.value = String(v);
hexLabel.textContent = String(v);
},
};
}
function renderTime(
controlEl: HTMLDivElement,
field: FormField,
initialValue?: FieldValue,
): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } {
const input = controlEl.createEl('input', {
cls: 'ff-input',
type: 'time',
placeholder: field.placeholder ?? '',
});
if (initialValue !== undefined) {
input.value = String(initialValue);
}
return {
getValue: () => input.value,
setValue: (v) => {
input.value = String(v);
},
};
}
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();
});
ratingEl.setAttribute('tabindex', '0');
ratingEl.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
rating = Math.min(5, rating + 1);
updateStars();
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
e.preventDefault();
rating = Math.max(1, rating - 1);
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;
case 'slider':
accessor = renderSlider(controlEl, field, initialValue);
break;
case 'color':
accessor = renderColor(controlEl, field, initialValue);
break;
case 'time':
accessor = renderTime(controlEl, field, initialValue);
break;
default:
accessor = renderText(controlEl, field, initialValue);
break;
}
return {
getValue: accessor.getValue,
setValue: accessor.setValue,
setError,
};
}