带有Web组件和“ ElementInternals”的自定义表单
#javascript #forms #Web组件 #elementinternals

随着2023年3月的Safari 16.4发布,Web组件在使用koude1 API<form>元素互动的能力中达到了一个里程碑。在此之前,无法通过表格发现位于Shadow DOM中的输入元素,这意味着它们不会在表单提交中验证,并且其数据将不包含在FormData对象中。 ElementInterals API允许我们创建与形式相关的自定义元素(简短面对)。现在,我们的自定义元素可以像本地输入元素一样行事,并利用form constraint validation的形式API。

我想探索API的功能,以便我们可以在工作中的组件库中实现它,但是当我开始工作时,我无法总是看到一条明确的途径,所以我决定写这篇文章任何遇到相同问题的人的文章。我还将在示例中使用TypeScript来帮助定义API。

入门

如果您已经设置了组件,或者您不按照自己的方式进行关注,请随时跳到以下表单的“自定义元素”。有代号可帮助您跳过。如果没有,here is a CodePen您可以开始。

设置组件

为了演示ElementInternals API,我们的组件设置将非常简单,最初只有两个属性-valuerequired。此示例将使用“香草Web组件”,因此,如果您使用库或框架来构建组件,则组件设置可能会有所不同。设置组件时,请务必遵循这些最佳实践。好消息是,无论您使用哪种工具,实施ElementInternals API似乎都相当一致。

添加输入

我们要做的第一件事是使用组件的connectedCallback lifecycle钩子在我们的组件中添加阴影根并在其中插入输入和标签。

customElements.define('my-input', class extends HTMLElement {
  connectedCallback() {
    const shadowRoot = this.attachShadow({ mode: 'open' });
    shadowRoot.innerHTML = `
      <label>
        My Input
        <input type="text" />
      </label>`;
  }
});

让我们提到我们的输入元素。我们可以通过在我们的班级根部创建属性来做到这一点(我选择以$的前缀为前缀,以区分引用HTML元素与其他属性的属性)。

private $input: HTMLInputElement;

在我们的connectedCallback方法中,我们可以查询输入元素的阴影dom内容。

connectedCallback() {
    ...
    this.$input = shadowRoot.querySelector('input');
}

添加属性

让我们添加valuerequired属性,以便我们可以将它们设置在我们的自定义元素标签上,然后将它们传递到我们的内部元素(<my-input value=”abc” required></my-input)。我们将通过添加observedAttributes属性并返回属性名称的数组来做到这一点。

static get observedAttributes() {
  return ["required", "value"];
}

现在,当使用attributeChangedCallback生命周期钩更改属性时,我们需要更新输入属性,但是我们将存在一个计时问题。 attributeChangedCallback方法将在我们的内部输入有机会渲染和查询选择器之前运行,并将其分配给我们的$input变量。为了解决这个问题,我们将创建一个组件变量来捕获属性和值,并且在准备就绪时将更新内部输入。

首先,让我们在我们的组件中添加一个名为_attrs的私有属性,并将值设置为空对象。

private _attrs = {};

在我们的attributeChangeCallback方法中,让我们分配任何属性更改为name是更改的属性,而next是新值。

attributeChangedCallback(name, prev, next) {
  this._attrs[name] = next;
}

现在,让我们创建一个私人方法,该方法将使用我们的_attrs值来更新我们的输入元素。

private setProps() {
  // prevent any errors in case the input isn't set
  if (!this.$input) {
    return;
  }

  // loop over the properties and apply them to the input
  for (let prop in this._attrs) {
    switch (prop) {
      case "value":
        this.$input.value = this._attrs[prop];
        break;
      case "required":
        const required = this._attrs[prop];
        if (
            required === "true" || 
            required === true || 
            required === ""
        ) {
          this.$input.setAttribute("required", "");
        }

        if (
            required === "false" || 
            required === false || 
            required === undefined
        ) {
          this.$input.removeAttribute("required");
        }
        break;
    }
  }

  // reset the attributes to prevent unwanted changes later
  this._attrs = {};
}

我们现在可以将其添加到connectedCallback方法中,以更新我们的输入元素,并在渲染之前发生任何更改。

connectedCallback() {
  ...
  this.$input = shadowRoot.querySelector('input');
  this.setProps();
}

我们还可以将其添加到attributeChangedCallback方法中,以便将调用connectedCallback方法调用后发生的任何属性更改都应用于输入元素。

attributeChangedCallback(name, prev, next) {
  this._attrs[name] = next;
  this.setProps();
}

现在,我们应该能够将属性添加到我们的元素标签中,并使它们传递到渲染的内部输入元素。

将自定义元素与表格相关联

