created text input

This commit is contained in:
stfrigerio 2025-04-07 18:11:01 +02:00
parent 6ca1207333
commit d8cc77f863
15 changed files with 880 additions and 0 deletions

View file

@ -18,6 +18,7 @@ import { injectDateNavigatorStyles, removeDateNavigatorStyles } from './src/styl
import { registerHabitCounter } from "./src/webcomponents/HabitCounter/registerHabitCounter";
import { registerBooleanSwitch } from "src/webcomponents/BooleanSwitch/registerBooleanSwitch";
import { registerTextInput } from "src/webcomponents/TextInput/registerTextInput";
export default class SQLiteDBPlugin extends Plugin {
settings: SQLiteDBSettings;
@ -36,6 +37,7 @@ export default class SQLiteDBPlugin extends Plugin {
this.registerMarkdownPostProcessor((el, ctx) => {
registerHabitCounter(el, this.dbService);
registerBooleanSwitch(el, this.dbService);
registerTextInput(el, this.app, this.dbService);
});
//? Commands

View file

@ -0,0 +1,67 @@
import { App, Modal, Notice, Setting } from "obsidian";
//? A very basic example modal for selecting a time.
export class TimePickerModal extends Modal {
private currentTime: string; // e.g., "HH:MM"
private onSelectCallback: (selectedTime: string) => void;
constructor(app: App, initialTime: string | undefined, onSelect: (selectedTime: string) => void) {
super(app);
//? Basic validation or default
this.currentTime = initialTime && /^\d{2}:\d{2}$/.test(initialTime) ? initialTime : "12:00";
this.onSelectCallback = onSelect;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("time-picker-modal-content"); // For styling
contentEl.createEl("h2", { text: "Select Time" });
// --- Basic Time Input (replace with better UI later) ---
let tempTime = this.currentTime;
new Setting(contentEl)
.setName("Time (HH:MM)")
.addText(text => text
.setValue(tempTime)
.setPlaceholder("HH:MM")
.onChange(value => {
// Basic format check
if (/^\d{1,2}:\d{1,2}$/.test(value)) {
// Format to HH:MM
const parts = value.split(':');
const hh = parts[0].padStart(2,'0');
const mm = parts[1].padStart(2,'0');
// Basic hour/minute range check (can be improved)
if (parseInt(hh) >= 0 && parseInt(hh) <= 23 && parseInt(mm) >= 0 && parseInt(mm) <= 59) {
tempTime = `${hh}:${mm}`;
} else {
// Handle invalid range slightly? Or let confirm check.
}
}
}));
// --- Action Buttons ---
new Setting(contentEl)
.addButton(button => button
.setButtonText("Cancel")
.onClick(() => this.close()))
.addButton(button => button
.setButtonText("Confirm")
.setCta() // Make it the primary action
.onClick(() => {
//? Final validation before confirming
if (/^\d{2}:\d{2}$/.test(tempTime)) {
this.onSelectCallback(tempTime);
this.close();
} else {
new Notice("Invalid time format. Use HH:MM.");
}
}));
}
onClose() {
this.contentEl.empty();
}
}

View file

@ -0,0 +1,157 @@
import { App } from "obsidian";
import { DBService } from "src/DBService";
import { TextInputDataService } from "../services/TextInputDataService";
import { applyTextInputStyles } from "./styles/applyTextInputStyles";
import { buildTextInputDOM, } from "./dom/buildTextInputDOM";
import { createInputChangeHandler } from "./eventHandlers/inputHandlers";
import { createModalTriggerHandler } from "./eventHandlers/modalTriggerHandlers";
import { TextInputConfig, ModalType, TextInputEventProps, TextInputDOMElements } from "./TextInput.types";
import { loadTextValue } from "./data/loadTextValue";
import { upsertTextValue } from "./data/upsertTextValue";
import { updateInputValue, setTextErrorState, clearTextErrorState } from "./dom/textInputUiUpdaters";
export class TextInput extends HTMLElement {
// --- Dependencies ---
private appInstance: App | null = null;
public textDataService: TextInputDataService | null = null;
// --- State & Config ---
public config: TextInputConfig = {};
public currentValue: string = "";
private _isInitialized: boolean = false;
//^ data related state/config
public table: string = "";
public date: string = "";
public valueCol: string = "";
public dateCol: string = "";
// --- Shadow DOM Elements ---
private uiElements!: TextInputDOMElements;
// --- Lifecycle ---
connectedCallback() {
if (this._isInitialized) return;
// Initialization requires appInstance
}
// --- Public Methods ---
/** Injects DBService first, then App instance. */
public setDependencies(dbService: DBService, app: App): void {
if (!dbService || !app) {
console.error("[TextInput] Invalid DBService or App instance provided.");
//? Maybe show an error state on the element directly?
this.textContent = "[Init Error]";
return;
}
this.appInstance = app;
try {
this.textDataService = new TextInputDataService(dbService); //^ Init Data Service
} catch(error){
console.error("[TextInput] Failed to initialize TextInputDataService:", error);
this.textContent = "[Data Service Error]";
return;
}
if (!this._isInitialized) {
this._readAttributesAndBuildConfig();
this._initializeComponent();
this._isInitialized = true;
}
}
// --- Internal Initialization ---
private _readAttributesAndBuildConfig(): void {
this.config = {
// UI Config
label: this.getAttribute("data-label") ?? undefined,
placeholder: this.getAttribute("placeholder") ?? undefined,
initialValueAttr: this.getAttribute("data-initial-value") ?? "", // Read initial value from attr
modalType: (this.getAttribute("data-modal-type") as ModalType) ?? 'none',
isButton: this.getAttribute("data-is-button")?.toLowerCase() === 'true', //^ Read isButton flag
// Data Config
table: this.getAttribute("data-table") ?? undefined,
date: this.getAttribute("data-date") ?? "@date", // Default to @date
valueCol: this.getAttribute("data-value-col") ?? undefined,
dateCol: this.getAttribute("data-date-col") ?? undefined,
};
//? Copy needed values from config/attributes to 'this' properties
this.table = this.config.table ?? "";
this.date = this.config.date ?? "@date";
this.valueCol = this.config.valueCol ?? "";
this.dateCol = this.config.dateCol ?? "";
}
private _initializeComponent(): void {
if (!this.appInstance) {
console.error("[TextInput] Cannot initialize: App instance not set.");
return;
}
const eventProps: TextInputEventProps = {
...this.config, // Includes data config now
app: this.appInstance,
uiElements: this.uiElements, // Placeholder
currentValue: this.currentValue,
setValue: this._setValue,
};
const handlers = {
handleInputChange: createInputChangeHandler(eventProps),
handleModalTrigger: createModalTriggerHandler(eventProps),
};
this.uiElements = buildTextInputDOM(this.attachShadow({ mode: "open" }), this.config, handlers);
eventProps.uiElements = this.uiElements;
applyTextInputStyles(this.shadowRoot!);
//^ --- Trigger Initial Load (if data configured) ---
if (this.config.table && this.config.date && this.config.valueCol && this.config.dateCol) {
loadTextValue(this).catch(err => console.error(`[TextInput ${this.config.table}] Unhandled error during initial load:`, err));
} else {
//? No data config, just use initialValueAttr
this._updateDisplay(this.config.initialValueAttr ?? "");
}
// todo: date change listener if needed
}
/** Central method to update state, UI, and trigger save. */
private _setValue = (newValue: string, triggerSave: boolean = false): void => {
if (this.currentValue !== newValue) {
this.currentValue = newValue;
//? Always update the input display
this._updateDisplay(this.currentValue);
this.dispatchEvent(new CustomEvent('input-change', { detail: { value: this.currentValue }, /*...*/ }));
//? Trigger save only if configured and requested
if (triggerSave && this.config.table && this.config.date && this.config.valueCol && this.config.dateCol) {
//? Call the upsert helper
upsertTextValue(this).catch(err => {
console.error(`[TextInput ${this.config.table}] Error during save:`, err);
//? Consider reverting UI or showing persistent error?
// this.showErrorState("Save Failed");
});
}
}
};
//? Public method called by data helpers to update display
public _updateDisplay(newValue: string): void {
this.currentValue = newValue; // Update internal state
updateInputValue(this.uiElements, newValue); //~ Update DOM via helper
this.clearErrorState(); //? Clear errors on successful update
//? Enable/disable logic might be needed based on state
}
//? Public methods for UI state helpers
public showErrorState(message?: string): void { setTextErrorState(this, this.uiElements, message); }
public clearErrorState(): void { clearTextErrorState(this, this.uiElements); }
// todo: disable/enable methods
}
// --- Custom Element Definition ---
if (!customElements.get("text-input")) { customElements.define("text-input", TextInput); }

View file

@ -0,0 +1,54 @@
import { App } from "obsidian";
//? Type defining the available modal types this component can trigger
export type ModalType = 'time-picker' | 'date-picker' | 'none';
//? Configuration options read from attributes for TextInput
export interface TextInputConfig {
//? UI Config
label?: string;
placeholder?: string;
initialValueAttr?: string;
modalType?: ModalType;
isButton?: boolean;
//? Data Config (Similar to BooleanSwitch/HabitCounter)
table?: string;
date?: string;
valueCol?: string;
dateCol?: string;
}
//? References to the DOM elements for TextInput
export interface TextInputDOMElements {
wrapper: HTMLElement;
labelElement: HTMLLabelElement | null;
inputElement: HTMLInputElement;
modalTriggerElement: HTMLElement;
}
//? Properties needed by event handlers for TextInput
export interface TextInputEventProps extends TextInputConfig {
app: App;
uiElements: TextInputDOMElements;
currentValue: string;
setValue: (newValue: string, triggerSave?: boolean) => void;
}
//? Types for the Data Service
export interface TextRecord {
value: string | null; // Value can be text or null
}
//? Base arguments for identifying an entry in DB
export interface TextDataArgs {
table: string;
date: string; // Date to identify the row
valueCol: string; // Column to fetch/update
dateCol: string; // Column containing the date key
}
//? Arguments for upserting the text value
export interface UpsertTextArgs extends TextDataArgs {
newValue: string | null; // Allow saving null/empty string
}

View file

@ -0,0 +1,61 @@
import { TextInputDataService } from "../../services/TextInputDataService";
import { calculateEffectiveDate } from "../../services/utils/calculateEffectiveDate";
/** Interface describing the necessary parts of the TextInput component instance for loading. */
interface LoadTextComponentInstance {
textDataService: TextInputDataService | null; // Use the correct service
table: string;
date: string; // The initial date string ('@date' or 'YYYY-MM-DD')
valueCol: string;
dateCol: string;
_updateDisplay: (value: string) => void; // Method to update component UI/state
showErrorState: (message: string) => void; // Method to show error UI
config: { initialValueAttr?: string }; // Access to initial value from attribute if DB fails/missing
}
/**
* Orchestrates loading the text value using the TextInputDataService.
* @param instance - The TextInput component instance.
*/
export async function loadTextValue(instance: LoadTextComponentInstance): Promise<void> {
if (!instance.textDataService) {
console.error(`[LoadTextValue ${instance.table}] Cannot load data: Missing textDataService.`);
//? Use initial value from attribute as fallback before showing error?
instance._updateDisplay(instance.config.initialValueAttr ?? "");
instance.showErrorState("Load Error - DB");
return;
}
const missingProps: string[] = [];
if (!instance.table) missingProps.push("table");
if (!instance.date) missingProps.push("date");
if (!instance.valueCol) missingProps.push("valueCol");
if (!instance.dateCol) missingProps.push("dateCol");
if (missingProps.length > 0) {
const missingPropsStr = missingProps.join(", ");
console.error(`[LoadTextValue ${instance.table || 'UNKNOWN'}] Cannot load data: Missing required configuration properties: ${missingPropsStr}.`);
//? Use initial value from attribute as fallback before showing error?
instance._updateDisplay(instance.config.initialValueAttr ?? "");
instance.showErrorState("Load Error - Config");
return;
}
const args = {
table: instance.table,
date: calculateEffectiveDate(instance.date), // Use effective date
valueCol: instance.valueCol,
dateCol: instance.dateCol,
};
try {
const value = await instance.textDataService.fetchTextValue(args);
instance._updateDisplay(value); // Update component with fetched value (or empty string if not found)
} catch (error) {
console.error(`[LoadTextValue ${instance.table}] Failed to load data:`, error);
//? Use initial value from attribute as fallback on DB error
instance._updateDisplay(instance.config.initialValueAttr ?? "");
instance.showErrorState("Load Error - DB");
}
}

View file

@ -0,0 +1,49 @@
import { TextInputDataService } from "../../services/TextInputDataService";
import { calculateEffectiveDate } from "../../services/utils/calculateEffectiveDate";
import { Notice } from "obsidian";
//? Interface describing the necessary parts of the TextInput component instance for upserting.
interface UpsertTextComponentInstance {
textDataService: TextInputDataService | null;
table: string;
date: string; // The initial date string ('@date' or 'YYYY-MM-DD')
currentValue: string; // The current value held by the component
valueCol: string;
dateCol: string;
_updateDisplay: (value: string) => void; // Method to update UI (maybe not needed here if optimistic?)
showErrorState: (message: string) => void;
clearErrorState: () => void;
}
/**
* Orchestrates upserting the text value via the TextInputDataService.
* @param instance - The TextInput component instance.
*/
export async function upsertTextValue(instance: UpsertTextComponentInstance): Promise<void> {
if (!instance.textDataService || !instance.table || !instance.date || !instance.valueCol || !instance.dateCol) {
console.error(`[UpsertTextValue ${instance.table}] Cannot save data: Missing required configuration properties.`);
instance.showErrorState("Save Error - Config");
new Notice("Cannot save text input: Configuration incomplete.");
return;
}
const args = {
table: instance.table,
date: calculateEffectiveDate(instance.date),
newValue: instance.currentValue,
valueCol: instance.valueCol,
dateCol: instance.dateCol,
};
try {
await instance.textDataService.upsertTextValue(args);
instance.clearErrorState();
} catch (error) {
console.error(`[UpsertTextValue ${instance.table}] Failed to save data:`, error);
instance.showErrorState("Save Error - DB");
new Notice(`Error saving value for ${instance.table}`);
//? Should we revert the UI? Depends on desired UX.
//? For now, keep the user's input but show error state.
}
}

View file

@ -0,0 +1,83 @@
import type { TextInputConfig, TextInputDOMElements } from "../TextInput.types";
import type { InputChangeEventHandler } from "../eventHandlers/inputHandlers";
import type { ModalTriggerHandler } from "../eventHandlers/modalTriggerHandlers";
//? Interface for handlers needed by the DOM builder
export interface TextInputDOMHandlers {
handleInputChange: InputChangeEventHandler;
handleModalTrigger: ModalTriggerHandler;
}
/**
* Builds the Shadow DOM structure for the TextInput component.
* @param shadowRoot The ShadowRoot to append elements to.
* @param config Configuration options from attributes.
* @param handlers Event handlers for input change and modal trigger.
* @returns Object containing references to the created UI elements.
*/
export function buildTextInputDOM(
shadowRoot: ShadowRoot,
config: TextInputConfig,
handlers: TextInputDOMHandlers
): TextInputDOMElements {
if (!shadowRoot) throw new Error("Cannot build DOM: ShadowRoot is null.");
//? Determine if a separate button is needed *initially*
//? Button mode overrides this later if needed
const needsSeparateButton = config.modalType && config.modalType !== 'none' && !config.isButton;
// --- Optional Label ---
let labelElement: HTMLLabelElement | null = null;
if (config.label) {
labelElement = document.createElement("label");
labelElement.textContent = config.label;
labelElement.className = "input-label";
//? Associate label with input later via ID
shadowRoot.appendChild(labelElement); // Append label first if present
}
// --- Wrapper for Input and (optional) Button ---
const inputWrapper = document.createElement("div");
inputWrapper.className = "input-wrapper";
const inputElement = document.createElement("input");
inputElement.type = "text";
inputElement.className = "main-input";
inputElement.value = config.initialValueAttr ?? ""; // Use value from attribute initially
inputElement.placeholder = config.placeholder ?? "";
//^ Apply button mode logic
if (config.isButton) {
inputElement.readOnly = true; // Make input non-editable
//? Attach modal trigger directly to input click
inputElement.addEventListener("click", handlers.handleModalTrigger);
//? Add data attribute to host for styling
(shadowRoot.host as HTMLElement).dataset.buttonMode = 'true';
} else {
//? Normal input mode - listen for changes
inputElement.addEventListener("change", handlers.handleInputChange);
}
// ... (associate label if present) ...
inputWrapper.appendChild(inputElement);
let modalTriggerElement: HTMLElement = inputElement; // Default trigger is input
//^ Create separate button only if needed AND not in button mode
if (needsSeparateButton) {
const buttonElement = document.createElement("button");
// ... (create button content/attributes) ...
buttonElement.addEventListener("click", handlers.handleModalTrigger);
inputWrapper.appendChild(buttonElement);
modalTriggerElement = buttonElement; // Button is now the trigger
}
shadowRoot.appendChild(inputWrapper);
return {
wrapper: shadowRoot.host as HTMLElement,
labelElement,
inputElement,
modalTriggerElement // Might be input or button depending on mode
};
}

View file

@ -0,0 +1,49 @@
import type { TextInputDOMElements } from "../TextInput.types";
/** Updates the value displayed in the input element. */
export function updateInputValue(elements: TextInputDOMElements, newValue: string): void {
if (elements.inputElement && elements.inputElement.value !== newValue) {
elements.inputElement.value = newValue;
} else if (!elements.inputElement) {
console.error("[TextInputUI] inputElement not found for value update.");
}
}
/** Updates static UI parts like the label text. */
export function updateStaticTextUI(elements: TextInputDOMElements, label?: string): void {
if (elements.labelElement && label) {
elements.labelElement.textContent = label;
}
//? Could add updates for aria-labels here if needed
}
/** Sets the component to visually indicate an error state and disables input/button. */
export function setTextErrorState(hostElement: HTMLElement, elements: TextInputDOMElements, message: string = "Error"): void {
if (elements.inputElement) {
elements.inputElement.disabled = true; // Disable input on error
elements.inputElement.style.borderColor = "var(--text-error)"; // Example styling
}
if (elements.modalTriggerElement && elements.modalTriggerElement !== elements.inputElement) {
// Disable separate button if it exists
(elements.modalTriggerElement as HTMLButtonElement).disabled = true;
(elements.modalTriggerElement as HTMLButtonElement).style.borderColor = "var(--text-error)";
}
hostElement.classList.add('error');
hostElement.setAttribute('aria-disabled', 'true'); // Indicate disabled state
hostElement.setAttribute('title', message);
}
/** Clears any visual error indication and enables input/button. */
export function clearTextErrorState(hostElement: HTMLElement, elements: TextInputDOMElements): void {
if (elements.inputElement) {
elements.inputElement.disabled = false;
elements.inputElement.style.borderColor = ""; // Reset border
}
if (elements.modalTriggerElement && elements.modalTriggerElement !== elements.inputElement) {
(elements.modalTriggerElement as HTMLButtonElement).disabled = false;
(elements.modalTriggerElement as HTMLButtonElement).style.borderColor = "";
}
hostElement.classList.remove('error');
hostElement.removeAttribute('aria-disabled');
hostElement.removeAttribute('title');
}

View file

@ -0,0 +1,13 @@
import type { TextInputEventProps } from "../TextInput.types";
export type InputChangeEventHandler = (event: Event) => void;
/** Creates the handler for the input element's 'change' event. */
export function createInputChangeHandler(props: TextInputEventProps): InputChangeEventHandler {
return (event: Event) => {
if (event.target instanceof HTMLInputElement) {
const newValue = event.target.value;
props.setValue(newValue, true);
}
};
}

View file

@ -0,0 +1,35 @@
import type { TextInputEventProps } from "../TextInput.types";
import { TimePickerModal } from "../../../components/TimePickerModal";
import { DatePickerModal } from "../../../codeblocks/dateHeader/components/datePicker/DatePickerModal";
export type ModalTriggerHandler = (event: MouseEvent) => void;
/** //? Creates the handler for the modal trigger element's 'click' event. */
export function createModalTriggerHandler(props: TextInputEventProps): ModalTriggerHandler { // Use renamed type
return (event: MouseEvent) => {
// No debug log needed at start
switch (props.modalType) {
case 'time-picker':
new TimePickerModal(props.app, props.currentValue, (selectedTime: string) => {
props.setValue(selectedTime, true);
}).open();
break;
case 'date-picker':
const initialDate = props.currentValue || new Date().toISOString().split('T')[0];
new DatePickerModal(props.app, initialDate, (selectedDate: string) => {
props.setValue(selectedDate, true);
}).open();
break;
// todo: Add cases for 'list-select', 'custom', etc.
case 'none':
default:
// Do nothing or console.warn if unexpected click
// console.warn(`[ModalTriggerHandler ${props.label}] Clicked trigger but modalType is '${props.modalType}'.`);
break;
}
};
}

View file

@ -0,0 +1,57 @@
import { App } from "obsidian";
import { TextInput } from "./TextInput";
import { DBService } from "src/DBService";
//? Define element if needed
if (!customElements.get("text-input")) {
customElements.define("text-input", TextInput);
}
/**
* Finds placeholder elements and replaces them with initialized TextInput components.
* @param el The container element to search within.
* @param app The Obsidian App instance.
*/
export const registerTextInput = (el: HTMLElement, app: App, dbService: DBService) => {
const placeholders = el.querySelectorAll("span.text-input-placeholder");
placeholders.forEach((placeholderEl) => {
if (!(placeholderEl instanceof HTMLElement)) return;
//~ Read all attributes
const configAttrs: Record<string, string | undefined> = {
label: placeholderEl.dataset.label,
placeholder: placeholderEl.dataset.placeholder,
initialValue: placeholderEl.dataset.initialValue,
modalType: placeholderEl.dataset.modalType,
isButton: placeholderEl.dataset.isButton, // Read button mode flag
table: placeholderEl.dataset.table,
date: placeholderEl.dataset.date,
valueCol: placeholderEl.dataset.valueCol,
dateCol: placeholderEl.dataset.dateCol,
};
try {
const component = document.createElement("text-input") as TextInput;
//~ Set all attributes on the component element
Object.entries(configAttrs).forEach(([key, value]) => {
if (value !== undefined) {
// Convert camelCase (like isButton) to kebab-case for attributes
const attrName = key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
component.setAttribute(`data-${attrName}`, value);
}
});
// Manually set placeholder if needed as it's not a data-* attribute
if (configAttrs.placeholder) component.setAttribute("placeholder", configAttrs.placeholder);
//^ Inject BOTH dependencies AFTER setting attributes
component.setDependencies(dbService, app);
placeholderEl.replaceWith(component);
} catch (error) {
console.error(`[TextInput Reg] Error processing placeholder:`, error, placeholderEl); // Keep error log
placeholderEl.textContent = "[Error Loading Input]";
}
});
}

View file

@ -0,0 +1,12 @@
import { getTextInputStyles } from "./getTextInputStyles";
/** Creates a style element and appends it to the shadow root. */
export function applyTextInputStyles(shadowRoot: ShadowRoot): void {
if (!shadowRoot) {
console.error("[TextInputStyles] Cannot apply styles: ShadowRoot is null.");
return;
}
const style = document.createElement("style");
style.textContent = getTextInputStyles();
shadowRoot.appendChild(style);
}

View file

@ -0,0 +1,133 @@
/** Returns CSS string for the TextInput component's Shadow DOM. */
export function getTextInputStyles(): string {
return `
:host {
display: inline-flex; /* Or block, depending on desired layout */
flex-direction: column; /* Stack label and input/button */
gap: var(--size-4-1); /* Space between label and input row */
font-family: var(--font-text);
width: 100%; /* Default to full width, can be overridden */
}
.input-wrapper {
display: flex;
align-items: stretch; /* Make input and button same height */
width: 100%;
}
/* Optional Label Styling */
.input-label {
font-size: var(--font-ui-small);
font-weight: var(--font-semibold);
color: var(--text-muted);
padding-left: var(--size-4-2); /* Indent slightly */
margin-bottom: -var(--size-4-1); /* Pull input closer if label exists */
}
/* Main Input Element Styling */
input[type="text"].main-input {
flex-grow: 1; /* Take available space */
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: var(--size-4-2) var(--size-4-3);
font-size: var(--font-ui-normal);
background-color: var(--background-secondary);
color: var(--text-normal);
min-width: 50px; /* Prevent collapsing too small */
transition: border-color 0.1s ease-in-out;
}
input[type="text"].main-input:focus {
outline: none;
border-color: var(--interactive-accent);
box-shadow: 0 0 0 2px var(--interactive-accent-translucent);
}
input[type="text"].main-input::placeholder {
color: var(--text-faint);
opacity: 0.8;
}
input[type="text"].main-input {
/* ... existing input styles ... */
/* //? Default cursor */
cursor: text;
}
/* //^ Styles for Button Mode */
:host([data-button-mode="true"]) input[type="text"].main-input {
cursor: pointer; /* Indicate clickability */
}
:host([data-button-mode="true"]) input[type="text"].main-input:hover {
background-color: var(--background-modifier-hover);
}
/* //? Hide the separate trigger button in button mode */
:host([data-button-mode="true"]) button.modal-trigger {
display: none;
}
/* //? Ensure input border radius is correct in button mode */
:host([data-button-mode="true"]) input[type="text"].main-input {
border-top-right-radius: var(--radius-m);
border-bottom-right-radius: var(--radius-m);
}
/* Modal Trigger Button Styling (if used) */
button.modal-trigger {
flex-shrink: 0; /* Don't shrink the button */
border: 1px solid var(--background-modifier-border);
border-left: none; /* Join with input */
background-color: var(--background-secondary);
color: var(--text-muted);
padding: 0 var(--size-4-3);
cursor: pointer;
border-top-right-radius: var(--radius-m);
border-bottom-right-radius: var(--radius-m);
font-size: var(--font-ui-small);
display: flex;
align-items: center;
justify-content: center;
}
/* Adjust input border radius when button is present */
input[type="text"].main-input + button.modal-trigger {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
button.modal-trigger:hover {
background-color: var(--background-modifier-hover);
color: var(--text-normal);
}
button.modal-trigger:active {
background-color: var(--background-modifier-active);
}
/* Styling when input itself is the trigger (no separate button) */
:host([data-input-as-trigger="true"]) input[type="text"].main-input {
cursor: pointer; /* Indicate clickability */
/* Optionally change appearance slightly */
/* background-image: url('data:image/svg+xml,...'); // Add dropdown arrow */
}
:host([data-input-as-trigger="true"]) input[type="text"].main-input:hover {
/* background-color: var(--background-modifier-hover); // Subtle hover on input */
}
/* Error State */
:host(.error) input[type="text"].main-input,
:host(.error) button.modal-trigger {
border-color: var(--text-error);
}
:host(.error) input[type="text"].main-input:focus {
box-shadow: 0 0 0 2px var(--text-error-translucent);
}
/* Disabled State */
:host([aria-disabled="true"]) {
opacity: 0.6;
cursor: not-allowed;
}
:host([aria-disabled="true"]) input[type="text"].main-input,
:host([aria-disabled="true"]) button.modal-trigger {
background-color: var(--background-modifier-border-hover);
cursor: not-allowed;
}
`;
}

View file

@ -0,0 +1,83 @@
import { DBService } from "src/DBService";
import { TextRecord, TextDataArgs, UpsertTextArgs } from "../TextInput/TextInput.types";
import { quoteSqlIdentifier } from "./utils/quoteSqlIdentifier";
import { validateBaseTextArgs, validateUpsertTextArgs } from "./utils/validateTextInputArgs";
export class TextInputDataService {
private dbService: DBService;
constructor(dbService: DBService) {
if (!dbService) throw new Error("TextInputDataService requires a valid DBService instance.");
this.dbService = dbService;
}
/** //? Fetches the current text value for a specific key/date. Returns empty string if not found. */
async fetchTextValue(args: TextDataArgs): Promise<string> {
try {
validateBaseTextArgs(args);
const safeTable = quoteSqlIdentifier(args.table);
const safeValueCol = quoteSqlIdentifier(args.valueCol);
const safeDateCol = quoteSqlIdentifier(args.dateCol);
const sql = `SELECT ${safeValueCol} AS value FROM ${safeTable} WHERE ${safeDateCol} = ?`;
const params = [args.date];
const result = await this.dbService.getQuery<TextRecord>(sql, params);
const value = result?.[0]?.value;
//? Return fetched string or empty string if null/undefined/not found
return typeof value === 'string' ? value : "";
} catch (error) {
console.error(`[TextInputDataService] Error in fetchTextValue for ${args.table}:`, error);
throw error;
}
}
/** //? Upserts the text value. Handles optional UUID column logic. */
async upsertTextValue(args: UpsertTextArgs): Promise<void> {
try {
validateUpsertTextArgs(args);
const safeTable = quoteSqlIdentifier(args.table);
const safeValueCol = quoteSqlIdentifier(args.valueCol);
const safeDateCol = quoteSqlIdentifier(args.dateCol);
const updatedAtCol = quoteSqlIdentifier("updatedAt"); //! 🔒 Hardcoded
const now = new Date().toISOString(); //~ Current timestamp for updatedAt
let sql = "";
let params: (string | number | null)[] = []; // Allow null for value
//? Ensure newValue is null if it's an empty string, if desired by DB schema
const valueToSave = args.newValue === "" ? null : args.newValue;
const fetchUuidSql = `SELECT uuid FROM ${safeTable} WHERE ${safeDateCol} = ?`;
const uuidResult = await this.dbService.getQuery<{ uuid: string }>(fetchUuidSql, [args.date]);
const existingUuid = uuidResult?.[0]?.uuid;
if (existingUuid) {
sql = `UPDATE ${safeTable} SET ${safeValueCol} = ?, ${updatedAtCol} = ? WHERE uuid = ?`;
params = [valueToSave, now, existingUuid];
} else {
// Standard UPSERT Path
sql = `
INSERT INTO ${safeTable} (${safeDateCol}, ${safeValueCol}, ${updatedAtCol})
VALUES (?, ?, ?)
ON CONFLICT(${safeDateCol}) DO UPDATE
SET ${safeValueCol} = excluded.${safeValueCol}, ${updatedAtCol} = excluded.${updatedAtCol};
`;
//! Requires UNIQUE constraint on (keyIdCol, dateCol)
params = [args.date, valueToSave, now];
}
await this.dbService.runQuery(sql, params);
} catch (error) {
console.error(`[TextInputDataService] Error in upsertTextValue for ${args.table}:`, error);
if (error instanceof Error && error.message.includes("ON CONFLICT clause does not match")) {
//^ Provide more specific guidance mentioning the user-provided columns
console.error(`//! DB Constraint Error: Table '${args.table}' needs a UNIQUE index/key on (${args.dateCol}). Run: CREATE UNIQUE INDEX IF NOT EXISTS idx_key_date ON ${quoteSqlIdentifier(args.table)} (${quoteSqlIdentifier(args.dateCol)});`);
throw new Error(`Missing UNIQUE constraint on (${args.dateCol}) in table ${args.table}. ${error.message}`);
}
throw error;
}
}
}

View file

@ -0,0 +1,25 @@
// src/webcomponents/services/utils/validateTextInputArgs.ts
import { TextDataArgs, UpsertTextArgs } from "src/webcomponents/TextInput/TextInput.types";
const IDENTIFIER_REGEX = /^[a-zA-Z0-9_]+$/;
/** //? Validates base arguments required for identifying text input data row by date. */
export function validateBaseTextArgs(args: TextDataArgs): void {
if (!args.table || !args.date) { //^ Need table and date
throw new Error(`Missing required identifying arguments: table='${args.table}', date='${args.date}'`);
}
if (!args.valueCol || !IDENTIFIER_REGEX.test(args.valueCol)) { //^ Need value column
throw new Error(`Invalid or missing value column name: '${args.valueCol}'`);
}
if (!args.dateCol || !IDENTIFIER_REGEX.test(args.dateCol)) { //^ Need date column
throw new Error(`Invalid or missing date column name: '${args.dateCol}'`);
}
}
/** //? Validates arguments required for upserting text input data. */
export function validateUpsertTextArgs(args: UpsertTextArgs): void {
validateBaseTextArgs(args);
if (typeof args.newValue !== 'string' && args.newValue !== null) {
throw new Error(`Invalid or missing newValue: '${args.newValue}'`);
}
}