import HTMLElement from '../html-element/HTMLElement.js';
import * as PropertySymbol from '../../PropertySymbol.js';
import Event from '../../event/Event.js';
import SubmitEvent from '../../event/events/SubmitEvent.js';
import HTMLFormControlsCollection from './HTMLFormControlsCollection.js';
import HTMLInputElement from '../html-input-element/HTMLInputElement.js';
import HTMLSelectElement from '../html-select-element/HTMLSelectElement.js';
import HTMLButtonElement from '../html-button-element/HTMLButtonElement.js';
import IBrowserFrame from '../../browser/types/IBrowserFrame.js';
import BrowserFrameNavigator from '../../browser/utilities/BrowserFrameNavigator.js';
import BrowserWindow from '../../window/BrowserWindow.js';
import THTMLFormControlElement from './THTMLFormControlElement.js';
import QuerySelector from '../../query-selector/QuerySelector.js';
import RadioNodeList from './RadioNodeList.js';
import WindowBrowserContext from '../../window/WindowBrowserContext.js';
import ClassMethodBinder from '../../ClassMethodBinder.js';
import Node from '../node/Node.js';
import Element from '../element/Element.js';
import EventTarget from '../../event/EventTarget.js';
/**
* HTML Form Element.
*
* Reference:
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement.
*/
export default class HTMLFormElement extends HTMLElement {
// Public properties
public declare cloneNode: (deep?: boolean) => HTMLFormElement;
// Internal properties.
public [PropertySymbol.elements]: HTMLFormControlsCollection | null = null;
public [PropertySymbol.proxy]: HTMLFormElement;
// Events
public onformdata: (event: Event) => void | null = null;
public onreset: (event: Event) => void | null = null;
public onsubmit: (event: Event) => void | null = null;
/**
* Constructor.
*/
constructor() {
super();
const methodBinder = new ClassMethodBinder(this, [
HTMLFormElement,
HTMLElement,
Element,
Node,
EventTarget
]);
const proxy = new Proxy(this, {
get: (target, property) => {
if (property === 'length') {
return target[PropertySymbol.getFormControlItems]().length;
}
if (property in target || typeof property === 'symbol') {
methodBinder.bind(property);
return target[property];
}
const index = Number(property);
if (!isNaN(index)) {
return target[PropertySymbol.getFormControlItems]()[index];
}
return target[PropertySymbol.getFormControlNamedItem](property) || undefined;
},
set(target, property, newValue): boolean {
methodBinder.bind(property);
if (typeof property === 'symbol') {
target[property] = newValue;
return true;
}
const index = Number(property);
if (isNaN(index)) {
target[property] = newValue;
}
return true;
},
deleteProperty(target, property): boolean {
if (typeof property === 'symbol') {
delete target[property];
return true;
}
const index = Number(property);
if (isNaN(index)) {
delete target[property];
}
return true;
},
ownKeys(target): string[] {
return Object.keys(target[PropertySymbol.getFormControlItems]());
},
has(target, property): boolean {
if (property in target) {
return true;
}
if (typeof property === 'symbol') {
return false;
}
const items = target[PropertySymbol.getFormControlItems]();
const index = Number(property);
if (!isNaN(index) && index >= 0 && index < items.length) {
return true;
}
property = String(property);
for (let i = 0; i < items.length; i++) {
const item = items[i];
const name =
item[PropertySymbol.attributes][PropertySymbol.namedItems].get('id')?.[
PropertySymbol.value
] ||
item[PropertySymbol.attributes][PropertySymbol.namedItems].get('name')?.[
PropertySymbol.value
];
if (name && name === property) {
return true;
}
}
return false;
},
defineProperty(target, property, descriptor): boolean {
methodBinder.preventBinding(property);
if (!descriptor.value) {
Object.defineProperty(target, property, descriptor);
return true;
}
const index = Number(descriptor.value);
if (isNaN(index)) {
Object.defineProperty(target, property, descriptor);
return true;
}
return false;
},
getOwnPropertyDescriptor(target, property): PropertyDescriptor {
if (property in target) {
return Object.getOwnPropertyDescriptor(target, property);
}
const items = target[PropertySymbol.getFormControlItems]();
const index = Number(property);
if (!isNaN(index) && index >= 0 && index < items.length) {
return {
value: items[index],
writable: false,
enumerable: true,
configurable: true
};
}
for (let i = 0; i < items.length; i++) {
const item = items[i];
const name =
item[PropertySymbol.attributes][PropertySymbol.namedItems].get('id')?.[
PropertySymbol.value
] ||
item[PropertySymbol.attributes][PropertySymbol.namedItems].get('name')?.[
PropertySymbol.value
];
if (name && name === property) {
return {
value: item,
writable: false,
enumerable: true,
configurable: true
};
}
}
}
});
this[PropertySymbol.proxy] = proxy;
this[PropertySymbol.formNode] = proxy;
return proxy;
}
/**
* Returns elements.
*
* @returns Elements.
*/
public get elements(): HTMLFormControlsCollection {
if (!this[PropertySymbol.elements]) {
this[PropertySymbol.elements] = new HTMLFormControlsCollection(
PropertySymbol.illegalConstructor,
this
);
}
return this[PropertySymbol.elements];
}
/**
* Returns length.
*
* @returns Length.
*/
public get length(): number {
return this[PropertySymbol.getFormControlItems]().length;
}
/**
* Returns name.
*
* @returns Name.
*/
public get name(): string {
return this.getAttribute('name') || '';
}
/**
* Sets name.
*
* @param name Name.
*/
public set name(name: string) {
this.setAttribute('name', name);
}
/**
* Returns method.
*
* @returns Method.
*/
public get method(): string {
return this.getAttribute('method') || 'get';
}
/**
* Sets method.
*
* @param method Method.
*/
public set method(method: string) {
this.setAttribute('method', method);
}
/**
* Returns target.
*
* @returns Target.
*/
public get target(): string {
return this.getAttribute('target') || '';
}
/**
* Sets target.
*
* @param target Target.
*/
public set target(target: string) {
this.setAttribute('target', target);
}
/**
* Returns action.
*
* @returns Action.
*/
public get action(): string {
if (!this.hasAttribute('action')) {
return this[PropertySymbol.ownerDocument].location.href;
}
try {
return new URL(this.getAttribute('action'), this[PropertySymbol.ownerDocument].location.href)
.href;
} catch (e) {
return '';
}
}
/**
* Sets action.
*
* @param action Action.
*/
public set action(action: string) {
this.setAttribute('action', action);
}
/**
* Returns encoding.
*
* @returns Encoding.
*/
public get encoding(): string {
return this.getAttribute('encoding') || '';
}
/**
* Sets encoding.
*
* @param encoding Encoding.
*/
public set encoding(encoding: string) {
this.setAttribute('encoding', encoding);
}
/**
* Returns enctype.
*
* @returns Enctype.
*/
public get enctype(): string {
return this.getAttribute('enctype') || '';
}
/**
* Sets enctype.
*
* @param enctype Enctype.
*/
public set enctype(enctype: string) {
this.setAttribute('enctype', enctype);
}
/**
* Returns autocomplete.
*
* @returns Autocomplete.
*/
public get autocomplete(): string {
return this.getAttribute('autocomplete') || '';
}
/**
* Sets autocomplete.
*
* @param autocomplete Autocomplete.
*/
public set autocomplete(autocomplete: string) {
this.setAttribute('autocomplete', autocomplete);
}
/**
* Returns accept charset.
*
* @returns Accept charset.
*/
public get acceptCharset(): string {
return this.getAttribute('acceptcharset') || '';
}
/**
* Sets accept charset.
*
* @param acceptCharset Accept charset.
*/
public set acceptCharset(acceptCharset: string) {
this.setAttribute('acceptcharset', acceptCharset);
}
/**
* Returns no validate.
*
* @returns No validate.
*/
public get noValidate(): boolean {
return this.getAttribute('novalidate') !== null;
}
/**
* Sets no validate.
*
* @param noValidate No validate.
*/
public set noValidate(noValidate: boolean) {
if (!noValidate) {
this.removeAttribute('novalidate');
} else {
this.setAttribute('novalidate', '');
}
}
/**
* Submits form. No submit event is raised. In particular, the form's "submit" event handler is not run.
*/
public submit(): void {
this.#submit();
}
/**
* Submits form, reports validity and raises submit event.
*
* @param [submitter] Submitter.
*/
public requestSubmit(submitter?: HTMLInputElement | HTMLButtonElement): void {
const noValidate = submitter?.formNoValidate || this.noValidate;
if (noValidate || this.checkValidity()) {
const event = new SubmitEvent('submit', {
bubbles: true,
cancelable: true,
submitter: submitter || this[PropertySymbol.proxy]
});
this.dispatchEvent(event);
if (!event.defaultPrevented) {
this.#submit(submitter);
}
}
}
/**
* Resets form.
*/
public reset(): void {
for (const element of this[PropertySymbol.getFormControlItems]()) {
switch (element[PropertySymbol.tagName]) {
case 'TEXTAREA':
element[PropertySymbol.value] = null;
break;
case 'INPUT':
element[PropertySymbol.value] = null;
element[PropertySymbol.checked] = null;
break;
case 'OUTPUT':
element.textContent = element[PropertySymbol.defaultValue];
break;
case 'SELECT':
let hasSelectedAttribute = false;
for (const option of (element).options) {
if (option.hasAttribute('selected')) {
hasSelectedAttribute = true;
option.selected = true;
break;
}
}
if (!hasSelectedAttribute && (element).options.length > 0) {
(element).options[0].selected = true;
}
break;
}
}
this.dispatchEvent(new Event('reset', { bubbles: true, cancelable: true }));
}
/**
* Checks validity.
*
* @returns "true" if validation does'nt fail.
*/
public checkValidity(): boolean {
const radioValidationState: { [k: string]: boolean } = {};
let isFormValid = true;
for (const element of this[PropertySymbol.getFormControlItems]()) {
if (element[PropertySymbol.tagName] === 'INPUT' && element.type === 'radio' && element.name) {
if (!radioValidationState[element.name]) {
radioValidationState[element.name] = true;
if (!element.checkValidity()) {
isFormValid = false;
}
}
} else if (!element.checkValidity()) {
isFormValid = false;
}
}
return isFormValid;
}
/**
* Reports validity.
*
* @returns "true" if validation does'nt fail.
*/
public reportValidity(): boolean {
return this.checkValidity();
}
/**
* @override
*/
public override [PropertySymbol.cloneNode](deep = false): HTMLFormElement {
return super[PropertySymbol.cloneNode](deep);
}
/**
* Returns form control items.
*
* @returns Form control items.
*/
public [PropertySymbol.getFormControlItems](): THTMLFormControlElement[] {
const elements = (
QuerySelector.querySelectorAll(this, 'input,select,textarea,button,fieldset,object,output')[
PropertySymbol.items
].slice()
);
if (this[PropertySymbol.isConnected]) {
const id =
this[PropertySymbol.attributes][PropertySymbol.namedItems].get('id')?.[
PropertySymbol.value
];
if (id) {
for (const element of (
QuerySelector.querySelectorAll(
this[PropertySymbol.ownerDocument],
`input[form="${id}"],select[form="${id}"],textarea[form="${id}"],button[form="${id}"],fieldset[form="${id}"],object[form="${id}"],output[form="${id}"]`
)[PropertySymbol.items]
)) {
if (!elements.includes(element)) {
elements.push(element);
}
}
}
}
return elements;
}
/**
* Returns form control named item.
*
* @param name
* @returns Form control named item.
*/
public [PropertySymbol.getFormControlNamedItem](
name: string
): THTMLFormControlElement | RadioNodeList | null {
const items = this[PropertySymbol.getFormControlItems]();
const namedItems = [];
name = String(name);
for (const item of items) {
if (
item[PropertySymbol.attributes][PropertySymbol.namedItems].get('id')?.[
PropertySymbol.value
] === name ||
item[PropertySymbol.attributes][PropertySymbol.namedItems].get('name')?.[
PropertySymbol.value
] === name
) {
namedItems.push(item);
}
}
if (!namedItems.length) {
return null;
}
if (namedItems.length === 1) {
return namedItems[0];
}
return new RadioNodeList(PropertySymbol.illegalConstructor, namedItems);
}
/**
* Submits form.
*
* @param browserFrame Browser frame. Injected by the constructor.
* @param [submitter] Submitter.
*/
#submit(submitter?: HTMLInputElement | HTMLButtonElement): void {
const action = submitter?.hasAttribute('formaction')
? submitter?.formAction || this.action
: this.action;
const browserFrame = new WindowBrowserContext(this[PropertySymbol.window]).getBrowserFrame();
if (!browserFrame) {
return;
}
if (!action) {
// The URL is invalid when the action is empty.
// This is what Chrome does when the URL is invalid.
this[PropertySymbol.ownerDocument].location.hash = '#blocked';
return;
}
const method = submitter?.formMethod || this.method;
const formData = new this[PropertySymbol.window].FormData(this);
let targetFrame: IBrowserFrame;
switch (submitter?.formTarget || this.target) {
default:
case '_self':
targetFrame = browserFrame;
break;
case '_top':
targetFrame = browserFrame.page.mainFrame;
break;
case '_parent':
targetFrame = browserFrame.parentFrame ?? browserFrame;
break;
case '_blank':
const newPage = browserFrame.page.context.newPage();
targetFrame = newPage.mainFrame;
targetFrame[PropertySymbol.openerFrame] = browserFrame;
break;
}
if (method === 'get') {
const url = new URL(action);
for (const [key, value] of formData) {
if (typeof value === 'string') {
url.searchParams.append(key, value);
}
}
BrowserFrameNavigator.navigate({
windowClass: (
this[PropertySymbol.ownerDocument][PropertySymbol.defaultView].constructor
),
frame: targetFrame,
url: url.href,
goToOptions: {
referrer: browserFrame.page.mainFrame.window.location.origin
}
});
return;
}
BrowserFrameNavigator.navigate({
windowClass: (
this[PropertySymbol.ownerDocument][PropertySymbol.defaultView].constructor
),
frame: targetFrame,
method: method,
url: action,
formData,
goToOptions: {
referrer: browserFrame.page.mainFrame.window.location.origin
}
});
}
}