import { css, html } from "lit";
import { property, query } from "lit/decorators.js";
import { provide } from "@lit/context";
import { BaseElement } from "../../core/base-element.ts";
import {
type FieldRegistration,
type FormContext,
formContext,
} from "./form-context.ts";
/**
* CFForm Component
*
* A form wrapper component that provides consistent layout and spacing for forms.
* Emits a custom cf-submit event when the form is submitted.
*
* Provides FormContext to descendant fields for coordinated submission with:
* - Field validation before submit
* - Atomic flushing of buffered values on submit
* - Coordinated reset of all fields
*
* @element cf-form
*
* @attr {string} method - HTTP method for form submission (GET or POST)
* @attr {string} action - URL for form submission
*
* @event cf-submit - Fired when the form is submitted and all fields are valid (includes form data in detail)
* @event cf-form-invalid - Fired when submit is attempted but validation fails (includes errors in detail)
*
* @slot - Form content (inputs, labels, buttons, etc.)
*
* @example
* ```html
*
* Name
*
*
* Email
*
*
*
* Submit
* Cancel
*
*
* ```
*/
export class CFForm extends BaseElement {
static override styles = css`
:host {
display: block;
width: 100%;
/* Default color values if not provided */
--background: #ffffff;
--foreground: #0f172a;
--border: #e2e8f0;
--ring: #94a3b8;
/* Form spacing variables */
--form-gap: 1.5rem;
--form-field-gap: 0.5rem;
--form-padding: 0;
}
form {
display: flex;
flex-direction: column;
gap: var(--form-gap);
padding: var(--form-padding);
width: 100%;
}
/* Direct children spacing */
::slotted(*) {
margin: 0;
}
/* Common form field patterns */
::slotted(cf-label) {
margin-bottom: var(--form-field-gap);
}
/* Field groups (divs, fieldsets) */
::slotted(div),
::slotted(fieldset) {
display: flex;
flex-direction: column;
gap: var(--form-field-gap);
margin: 0;
padding: 0;
border: none;
}
/* Horizontal field groups */
::slotted(.form-row),
::slotted([data-orientation="horizontal"]) {
flex-direction: row;
align-items: center;
gap: 1rem;
}
/* Form sections */
::slotted(.form-section) {
display: flex;
flex-direction: column;
gap: var(--form-gap);
}
/* Button groups typically at form bottom */
::slotted(.form-actions),
::slotted(.form-buttons) {
display: flex;
gap: 0.75rem;
margin-top: 0.5rem;
}
/* Responsive adjustments */
@media (max-width: 640px) {
::slotted(.form-row),
::slotted([data-orientation="horizontal"]) {
flex-direction: column;
align-items: stretch;
}
::slotted(.form-actions),
::slotted(.form-buttons) {
flex-direction: column;
}
::slotted(.form-actions) cf-button,
::slotted(.form-buttons) cf-button {
width: 100%;
}
}
`;
@property()
accessor method: "GET" | "POST" = "GET";
@property()
accessor action = "";
@query("form")
private accessor _form: HTMLFormElement | null = null;
/** Track registered fields for coordinated submit/reset */
private _fields = new Map();
/** Provide FormContext to descendant fields */
@provide({ context: formContext })
private accessor _formContext: FormContext = {
registerField: (reg) => this._registerField(reg),
};
/**
* Register a field with the form
* @param reg - Field registration with buffer and validation handlers
* @returns Unregister function to call on disconnectedCallback
*/
private _registerField(reg: FieldRegistration): () => void {
this._fields.set(reg.element, reg);
return () => {
this._fields.delete(reg.element);
};
}
override render() {
return html`
`;
}
private async handleSubmit(event: Event): Promise {
// Prevent default form submission
event.preventDefault();
event.stopPropagation();
if (!this._form) {
return;
}
// 1. Validate all registered fields
const errors: Array<{ element: HTMLElement; message?: string }> = [];
for (const [element, field] of this._fields) {
const result = field.validate();
if (!result.valid) {
errors.push({ element, message: result.message });
}
}
// If any fields are invalid, emit error event and return early
if (errors.length > 0) {
this.emit("cf-form-invalid", { errors });
return;
}
// 2. Flush all buffered values to their bound cells
// Await all flush operations to ensure cell updates are committed
try {
const flushPromises = Array.from(this._fields.values()).map((field) =>
field.flush()
);
await Promise.all(flushPromises);
} catch (error) {
// Emit error event if flush fails (e.g., network error, storage failure)
this.emit("cf-submit-error", {
error,
message: error instanceof Error
? error.message
: "Failed to save form data",
});
return;
}
// 3. Emit submit event - handlers read from cells directly
// (Pattern handlers run in the runtime context where cells are updated)
const submitted = this.emit("cf-submit");
// 4. If event wasn't prevented, submit the form natively
if (submitted && this.action) {
this._form.submit();
}
}
/**
* Get the form element
*/
get form(): HTMLFormElement | null {
return this._form;
}
/**
* Submit the form programmatically
*/
submit(): void {
if (this._form) {
const event = new Event("submit", {
bubbles: true,
cancelable: true,
});
this._form.dispatchEvent(event);
}
}
/**
* Reset the form
*/
reset(): void {
// Reset all registered fields to their initial cell values
for (const field of this._fields.values()) {
field.reset();
}
if (this._form) {
this._form.reset();
}
}
/**
* Check form validity
*/
checkValidity(): boolean {
return this._form?.checkValidity() ?? false;
}
/**
* Report form validity
*/
reportValidity(): boolean {
return this._form?.reportValidity() ?? false;
}
/**
* Check if any field in the form has unsaved changes
*/
isDirty(): boolean {
for (const field of this._fields.values()) {
if (field.isDirty()) {
return true;
}
}
return false;
}
}