关联您的自定义元素非常简单,可以以2个步骤完成:

  1. formAssociated静态属性设置为true

  2. 通过在组件的构造函数中调用this._internals = this.attachInternals();来公开ElementInternals api。

static formAssociated = true;
private _internals: ElementInternals;

constructor() {
  super();
  this._internals = this.attachInternals();
}

通过这两个更改,我们的自定义元素现在可以看到父级表单!作为快速测试,尝试将console.log(this._internals.form);添加到connectedCallback方法中,您应该在控制台中登录父。

使用标签

通过使其成为形式相关的自定义元素,浏览器现在将其视为输入元素,这意味着我们可以将标签移出组件。

connectedCallback() {
  ...
  shadowRoot.innerHTML = `<input type="text" />`;
}

我们可以将自定义输入元素标记为标准输入元素。让我们使用<label>并使用自定义元素上的id和标签上的for属性引用。

<form id="my-form">
  <label for="input">My Input</label>
  <my-input id="input"></my-input>
</form>

注意: SafariNVDA中有一个可访问性错误,其中标签与屏幕读取器的形式相关的自定义元素没有正确的关联。他们在铬浏览器(Chrome,Edger,Brave等)中使用配音透明,Mac上的Firefox,MS叙述者和窗户上的下颚。作为解决方法,您可以继续在元素中加入标签。

让我们还更新我们的影子根配置以委派焦点。这将允许在像本机输入元素一样单击标签时将输入集中在集中。

const shadowRoot = this.attachShadow({ mode: 'open', delegatesFocus: true });

如果delegatesFocustrue,当单击阴影DOM的不可合并部分时,或者.focus()被称为主机元素,则给出了第一个焦点部分,并给出了Shadow Host的任何可用的:focusâ:focus样式。

暴露验证

现在表单与我们的自定义元素关联,我们可以通过公开一些核心验证行为开始,我们期望使用checkValidityreportValidityvalidityvalidationMessage的输入元素。

public checkValidity(): boolean {
  return this._internals.checkValidity();
}

public reportValidity(): void {
  return this._internals.reportValidity();
}

public get validity(): ValidityState {
  return this._internals.validity;
}

public get validationMessage(): string {
  return this._internals.validationMessage;
}

控制验证

ElementIntenrals api使我们可以访问setValidity方法,我们可以使用该方法与我们的元素的有效性状态进行通信。

setValidity(flags: ValidityStateFlags, message?: string, anchor?: HTMLElement)

您可以看到,messageanchor属性是可选的。如果要重置验证,则可以将空对象({})作为标志参数传递。

标志

flags接口几乎与调用input.validity时获得的ValidityState对象几乎相同,但是每个属性都是可选的,可以设置(其中一个输入的validity是只读的属性)。这是界面的示例,也是某些何时将其用本机HTML输入元素设置的示例。

interface ValidityStateFlags {
  /** `true` if the element is required, but has no value */
  valueMissing?: boolean;
  /** `true` if the value is not in the required syntax (when the "type" is "email" or "URL") */
  typeMismatch?: boolean;
  /** `true` if the value does not match the specified pattern */
  patternMismatch?: boolean;
  /** `true` if the value exceeds the specified `maxlength` */
  tooLong?: boolean;
  /** `true` if the value fails to meet the specified `minlength` */
  tooShort?: boolean;
  /** `true` if the value is less than the minimum specified by the `min` attribute */
  rangeUnderflow?: boolean;
  /** `true` if the value is greater than the maximum specified by the `max` attribute */
  rangeOverflow?: boolean;
    /** `true` if the value does not fit the rules determined by the `step` attribute (that is, it's not evenly divisible by the step value) */
  stepMismatch?: boolean;
  /** `true` if the user has provided input that the browser is unable to convert */
  badInput?: boolean;
  /** `true` if the element's custom validity message has been set to a non-empty string by calling the element's `setCustomValidity()` method */
  customError?: boolean;
}

信息

message参数是我们可以在验证这些验证参数之一时向元素提供自定义错误消息的方式。

anchor参数是我们要将错误消息与。

关联的元素

添加验证

现在我们可以控制组件中的验证方式,让我们添加功能以使我们的输入required

初始化验证

现在让我们初始化验证。因为我们的输入ValidityStateValidityStateFlags的接口基本相同,所以我们可以使用输入的初始状态来设置ElementInterals状态。在我们在connectedCallback方法中的输入选择器之后,让我们根据输入调用setValidity方法。

connectedCallback() {
  ...
  this.$input = shadowRoot.querySelector('input');
  this._internals.setValidity(this.$input.validity, 
  this.$input.validationMessage, this.$input);
}

在这里,我们使用内部输入元素的ValidityState,但是您也可以通过ValidityStateFlags的子集和自定义错误消息。

