diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 264f3491..00000000 --- a/src/utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function debounce any>( - func: T, - delay: number, -): (...args: Parameters) => void { - let timeoutId: ReturnType; - - return function (this: ThisParameterType, ...args: Parameters) { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const context = this; - - clearTimeout(timeoutId); - timeoutId = setTimeout(() => { - func.apply(context, args); - }, delay); - }; -} - -/** - * Re-dispatches an event from the provided element. - * source code: https://github.com/material-components/material-web/blob/a9ee4f5bc1d6702e5dc352eefed13a1d849577e3/internal/events/redispatch-event.ts#L28 - */ -export function redispatchEvent(element: Element, event: Event) { - // For bubbling events in SSR light DOM (or composed), stop their propagation - // and dispatch the copy. - if (event.bubbles && (!element.shadowRoot || event.composed)) { - event.stopPropagation(); - } - - const newEvent = Reflect.construct(event.constructor, [ - event.type, - event, - ]) as Event; - - const dispatched = element.dispatchEvent(newEvent); - - if (!dispatched) event.preventDefault(); - - return dispatched; -} diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts new file mode 100644 index 00000000..002c3ffe --- /dev/null +++ b/src/utils/debounce.ts @@ -0,0 +1,19 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const debounce = any>( + func: T, + delay: number, +): ((...args: Parameters) => void) => { + let timeoutId: ReturnType; + + return function (this: ThisParameterType, ...args: Parameters) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const context = this; + + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + func.apply(context, args); + }, delay); + }; +}; + +export default debounce; diff --git a/src/utils/events/BaseEvent.ts b/src/utils/events/BaseEvent.ts new file mode 100644 index 00000000..82003db7 --- /dev/null +++ b/src/utils/events/BaseEvent.ts @@ -0,0 +1,17 @@ +abstract class BaseEvent extends Event { + private _details?: T; + + public get details(): T | undefined { + return this._details; + } + + constructor(name: string, eventInit?: EventInit & { details?: T }) { + const { details, ...init } = eventInit ?? {}; + + super(name, init); + + this._details = details; + } +} + +export default BaseEvent; diff --git a/src/utils/events/index.ts b/src/utils/events/index.ts new file mode 100644 index 00000000..ef8704c2 --- /dev/null +++ b/src/utils/events/index.ts @@ -0,0 +1,2 @@ +export { default as BaseEvent } from "./BaseEvent"; +export { default as redispatchEvent } from "./redispatch-event"; diff --git a/src/utils/events/redispatch-event.ts b/src/utils/events/redispatch-event.ts new file mode 100644 index 00000000..2069e26f --- /dev/null +++ b/src/utils/events/redispatch-event.ts @@ -0,0 +1,25 @@ +/** + * Re-dispatches an event from the provided element. + * + * cherry-picked from: https://github.com/material-components/material-web/blob/a9ee4f5bc1d6702e5dc352eefed13a1d849577e3/internal/events/redispatch-event.ts#L28 + */ +const redispatchEvent = (element: Element, event: Event) => { + // For bubbling events in SSR light DOM (or composed), stop their propagation + // and dispatch the copy. + if (event.bubbles && (!element.shadowRoot || event.composed)) { + event.stopPropagation(); + } + + const newEvent = Reflect.construct(event.constructor, [ + event.type, + event, + ]) as Event; + + const dispatched = element.dispatchEvent(newEvent); + + if (!dispatched) event.preventDefault(); + + return dispatched; +}; + +export default redispatchEvent; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..04898bf8 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,5 @@ +export * from "./events"; +export * from "./mixins"; + +export { default as debounce } from "./debounce"; +export { default as logger } from "./logger"; diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 00000000..d01a2c90 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,20 @@ +const logger = ( + message: string, + scope: string, + type: "error" | "default" | "warning" = "default", +) => { + const typesMap: Record = { + /* eslint-disable no-console */ + error: console.error, + default: console.log, + warning: console.warn, + /* eslint-enable no-console */ + }; + + const logFn = typesMap[type]; + const completeMessage = `[TAPSI][${scope}]: ${message}`; + + logFn(completeMessage); +}; + +export default logger; diff --git a/src/utils/mixins/index.ts b/src/utils/mixins/index.ts new file mode 100644 index 00000000..a600b8b4 --- /dev/null +++ b/src/utils/mixins/index.ts @@ -0,0 +1,2 @@ +export { default as withElementInternals } from "./with-element-internals"; +export { default as withFormAssociated } from "./with-form-associated"; diff --git a/src/utils/mixins/types.ts b/src/utils/mixins/types.ts new file mode 100644 index 00000000..abbeb9e5 --- /dev/null +++ b/src/utils/mixins/types.ts @@ -0,0 +1,10 @@ +export type MixinBase = abstract new ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...args: any[] +) => ExpectedBase; + +export type MixinReturn = (abstract new ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...args: any[] +) => MixinClass) & + MixinBase; diff --git a/src/utils/mixins/with-element-internals.ts b/src/utils/mixins/with-element-internals.ts new file mode 100644 index 00000000..3b178f44 --- /dev/null +++ b/src/utils/mixins/with-element-internals.ts @@ -0,0 +1,31 @@ +import { type LitElement } from "lit"; +import type { MixinBase, MixinReturn } from "./types"; + +export const internals = Symbol("internals"); + +export interface WithElementInternals { + [internals]: ElementInternals; +} + +const withElementInternals = >( + BaseClass: T, +): MixinReturn => { + abstract class WithElementInternalsClass + extends BaseClass + implements WithElementInternals + { + private _internals?: ElementInternals; + + public get [internals](): ElementInternals { + if (!this._internals) { + this._internals = (this as HTMLElement).attachInternals(); + } + + return this._internals; + } + } + + return WithElementInternalsClass; +}; + +export default withElementInternals; diff --git a/src/utils/mixins/with-form-associated.ts b/src/utils/mixins/with-form-associated.ts new file mode 100644 index 00000000..f66a6d10 --- /dev/null +++ b/src/utils/mixins/with-form-associated.ts @@ -0,0 +1,233 @@ +/* + Mixes in form-associated behavior for a class. This allows an element to add + values to `
` elements. + + Cherry-picked from: https://github.com/material-components/material-web/blob/main/labs/behaviors/form-associated.ts +*/ + +import type { LitElement, PropertyDeclaration } from "lit"; +import { property } from "lit/decorators.js"; +import type { MixinBase, MixinReturn } from "./types"; +import { internals, type WithElementInternals } from "./with-element-internals"; + +export const getFormValue = Symbol("getFormValue"); +export const getFormState = Symbol("getFormState"); + +export type FormValue = File | string | FormData; + +export type FormRestoreState = + | File + | string + | Array<[string, FormDataEntryValue]>; + +export type FormRestoreReason = "restore" | "autocomplete"; + +export interface FormAssociatedConstructor { + /** + * Indicates that an element is participating in form association. + */ + readonly formAssociated: true; +} + +export interface FormAssociated { + /** + * The associated form element with which this element's value will submit. + */ + readonly form: HTMLFormElement | null; + + /** + * The labels this element is associated with. + */ + readonly labels: NodeList; + + /** + * The HTML name to use in form submission. + */ + name: string; + + /** + * Whether or not the element is disabled. + */ + disabled: boolean; + + /** + * Gets the current form value of a component. + * + * @return The current form value. + */ + [getFormValue](): FormValue | null; + + /** + * Gets the current form state of a component. Defaults to the component's + * `[formValue]`. + * + * Use this when the state of an element is different from its value, such as + * checkboxes (internal boolean state and a user string value). + * + * @return The current form state, defaults to the form value. + */ + [getFormState](): FormValue | null; + + /** + * A callback for when a form component should be disabled or enabled. This + * can be called in a variety of situations, such as disabled `
`s. + * + * @param disabled Whether or not the form control should be disabled. + */ + formDisabledCallback(disabled: boolean): void; + + /** + * A callback for when the form requests to reset its value. Typically, the + * default value that is reset is represented in the attribute of an element. + * + * This means the attribute used for the value should not update as the value + * changes. For example, a checkbox should not change its default `checked` + * attribute when selected. Ensure form values do not reflect. + */ + formResetCallback(): void; + + /** + * A callback for when the form restores the state of a component. For + * example, when a page is reloaded or forms are autofilled. + * + * @param state The state to restore, or null to reset the form control's + * value. + * @param reason The reason state was restored, either `'restore'` or + * `'autocomplete'`. + */ + formStateRestoreCallback( + state: FormRestoreState | null, + reason: FormRestoreReason, + ): void; + + /** + * An optional callback for when the associated form changes. + * + * @param form The new associated form, or `null` if there is none. + */ + formAssociatedCallback?(form: HTMLFormElement | null): void; +} + +const withFormAssociated = < + T extends MixinBase, +>( + BaseClass: T, +): MixinReturn => { + abstract class WithFormAssociatedClass + extends BaseClass + implements FormAssociated + { + public static readonly formAssociated = true; + + public get form(): HTMLFormElement | null { + return this[internals].form; + } + + public get labels(): NodeList { + return this[internals].labels; + } + + /* + We don't use Lit's default getter/setter (`noAccessor: true`) because + the attributes need to be updated synchronously to work with synchronous + form APIs, and Lit updates attributes async by default. + */ + @property({ noAccessor: true }) + get name(): string { + return this.getAttribute("name") ?? ""; + } + + public set name(name: string) { + // Note: setting name to null or empty does not remove the attribute. + + this.setAttribute("name", name); + + /* + We don't need to call `requestUpdate()` since it's called synchronously + in `attributeChangedCallback()`. + */ + } + + @property({ type: Boolean, noAccessor: true }) + public get disabled() { + return this.hasAttribute("disabled"); + } + + public set disabled(disabled: boolean) { + this.toggleAttribute("disabled", disabled); + + /* + We don't need to call `requestUpdate()` since it's called synchronously + in `attributeChangedCallback()`. + */ + } + + public override attributeChangedCallback( + attrName: string, + attrOldValue: string | null, + attrNewValue: string | null, + ): void { + /* + Manually `requestUpdate()` for `name` and `disabled` when their + attribute or property changes. + + The properties update their attributes, so this callback is invoked + immediately when the properties are set. We call `requestUpdate()` here + instead of letting Lit set the properties from the attribute change. + That would cause the properties to re-set the attribute and invoke this + callback again in a loop. This leads to stale state when Lit tries to + determine if a property changed or not. + */ + if (attrName === "name" || attrName === "disabled") { + const oldValue = + attrName === "disabled" ? attrOldValue !== null : attrOldValue; + + this.requestUpdate(attrName, oldValue); + return; + } + + super.attributeChangedCallback(attrName, attrOldValue, attrNewValue); + } + + public override requestUpdate( + name?: PropertyKey, + oldValue?: unknown, + options?: PropertyDeclaration, + ) { + super.requestUpdate(name, oldValue, options); + + /* + If any properties change, update the form value, which may have changed + as well. + + Update the form value synchronously in `requestUpdate()` rather than + `update()` or `updated()`, which are async. This is necessary to ensure + that form data is updated in time for synchronous event listeners. + */ + this[internals].setFormValue(this[getFormValue](), this[getFormState]()); + } + + public [getFormValue](): FormValue | null { + throw new Error("Method not implemented. Implement [getFormValue]."); + } + + public [getFormState](): FormValue | null { + return this[getFormValue](); + } + + public abstract formDisabledCallback(disabled: boolean): void; + + public abstract formAssociatedCallback?(form: HTMLFormElement | null): void; + + public abstract formResetCallback(): void; + + public abstract formStateRestoreCallback( + state: FormRestoreState | null, + reason: FormRestoreReason, + ): void; + } + + return WithFormAssociatedClass; +}; + +export default withFormAssociated;