[web] annotate obscured text fields as passwords (flutter/engine#54664)

The `type` attribute is needed for screen readers to tell the user whether the text field is a password or a plain text field.

Use `-webkit-text-security` for multi-line fields as a best effort (it's non-standard but is supported by all major browsers).

Fixes https://github.com/flutter/flutter/issues/153801
This commit is contained in:
Yegor
2024-08-21 10:40:04 -07:00
committed by GitHub
parent de582e9145
commit 79a6989314
2 changed files with 108 additions and 46 deletions

View File

@@ -223,10 +223,33 @@ class SemanticTextField extends SemanticRole {
return true;
}
DomHTMLInputElement _createSingleLineField() {
return createDomHTMLInputElement()
..type = semanticsObject.hasFlag(ui.SemanticsFlag.isObscured)
? 'password'
: 'text';
}
DomHTMLTextAreaElement _createMultiLineField() {
final textArea = createDomHTMLTextAreaElement();
if (semanticsObject.hasFlag(ui.SemanticsFlag.isObscured)) {
// -webkit-text-security is not standard, but it's the best we can do.
// Another option would be to create a single-line <input type="password">
// but that may have layout quirks, since it cannot represent multi-line
// text. Worst case with -webkit-text-security is the browser does not
// support it and it does not obscure text. However, that's not a huge
// problem because semantic DOM is already invisible.
textArea.style.setProperty('-webkit-text-security', 'circle');
}
return textArea;
}
void _initializeEditableElement() {
editableElement = semanticsObject.hasFlag(ui.SemanticsFlag.isMultiline)
? createDomHTMLTextAreaElement()
: createDomHTMLInputElement();
? _createMultiLineField()
: _createSingleLineField();
_updateEnabledState();
// On iOS, even though the semantic text field is transparent, the cursor

View File

@@ -60,7 +60,8 @@ void testMain() {
value: 'hi',
isFocused: true,
);
final SemanticTextField textField = textFieldSemantics.semanticRole! as SemanticTextField;
final SemanticTextField textField =
textFieldSemantics.semanticRole! as SemanticTextField;
// ensureInitialized() isn't called prior to calling dispose() here.
// Since we are conditionally calling dispose() on our
@@ -92,41 +93,53 @@ void testMain() {
test('renders a text field', () {
createTextFieldSemantics(value: 'hello');
expectSemanticsTree(owner(), '''
<sem>
<input />
</sem>''');
expectSemanticsTree(
owner(),
'<sem><input type="text" /></sem>',
);
// TODO(yjbanov): this used to attempt to test that value="hello" but the
// test was a false positive. We should revise this test and
// make sure it tests the right things:
// https://github.com/flutter/flutter/issues/147200
final SemanticsObject node = owner().debugSemanticsTree![0]!;
final SemanticTextField textFieldRole = node.semanticRole! as SemanticTextField;
final DomHTMLInputElement inputElement =
textFieldRole.editableElement as DomHTMLInputElement;
final node = owner().debugSemanticsTree![0]!;
final textFieldRole = node.semanticRole! as SemanticTextField;
final inputElement = textFieldRole.editableElement as DomHTMLInputElement;
expect(inputElement.tagName.toLowerCase(), 'input');
expect(inputElement.value, '');
expect(inputElement.disabled, isFalse);
});
test('renders a password field', () {
createTextFieldSemantics(value: 'secret', isObscured: true);
expectSemanticsTree(
owner(),
'<sem><input type="password" /></sem>',
);
final node = owner().debugSemanticsTree![0]!;
final textFieldRole = node.semanticRole! as SemanticTextField;
final inputElement = textFieldRole.editableElement as DomHTMLInputElement;
expect(inputElement.disabled, isFalse);
});
test('renders a disabled text field', () {
createTextFieldSemantics(isEnabled: false, value: 'hello');
expectSemanticsTree(owner(), '''<sem><input /></sem>''');
final SemanticsObject node = owner().debugSemanticsTree![0]!;
final SemanticTextField textFieldRole = node.semanticRole! as SemanticTextField;
final DomHTMLInputElement inputElement =
textFieldRole.editableElement as DomHTMLInputElement;
final node = owner().debugSemanticsTree![0]!;
final textFieldRole = node.semanticRole! as SemanticTextField;
final inputElement = textFieldRole.editableElement as DomHTMLInputElement;
expect(inputElement.tagName.toLowerCase(), 'input');
expect(inputElement.disabled, isTrue);
});
test('sends a SemanticsAction.focus action when browser requests focus',
() async {
final SemanticsActionLogger logger = SemanticsActionLogger();
final logger = SemanticsActionLogger();
createTextFieldSemantics(value: 'hello');
final DomElement textField = owner()
final textField = owner()
.semanticsHost
.querySelector('input[data-semantics-role="text-field"]')!;
@@ -163,14 +176,14 @@ void testMain() {
);
// Create
final SemanticsObject textFieldSemantics = createTextFieldSemantics(
final textFieldSemantics = createTextFieldSemantics(
value: 'hello',
label: 'greeting',
isFocused: true,
rect: const ui.Rect.fromLTWH(0, 0, 10, 15),
);
final SemanticTextField textField = textFieldSemantics.semanticRole! as SemanticTextField;
final textField = textFieldSemantics.semanticRole! as SemanticTextField;
expect(owner().semanticsHost.ownerDocument?.activeElement,
strategy.domElement);
expect(textField.editableElement, strategy.domElement);
@@ -231,16 +244,16 @@ void testMain() {
onAction: (_) {},
);
final SemanticsObject textFieldSemantics = createTextFieldSemantics(
value: 'hello',
textSelectionBase: 1,
textSelectionExtent: 3,
isFocused: true,
rect: const ui.Rect.fromLTWH(0, 0, 10, 15));
final textFieldSemantics = createTextFieldSemantics(
value: 'hello',
textSelectionBase: 1,
textSelectionExtent: 3,
isFocused: true,
rect: const ui.Rect.fromLTWH(0, 0, 10, 15),
);
final SemanticTextField textField = textFieldSemantics.semanticRole! as SemanticTextField;
final DomHTMLInputElement editableElement =
textField.editableElement as DomHTMLInputElement;
final textField = textFieldSemantics.semanticRole! as SemanticTextField;
final editableElement = textField.editableElement as DomHTMLInputElement;
expect(editableElement, strategy.domElement);
expect(editableElement.value, '');
@@ -262,16 +275,16 @@ void testMain() {
onAction: (_) {},
);
final SemanticsObject textFieldSemantics = createTextFieldSemantics(
value: 'hello',
textSelectionBase: 1,
textSelectionExtent: 3,
isFocused: true,
rect: const ui.Rect.fromLTWH(0, 0, 10, 15));
final textFieldSemantics = createTextFieldSemantics(
value: 'hello',
textSelectionBase: 1,
textSelectionExtent: 3,
isFocused: true,
rect: const ui.Rect.fromLTWH(0, 0, 10, 15),
);
final SemanticTextField textField = textFieldSemantics.semanticRole! as SemanticTextField;
final DomHTMLInputElement editableElement =
textField.editableElement as DomHTMLInputElement;
final textField = textFieldSemantics.semanticRole! as SemanticTextField;
final editableElement = textField.editableElement as DomHTMLInputElement;
// No updates expected on semantic updates
expect(editableElement, strategy.domElement);
@@ -280,7 +293,7 @@ void testMain() {
expect(editableElement.selectionEnd, 0);
// Update from framework
const MethodCall setEditingState =
const setEditingState =
MethodCall('TextInput.setEditingState', <String, dynamic>{
'text': 'updated',
'selectionBase': 2,
@@ -306,12 +319,12 @@ void testMain() {
onChange: (_, __) {},
onAction: (_) {},
);
final SemanticsObject textFieldSemantics = createTextFieldSemantics(
final textFieldSemantics = createTextFieldSemantics(
value: 'hello',
isFocused: true,
);
final SemanticTextField textField = textFieldSemantics.semanticRole! as SemanticTextField;
final textField = textFieldSemantics.semanticRole! as SemanticTextField;
expect(textField.editableElement, strategy.domElement);
expect(owner().semanticsHost.ownerDocument?.activeElement,
strategy.domElement);
@@ -335,7 +348,7 @@ void testMain() {
expect(strategy.domElement, isNull);
// During the semantics update the DOM element is created and is focused on.
final SemanticsObject textFieldSemantics = createTextFieldSemantics(
final textFieldSemantics = createTextFieldSemantics(
value: 'hello',
isFocused: true,
);
@@ -347,7 +360,7 @@ void testMain() {
expect(strategy.domElement, isNull);
// It doesn't remove the DOM element.
final SemanticTextField textField = textFieldSemantics.semanticRole! as SemanticTextField;
final textField = textFieldSemantics.semanticRole! as SemanticTextField;
expect(owner().semanticsHost.contains(textField.editableElement), isTrue);
// Editing element is not enabled.
expect(strategy.isEnabled, isFalse);
@@ -412,8 +425,11 @@ void testMain() {
isMultiline: true,
);
final DomHTMLTextAreaElement textArea =
strategy.domElement! as DomHTMLTextAreaElement;
final textArea = strategy.domElement! as DomHTMLTextAreaElement;
expect(
textArea.style.getPropertyValue('-webkit-text-security'),
'',
);
expect(owner().semanticsHost.ownerDocument?.activeElement,
strategy.domElement);
@@ -435,6 +451,27 @@ void testMain() {
expect(strategy.isEnabled, isFalse);
});
test('multi-line and obscured', () {
strategy.enable(
multilineConfig,
onChange: (_, __) {},
onAction: (_) {},
);
createTextFieldSemantics(
value: 'hello',
isFocused: true,
isMultiline: true,
isObscured: true,
);
expectSemanticsTree(
owner(),
'<sem><textarea style="-webkit-text-security: circle"></textarea></sem>',
);
strategy.disable();
});
test('Does not position or size its DOM element', () {
strategy.enable(
singlelineConfig,
@@ -444,7 +481,7 @@ void testMain() {
// Send width and height that are different from semantics values on
// purpose.
final EditableTextGeometry geometry = EditableTextGeometry(
final geometry = EditableTextGeometry(
height: 12,
width: 13,
globalTransform: Matrix4.translationValues(14, 15, 0).storage,
@@ -534,11 +571,12 @@ SemanticsObject createTextFieldSemantics({
bool isEnabled = true,
bool isFocused = false,
bool isMultiline = false,
bool isObscured = false,
ui.Rect rect = const ui.Rect.fromLTRB(0, 0, 100, 50),
int textSelectionBase = 0,
int textSelectionExtent = 0,
}) {
final SemanticsTester tester = SemanticsTester(owner());
final tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
isEnabled: isEnabled,
@@ -547,6 +585,7 @@ SemanticsObject createTextFieldSemantics({
isTextField: true,
isFocused: isFocused,
isMultiline: isMultiline,
isObscured: isObscured,
hasTap: true,
rect: rect,
textDirection: ui.TextDirection.ltr,