this._internals.setValidity(
  {
    valueMissing: true
  }, 
  'Please fill out this required field', 
  this.$input
);

测试验证

所有内容都应连接起来,所以让我们对其进行测试。让我们更新HTML以将提交按钮和一个required属性添加到我们的自定义元素。

<form id="my-form">
  <label for="input">My Input</label>
  <my-input id="input" required></my-input>
  <button>Submit</button>
</form>

单击“提交”按钮时,您应该查看所需字段的浏览器验证消息。如果我们选择表格并致电form.checkValidity(),则应返回false

使用setValidity更新验证

直到我们对其进行更新之前,验证状态将保持原样。如果您在输入中输入文本并单击“提交”,您仍然会看到一条错误消息,而form.checkValidity()仍将返回false

对于此演示,每当用户输入字段中的内容时,我们都可以设置一个简单的更新。为此,我们将在选择输入元素后将事件侦听器添加到我们的connectedCallback方法中。

connectedCallback() {
  ...
  this.$input.addEventListener('input', () => this.handleInput());
}

private handleInput() {
  this._internals.setValidity(this.$input.validity, this.$input.validationMessage, this.$input);
}

使用setFormValue更新表单值

使用setFormValueElementInternals API上,我们现在可以在自定义元素中的值更改时更新表单。这使开发人员可以使用FormData API轻松获取表单值。

当组件加载在connectedCallback方法中时,请设置初始值。

connectedCallback() {
  ...
  this._internals.setFormValue(this.value);
}

现在,让我们在input事件触发的任何时候更新值。

private handleInput() {
  ...
  this._internals.setFormValue(this.value);
}

测试表格数据

要测试我们的价值

<form id="my-form">
  <label for="input">My Input</label>
  <my-input id="input" name="myInput" required></my-input>
  <button>Submit</button>
</form>

我们现在可以通过将事件侦听器添加到表单submit事件并获取表单数据来查看我们的价值是否绑定到表单。

const form = document.getElementById("my-form");
form.addEventListener("submit", (e) => {
  e.preventDefault();
  const formData = new FormData(e.target);
  console.log(`My Input Value - '${formData.get('myInput')}'`);
});

在输入中键入一个值,然后单击“提交”按钮。您应该在控制台中看到您的值登录。

ElementInternals生命周期钩

ElementInternals API为我们提供了一些其他生命周钩,这些钩子对于控制与浏览器和其他元素的交互作用很重要。重要的是要注意,这些是可选的,仅应在必要时使用。

formAssociatedCallback(form: HTMLFormElement)

一旦元素与表单关联,这将立即调用。我们现在真的不需要这一点,所以我们现在就不会实施。

formDisabledCallback(disabled: boolean)

每当禁用元素或parent <fieldset>元素时。我们可以使用它来帮助管理内部元素的残疾状态。我们将在类中添加回调方法并在更改内部输入元素时更新。

formDisabledCallback(disabled: boolean) {
  this.$input.disabled = disabled;
}

formResetCallback()

这使我们能够在用户重置表单时控制元素的行为。在我们的情况下,我们将保持简单,并将输入值重置为加载组件时的初始值。我们将创建一个名为_defaultValue的私有属性,将其设置在connectedCallback方法中,然后使用formResetCallback回调方法将值重置(如果复位)。

private _defaultValue = "";

connectedCallback() {
  ...
  this._defaultValue = this.$input.value;
}

formResetCallback() {
  this.$input.value = this._defaultValue;
}

让我们更新我们的表格以包括一个重置按钮,并在输入中添加初始值。现在我们可以更改值并按重置按钮,并将其恢复为原始值。

<form id="my-form">
  <label for="input">My Input</label>
  <my-input id="input" name="myInput" value="test" required></my-input>
  <button type="reset">Reset</button>
  <button>Submit</button>
</form> 

formStateRestoreCallback(state, mode)

此方法回调使开发人员控制浏览器完成表单元素时发生的情况。 state属性提供了使用setFormValue设置的值,而mode具有两个可能的值-restoreautocomplete。当用户从表单中导航并再次返回时,将设置restore值,从而使其继续在其上停止的位置。当浏览器的输入辅助器试图自动完成表单时,使用autocomplete值。 autocomplete功能的缺点是,根据this article的说法,它尚未得到支持。在这种情况下,我们可以使用一个简单的实现来将输入恢复为保存值。

formStateRestoreCallback(state, mode) {
  this.$input.value = state;
}

结论

您可以看到,这只会刮擦这些新API可以做的事情的潜力。我们仅在input元素中可用的许多属性中实现了两个属性,当您介绍selecttextareabutton等其他表单元素时,事情变得更加有趣。希望这能为您提供与形式相关的自定义元素的扎实开端。愉快的编码!