{"id":8896,"date":"2026-03-11T17:24:06","date_gmt":"2026-03-11T22:24:06","guid":{"rendered":"https:\/\/frontendmasters.com\/blog\/?p=8896"},"modified":"2026-04-02T16:28:28","modified_gmt":"2026-04-02T21:28:28","slug":"form-associated-custom-elements-in-practice","status":"publish","type":"post","link":"https:\/\/frontendmasters.com\/blog\/form-associated-custom-elements-in-practice\/","title":{"rendered":"Form-Associated Custom Elements in Practice"},"content":{"rendered":"\n<p>I was well over three-quarters of the way through <a href=\"https:\/\/frontendmasters.com\/blog\/post-mortem-rewriting-agnosticui-with-lit-web-components\/\">rewriting the AgnosticUI components in Lit<\/a> when I realized I had a massive blind spot. My <code>&lt;ag-input&gt;<\/code> looked solid and fired events correctly, but it lacked <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/ElementInternals\">Form-Associated Custom Element<\/a> (FACE) support. This meant it was essentially invisible to native <code>&lt;form&gt;<\/code> submissions.<\/p>\n\n\n\n<p>I was completely unaware of this until a conversation with my friend <a href=\"https:\/\/www.linkedin.com\/in\/mvneerven\/\">Marc van Neerven<\/a>. We were discussing the nuances of Web Components and <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Web_components\/Using_shadow_DOM\">Shadow DOM<\/a> when Marc pointed out the importance of <strong>form association<\/strong>.<\/p>\n\n\n\n<p>Fueled by the mild embarrassment of having missed something so fundamental, I immediately started digging through articles like <a href=\"https:\/\/webkit.org\/blog\/13711\/elementinternals-and-form-associated-custom-elements\/\">ElementInternals and Form-Associated Custom Elements<\/a> to understand how this <em>Form-Associated Custom Element<\/em> stuff worked. Of course, I understood that native HTML form controls have built-in submission logic and <code>FormData<\/code> support, but I had absolutely no idea a FACE API even existed.<\/p>\n\n\n\n<p>It&#8217;s a massive facepalm moment when you believe you&#8217;re &#8220;code complete&#8221; on a dozen different form components, only to realize they don&#8217;t support the most basic functionality of a form. If you wrap a naively built custom element in a <code>&lt;form&gt;<\/code> and hit submit, the browser will have no idea the component is even there. Try setting a breakpoint on your submit handler; you&#8217;ll see an empty <code>FormData<\/code> object staring back at you.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"626\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558955386-ada204cb-7ec0-43b2-824a-755a29a558cc.png?resize=1024%2C626&#038;ssl=1\" alt=\"Comparison of native input and custom element in a form, showing how the native input correctly captures the value 'hello' in FormData, while the custom element results in empty FormData.\" class=\"wp-image-8897\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558955386-ada204cb-7ec0-43b2-824a-755a29a558cc.png?resize=1024%2C626&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558955386-ada204cb-7ec0-43b2-824a-755a29a558cc.png?resize=300%2C183&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558955386-ada204cb-7ec0-43b2-824a-755a29a558cc.png?resize=768%2C469&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558955386-ada204cb-7ec0-43b2-824a-755a29a558cc.png?resize=1536%2C939&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558955386-ada204cb-7ec0-43b2-824a-755a29a558cc.png?w=1672&amp;ssl=1 1672w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p>That empty object is what eventually reaches your server. Additionally, if you call <code>form.reset()<\/code>, your custom fields remain filled, and even a <code>&lt;fieldset disabled&gt;<\/code> wrapper gets completely ignored.<\/p>\n\n\n\n<p>Fixing this meant retrofitting every single form component in AgnosticUI. It was a massive undertaking, but it forced me to distill the spec&#8217;s complexities into a single, reusable Lit mixin. This helped encapsulate the boilerplate in one place, keeping the code DRY and ensuring my components finally became form-aware.<\/p>\n\n\n\n<p>The following is what I learned during that process.<\/p>\n\n\n<div class=\"box article-series\">\n  <header>\n    <h3 class=\"article-series-header\">Article Series<\/h3>\n  <\/header>\n  <div class=\"box-content\">\n            <ol>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/post-mortem-rewriting-agnosticui-with-lit-web-components\/\">Post Mortem: Rewriting AgnosticUI with Lit Web Components<\/a>\n            <\/li>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/form-associated-custom-elements-in-practice\/\">Form-Associated Custom Elements in Practice<\/a>\n            <\/li>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/shadow-dom-focus-delegation-getting-delegatesfocus-right\/\">Shadow DOM Focus Delegation: Getting\u00a0delegatesFocus\u00a0Right<\/a>\n            <\/li>\n                  <\/ol>\n        <\/div>\n<\/div>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"what-face-actually-is\">What FACE Actually Is<\/h2>\n\n\n\n<p>Enabling FACE starts with a deceptive bit of boilerplate. You tell the browser your element wants to participate in forms, and then you grab a handle to the <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/ElementInternals\">ElementInternals API<\/a>.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-class\"><span class=\"hljs-keyword\">class<\/span> <span class=\"hljs-title\">MyInput<\/span> <span class=\"hljs-keyword\">extends<\/span> <span class=\"hljs-title\">HTMLElement<\/span> <\/span>{\n  <span class=\"hljs-keyword\">static<\/span> formAssociated = <span class=\"hljs-literal\">true<\/span>; <span class=\"hljs-comment\">\/\/ The \"I'm a form control\" flag<\/span>\n\n  <span class=\"hljs-keyword\">constructor<\/span>() {\n    <span class=\"hljs-keyword\">super<\/span>();\n    <span class=\"hljs-comment\">\/\/ This gives you the keys to the kingdom<\/span>\n    <span class=\"hljs-keyword\">this<\/span>._internals = <span class=\"hljs-keyword\">this<\/span>.attachInternals();\n  }\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"484\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558964092-d9736006-52f1-4252-acca-39f9e71a56c4.png?resize=1024%2C484&#038;ssl=1\" alt=\"Diagram illustrating two steps of using formAssociated in a web development context. Step 1 explains registering the element with the browser, while Step 2 outlines attaching internal methods for form control behavior.\" class=\"wp-image-8898\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558964092-d9736006-52f1-4252-acca-39f9e71a56c4.png?resize=1024%2C484&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558964092-d9736006-52f1-4252-acca-39f9e71a56c4.png?resize=300%2C142&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558964092-d9736006-52f1-4252-acca-39f9e71a56c4.png?resize=768%2C363&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558964092-d9736006-52f1-4252-acca-39f9e71a56c4.png?resize=1536%2C726&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558964092-d9736006-52f1-4252-acca-39f9e71a56c4.png?w=1696&amp;ssl=1 1696w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p>If only it ended there. While those two steps &#8220;engage&#8221; the API, the actual work happens through <code>ElementInternals<\/code>. This is your side of the contract with the browser&#8217;s form system. It isn&#8217;t just a single property; it&#8217;s a suite of methods and properties that let your component finally talk to the parent <code>&lt;form&gt;<\/code>.<\/p>\n\n\n\n<p>Through <code>_internals<\/code>, you can:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Submit a value:<\/strong> Use <code>setFormValue()<\/code> so your element actually shows up in <code>FormData<\/code>.<\/li>\n\n\n\n<li><strong>Report validity:<\/strong> Use <code>setValidity()<\/code> to participate in <code>form.checkValidity()<\/code> and trigger native browser validation UI.<\/li>\n\n\n\n<li><strong>Manage state:<\/strong> Use the <code>.states<\/code> property to toggle custom pseudo-classes like <code>:state(checked)<\/code>, which is a lifesaver for styling.<\/li>\n\n\n\n<li><strong>Access metadata:<\/strong> Read properties like <code>.form<\/code>, <code>.willValidate<\/code>, or <code>.validationMessage<\/code> directly from the instance.<\/li>\n<\/ul>\n\n\n\n<p>On the flip side, the browser expects you to handle specific lifecycle callbacks. It&#8217;ll call <code>formResetCallback<\/code> when the form clears, <code>formDisabledCallback<\/code> when a <code>&lt;fieldset disabled&gt;<\/code> ancestor changes, and <code>formStateRestoreCallback<\/code> when the browser tries to help the user autofill a form after a navigation.<\/p>\n\n\n\n<p>It&#8217;s a lot of &#8220;stuff&#8221; to manage. As of early 2026,&nbsp;browser support is now broadly available (Chromium, Firefox, and Safari 16.4+), so we&#8217;re finally at a point where we can use this without reaching for a clunky polyfill.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"sharing-the-boilerplate-the-case-for-a-mixin\">Sharing the Boilerplate: The Case for a Mixin<\/h2>\n\n\n\n<p>The first decision when rolling FACE out across a dozen components is where to put the shared code. The boilerplate is identical every time: you need the <code>static<\/code> flag, the <code>attachInternals()<\/code> call, and about six different getters to proxy the internal state.<\/p>\n\n\n\n<p>In efforts to keep things DRY, a base class like <code>AgFormControl extends LitElement<\/code> seems like the obvious choice. But, JavaScript only allows single inheritance, so if a component already needs to extend something else, you\u2019ll be stuck.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"the-lit-mixin-pattern\">The Lit Mixin Pattern<\/h3>\n\n\n\n<p>The solution was a <a href=\"https:\/\/lit.dev\/docs\/composition\/mixins\/\">Lit Mixin<\/a>. It allows us to &#8220;plug in&#8221; form capabilities to any component while keeping the code DRY. To keep TypeScript happy with <code>protected<\/code> members, we use a companion <code>declare class<\/code>: this acts as a &#8220;blueprint&#8221; that tells the compiler exactly what the mixin is adding to the class.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-comment\">\/\/ 1. The \"Blueprint\" for TypeScript<\/span>\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">declare<\/span> <span class=\"hljs-keyword\">class<\/span> FaceMixinInterface {\n  <span class=\"hljs-keyword\">static<\/span> readonly formAssociated: <span class=\"hljs-built_in\">boolean<\/span>;\n  <span class=\"hljs-keyword\">protected<\/span> _internals: ElementInternals;\n  name: <span class=\"hljs-built_in\">string<\/span>;\n  readonly form: HTMLFormElement | <span class=\"hljs-literal\">null<\/span>;\n  readonly validity: ValidityState;\n  readonly validationMessage: <span class=\"hljs-built_in\">string<\/span>;\n  readonly willValidate: <span class=\"hljs-built_in\">boolean<\/span>;\n  checkValidity(): <span class=\"hljs-built_in\">boolean<\/span>;\n  reportValidity(): <span class=\"hljs-built_in\">boolean<\/span>;\n  formDisabledCallback(disabled: <span class=\"hljs-built_in\">boolean<\/span>): <span class=\"hljs-built_in\">void<\/span>;\n  formResetCallback(): <span class=\"hljs-built_in\">void<\/span>;\n}\n\n<span class=\"hljs-keyword\">type<\/span> Constructor&lt;T = {}&gt; = <span class=\"hljs-keyword\">new<\/span> (...args: <span class=\"hljs-built_in\">any<\/span>&#91;]) =&gt; T;\n\n<span class=\"hljs-comment\">\/\/ 2. The Actual Mixin<\/span>\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">const<\/span> FaceMixin = &lt;T <span class=\"hljs-keyword\">extends<\/span> Constructor&lt;LitElement&gt;&gt;<span class=\"hljs-function\">(<span class=\"hljs-params\"><span class=\"hljs-params\">superClass<\/span>: <span class=\"hljs-params\">T<\/span><\/span>) =&gt;<\/span> {\n  <span class=\"hljs-keyword\">class<\/span> FaceElement <span class=\"hljs-keyword\">extends<\/span> superClass {\n    <span class=\"hljs-keyword\">static<\/span> readonly formAssociated = <span class=\"hljs-literal\">true<\/span>;\n    <span class=\"hljs-keyword\">protected<\/span> _internals: ElementInternals;\n\n    <span class=\"hljs-meta\">@property<\/span>({ <span class=\"hljs-keyword\">type<\/span>: <span class=\"hljs-built_in\">String<\/span>, reflect: <span class=\"hljs-literal\">true<\/span> }) name = <span class=\"hljs-string\">\"\"<\/span>;\n\n    <span class=\"hljs-keyword\">constructor<\/span>(<span class=\"hljs-params\">...args: <span class=\"hljs-built_in\">any<\/span>&#91;]<\/span>) {\n      <span class=\"hljs-keyword\">super<\/span>(...args);\n      <span class=\"hljs-keyword\">this<\/span>._internals = <span class=\"hljs-keyword\">this<\/span>.attachInternals();\n    }\n\n    <span class=\"hljs-keyword\">get<\/span> form() {\n      <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">this<\/span>._internals.form;\n    }\n    <span class=\"hljs-keyword\">get<\/span> validity() {\n      <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">this<\/span>._internals.validity;\n    }\n    <span class=\"hljs-keyword\">get<\/span> validationMessage() {\n      <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">this<\/span>._internals.validationMessage;\n    }\n    <span class=\"hljs-keyword\">get<\/span> willValidate() {\n      <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">this<\/span>._internals.willValidate;\n    }\n\n    checkValidity() {\n      <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">this<\/span>._internals.checkValidity();\n    }\n    reportValidity() {\n      <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">this<\/span>._internals.reportValidity();\n    }\n\n    formDisabledCallback(disabled: <span class=\"hljs-built_in\">boolean<\/span>) {\n      (<span class=\"hljs-keyword\">this<\/span> <span class=\"hljs-keyword\">as<\/span> <span class=\"hljs-built_in\">any<\/span>).disabled = disabled;\n    }\n\n    formResetCallback() {\n      <span class=\"hljs-comment\">\/* Subclasses override this *\/<\/span>\n    }\n  }\n  <span class=\"hljs-comment\">\/\/ This cast merges the blueprint with the original class<\/span>\n  <span class=\"hljs-keyword\">return<\/span> FaceElement <span class=\"hljs-keyword\">as<\/span> unknown <span class=\"hljs-keyword\">as<\/span> Constructor&lt;FaceMixinInterface&gt; &amp; T;\n};\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Using it is a one-liner: <\/p>\n\n\n\n<p><code>export class AgInput extends FaceMixin(LitElement) { ... }<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"dividing-the-labor\">Dividing the Labor<\/h3>\n\n\n\n<p>The mixin owns the <strong>infrastructure<\/strong>, but the component owns the <strong>semantics<\/strong>. Here&#8217;s how I split the responsibilities:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>The Mixin handles:<\/strong> The <code>formAssociated<\/code> flag, <code>attachInternals<\/code>, the <code>name<\/code> property, and all proxy getters (like <code>validity<\/code> and <code>validationMessage<\/code>).<\/li>\n\n\n\n<li><strong>The Component handles:<\/strong> Deciding <em>when<\/em> to call <code>setFormValue()<\/code>, what actual value to submit, and the specific logic for <code>formResetCallback()<\/code>.<\/li>\n<\/ul>\n\n\n\n<p>Each component knows what &#8220;value&#8221; means for itself. The mixin just provides the megaphone to tell the browser about it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"two-validation-strategies\">Two Validation Strategies<\/h2>\n\n\n\n<p>One of the more instructive things the rollout revealed is that constraint validation splits cleanly into two strategies. You need to understand and use both!<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"381\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558976906-b27cb5aa-c8a6-41d0-86f1-c29f06ec520f.png?resize=1024%2C381&#038;ssl=1\" alt=\"Diagram comparing two validation strategies in a web component context: 'Strategy 1: Delegate' showing how to manage validity with a host element and shadow DOM, and 'Strategy 2: Direct' illustrating direct validation handling without delegation.\" class=\"wp-image-8899\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558976906-b27cb5aa-c8a6-41d0-86f1-c29f06ec520f.png?resize=1024%2C381&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558976906-b27cb5aa-c8a6-41d0-86f1-c29f06ec520f.png?resize=300%2C111&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558976906-b27cb5aa-c8a6-41d0-86f1-c29f06ec520f.png?resize=768%2C285&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558976906-b27cb5aa-c8a6-41d0-86f1-c29f06ec520f.png?resize=1536%2C571&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/558976906-b27cb5aa-c8a6-41d0-86f1-c29f06ec520f.png?w=1760&amp;ssl=1 1760w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"strategy-1-delegate-to-the-native-form-engine\">Strategy 1: Delegate to the Native Form Engine<\/h3>\n\n\n\n<p>If a component renders a native <code>&lt;input&gt;<\/code>, <code>&lt;textarea&gt;<\/code>, or <code>&lt;select&gt;<\/code> in its Shadow DOM, don&#8217;t reimplement the wheel. Just delegate to it. That inner element already knows how to run the browser&#8217;s full constraint validation engine, giving you <code>required<\/code>, <code>minlength<\/code>, and <code>pattern<\/code> for free. To keep things DRY across <code>AgInput<\/code>, <code>AgSelect<\/code>, and <code>AgCheckbox<\/code>, we use a single utility helper, <code>syncInnerInputValidity<\/code>, to bridge that internal state to our host element.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">syncInnerInputValidity<\/span>(<span class=\"hljs-params\">\n  internals: ElementInternals,\n  inputEl:\n    | HTMLInputElement\n    | HTMLTextAreaElement\n    | HTMLSelectElement\n    | <span class=\"hljs-literal\">null<\/span>\n    | <span class=\"hljs-literal\">undefined<\/span>,\n<\/span>): <span class=\"hljs-title\">void<\/span> <\/span>{\n  <span class=\"hljs-keyword\">if<\/span> (!inputEl) <span class=\"hljs-keyword\">return<\/span>;\n\n  <span class=\"hljs-keyword\">if<\/span> (!inputEl.validity.valid) {\n    <span class=\"hljs-comment\">\/\/ We pass the inputEl as the \"anchor\" so the browser<\/span>\n    <span class=\"hljs-comment\">\/\/ knows where to point the validation bubble.<\/span>\n    internals.setValidity(inputEl.validity, inputEl.validationMessage, inputEl);\n  } <span class=\"hljs-keyword\">else<\/span> {\n    internals.setValidity({});\n  }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>The third argument to <code>setValidity<\/code> is the <strong>validation target<\/strong>. It tells the browser where to render the native validation bubble. By passing the inner <code>&lt;input&gt;<\/code>, the tooltip appears in the correct place (rather than floating awkwardly over the custom element&#8217;s host boundary).<\/p>\n\n\n\n<p>AgnosticUI components that use this pattern: <code>AgInput<\/code>, <code>AgCheckbox<\/code>, <code>AgSelect<\/code>.<\/p>\n\n\n\n<p class=\"learn-more\"><strong>The AgRadio Caveat:<\/strong> Even though AgRadio renders an inner <code>&lt;input type=\"radio\"&gt;<\/code>, delegation isn&#8217;t enough. Shadow DOM isolation actually breaks the native <code>required<\/code> constraint for radio groups. I&#8217;ll explain how to handle that specifically in the AgRadio section below.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"strategy-2-implement-directly-against-component-state\">Strategy 2: Implement Directly Against Component State<\/h3>\n\n\n\n<p>If a component uses a custom widget like <code>AgToggle<\/code> (which uses a <code>&lt;button role=\"switch\"&gt;<\/code>), there&#8217;s no native input to delegate to. In these cases, we have to implement <code>_syncValidity()<\/code> directly against the component&#8217;s reactive state.<\/p>\n\n\n\n<p>While we use the <code>required<\/code> attribute in our component&#8217;s API, the browser&#8217;s internal validation engine tracks this failure as <code>valueMissing<\/code> so we use that in the call to <code>setValidity<\/code>.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-comment\">\/\/ AgToggle example<\/span>\n<span class=\"hljs-keyword\">private<\/span> _syncValidity(): <span class=\"hljs-built_in\">void<\/span> {\n  <span class=\"hljs-keyword\">if<\/span> (<span class=\"hljs-keyword\">this<\/span>.required &amp;&amp; !<span class=\"hljs-keyword\">this<\/span>.checked) {\n    <span class=\"hljs-comment\">\/\/ Note: In a production library, 'validationMessage' should be a<\/span>\n    <span class=\"hljs-comment\">\/\/ localized property rather than a hard-coded string.<\/span>\n    <span class=\"hljs-keyword\">this<\/span>._internals.setValidity({ valueMissing: <span class=\"hljs-literal\">true<\/span> }, <span class=\"hljs-keyword\">this<\/span>.validationMessage);\n  } <span class=\"hljs-keyword\">else<\/span> {\n    <span class=\"hljs-keyword\">this<\/span>._internals.setValidity({});\n  }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>For a required switch or checkbox, <code>valueMissing<\/code> is typically the only constraint that applies. A more complex custom component such as a range slider might, for example, account for flags like <code>rangeUnderflow<\/code> or <code>stepMismatch<\/code>.<\/p>\n\n\n\n<p><strong>The rule:<\/strong> If the component renders an inner native form control (like an <code>input<\/code>, <code>textarea<\/code>, or <code>select<\/code>), delegate. If it&#8217;s a custom-built widget like <code>AgToggle<\/code>, we own the validation logic.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"how-to-verify-its-working\">How to Verify it&#8217;s Working<\/h2>\n\n\n\n<p>The fastest way to confirm your FACE implementation is wired up correctly is to run a few manual &#8220;smoke tests&#8221; directly in the browser.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"1-check-formdata-on-submit\">1. Check FormData on Submit<\/h3>\n\n\n\n<p>The ultimate proof is in the <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/FormData\">FormData<\/a> object itself. Hook into a form&#8217;s submit event and log the <code>entries<\/code>:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\">form.addEventListener(<span class=\"hljs-string\">\"submit\"<\/span>, (e) =&gt; {\n  e.preventDefault();\n  <span class=\"hljs-keyword\">const<\/span> data = <span class=\"hljs-built_in\">Object<\/span>.fromEntries(<span class=\"hljs-keyword\">new<\/span> FormData(e.target).entries());\n  <span class=\"hljs-built_in\">console<\/span>.log(data);\n});<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>If your component&#8217;s <code>name<\/code> and <code>value<\/code> are missing from that object, one of three things happened: <code>setFormValue()<\/code> wasn&#8217;t called, <code>formAssociated<\/code> is missing, or the component doesn&#8217;t have a <code>name<\/code> attribute set in the DOM.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"2-inspect-via-the-devtools-console\">2. Inspect via the DevTools Console<\/h3>\n\n\n\n<p>Select your component in the <strong>Elements<\/strong> panel so it becomes <code>$0<\/code> in the console, then run these checks:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\">$<span class=\"hljs-number\">0.<\/span>form; <span class=\"hljs-comment\">\/\/ Should return the parent &lt;form&gt;, not undefined<\/span>\n$<span class=\"hljs-number\">0.<\/span>willValidate; <span class=\"hljs-comment\">\/\/ Should return true<\/span>\n$<span class=\"hljs-number\">0.<\/span>validity.valid; <span class=\"hljs-comment\">\/\/ Should reflect the current validation state<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>If <code>$0.form<\/code> returns <code>null<\/code>, the element isn&#8217;t form-associated. This usually means <code>static formAssociated = true<\/code> is missing or <code>attachInternals()<\/code> wasn&#8217;t called in the constructor.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"3-verify-form-participation\">3. Verify Form Participation<\/h3>\n\n\n\n<p>Finally, check if the form itself &#8220;sees&#8221; your component as one of its controls:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-built_in\">Array<\/span>.from(<span class=\"hljs-built_in\">document<\/span>.querySelector(<span class=\"hljs-string\">\"form\"<\/span>).elements).map(<span class=\"hljs-function\">(<span class=\"hljs-params\">el<\/span>) =&gt;<\/span> el.tagName);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Your custom elements should appear in this list alongside native inputs.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"component-walkthroughs\">Component Walkthroughs<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"aginput-the-reference-implementation\">AgInput: The Reference Implementation<\/h3>\n\n\n\n<p><code>AgInput<\/code> established the pattern for the rest of the library. It&#8217;s a textbook example of <strong>Strategy 1: Delegation.<\/strong><\/p>\n\n\n\n<p><strong>Value submission:<\/strong> We call <code>_internals.setFormValue(this.value)<\/code> in three places: the <code>input<\/code> handler (every keystroke), the <code>change<\/code> handler (on commit), and during <code>firstUpdated<\/code>, Lit&#8217;s lifecycle hook that runs after the component&#8217;s first render. Syncing on <code>firstUpdated<\/code> is critical, as without it, the form doesn&#8217;t know the initial value until the user clicks into the field.<\/p>\n\n\n\n<p><strong>Validation (The Delegation Path):<\/strong> Because <code>AgInput<\/code> renders a native <code>&lt;input&gt;<\/code>, we don&#8217;t need to write custom logic for <code>required<\/code> or <code>minlength<\/code>. We simply point <code>ElementInternals<\/code> at the inner element\u2019s state.<\/p>\n\n\n\n<p class=\"learn-more\"><strong>Wait, what if I don&#8217;t have a native input?<\/strong> If you were building a custom slider or a star-rating component (Strategy 2), you wouldn&#8217;t &#8220;sync&#8221; from an inner element. Instead, you would manually call <code>this._internals.setValidity({ valueMissing: true }, \"Message\")<\/code> inside your own property setters (like <code>set value()<\/code>).<\/p>\n\n\n\n<p><strong>Accessible error messages:<\/strong> The error container in <code>AgInput<\/code> uses <code>role=\"alert\"<\/code> and <code>aria-atomic=\"true\"<\/code>. The container is <strong>always<\/strong> in the DOM. We only swap out its text content when an error occurs. This matters because screen readers register the alert region on page load. If you show and hide the whole element instead, screen reader announcements become unreliable.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"agtoggle-the-checkbox-pattern-component\">AgToggle: The Checkbox-Pattern Component<\/h3>\n\n\n\n<p><code>AgToggle<\/code> differs from text inputs in two ways.<\/p>\n\n\n\n<p><strong>Null form value:<\/strong> A native checkbox that is unchecked is simply absent from <code>FormData<\/code>, not an empty string. Passing <code>null<\/code> to <code>setFormValue<\/code> replicates this:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">this<\/span>._internals.setFormValue(<span class=\"hljs-keyword\">this<\/span>.checked ? <span class=\"hljs-keyword\">this<\/span>.value || <span class=\"hljs-string\">\"on\"<\/span> : <span class=\"hljs-literal\">null<\/span>);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>The <code>'on'<\/code> default matches native checkbox behavior when no <code>value<\/code> attribute is set and the checkbox is checked. For any server processing form submissions, a missing key and an empty-string key are handled differently.<\/p>\n\n\n\n<p><strong>Direct validity:<\/strong> Only <code>required<\/code> applies. There is no inner <code>&lt;input&gt;<\/code> to delegate to, so we implement validity directly against <code>this.checked<\/code> (called in <code>_performToggle()<\/code> on every state change).<\/p>\n\n\n\n<p class=\"learn-more\"><strong>The <code>value<\/code> property default:<\/strong> The component uses <code>this.value || 'on'<\/code> so that <code>FormData<\/code> always produces <code>'on'<\/code> when no explicit value is configured. The property itself defaults to <code>''<\/code>. This keeps form submission behavior correct while the property API stays clean.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"agcheckbox-where-the-two-strategies-meet\">AgCheckbox: Where the Two Strategies Meet<\/h3>\n\n\n\n<p><code>AgCheckbox<\/code> is perhaps the most instructive component in the rollout because it highlights exactly why we need FACE even when we are using native inputs encapsulated within a Shadow DOM.<\/p>\n\n\n\n<p><strong>Shadow DOM inputs are invisible to parent forms.<\/strong> Here&#8217;s the deal: an <code>&lt;input&gt;<\/code> rendered inside a Shadow Root is isolated from the parent document, so even with a <code>name<\/code> and a <code>value<\/code>, it will <strong>never<\/strong> appear in the parent form&#8217;s <code>FormData<\/code>. This isolation is why FACE isn&#8217;t an optional feature; it&#8217;s a requirement for any UI library or design system using Shadow DOM.<\/p>\n\n\n\n<p><strong>Delegation still works.<\/strong> Despite the isolation, that inner checkbox still has a native <code>.validity<\/code> object. We can still use <strong>Strategy 1<\/strong> by mirroring those properties to the host:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">private<\/span> _syncValidity(): <span class=\"hljs-built_in\">void<\/span> {\n  <span class=\"hljs-comment\">\/\/ Our utility helper doesn't care if it's an input, textarea, select, or checkbox<\/span>\n  syncInnerInputValidity(<span class=\"hljs-keyword\">this<\/span>._internals, <span class=\"hljs-keyword\">this<\/span>.inputRef);\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p><strong>Syncing programmatic changes.<\/strong> Unlike <code>AgInput<\/code>, where a user usually types to change the value, a checkbox is often toggled programmatically. Think of &#8220;Select All&#8221; buttons or state-driven resets. To make the component reliable, we have to handle both cases: the user&#8217;s manual click and the developer&#8217;s code. If we don&#8217;t sync on both paths, the form data won&#8217;t match what the user sees on the screen.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-comment\">\/\/ 1. User interaction path<\/span>\nhandleChange(e: Event) {\n  <span class=\"hljs-keyword\">this<\/span>.checked = (e.target <span class=\"hljs-keyword\">as<\/span> HTMLInputElement).checked;\n  <span class=\"hljs-keyword\">this<\/span>._updateFormValue();\n}\n\n<span class=\"hljs-comment\">\/\/ 2. Programmatic path<\/span>\noverride updated(changedProperties: PropertyValues) {\n  <span class=\"hljs-keyword\">super<\/span>.updated(changedProperties);\n  <span class=\"hljs-keyword\">if<\/span> (changedProperties.has(<span class=\"hljs-string\">'checked'<\/span>)) {\n    <span class=\"hljs-keyword\">this<\/span>._updateFormValue();\n  }\n}\n\n<span class=\"hljs-keyword\">private<\/span> _updateFormValue() {\n  <span class=\"hljs-keyword\">this<\/span>._internals.setFormValue(<span class=\"hljs-keyword\">this<\/span>.checked ? (<span class=\"hljs-keyword\">this<\/span>.value || <span class=\"hljs-string\">'on'<\/span>) : <span class=\"hljs-literal\">null<\/span>);\n  <span class=\"hljs-keyword\">this<\/span>._syncValidity();\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h3 class=\"wp-block-heading\" id=\"agselect-multi-value-and-the-formdata-overload\">AgSelect: Multi-Value and the FormData Overload<\/h3>\n\n\n\n<p>It&#8217;s important to note that <code>AgSelect<\/code> is a direct wrapper around the native <code>&lt;select&gt;<\/code> element. Unlike &#8220;custom&#8221; dropdowns that use divs and ARIA lists, <code>AgSelect<\/code> uses the platform&#8217;s native control. This allows us to leverage native properties that would be difficult to track manually.<\/p>\n\n\n\n<p><strong>Handling multiple values:<\/strong> <code>setFormValue()<\/code> has three overloads. While a string works for most components, <code>multiple<\/code> select requires the <code>FormData<\/code> overload. By passing a <code>FormData<\/code> object to <code>setFormValue<\/code>, you&#8217;re providing a list of entries that the browser will automatically &#8220;spread&#8221; into the parent form&#8217;s master collection at submission time.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-11\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">private<\/span> _syncFormValue(): <span class=\"hljs-built_in\">void<\/span> {\n  <span class=\"hljs-keyword\">if<\/span> (!<span class=\"hljs-keyword\">this<\/span>.selectElement) <span class=\"hljs-keyword\">return<\/span>;\n  <span class=\"hljs-keyword\">if<\/span> (<span class=\"hljs-keyword\">this<\/span>.multiple) {\n    <span class=\"hljs-keyword\">const<\/span> formData = <span class=\"hljs-keyword\">new<\/span> FormData();\n    <span class=\"hljs-built_in\">Array<\/span>.from(<span class=\"hljs-keyword\">this<\/span>.selectElement.selectedOptions).forEach(<span class=\"hljs-function\"><span class=\"hljs-params\">opt<\/span> =&gt;<\/span> {\n      <span class=\"hljs-comment\">\/\/ The browser merges these entries into the parent form's data<\/span>\n      formData.append(<span class=\"hljs-keyword\">this<\/span>.name, opt.value);\n    });\n    <span class=\"hljs-keyword\">this<\/span>._internals.setFormValue(formData);\n  } <span class=\"hljs-keyword\">else<\/span> {\n    <span class=\"hljs-keyword\">this<\/span>._internals.setFormValue(<span class=\"hljs-keyword\">this<\/span>.selectElement.value || <span class=\"hljs-string\">''<\/span>);\n  }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-11\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p><strong>Resetting to the original selection:<\/strong> Native elements have a built-in memory of their initial state. <code>option.defaultSelected<\/code> reflects the <code>selected<\/code> attribute as it was originally parsed from HTML. It&#8217;s the perfect source of truth for our <code>formResetCallback<\/code>:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-12\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\">override formResetCallback(): <span class=\"hljs-built_in\">void<\/span> {\n  <span class=\"hljs-keyword\">if<\/span> (<span class=\"hljs-keyword\">this<\/span>.selectElement) {\n    <span class=\"hljs-built_in\">Array<\/span>.from(<span class=\"hljs-keyword\">this<\/span>.selectElement.options)\n      .forEach(<span class=\"hljs-function\"><span class=\"hljs-params\">opt<\/span> =&gt;<\/span> (opt.selected = opt.defaultSelected));\n  }\n  <span class=\"hljs-keyword\">this<\/span>._syncFormValue();\n  <span class=\"hljs-keyword\">this<\/span>._internals.setValidity({});\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-12\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>This ensures that hitting &#8220;Reset&#8221; restores the form to its original HTML state, matching the exact behavior users expect.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"agradio-coordination-across-shadow-boundaries\">AgRadio: Coordination Across Shadow Boundaries<\/h3>\n\n\n\n<p>Radio groups require coordination: when one is selected, others must deselect. While native <code>&lt;input type=\"radio\"&gt;<\/code> handles this automatically, elements isolated in separate Shadow DOM trees are &#8220;blind&#8221; to their siblings. This breaks everything from value syncing to <code>required<\/code> validation.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">The Strategy: Tapping into Lit&#8217;s Reactive Loop<\/h4>\n\n\n\n<p>We don&#8217;t need a complex messaging system. When an <code>AgRadio<\/code> is checked, it finds other <code>&lt;ag-radio&gt;<\/code> instances with the same <code>name<\/code> and sets <code>instance.checked = false<\/code>.<\/p>\n\n\n\n<p>Crucially, this isn&#8217;t &#8220;magic.&#8221; Because <code>checked<\/code> is a Lit <code>@property<\/code>, this manual assignment triggers the <code>updated()<\/code> <a href=\"https:\/\/lit.dev\/docs\/components\/lifecycle\/\">lifecycle<\/a> on every radio in the group. We then <strong>tap into that lifecycle<\/strong> to run our glue code, explicitly calling <code>setFormValue()<\/code> and <code>_syncValidity()<\/code> to push the new state into the <code>ElementInternals<\/code> engine:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-13\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\">override updated(changedProperties: PropertyValues) {\n  <span class=\"hljs-keyword\">super<\/span>.updated(changedProperties);\n  <span class=\"hljs-comment\">\/\/ This is the \"glue\": Lit tells us something changed,<\/span>\n  <span class=\"hljs-comment\">\/\/ and we manually inform the browser's form engine.<\/span>\n  <span class=\"hljs-keyword\">if<\/span> (changedProperties.has(<span class=\"hljs-string\">'checked'<\/span>)) {\n    <span class=\"hljs-keyword\">this<\/span>._internals.setFormValue(<span class=\"hljs-keyword\">this<\/span>.checked ? <span class=\"hljs-keyword\">this<\/span>.value : <span class=\"hljs-literal\">null<\/span>);\n    <span class=\"hljs-keyword\">this<\/span>._syncValidity();\n  }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-13\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h4 class=\"wp-block-heading\">The Trap: <code>required<\/code> and Shadow Isolation<\/h4>\n\n\n\n<p>This is the biggest &#8220;gotcha.&#8221; Normally, a browser knows a <code>required<\/code> radio group is valid if <em>any<\/em> radio is checked. But because our inner inputs are isolated in separate Shadow Roots, the browser can&#8217;t see the group. Each unchecked radio will incorrectly report <code>valueMissing: true<\/code>.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">The &#8220;Where am I?&#8221; Problem: Global vs. Encapsulated Scopes<\/h4>\n\n\n\n<p>To understand why we need <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Node\/getRootNode\"><code>this.getRootNode()<\/code><\/a>, we have to look at where our <code>&lt;ag-radio&gt;<\/code> tags are actually being placed. It isn&#8217;t about the framework&#8217;s internal engine; it&#8217;s about whether the tags are sitting in the global document or inside a private &#8220;neighborhood&#8221;:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>The Global Scope (React\/Vue\/Static HTML):<\/strong> You are usually placing <code>&lt;ag-radio&gt;<\/code> tags directly into the main page. Here, <code>document.querySelectorAll<\/code> works fine because everything is &#8220;on the main street.&#8221;<\/li>\n\n\n\n<li><strong>The Encapsulated Scope (Svelte, Solid, Lit, or Vanilla WC):<\/strong> If you build a component that uses its own Shadow DOM, any <code>ag-radio<\/code> you place inside it is hidden from the outside world. Even in Svelte or Solid, the <strong>Web Components Shadow Root<\/strong> acts as a barrier that the global <code>document<\/code> cannot pierce.<\/li>\n<\/ul>\n\n\n\n<h4 class=\"wp-block-heading\">The Fix: Group-Aware Validation with <code>getRootNode()<\/code><\/h4>\n\n\n\n<p>We have to manually verify the group state. Instead of asking the global <code>document<\/code>, we ask the element: &#8220;What is the root of the neighborhood I live in?&#8221; We use <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Node\/getRootNode\"><code>this.getRootNode()<\/code><\/a> to find that root.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>In Global Scopes:<\/strong> It returns the <code>document<\/code>.<\/li>\n\n\n\n<li><strong>In Encapsulated Components:<\/strong> It returns that parent&#8217;s <code>ShadowRoot<\/code>.<\/li>\n<\/ul>\n\n\n\n<p>By querying that local root, we find our siblings regardless of how many layers of nesting are involved.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-14\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">private<\/span> _isGroupChecked(): <span class=\"hljs-built_in\">boolean<\/span> {\n  <span class=\"hljs-keyword\">if<\/span> (<span class=\"hljs-keyword\">this<\/span>.checked) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-literal\">true<\/span>;\n\n  <span class=\"hljs-comment\">\/\/ Find our \"neighborhood\" (either the Document or a ShadowRoot)<\/span>\n  <span class=\"hljs-keyword\">const<\/span> root = <span class=\"hljs-keyword\">this<\/span>.getRootNode() <span class=\"hljs-keyword\">as<\/span> Document | ShadowRoot;\n\n  <span class=\"hljs-comment\">\/\/ Now we can find all radios sharing our scope and `name`<\/span>\n  <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-built_in\">Array<\/span>.from(root.querySelectorAll(<span class=\"hljs-string\">`ag-radio&#91;name=\"<span class=\"hljs-subst\">${<span class=\"hljs-keyword\">this<\/span>.name}<\/span>\"]`<\/span>))\n    .some(<span class=\"hljs-function\">(<span class=\"hljs-params\"><span class=\"hljs-params\">el<\/span><\/span>) =&gt;<\/span> (el <span class=\"hljs-keyword\">as<\/span> AgRadio).checked);\n}\n\n<span class=\"hljs-keyword\">private<\/span> _syncValidity(): <span class=\"hljs-built_in\">void<\/span> {\n  <span class=\"hljs-keyword\">if<\/span> (!<span class=\"hljs-keyword\">this<\/span>.required) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">this<\/span>._internals.setValidity({});\n\n  <span class=\"hljs-keyword\">if<\/span> (<span class=\"hljs-keyword\">this<\/span>._isGroupChecked()) {\n    <span class=\"hljs-keyword\">this<\/span>._internals.setValidity({});\n  } <span class=\"hljs-keyword\">else<\/span> {\n    <span class=\"hljs-keyword\">this<\/span>._internals.setValidity({ valueMissing: <span class=\"hljs-literal\">true<\/span> }, <span class=\"hljs-keyword\">this<\/span>.validationMessage);\n  }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-14\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h4 class=\"wp-block-heading\">The Final Edge Case: Forcing Sync<\/h4>\n\n\n\n<p>If you set <code>radio.checked = false<\/code> on a sibling that was <em>already<\/em> false, Lit&#8217;s <code>updated()<\/code> won&#8217;t fire. But that sibling still needs to re-run <code>_syncValidity()<\/code> because the group state just changed. We have to force the sync manually:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-15\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\">allRadios.forEach(<span class=\"hljs-function\">(<span class=\"hljs-params\"><span class=\"hljs-params\">radio<\/span><\/span>) =&gt;<\/span> {\n  <span class=\"hljs-keyword\">if<\/span> (radio !== <span class=\"hljs-keyword\">this<\/span> &amp;&amp; radio <span class=\"hljs-keyword\">instanceof<\/span> AgRadio) {\n    radio.checked = <span class=\"hljs-literal\">false<\/span>;\n    <span class=\"hljs-comment\">\/\/ Force a re-sync because Lit won't trigger updated()<\/span>\n    <span class=\"hljs-comment\">\/\/ if the value was already false.<\/span>\n    radio._syncValidity();\n  }\n});<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-15\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h3 class=\"wp-block-heading\" id=\"agslider-migrating-hand-rolled-face-to-the-mixin\">AgSlider: Migrating Hand-Rolled FACE to the Mixin<\/h3>\n\n\n\n<p>AgSlider already had a partial, hand-rolled FACE infrastructure. It manually declared <code>static formAssociated<\/code>, called <code>attachInternals()<\/code>, and featured a custom <code>_updateFormValue()<\/code> method alongside six different getters for <code>form<\/code> and <code>validity<\/code>.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">The Problem: Missing the Lifecycle<\/h4>\n\n\n\n<p>Because it didn&#8217;t use our <code>FaceMixin<\/code>, it was missing critical browser integrations: <code>formDisabledCallback<\/code> (for <code>&lt;fieldset&gt;<\/code> propagation) and <code>formResetCallback<\/code> (for <code>form.reset()<\/code> support). It also failed to set its initial form value on boot.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">The Migration: Deletion as a Feature<\/h4>\n\n\n\n<p>The refactor resulted in removing the manual <code>_internals<\/code> field, the constructor-based <code>attachInternals()<\/code>, and all six hand-rolled getters. <code>FaceMixin<\/code> now provides all of that out of the box.<\/p>\n\n\n\n<p>We then added the &#8220;missing links&#8221; to handle initial state and resets:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-16\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\">override firstUpdated() {\n  <span class=\"hljs-comment\">\/\/ Capture the initial state provided by the consumer<\/span>\n  <span class=\"hljs-keyword\">this<\/span>._defaultValue = <span class=\"hljs-built_in\">Array<\/span>.isArray(<span class=\"hljs-keyword\">this<\/span>.value)\n    ? (&#91;...this.value] <span class=\"hljs-keyword\">as<\/span> &#91;<span class=\"hljs-built_in\">number<\/span>, <span class=\"hljs-built_in\">number<\/span>])\n    : <span class=\"hljs-keyword\">this<\/span>.value;\n\n  <span class=\"hljs-keyword\">this<\/span>._updateFormValue();\n}\n\noverride formResetCallback(): <span class=\"hljs-built_in\">void<\/span> {\n  <span class=\"hljs-comment\">\/\/ Restore the captured default value<\/span>\n  <span class=\"hljs-keyword\">this<\/span>.value = <span class=\"hljs-built_in\">Array<\/span>.isArray(<span class=\"hljs-keyword\">this<\/span>._defaultValue)\n    ? (&#91;...this._defaultValue] <span class=\"hljs-keyword\">as<\/span> &#91;<span class=\"hljs-built_in\">number<\/span>, <span class=\"hljs-built_in\">number<\/span>])\n    : <span class=\"hljs-keyword\">this<\/span>._defaultValue;\n\n  <span class=\"hljs-keyword\">this<\/span>._updateFormValue();\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-16\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h4 class=\"wp-block-heading\">Why <code>firstUpdated<\/code>?<\/h4>\n\n\n\n<p>We capture <code>this._defaultValue<\/code> here because it&#8217;s the first moment we can be sure the component has processed its initial properties. By shallow-copying the array for dual mode, we ensure that future movements of the slider don&#8217;t accidentally mutate our &#8220;save point&#8221; for the form reset.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">The Dual-Slider<\/h4>\n\n\n\n<p>The existing <code>_updateFormValue()<\/code> utilized a sophisticated <code>ElementInternals<\/code> feature: the <strong>FormData overload<\/strong>. In dual-slider mode, we need to submit both a <code>min<\/code> and <code>max<\/code> value under a single <code>name<\/code> key.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-17\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ A peek at the existing logic inside _updateFormValue<\/span>\n<span class=\"hljs-keyword\">const<\/span> data = <span class=\"hljs-keyword\">new<\/span> FormData();\ndata.append(<span class=\"hljs-keyword\">this<\/span>.name, <span class=\"hljs-built_in\">String<\/span>(<span class=\"hljs-keyword\">this<\/span>.value&#91;<span class=\"hljs-number\">0<\/span>]));\ndata.append(<span class=\"hljs-keyword\">this<\/span>.name, <span class=\"hljs-built_in\">String<\/span>(<span class=\"hljs-keyword\">this<\/span>.value&#91;<span class=\"hljs-number\">1<\/span>]));\n<span class=\"hljs-keyword\">this<\/span>._internals.setFormValue(data);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-17\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h3 class=\"wp-block-heading\" id=\"agrating-direct-validity-no-native-element\">AgRating: Direct Validity, No Native Element<\/h3>\n\n\n\n<p>AgRating uses a custom <code>role=\"slider\"<\/code> div: there is no inner <code>&lt;input&gt;<\/code> at all. Like AgToggle, this means we must implement <code>_syncValidity()<\/code> directly on the host element. A rating of <code>0<\/code> is treated as the unselected state. We explicitly map this to the browser&#8217;s <code>valueMissing<\/code> state so that <code>required<\/code> validation works as expected.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-18\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">private<\/span> _syncValidity(): <span class=\"hljs-built_in\">void<\/span> {\n  <span class=\"hljs-keyword\">if<\/span> (<span class=\"hljs-keyword\">this<\/span>.required &amp;&amp; <span class=\"hljs-keyword\">this<\/span>.value === <span class=\"hljs-number\">0<\/span>) {\n    <span class=\"hljs-keyword\">this<\/span>._internals.setValidity({ valueMissing: <span class=\"hljs-literal\">true<\/span> }, <span class=\"hljs-keyword\">this<\/span>.validationMessage);\n  } <span class=\"hljs-keyword\">else<\/span> {\n    <span class=\"hljs-keyword\">this<\/span>._internals.setValidity({});\n  }\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-18\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"learn-more\"><strong>Note: Why treat 0 as null?<\/strong> In a 5-star system, <code>0<\/code> usually means &#8220;unselected&#8221; rather than a score of zero. By submitting <code>null<\/code> for a <code>0<\/code> value, we ensure the field is omitted from the form payload. This allows the server to distinguish between an intentional score and a skipped field.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">The Unified Update Path<\/h4>\n\n\n\n<p>In AgRating, all user interactions flow through the <code>commitValue()<\/code> method. This includes clicks, pointer events, and keyboard interactions. By wiring the FACE synchronization here, we ensure that every manual change is immediately reflected in the form state.<\/p>\n\n\n\n<p>We also include a synchronization call in the <code>updated()<\/code> lifecycle to handle programmatic changes. These two points of contact keep the internal form state and the visual UI in lock-step without the need for complex event listeners.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-19\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-comment\">\/\/ Inside AgRating<\/span>\ncommitValue(val: <span class=\"hljs-built_in\">number<\/span>) {\n  <span class=\"hljs-keyword\">this<\/span>.value = val;\n  <span class=\"hljs-keyword\">this<\/span>._updateFormValue(); <span class=\"hljs-comment\">\/\/ Syncs to ElementInternals<\/span>\n}\n\noverride updated(changedProperties: PropertyValues) {\n  <span class=\"hljs-keyword\">super<\/span>.updated(changedProperties);\n  <span class=\"hljs-keyword\">if<\/span> (changedProperties.has(<span class=\"hljs-string\">'value'<\/span>)) {\n    <span class=\"hljs-keyword\">this<\/span>._updateFormValue(); <span class=\"hljs-comment\">\/\/ Syncs programmatic changes<\/span>\n    <span class=\"hljs-keyword\">this<\/span>._syncValidity();\n  }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-19\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h3 class=\"wp-block-heading\" id=\"selectionbuttongroup-and-selectioncardgroup-face-on-the-coordinator\">SelectionButtonGroup and SelectionCardGroup: FACE on the Coordinator<\/h3>\n\n\n\n<p>Selection groups are composite widgets: they consist of individual buttons or cards inside a coordinating group element. The group is the brain, not the items. The group element manages the <code>name<\/code>, the <code>type<\/code> (<code>radio<\/code> vs. <code>checkbox<\/code>), and the full set of selected values.<\/p>\n\n\n\n<p>This follows the same model as the native <code>&lt;select&gt;<\/code> element. The options are not form-associated; the select is. Both groups use a <code>type<\/code> property to determine form value semantics:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-20\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">private<\/span> _syncFormValue(): <span class=\"hljs-built_in\">void<\/span> {\n  <span class=\"hljs-keyword\">const<\/span> selected = <span class=\"hljs-keyword\">this<\/span>._getSelectedValues();\n  <span class=\"hljs-keyword\">if<\/span> (<span class=\"hljs-keyword\">this<\/span>.type === <span class=\"hljs-string\">'radio'<\/span>) {\n    <span class=\"hljs-comment\">\/\/ Single value or null<\/span>\n    <span class=\"hljs-keyword\">this<\/span>._internals.setFormValue(selected.length &gt; <span class=\"hljs-number\">0<\/span> ? selected&#91;<span class=\"hljs-number\">0<\/span>] : <span class=\"hljs-literal\">null<\/span>);\n  } <span class=\"hljs-keyword\">else<\/span> {\n    <span class=\"hljs-comment\">\/\/ Multiple values via FormData overload<\/span>\n    <span class=\"hljs-keyword\">if<\/span> (selected.length === <span class=\"hljs-number\">0<\/span>) {\n      <span class=\"hljs-keyword\">this<\/span>._internals.setFormValue(<span class=\"hljs-literal\">null<\/span>);\n    } <span class=\"hljs-keyword\">else<\/span> {\n      <span class=\"hljs-keyword\">const<\/span> formData = <span class=\"hljs-keyword\">new<\/span> FormData();\n      selected.forEach(<span class=\"hljs-function\"><span class=\"hljs-params\">val<\/span> =&gt;<\/span> formData.append(<span class=\"hljs-keyword\">this<\/span>.name, val));\n\n      <span class=\"hljs-comment\">\/\/ The browser merges these entries into the parent form's data<\/span>\n      <span class=\"hljs-keyword\">this<\/span>._internals.setFormValue(formData);\n    }\n  }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-20\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>The <code>formResetCallback<\/code> (not shown) handles the cleanup. It clears internal values, resets the form value to null, and triggers <code>_syncValidity()<\/code>. This ensures a required group correctly reports as invalid after a reset while updating child elements so the UI reflects the cleared state immediately.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"agcombobox-managing-selection-vs-search\">AgCombobox: Managing Selection vs. Search<\/h3>\n\n\n\n<p>AgCombobox can appear complex because it manages a text input, a filtered dropdown, and a multi-tag UI. However, the form value logic is remarkably stable: only a committed selection counts as the value. While a user types, the <code>_searchTerm<\/code> state updates to filter the list, but <code>this.value<\/code> remains untouched until an option is explicitly selected.<\/p>\n\n\n\n<p>To bridge this with <code>ElementInternals<\/code>, we synchronized the state across the two primary interaction paths:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-21\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-comment\">\/\/ Picking an item from the list<\/span>\nselectOption(optionOrValue: ComboboxOption | <span class=\"hljs-built_in\">string<\/span>) {\n  <span class=\"hljs-comment\">\/\/ ... logic to update _selectedOptions<\/span>\n  <span class=\"hljs-keyword\">this<\/span>._selectionChanged(); <span class=\"hljs-comment\">\/\/ This updates this.value<\/span>\n\n  <span class=\"hljs-comment\">\/\/ FACE: sync form value and validity after selection<\/span>\n  <span class=\"hljs-keyword\">this<\/span>._syncFormValue();\n  <span class=\"hljs-keyword\">this<\/span>._syncValidity();\n}\n\n<span class=\"hljs-comment\">\/\/ Clearing the selection<\/span>\nclearSelection() {\n  <span class=\"hljs-keyword\">this<\/span>._selectedOptions = &#91;];\n  <span class=\"hljs-keyword\">this<\/span>._selectionChanged();\n\n  <span class=\"hljs-comment\">\/\/ FACE: sync form value and validity on clear<\/span>\n  <span class=\"hljs-keyword\">this<\/span>._syncFormValue();\n  <span class=\"hljs-keyword\">this<\/span>._syncValidity();\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-21\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h4 class=\"wp-block-heading\">The Update Override<\/h4>\n\n\n\n<p>Like our other complex components, we use the <code>updated()<\/code> lifecycle as a safety net for programmatic changes. If a developer sets something like <code>combobox.value = 'CSS'<\/code> via JavaScript, the component detects the property change and triggers the synchronization logic.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-22\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\">override updated(changedProperties: Map&lt;<span class=\"hljs-built_in\">string<\/span>, unknown&gt;) {\n  <span class=\"hljs-keyword\">super<\/span>.updated(changedProperties);\n  <span class=\"hljs-keyword\">if<\/span> (changedProperties.has(<span class=\"hljs-string\">'value'<\/span>)) {\n    <span class=\"hljs-keyword\">this<\/span>._syncFormValue();\n    <span class=\"hljs-keyword\">this<\/span>._syncValidity();\n  }\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-22\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h4 class=\"wp-block-heading\">Handling Resets<\/h4>\n\n\n\n<p>The <code>formResetCallback<\/code> ensures the component returns to a clean state when a form is cleared. It nulls the internal selection, clears the form value, and resets the validity state so that a <code>required<\/code> combobox doesn&#8217;t stay in an &#8220;invalid&#8221; state after the user clicks reset.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"the-reality-of-elementinternals\">The Reality of <code>ElementInternals<\/code><\/h2>\n\n\n\n<p>If there is one elephant in the room after this migration, it is this: implementing FACE is a significant undertaking. It is a necessary evil for anyone building a robust web component system. While it provides the magic of native form integration, it requires manual wiring for every state: validation, disabled states, and value syncing. These are features that we often take for granted in framework-specific components.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"technical-gotchas\">Technical Gotchas<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong><code>formAssociated = true<\/code> is just an invitation.<\/strong> Setting this property only &#8220;opens the door.&#8221; Values do not appear in <code>FormData<\/code> until you call <code>setFormValue()<\/code>. Validation does not work until you call <code>setValidity()<\/code>. Nothing happens automatically.<\/li>\n\n\n\n<li><strong>Shadow DOM is invisible to Forms.<\/strong> A native <code>&lt;input&gt;<\/code> inside a shadow root is invisible to an ancestor <code>&lt;form&gt;<\/code>. Using <code>setFormValue()<\/code> on the host element is the only way to create that connection.<\/li>\n\n\n\n<li><strong>The Submit Button Bridge.<\/strong> Discovered during consumer testing, this issue highlights a specific shadow DOM limitation. A button inside a shadow root cannot trigger a parent form submission. We implemented a light DOM traversal using <code>this.closest('form').requestSubmit()<\/code> to bridge that gap.<\/li>\n\n\n\n<li><strong>Disabled states have two masters.<\/strong> The <code>formDisabledCallback<\/code> only fires when an ancestor, such as a <code>&lt;fieldset&gt;<\/code>, is disabled. It does not fire when the element&#8217;s own <code>disabled<\/code> attribute is toggled. You must manage both paths to ensure they do not overwrite each other.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"universal-rules-for-every-face-component\">Universal Rules for Every FACE Component<\/h3>\n\n\n\n<p>These strategic lessons are universal rules for all FACE components. They apply whether you are delegating to a native input (Strategy 1) or managing state directly (Strategy 2).<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>The <code>firstUpdated<\/code> sync is non-negotiable.<\/strong> Every component must call <code>setFormValue()<\/code> in <code>firstUpdated()<\/code>. Without this, a pre-filled form where the value is set via a property will not register its data until a user interacts with it.<\/li>\n\n\n\n<li><strong>Cover programmatic changes in <code>updated()<\/code>.<\/strong> While event handlers cover user input, the <code>updated()<\/code> lifecycle covers everything else. This includes test code, parent components, and controlled modes. In Strategy 1, this ensures property changes reach the inner native element. In Strategy 2, it keeps <code>ElementInternals<\/code> in sync.<\/li>\n\n\n\n<li><strong>Null means &#8220;Absent,&#8221; not &#8220;Empty.&#8221;<\/strong> For checkboxes and toggles, passing <code>null<\/code> to <code>setFormValue()<\/code> ensures the key is absent from the form payload. Passing an empty string <code>''<\/code> keeps the key present. Matching native checkbox behavior is critical for backend compatibility.<\/li>\n\n\n\n<li><strong>Complexity is often a mirage.<\/strong> We expected radio groups and selection groups to require complex coordination. In reality, Lit&#8217;s reactive property system was already the right shape. Wiring FACE into existing change paths was enough to propagate state automatically.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>A few items remain on the roadmap \u2014 <code>formStateRestoreCallback<\/code>, cleaner disabled-state separation, and runtime validation injection \u2014 but the core contract is fulfilled.<\/p>\n\n\n\n<p>The irony isn&#8217;t lost on me. I spent months building form components and missed the most fundamental thing a form component needs to do: <em>participate in a form.<\/em><\/p>\n\n\n\n<p>FACE humbled me. I walked away thinking: &#8220;Gee, that&#8217;s a LOT of code to manage\u2026I hope I didn&#8217;t make a mistake&#8221;. But, I suppose it also saved me, because now every <code>ag-*<\/code> form control properly submits its value, respects resets, and actually listens to its parent fieldset. No consumer workarounds, no hacks, no prayers required. I&#8217;m still trying to figure out how &#8220;I feel&#8221; about all this, but hey, sometimes finishing <em>the thing<\/em> is what&#8217;s important.<\/p>\n\n\n<div class=\"box article-series\">\n  <header>\n    <h3 class=\"article-series-header\">Article Series<\/h3>\n  <\/header>\n  <div class=\"box-content\">\n            <ol>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/post-mortem-rewriting-agnosticui-with-lit-web-components\/\">Post Mortem: Rewriting AgnosticUI with Lit Web Components<\/a>\n            <\/li>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/form-associated-custom-elements-in-practice\/\">Form-Associated Custom Elements in Practice<\/a>\n            <\/li>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/shadow-dom-focus-delegation-getting-delegatesfocus-right\/\">Shadow DOM Focus Delegation: Getting\u00a0delegatesFocus\u00a0Right<\/a>\n            <\/li>\n                  <\/ol>\n        <\/div>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>When you make a Web Component for a form element, you&#8217;ve got a bit of extra work to do to make sure they participate on the form in expected ways.<\/p>\n","protected":false},"author":45,"featured_media":8909,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"sig_custom_text":"","sig_image_type":"featured-image","sig_custom_image":0,"sig_is_disabled":false,"inline_featured_image":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":""},"categories":[1],"tags":[464,463,59,36],"class_list":["post-8896","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog-post","tag-agnosticui","tag-attachinternals","tag-forms","tag-web-components"],"acf":[],"jetpack_featured_media_url":"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/03\/face.jpg?fit=2000%2C1200&ssl=1","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8896","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/users\/45"}],"replies":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/comments?post=8896"}],"version-history":[{"count":15,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8896\/revisions"}],"predecessor-version":[{"id":9198,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8896\/revisions\/9198"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media\/8909"}],"wp:attachment":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media?parent=8896"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/categories?post=8896"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/tags?post=8896"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}