Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit 54eb4db

Browse files
authored
[web] annotate obscured text fields as passwords (#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 flutter/flutter#153801
1 parent 7e3ecc0 commit 54eb4db

File tree

2 files changed

+108
-46
lines changed

2 files changed

+108
-46
lines changed

lib/web_ui/lib/src/engine/semantics/text_field.dart

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,10 +223,33 @@ class SemanticTextField extends SemanticRole {
223223
return true;
224224
}
225225

226+
DomHTMLInputElement _createSingleLineField() {
227+
return createDomHTMLInputElement()
228+
..type = semanticsObject.hasFlag(ui.SemanticsFlag.isObscured)
229+
? 'password'
230+
: 'text';
231+
}
232+
233+
DomHTMLTextAreaElement _createMultiLineField() {
234+
final textArea = createDomHTMLTextAreaElement();
235+
236+
if (semanticsObject.hasFlag(ui.SemanticsFlag.isObscured)) {
237+
// -webkit-text-security is not standard, but it's the best we can do.
238+
// Another option would be to create a single-line <input type="password">
239+
// but that may have layout quirks, since it cannot represent multi-line
240+
// text. Worst case with -webkit-text-security is the browser does not
241+
// support it and it does not obscure text. However, that's not a huge
242+
// problem because semantic DOM is already invisible.
243+
textArea.style.setProperty('-webkit-text-security', 'circle');
244+
}
245+
246+
return textArea;
247+
}
248+
226249
void _initializeEditableElement() {
227250
editableElement = semanticsObject.hasFlag(ui.SemanticsFlag.isMultiline)
228-
? createDomHTMLTextAreaElement()
229-
: createDomHTMLInputElement();
251+
? _createMultiLineField()
252+
: _createSingleLineField();
230253
_updateEnabledState();
231254

232255
// On iOS, even though the semantic text field is transparent, the cursor

lib/web_ui/test/engine/semantics/text_field_test.dart

Lines changed: 83 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ void testMain() {
6060
value: 'hi',
6161
isFocused: true,
6262
);
63-
final SemanticTextField textField = textFieldSemantics.semanticRole! as SemanticTextField;
63+
final SemanticTextField textField =
64+
textFieldSemantics.semanticRole! as SemanticTextField;
6465

6566
// ensureInitialized() isn't called prior to calling dispose() here.
6667
// Since we are conditionally calling dispose() on our
@@ -92,41 +93,53 @@ void testMain() {
9293
test('renders a text field', () {
9394
createTextFieldSemantics(value: 'hello');
9495

95-
expectSemanticsTree(owner(), '''
96-
<sem>
97-
<input />
98-
</sem>''');
96+
expectSemanticsTree(
97+
owner(),
98+
'<sem><input type="text" /></sem>',
99+
);
99100

100101
// TODO(yjbanov): this used to attempt to test that value="hello" but the
101102
// test was a false positive. We should revise this test and
102103
// make sure it tests the right things:
103104
// https://github.com/flutter/flutter/issues/147200
104-
final SemanticsObject node = owner().debugSemanticsTree![0]!;
105-
final SemanticTextField textFieldRole = node.semanticRole! as SemanticTextField;
106-
final DomHTMLInputElement inputElement =
107-
textFieldRole.editableElement as DomHTMLInputElement;
105+
final node = owner().debugSemanticsTree![0]!;
106+
final textFieldRole = node.semanticRole! as SemanticTextField;
107+
final inputElement = textFieldRole.editableElement as DomHTMLInputElement;
108108
expect(inputElement.tagName.toLowerCase(), 'input');
109109
expect(inputElement.value, '');
110110
expect(inputElement.disabled, isFalse);
111111
});
112112

113+
test('renders a password field', () {
114+
createTextFieldSemantics(value: 'secret', isObscured: true);
115+
116+
expectSemanticsTree(
117+
owner(),
118+
'<sem><input type="password" /></sem>',
119+
);
120+
121+
final node = owner().debugSemanticsTree![0]!;
122+
final textFieldRole = node.semanticRole! as SemanticTextField;
123+
final inputElement = textFieldRole.editableElement as DomHTMLInputElement;
124+
expect(inputElement.disabled, isFalse);
125+
});
126+
113127
test('renders a disabled text field', () {
114128
createTextFieldSemantics(isEnabled: false, value: 'hello');
115129
expectSemanticsTree(owner(), '''<sem><input /></sem>''');
116-
final SemanticsObject node = owner().debugSemanticsTree![0]!;
117-
final SemanticTextField textFieldRole = node.semanticRole! as SemanticTextField;
118-
final DomHTMLInputElement inputElement =
119-
textFieldRole.editableElement as DomHTMLInputElement;
130+
final node = owner().debugSemanticsTree![0]!;
131+
final textFieldRole = node.semanticRole! as SemanticTextField;
132+
final inputElement = textFieldRole.editableElement as DomHTMLInputElement;
120133
expect(inputElement.tagName.toLowerCase(), 'input');
121134
expect(inputElement.disabled, isTrue);
122135
});
123136

124137
test('sends a SemanticsAction.focus action when browser requests focus',
125138
() async {
126-
final SemanticsActionLogger logger = SemanticsActionLogger();
139+
final logger = SemanticsActionLogger();
127140
createTextFieldSemantics(value: 'hello');
128141

129-
final DomElement textField = owner()
142+
final textField = owner()
130143
.semanticsHost
131144
.querySelector('input[data-semantics-role="text-field"]')!;
132145

@@ -163,14 +176,14 @@ void testMain() {
163176
);
164177

165178
// Create
166-
final SemanticsObject textFieldSemantics = createTextFieldSemantics(
179+
final textFieldSemantics = createTextFieldSemantics(
167180
value: 'hello',
168181
label: 'greeting',
169182
isFocused: true,
170183
rect: const ui.Rect.fromLTWH(0, 0, 10, 15),
171184
);
172185

173-
final SemanticTextField textField = textFieldSemantics.semanticRole! as SemanticTextField;
186+
final textField = textFieldSemantics.semanticRole! as SemanticTextField;
174187
expect(owner().semanticsHost.ownerDocument?.activeElement,
175188
strategy.domElement);
176189
expect(textField.editableElement, strategy.domElement);
@@ -231,16 +244,16 @@ void testMain() {
231244
onAction: (_) {},
232245
);
233246

234-
final SemanticsObject textFieldSemantics = createTextFieldSemantics(
235-
value: 'hello',
236-
textSelectionBase: 1,
237-
textSelectionExtent: 3,
238-
isFocused: true,
239-
rect: const ui.Rect.fromLTWH(0, 0, 10, 15));
247+
final textFieldSemantics = createTextFieldSemantics(
248+
value: 'hello',
249+
textSelectionBase: 1,
250+
textSelectionExtent: 3,
251+
isFocused: true,
252+
rect: const ui.Rect.fromLTWH(0, 0, 10, 15),
253+
);
240254

241-
final SemanticTextField textField = textFieldSemantics.semanticRole! as SemanticTextField;
242-
final DomHTMLInputElement editableElement =
243-
textField.editableElement as DomHTMLInputElement;
255+
final textField = textFieldSemantics.semanticRole! as SemanticTextField;
256+
final editableElement = textField.editableElement as DomHTMLInputElement;
244257

245258
expect(editableElement, strategy.domElement);
246259
expect(editableElement.value, '');
@@ -262,16 +275,16 @@ void testMain() {
262275
onAction: (_) {},
263276
);
264277

265-
final SemanticsObject textFieldSemantics = createTextFieldSemantics(
266-
value: 'hello',
267-
textSelectionBase: 1,
268-
textSelectionExtent: 3,
269-
isFocused: true,
270-
rect: const ui.Rect.fromLTWH(0, 0, 10, 15));
278+
final textFieldSemantics = createTextFieldSemantics(
279+
value: 'hello',
280+
textSelectionBase: 1,
281+
textSelectionExtent: 3,
282+
isFocused: true,
283+
rect: const ui.Rect.fromLTWH(0, 0, 10, 15),
284+
);
271285

272-
final SemanticTextField textField = textFieldSemantics.semanticRole! as SemanticTextField;
273-
final DomHTMLInputElement editableElement =
274-
textField.editableElement as DomHTMLInputElement;
286+
final textField = textFieldSemantics.semanticRole! as SemanticTextField;
287+
final editableElement = textField.editableElement as DomHTMLInputElement;
275288

276289
// No updates expected on semantic updates
277290
expect(editableElement, strategy.domElement);
@@ -280,7 +293,7 @@ void testMain() {
280293
expect(editableElement.selectionEnd, 0);
281294

282295
// Update from framework
283-
const MethodCall setEditingState =
296+
const setEditingState =
284297
MethodCall('TextInput.setEditingState', <String, dynamic>{
285298
'text': 'updated',
286299
'selectionBase': 2,
@@ -306,12 +319,12 @@ void testMain() {
306319
onChange: (_, __) {},
307320
onAction: (_) {},
308321
);
309-
final SemanticsObject textFieldSemantics = createTextFieldSemantics(
322+
final textFieldSemantics = createTextFieldSemantics(
310323
value: 'hello',
311324
isFocused: true,
312325
);
313326

314-
final SemanticTextField textField = textFieldSemantics.semanticRole! as SemanticTextField;
327+
final textField = textFieldSemantics.semanticRole! as SemanticTextField;
315328
expect(textField.editableElement, strategy.domElement);
316329
expect(owner().semanticsHost.ownerDocument?.activeElement,
317330
strategy.domElement);
@@ -335,7 +348,7 @@ void testMain() {
335348
expect(strategy.domElement, isNull);
336349

337350
// During the semantics update the DOM element is created and is focused on.
338-
final SemanticsObject textFieldSemantics = createTextFieldSemantics(
351+
final textFieldSemantics = createTextFieldSemantics(
339352
value: 'hello',
340353
isFocused: true,
341354
);
@@ -347,7 +360,7 @@ void testMain() {
347360
expect(strategy.domElement, isNull);
348361

349362
// It doesn't remove the DOM element.
350-
final SemanticTextField textField = textFieldSemantics.semanticRole! as SemanticTextField;
363+
final textField = textFieldSemantics.semanticRole! as SemanticTextField;
351364
expect(owner().semanticsHost.contains(textField.editableElement), isTrue);
352365
// Editing element is not enabled.
353366
expect(strategy.isEnabled, isFalse);
@@ -412,8 +425,11 @@ void testMain() {
412425
isMultiline: true,
413426
);
414427

415-
final DomHTMLTextAreaElement textArea =
416-
strategy.domElement! as DomHTMLTextAreaElement;
428+
final textArea = strategy.domElement! as DomHTMLTextAreaElement;
429+
expect(
430+
textArea.style.getPropertyValue('-webkit-text-security'),
431+
'',
432+
);
417433

418434
expect(owner().semanticsHost.ownerDocument?.activeElement,
419435
strategy.domElement);
@@ -435,6 +451,27 @@ void testMain() {
435451
expect(strategy.isEnabled, isFalse);
436452
});
437453

454+
test('multi-line and obscured', () {
455+
strategy.enable(
456+
multilineConfig,
457+
onChange: (_, __) {},
458+
onAction: (_) {},
459+
);
460+
createTextFieldSemantics(
461+
value: 'hello',
462+
isFocused: true,
463+
isMultiline: true,
464+
isObscured: true,
465+
);
466+
467+
expectSemanticsTree(
468+
owner(),
469+
'<sem><textarea style="-webkit-text-security: circle"></textarea></sem>',
470+
);
471+
472+
strategy.disable();
473+
});
474+
438475
test('Does not position or size its DOM element', () {
439476
strategy.enable(
440477
singlelineConfig,
@@ -444,7 +481,7 @@ void testMain() {
444481

445482
// Send width and height that are different from semantics values on
446483
// purpose.
447-
final EditableTextGeometry geometry = EditableTextGeometry(
484+
final geometry = EditableTextGeometry(
448485
height: 12,
449486
width: 13,
450487
globalTransform: Matrix4.translationValues(14, 15, 0).storage,
@@ -534,11 +571,12 @@ SemanticsObject createTextFieldSemantics({
534571
bool isEnabled = true,
535572
bool isFocused = false,
536573
bool isMultiline = false,
574+
bool isObscured = false,
537575
ui.Rect rect = const ui.Rect.fromLTRB(0, 0, 100, 50),
538576
int textSelectionBase = 0,
539577
int textSelectionExtent = 0,
540578
}) {
541-
final SemanticsTester tester = SemanticsTester(owner());
579+
final tester = SemanticsTester(owner());
542580
tester.updateNode(
543581
id: 0,
544582
isEnabled: isEnabled,
@@ -547,6 +585,7 @@ SemanticsObject createTextFieldSemantics({
547585
isTextField: true,
548586
isFocused: isFocused,
549587
isMultiline: isMultiline,
588+
isObscured: isObscured,
550589
hasTap: true,
551590
rect: rect,
552591
textDirection: ui.TextDirection.ltr,

0 commit comments

Comments
 (0)