Skip to content

Commit 3689414

Browse files
authored
feat(codegen): do not use contenteditable text in generated fill selectors (#42059)
1 parent 30d5b2e commit 3689414

9 files changed

Lines changed: 147 additions & 125 deletions

File tree

packages/injected/src/injectedScript.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1620,7 +1620,7 @@ export class InjectedScript {
16201620
} else if (expression === 'to.have.accessible.name') {
16211621
received = getElementAccessibleNameText(element, false /* includeHidden */);
16221622
} else if (expression === 'to.have.accessible.description') {
1623-
received = getElementAccessibleDescription(element, false /* includeHidden */);
1623+
received = getElementAccessibleDescription(element, false /* includeHidden */).text;
16241624
} else if (expression === 'to.have.accessible.error.message') {
16251625
received = getElementAccessibleErrorMessage(element);
16261626
} else if (expression === 'to.have.role') {

packages/injected/src/recorder/recorder.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ class InspectTool implements RecorderTool {
123123

124124
let model: HighlightModel | null = null;
125125
if (this._hoveredElement) {
126-
const generated = this._recorder.injectedScript.generateSelector(this._hoveredElement, { testIdAttributeName: this._recorder.state.testIdAttributeName, multiple: false });
126+
const generated = this._recorder.injectedScript.generateSelector(this._hoveredElement, { testIdAttributeName: this._recorder.state.testIdAttributeName });
127127
model = {
128128
selector: generated.selector,
129129
elements: generated.elements,
@@ -403,9 +403,12 @@ class RecordActionTool implements RecorderTool {
403403
return;
404404
}
405405

406+
// By the time the input event arrives, the contenteditable already contains the new text.
407+
// Generate a selector that does not depend on that text, so that it works before the fill.
408+
const selector = target.isContentEditable ? this._selectorForElement(target, { noText: true }) : this._activeSelectorForEvent(event);
406409
this._recordAction({
407410
name: 'fill',
408-
selector: this._activeSelectorForEvent(event),
411+
selector,
409412
text: target.isContentEditable ? target.innerText : (target as HTMLInputElement).value,
410413
});
411414
}
@@ -561,8 +564,8 @@ class RecordActionTool implements RecorderTool {
561564
consumeEvent(event);
562565
}
563566

564-
private _selectorForElement(element: HTMLElement): string {
565-
return this._recorder.injectedScript.generateSelector(element, { testIdAttributeName: this._recorder.state.testIdAttributeName }).selector;
567+
private _selectorForElement(element: HTMLElement, options?: { noText?: boolean }): string {
568+
return this._recorder.injectedScript.generateSelector(element, { ...options, testIdAttributeName: this._recorder.state.testIdAttributeName }).selector;
566569
}
567570

568571
private _modelForElement(element: HTMLElement): HighlightModelWithSelector | null {

packages/injected/src/roleSelectorEngine.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: bo
175175
}
176176
if (options.description !== undefined) {
177177
// Always normalize whitespace in the accessible description.
178-
const accessibleDescription = normalizeWhiteSpace(getElementAccessibleDescription(element, !!options.includeHidden));
178+
const accessibleDescription = normalizeWhiteSpace(getElementAccessibleDescription(element, !!options.includeHidden).text);
179179
if (typeof options.description === 'string')
180180
options.description = normalizeWhiteSpace(options.description);
181181
// internal:role assumes that [description="foo"i] also means substring.

packages/injected/src/roleUtils.ts

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -510,26 +510,32 @@ function allowsNameFromContent(role: string, targetDescendant: boolean) {
510510
return alwaysAllowsNameFromContent || descendantAllowsNameFromContent;
511511
}
512512

513-
function computeAccessibleNameComposite(element: Element, includeHidden: boolean, collectElements: boolean): CompositeString {
513+
export type AccessibleName = CompositeString & {
514+
derivedFromContent: boolean,
515+
};
516+
517+
function computeAccessibleNameComposite(element: Element, includeHidden: boolean, collectElements: boolean): AccessibleName {
514518
// https://w3c.github.io/accname/#computation-steps
515519

516520
// step 1.
517521
// https://w3c.github.io/aria/#namefromprohibited
518522
const elementProhibitsNaming = ['caption', 'code', 'definition', 'deletion', 'emphasis', 'generic', 'insertion', 'mark', 'paragraph', 'presentation', 'strong', 'subscript', 'suggestion', 'superscript', 'term', 'time'].includes(getAriaRole(element) || '');
519523
if (elementProhibitsNaming)
520-
return emptyCompositeString();
524+
return { ...emptyCompositeString(), derivedFromContent: false };
521525

522526
// step 2.
527+
const outDerivedFromContent = { value: false };
523528
const result = getTextAlternativeInternal(element, {
524529
includeHidden,
525530
collectElements,
531+
outDerivedFromContent,
526532
visitedElements: new Set(),
527533
embeddedInTargetElement: 'self',
528534
});
529-
return { text: asFlatString(result.text), elements: result.elements };
535+
return { text: asFlatString(result.text), elements: result.elements, derivedFromContent: outDerivedFromContent.value };
530536
}
531537

532-
export function getElementAccessibleName(element: Element, includeHidden: boolean): CompositeString {
538+
export function getElementAccessibleName(element: Element, includeHidden: boolean): AccessibleName {
533539
const cache = (includeHidden ? cacheAccessibleNameHidden : cacheAccessibleName);
534540
let accessibleName = cache?.get(element);
535541
if (accessibleName === undefined) {
@@ -553,31 +559,37 @@ export function getElementAccessibleNameText(element: Element, includeHidden: bo
553559
return text;
554560
}
555561

556-
export function getElementAccessibleDescription(element: Element, includeHidden: boolean): string {
562+
export type AccessibleDescription = {
563+
text: string,
564+
derivedFromContent: boolean,
565+
};
566+
567+
export function getElementAccessibleDescription(element: Element, includeHidden: boolean): AccessibleDescription {
557568
const cache = (includeHidden ? cacheAccessibleDescriptionHidden : cacheAccessibleDescription);
558569
let accessibleDescription = cache?.get(element);
559570

560571
if (accessibleDescription === undefined) {
561572
// https://w3c.github.io/accname/#mapping_additional_nd_description
562573
// https://www.w3.org/TR/html-aam-1.0/#accdesc-computation
563-
accessibleDescription = '';
574+
accessibleDescription = { text: '', derivedFromContent: false };
564575

565576
if (element.hasAttribute('aria-describedby')) {
566577
// precedence 1
567578
const describedBy = getIdRefs(element, element.getAttribute('aria-describedby'));
568-
accessibleDescription = asFlatString(describedBy.map(ref => getTextAlternativeInternal(ref, {
579+
accessibleDescription.text = asFlatString(describedBy.map(ref => getTextAlternativeInternal(ref, {
569580
includeHidden,
570581
visitedElements: new Set(),
571582
embeddedInDescribedBy: { element: ref, hidden: isElementHiddenForAria(ref) },
572583
}).text).join(' '));
584+
accessibleDescription.derivedFromContent = describedBy.some(ref => ref === element || element.contains(ref));
573585
} else if (element.hasAttribute('aria-description')) {
574586
// precedence 2
575-
accessibleDescription = asFlatString(element.getAttribute('aria-description') || '');
587+
accessibleDescription.text = asFlatString(element.getAttribute('aria-description') || '');
576588
} else {
577589
// TODO: handle precedence 3 - html-aam-specific cases like table>caption.
578590
// https://www.w3.org/TR/html-aam-1.0/#accdesc-computation
579591
// precedence 4
580-
accessibleDescription = asFlatString(element.getAttribute('title') || '');
592+
accessibleDescription.text = asFlatString(element.getAttribute('title') || '');
581593
}
582594

583595
cache?.set(element, accessibleDescription);
@@ -658,13 +670,20 @@ type AccessibleNameOptions = {
658670
visitedElements: Set<Element>,
659671
collectElements?: boolean,
660672
includeHidden?: boolean,
673+
// Set to true during the computation when the name is derived from the content of the target
674+
// element, e.g. inner text or aria-labelledby pointing inside the element.
675+
outDerivedFromContent?: { value: boolean },
661676
embeddedInDescribedBy?: { element: Element, hidden: boolean },
662677
embeddedInLabelledBy?: { element: Element, hidden: boolean },
663678
embeddedInLabel?: { element: Element, hidden: boolean },
664679
embeddedInNativeTextAlternative?: { element: Element, hidden: boolean },
665680
embeddedInTargetElement?: 'self' | 'descendant',
666681
};
667682

683+
function insideTargetElement(options: AccessibleNameOptions) {
684+
return options.embeddedInTargetElement === 'self' || options.embeddedInTargetElement === 'descendant';
685+
}
686+
668687
function getTextAlternativeInternal(element: Element, options: AccessibleNameOptions): CompositeString {
669688
if (options.visitedElements.has(element))
670689
return emptyCompositeString();
@@ -705,8 +724,11 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
705724
embeddedInLabel: undefined,
706725
embeddedInNativeTextAlternative: undefined,
707726
})), ' ', options.collectElements);
708-
if (accessibleName.text)
727+
if (accessibleName.text) {
728+
if (options.outDerivedFromContent && insideTargetElement(options) && (labelledBy || []).some(ref => ref === element || element.contains(ref)))
729+
options.outDerivedFromContent.value = true;
709730
return accessibleName;
731+
}
710732
}
711733

712734
const role = getAriaRole(element) || '';
@@ -972,6 +994,8 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
972994
// So we follow the spec everywhere except for the target element itself. This can probably be improved.
973995
const maybeTrimmedAccessibleName = options.embeddedInTargetElement === 'self' ? trimFlatString(accessibleName.text) : accessibleName.text;
974996
if (maybeTrimmedAccessibleName) {
997+
if (options.outDerivedFromContent && insideTargetElement(options) && trimFlatString(accessibleName.text))
998+
options.outDerivedFromContent.value = true;
975999
// This element owns the accumulated content - record it alongside the descendants it was computed from.
9761000
accessibleName.elements?.add(element);
9771001
return accessibleName;
@@ -1242,12 +1266,12 @@ export function receivesPointerEvents(element: Element): boolean {
12421266
return result;
12431267
}
12441268

1245-
let cacheAccessibleName: Map<Element, CompositeString> | undefined;
1246-
let cacheAccessibleNameHidden: Map<Element, CompositeString> | undefined;
1269+
let cacheAccessibleName: Map<Element, AccessibleName> | undefined;
1270+
let cacheAccessibleNameHidden: Map<Element, AccessibleName> | undefined;
12471271
let cacheAccessibleNameText: Map<Element, string> | undefined;
12481272
let cacheAccessibleNameTextHidden: Map<Element, string> | undefined;
1249-
let cacheAccessibleDescription: Map<Element, string> | undefined;
1250-
let cacheAccessibleDescriptionHidden: Map<Element, string> | undefined;
1273+
let cacheAccessibleDescription: Map<Element, AccessibleDescription> | undefined;
1274+
let cacheAccessibleDescriptionHidden: Map<Element, AccessibleDescription> | undefined;
12511275
let cacheAccessibleErrorMessage: Map<Element, string> | undefined;
12521276
let cacheIsHidden: Map<Element, boolean> | undefined;
12531277
let cachePseudoContent: Map<Element, string | undefined> | undefined;

0 commit comments

Comments
 (0)