diff --git a/research/form-associated-ce-poc/face-text-field-no-delegate.js b/research/form-associated-ce-poc/face-text-field-no-delegate.js new file mode 100644 index 0000000000..33cc70f437 --- /dev/null +++ b/research/form-associated-ce-poc/face-text-field-no-delegate.js @@ -0,0 +1,103 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * + * Variant WITHOUT delegatesFocus. + * Tests whether removing delegatesFocus changes how internals.role + * and internals.ariaLabel interact with the accessibility tree. + * + * If delegatesFocus is what causes the shadow input to be the + * "focused" element, then without it the HOST should receive focus + * and the SR should read internals.ariaLabel from the host. + */ +export class FaceTextFieldNoDelegate extends HTMLElement { + static formAssociated = true; + + static get observedAttributes() { + return [ + 'value', + 'placeholder', + 'type', + 'use-internals-aria-label', + 'internals-role', + 'labelling-strategy', + ]; + } + + #internals; + #input; + + constructor() { + super(); + this.#internals = this.attachInternals(); + + // No delegatesFocus — focus stays on the host unless user clicks input + const shadow = this.attachShadow({ mode: 'open' }); + shadow.innerHTML = ` + + + `; + + this.#input = shadow.getElementById('internal-input'); + this.#input.addEventListener('input', () => { + this.#internals.setFormValue(this.#input.value); + }); + } + + connectedCallback() { + const role = this.getAttribute('internals-role'); + if (role) { + this.#internals.role = role; + } + + const label = this.getAttribute('use-internals-aria-label'); + if (label) { + const strategy = this.getAttribute('labelling-strategy') || 'dual-write'; + if (strategy === 'internals-only' || strategy === 'dual-write') { + this.#internals.ariaLabel = label; + } + if (strategy === 'input-only' || strategy === 'dual-write') { + this.#input.setAttribute('aria-label', label); + } + } + + const placeholder = this.getAttribute('placeholder'); + if (placeholder) { + this.#input.placeholder = placeholder; + } + + const value = this.getAttribute('value'); + if (value) { + this.#input.value = value; + } + } + + get value() { + return this.#input?.value ?? ''; + } + + set value(v) { + if (this.#input) { + this.#input.value = v; + this.#internals.setFormValue(v); + } + } +} + +customElements.define('face-text-field-no-delegate', FaceTextFieldNoDelegate); diff --git a/research/form-associated-ce-poc/face-text-field.js b/research/form-associated-ce-poc/face-text-field.js new file mode 100644 index 0000000000..52478a3feb --- /dev/null +++ b/research/form-associated-ce-poc/face-text-field.js @@ -0,0 +1,434 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * + * Minimal form-associated text field custom element. + * Tests what ElementInternals does OUT OF THE BOX — no custom JS + * that resolves cross-root IDREFs or forwards text between scopes. + * + * What this component does: + * - static formAssociated = true + * - ElementInternals for form value, validation, role, and ARIA + * - labelling-strategy attribute to isolate which layer carries the name + * - Constraint validation (setValidity, reportValidity) + * - formResetCallback, formDisabledCallback, formStateRestoreCallback + * + * What this component does NOT do (intentionally removed): + * - No cross-root IDREF resolution + * - No reading text from light DOM elements by ID + * - No slotchange-based label forwarding + * - No ariaLabelledByElements / ariaDescribedByElements usage + */ +export class FaceTextField extends HTMLElement { + static formAssociated = true; + + static get observedAttributes() { + return [ + 'value', + 'name', + 'required', + 'pattern', + 'placeholder', + 'type', + 'disabled', + 'internal-label', + 'internal-help', + 'use-internals-aria-label', + 'internals-role', + 'labelling-strategy', + 'min-value', + 'aria-label', + 'aria-errormessage', + ]; + } + + #internals; + #input; + #shadowLabel; + #shadowHelp; + + constructor() { + super(); + this.#internals = this.attachInternals(); + + const shadow = this.attachShadow({ + mode: 'open', + delegatesFocus: true, + }); + shadow.innerHTML = ` + + + + + + + + + + `; + + this.#input = shadow.getElementById('internal-input'); + this.#shadowLabel = shadow.getElementById('shadow-label'); + this.#shadowHelp = shadow.getElementById('shadow-help'); + + this.#input.addEventListener('input', () => { + this.#syncValue(); + this.#validate(); + }); + + this.#input.addEventListener('change', () => { + this.#validate(); + }); + } + + connectedCallback() { + this.#syncFromAttributes(); + this.#syncValue(); + this.#validate(); + } + + attributeChangedCallback(name, _oldVal, newVal) { + switch (name) { + case 'value': + if (this.#input && this.#input.value !== newVal) { + this.#input.value = newVal ?? ''; + this.#syncValue(); + } + break; + case 'placeholder': + if (this.#input) { + this.#input.placeholder = newVal ?? ''; + } + break; + case 'type': + if (this.#input) { + this.#input.type = newVal ?? 'text'; + } + break; + case 'required': + if (this.#input) { + this.#input.required = newVal !== null; + } + break; + case 'pattern': + if (this.#input) { + this.#input.pattern = newVal ?? ''; + } + break; + case 'disabled': + if (this.#input) { + this.#input.disabled = newVal !== null; + } + break; + case 'internal-label': + this.#updateInternalLabel(newVal); + break; + case 'internal-help': + this.#updateInternalHelp(newVal); + break; + case 'use-internals-aria-label': + if (newVal !== null) { + this.#applyLabel(newVal); + } + break; + case 'internals-role': + if (newVal !== null) { + this.#internals.role = newVal; + } + break; + case 'aria-label': + if (newVal !== null) { + this.#applyLabel(newVal); + } + break; + case 'min-value': + this.#validate(); + break; + case 'aria-errormessage': + break; + } + } + + // -- Public form API ------------------------------------------------------- + + get form() { + return this.#internals.form; + } + + get name() { + return this.getAttribute('name'); + } + + get type() { + return this.localName; + } + + get value() { + return this.#input?.value ?? ''; + } + + set value(v) { + if (this.#input) { + this.#input.value = v; + this.#syncValue(); + } + } + + get validity() { + return this.#internals.validity; + } + + get validationMessage() { + return this.#internals.validationMessage; + } + + get willValidate() { + return this.#internals.willValidate; + } + + checkValidity() { + this.#validate(); + return this.#internals.checkValidity(); + } + + reportValidity() { + this.#validate(); + return this.#internals.reportValidity(); + } + + formResetCallback() { + const defaultValue = this.getAttribute('value') ?? ''; + this.#input.value = defaultValue; + this.#syncValue(); + this.#validate(); + this.#clearErrorState(); + } + + formDisabledCallback(disabled) { + this.#input.disabled = disabled; + if (disabled) { + this.setAttribute('disabled', ''); + } else { + this.removeAttribute('disabled'); + } + } + + formStateRestoreCallback(state, _mode) { + this.#input.value = state ?? ''; + this.#syncValue(); + } + + // -- Private methods ------------------------------------------------------- + + #syncFromAttributes() { + const attrs = { value: '', placeholder: '', type: 'text' }; + for (const [attr, fallback] of Object.entries(attrs)) { + const val = this.getAttribute(attr) ?? fallback; + if (attr === 'value') { + this.#input.value = val; + } else { + this.#input[attr] = val; + } + } + + if (this.hasAttribute('required')) { + this.#input.required = true; + } + if (this.hasAttribute('pattern')) { + this.#input.pattern = this.getAttribute('pattern'); + } + if (this.hasAttribute('disabled')) { + this.#input.disabled = true; + } + + const internalLabel = this.getAttribute('internal-label'); + if (internalLabel) { + this.#updateInternalLabel(internalLabel); + } + + const internalHelp = this.getAttribute('internal-help'); + if (internalHelp) { + this.#updateInternalHelp(internalHelp); + } + + const internalsRole = this.getAttribute('internals-role'); + if (internalsRole) { + this.#internals.role = internalsRole; + } + + const internalsAriaLabel = this.getAttribute('use-internals-aria-label'); + if (internalsAriaLabel) { + this.#applyLabel(internalsAriaLabel); + } + + const hostAriaLabel = this.getAttribute('aria-label'); + if (hostAriaLabel) { + this.#applyLabel(hostAriaLabel); + } + } + + #syncValue() { + this.#internals.setFormValue(this.#input.value); + } + + #validate() { + const input = this.#input; + + const minValue = this.getAttribute('min-value'); + if (minValue !== null && input.value !== '') { + const num = parseFloat(input.value); + if (!isNaN(num) && num < parseFloat(minValue)) { + this.#internals.setValidity( + { rangeUnderflow: true }, + `Value must be at least ${minValue}.`, + input + ); + this.#showErrorState(); + return; + } + } + + if (!input.validity.valid) { + this.#internals.setValidity( + input.validity, + input.validationMessage, + input + ); + this.#showErrorState(); + } else { + this.#internals.setValidity({}); + this.#clearErrorState(); + } + } + + #showErrorState() { + this.#internals.ariaInvalid = 'true'; + const errorId = this.getAttribute('aria-errormessage'); + if (errorId) { + const errorEl = document.getElementById(errorId); + if (errorEl) { + errorEl.hidden = false; + } + } + } + + #clearErrorState() { + this.#internals.ariaInvalid = 'false'; + const errorId = this.getAttribute('aria-errormessage'); + if (errorId) { + const errorEl = document.getElementById(errorId); + if (errorEl) { + errorEl.hidden = true; + } + } + } + + #updateInternalLabel(text) { + if (text) { + this.#shadowLabel.textContent = text; + this.#shadowLabel.hidden = false; + this.#input.setAttribute('aria-labelledby', 'shadow-label'); + } else { + this.#shadowLabel.hidden = true; + this.#input.removeAttribute('aria-labelledby'); + } + } + + #updateInternalHelp(text) { + if (text) { + this.#shadowHelp.textContent = text; + this.#shadowHelp.hidden = false; + const existing = this.#input.getAttribute('aria-describedby') ?? ''; + if (!existing.includes('shadow-help')) { + this.#input.setAttribute( + 'aria-describedby', + (existing + ' shadow-help').trim() + ); + } + } else { + this.#shadowHelp.hidden = true; + } + } + + get #labellingStrategy() { + return this.getAttribute('labelling-strategy') || 'dual-write'; + } + + /** + * Applies a label using the configured strategy. + * No cross-root resolution — just sets the value where configured. + */ + #applyLabel(text) { + if (!text) { + return; + } + const strategy = this.#labellingStrategy; + + if (strategy === 'internals-only' || strategy === 'dual-write') { + this.#internals.ariaLabel = this.#internals.ariaLabel || text; + } + + if (strategy === 'input-only' || strategy === 'dual-write') { + if (!this.#input.hasAttribute('aria-labelledby')) { + this.#input.setAttribute('aria-label', text); + } + } + } +} + +customElements.define('face-text-field', FaceTextField); diff --git a/research/form-associated-ce-poc/index.html b/research/form-associated-ce-poc/index.html new file mode 100644 index 0000000000..eb6dba49a7 --- /dev/null +++ b/research/form-associated-ce-poc/index.html @@ -0,0 +1,712 @@ + + + + + + + FACE text field PoC: ElementInternals research + + + +

Form-associated custom element PoC: text field

+

+ Research artifact for ElementInternals and FACE patterns. Not a migration + of + sp-textfield + ; a standalone platform test. +

+ + + + + +

1. Native form participation

+ +
+

1a. FormData and submit

+
+
+ + +
+
+ + +
+
+
+ Submit or reset the form to see results. +
+
+ +
+

+ 1b. Constraint validation ( + required + + + pattern + ) +

+
+
+ + + + Letters a through z, no spaces or numbers. + +
+
+ +
+
+
+ Submit with invalid data to see constraint validation. +
+
+ +
+

+ 1c. Disabled state via + fieldset +

+
+
+ Disabled fieldset +
+ + +
+
+
+ +
+
+
+ Field should be disabled via ancestor fieldset. +
+
+ + + + + +

2. IDREF matrix: label and description wiring

+ +
+ Each scenario tests whether + aria-labelledby + and + aria-describedby + resolve correctly across shadow DOM boundaries. Inspect the accessibility + tree in DevTools for each. +
+ + +
+

Scenario A: light DOM label and help as siblings

+

+ IDs are on elements + outside + the component; referenced via + aria-labelledby + and + aria-describedby + on the host. +

+
+
+ + + + We will never share your email. + +
+
+
+ + +
+

Scenario B: IDs on slotted light DOM children

+

+ Consumer projects content into the component via slots; IDs live on the + slotted elements. +

+
+
+ + Phone number + + Include country code. + + +
+
+
+ + +
+

Scenario C: shadow-local IDREF resolution (NOT cross-root)

+

+ Label and help text are rendered + inside the same shadow root + as the input. The input's + aria-labelledby + references a shadow-scoped ID. This is + not + cross-root ARIA — both elements share the same tree scope. +

+
+
+ +
+
+
+ + +
+

Scenario D: cross-root; light DOM label + shadow input

+

+ A + <label for="..."> + in light DOM targets the custom element host. The component uses + delegatesFocus + and + ElementInternals + to attempt forwarding. +

+
+
+ + + +
+
+
+ + + + + +

3. ElementInternals ARIA property APIs

+ +
+

3a. Isolated ARIA labelling strategies (one per field)

+

+ Each field uses exactly + one + labelling strategy in isolation. Inspect each in the accessibility tree + to determine which layer actually carries the accessible name to the + focused control. +

+
+
+ labelling-strategy="internals-only" + + + Sets + internals.ariaLabel + only. Does the focused input announce this name? + +
+
+ labelling-strategy="input-only" + + + Sets + aria-label + on the shadow + <input> + only. Does the focused input announce this name? + +
+
+ labelling-strategy="dual-write" + + + Sets both + internals.ariaLabel + and shadow input + aria-label + . Recommended resilient pattern. + +
+
+ aria-label (host attribute, input-only strategy) + + + Host + aria-label + forwarded to shadow input only. + +
+
+
+ Expected: + Only the shadow input's + aria-label + produces a SR announcement because + delegatesFocus + places focus there. + internals.ariaLabel + alone should NOT announce on the focused input. +
+
+ +
+

+ 3c. + internals.role + and labelling constraints +

+

+ When the host has an explicit role via + internals.role + , does that change how labelling works? Does the host become the + "primary" accessible element, or does + delegatesFocus + still route SR interaction to the shadow input? +

+
+
+ internals.role = "textbox" + internals-only label + + + Host has + role="textbox" + via internals + + internals.ariaLabel + . Does the SR now announce the label because the host IS the + textbox? + +
+
+ internals.role = "textbox" + dual-write label + + + Host has + role="textbox" + + dual-write. Baseline comparison. + +
+
+ internals.role = "textbox" + NO delegatesFocus + + + Same as above but + without + delegatesFocus + . Does the host now become the focused element and announce the + label? + +
+
+
+ Key question: + When + internals.role = "textbox" + and the host is the element with a role, does + internals.ariaLabel + finally get announced? Or does + delegatesFocus + still send focus to the shadow input, bypassing the host's role + entirely? +
+
+ +
+

+ 3b. + setValidity() + + + aria-errormessage +

+
+
+ + + Enter your age in years. + +
+
+ +
+
+
+ Submit with a value under 18 to test error announcement. +
+
+ + + + + +

4. axe-core automated audit

+ +
+ Loads axe-core from CDN and runs against the full page. Results show + violations, passes, and incomplete checks relevant to FACE patterns. +
+ + +
+ Click the button above to run axe-core. +
+ + + + + +

5. IDREF matrix results

+ +

+ This table checks + DOM state + (attributes, resolved text content) — not the actual accessibility tree. + DOM wiring is a necessary precondition but does not guarantee SR exposure. + Verify actual computed names in DevTools Accessibility panel or with a + screen reader. +

+ + + + + + + + + + + + + +
ScenarioLabel wiringDescription wiringDOM name sourceDOM description sourceStatus
+ + + + + + + + + + diff --git a/research/form-associated-ce-poc/package.json b/research/form-associated-ce-poc/package.json new file mode 100644 index 0000000000..b57916b3be --- /dev/null +++ b/research/form-associated-ce-poc/package.json @@ -0,0 +1,12 @@ +{ + "name": "face-textfield-poc", + "private": true, + "description": "Form-associated custom element PoC: text field with ElementInternals", + "scripts": { + "dev": "vite", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^6" + } +} diff --git a/research/form-associated-ce-poc/page-handlers.js b/research/form-associated-ce-poc/page-handlers.js new file mode 100644 index 0000000000..1870b52b50 --- /dev/null +++ b/research/form-associated-ce-poc/page-handlers.js @@ -0,0 +1,263 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * + * Page-level form handlers, matrix introspection, and axe-core integration. + * Separated from the component code for easier navigation. + */ + +// Form 1a: submit + reset +document.getElementById('form-1a').addEventListener('submit', (event) => { + event.preventDefault(); + const data = new FormData(event.target); + const out = document.getElementById('output-1a'); + const entries = [...data.entries()]; + out.textContent = + 'FormData entries:\n' + + entries.map(([k, v]) => ` ${k}: "${v}"`).join('\n') + + '\n\nForm participation: ' + + (entries.length > 0 ? 'PASS' : 'FAIL'); +}); + +document.getElementById('form-1a').addEventListener('reset', () => { + const out = document.getElementById('output-1a'); + requestAnimationFrame(() => { + const field = document.getElementById('field-1a'); + out.textContent = `Reset fired.\nValue after reset: "${field.value}"\nformResetCallback: ${field.value === 'Jane Doe' ? 'PASS' : 'FAIL'}`; + }); +}); + +// Form 1b: constraint validation +document.getElementById('form-1b').addEventListener('submit', (event) => { + event.preventDefault(); + const field = document.getElementById('field-1b'); + const out = document.getElementById('output-1b'); + const valid = field.reportValidity(); + out.textContent = + `reportValidity(): ${valid}\n` + + `validity.valid: ${field.validity.valid}\n` + + `validity.valueMissing: ${field.validity.valueMissing}\n` + + `validity.patternMismatch: ${field.validity.patternMismatch}\n` + + `validationMessage: "${field.validationMessage}"\n\n` + + `Constraint validation: ${!valid ? 'PASS (correctly rejected)' : 'Check input'}`; +}); + +// Form 1c: disabled fieldset +document.getElementById('form-1c').addEventListener('submit', (event) => { + event.preventDefault(); + const field = document.getElementById('field-1c'); + const out = document.getElementById('output-1c'); + const data = new FormData(event.target); + const shadowInput = field.shadowRoot?.querySelector('input'); + out.textContent = + `Shadow input disabled: ${shadowInput?.disabled ?? 'N/A'}\n` + + `Host has disabled attr: ${field.hasAttribute('disabled')}\n` + + `formDisabledCallback: fires when fieldset is disabled\n` + + `FormData includes field: ${data.has('disabledfield') ? 'YES (unexpected)' : 'NO (correct)'}\n\n` + + `Disabled via fieldset: ${!data.has('disabledfield') ? 'PASS' : 'FAIL'}`; +}); + +// Form 3b: custom validation + ariaInvalid +document.getElementById('form-3b').addEventListener('submit', (event) => { + event.preventDefault(); + const field = document.getElementById('field-3b'); + const shadowInput = field.shadowRoot?.querySelector('input'); + const out = document.getElementById('output-3b'); + const valid = field.reportValidity(); + out.textContent = + `reportValidity(): ${valid}\n` + + `validity.valid: ${field.validity.valid}\n` + + `validity.rangeUnderflow: ${field.validity.rangeUnderflow}\n` + + `validationMessage: "${field.validationMessage}"\n\n` + + `--- internals.ariaInvalid observations ---\n` + + `host aria-invalid attr: ${field.getAttribute('aria-invalid') ?? '(not reflected)'}\n` + + `shadow input aria-invalid attr: ${shadowInput?.getAttribute('aria-invalid') ?? '(not set)'}\n\n` + + `Custom validation: ${!valid ? 'PASS (correctly rejected)' : 'Check input value'}`; +}); + +// IDREF matrix introspection (reads DOM state, not a11y tree) +function populateMatrix() { + const scenarios = [ + { + id: 'A', + name: 'Light DOM siblings', + fieldId: 'field-2a', + labelMethod: 'aria-labelledby on host → light DOM #label-2a', + descMethod: 'aria-describedby on host → light DOM #help-2a', + }, + { + id: 'B', + name: 'Slotted light DOM children', + fieldId: 'field-2b', + labelMethod: 'Slotted with ID', + descMethod: 'Slotted with ID', + }, + { + id: 'C', + name: 'Shadow DOM internal IDs', + fieldId: 'field-2c', + labelMethod: 'internal-label attr → shadow #shadow-label', + descMethod: 'internal-help attr → shadow #shadow-help', + }, + { + id: 'D', + name: 'Cross-root (label for → host)', + fieldId: 'field-2d', + labelMethod: '