Add link support in web accessibility (flutter/engine#46117)

fixes https://github.com/flutter/flutter/issues/134795

## Pre-launch Checklist

- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide] and the [C++,
Objective-C, Java style guides].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I added new tests to check the change I am making or feature I am
adding, or the PR is [test-exempt]. See [testing the engine] for
instructions on writing and running engine tests.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] I signed the [CLA].
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/wiki/Tree-hygiene#overview
[Tree Hygiene]: https://github.com/flutter/flutter/wiki/Tree-hygiene
[test-exempt]:
https://github.com/flutter/flutter/wiki/Tree-hygiene#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo
[C++, Objective-C, Java style guides]:
https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
[testing the engine]:
https://github.com/flutter/flutter/wiki/Testing-the-engine
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/wiki/Tree-hygiene#handling-breaking-changes
[Discord]: https://github.com/flutter/flutter/wiki/Chat
This commit is contained in:
chunhtai
2023-10-20 12:20:08 -07:00
committed by GitHub
parent 92eac4f833
commit bcbf0887b9
17 changed files with 260 additions and 121 deletions

View File

@@ -2720,6 +2720,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/focusable.dart + ..
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/image.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/link.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/live_region.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/platform_view.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart + ../../../flutter/LICENSE
@@ -5496,6 +5497,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/focusable.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/image.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/link.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/live_region.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/platform_view.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart

View File

@@ -143,6 +143,7 @@ export 'engine/semantics/focusable.dart';
export 'engine/semantics/image.dart';
export 'engine/semantics/incrementable.dart';
export 'engine/semantics/label_and_value.dart';
export 'engine/semantics/link.dart';
export 'engine/semantics/live_region.dart';
export 'engine/semantics/platform_view.dart';
export 'engine/semantics/scrollable.dart';

View File

@@ -8,6 +8,7 @@ export 'semantics/focusable.dart';
export 'semantics/image.dart';
export 'semantics/incrementable.dart';
export 'semantics/label_and_value.dart';
export 'semantics/link.dart';
export 'semantics/live_region.dart';
export 'semantics/platform_view.dart';
export 'semantics/scrollable.dart';

View File

@@ -13,7 +13,6 @@
import 'package:ui/ui.dart' as ui;
import '../dom.dart';
import 'semantics.dart';
/// The specific type of checkable control.
@@ -63,18 +62,18 @@ class Checkable extends PrimaryRoleManager {
if (semanticsObject.isFlagsDirty) {
switch (_kind) {
case _CheckableKind.checkbox:
semanticsObject.setAriaRole('checkbox');
setAriaRole('checkbox');
case _CheckableKind.radio:
semanticsObject.setAriaRole('radio');
setAriaRole('radio');
case _CheckableKind.toggle:
semanticsObject.setAriaRole('switch');
setAriaRole('switch');
}
/// Adding disabled and aria-disabled attribute to notify the assistive
/// technologies of disabled elements.
_updateDisabledAttribute();
semanticsObject.element.setAttribute(
setAttribute(
'aria-checked',
(semanticsObject.hasFlag(ui.SemanticsFlag.isChecked) ||
semanticsObject.hasFlag(ui.SemanticsFlag.isToggled))
@@ -92,17 +91,15 @@ class Checkable extends PrimaryRoleManager {
void _updateDisabledAttribute() {
if (semanticsObject.enabledState() == EnabledState.disabled) {
final DomElement element = semanticsObject.element;
element
..setAttribute('aria-disabled', 'true')
..setAttribute('disabled', 'true');
setAttribute('aria-disabled', 'true');
setAttribute('disabled', 'true');
} else {
_removeDisabledAttribute();
}
}
void _removeDisabledAttribute() {
final DomElement element = semanticsObject.element;
element..removeAttribute('aria-disabled')..removeAttribute('disabled');
removeAttribute('aria-disabled');
removeAttribute('disabled');
}
}

View File

@@ -38,8 +38,8 @@ class Dialog extends PrimaryRoleManager {
}
return true;
}());
semanticsObject.element.setAttribute('aria-label', label ?? '');
semanticsObject.setAriaRole('dialog');
setAttribute('aria-label', label ?? '');
setAriaRole('dialog');
}
}
@@ -51,8 +51,8 @@ class Dialog extends PrimaryRoleManager {
return;
}
semanticsObject.setAriaRole('dialog');
semanticsObject.element.setAttribute(
setAriaRole('dialog');
setAttribute(
'aria-describedby',
routeName.semanticsObject.element.id,
);
@@ -61,7 +61,10 @@ class Dialog extends PrimaryRoleManager {
/// Supplies a description for the nearest ancestor [Dialog].
class RouteName extends RoleManager {
RouteName(SemanticsObject semanticsObject) : super(Role.routeName, semanticsObject);
RouteName(
SemanticsObject semanticsObject,
PrimaryRoleManager owner,
) : super(Role.routeName, semanticsObject, owner);
Dialog? _dialog;

View File

@@ -28,9 +28,9 @@ import 'semantics.dart';
///
/// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets
class Focusable extends RoleManager {
Focusable(SemanticsObject semanticsObject)
Focusable(SemanticsObject semanticsObject, PrimaryRoleManager owner)
: _focusManager = AccessibilityFocusManager(semanticsObject.owner),
super(Role.focusable, semanticsObject);
super(Role.focusable, semanticsObject, owner);
final AccessibilityFocusManager _focusManager;
@@ -38,7 +38,7 @@ class Focusable extends RoleManager {
void update() {
if (semanticsObject.isFocusable) {
if (!_focusManager.isManaging) {
_focusManager.manage(semanticsObject.id, semanticsObject.element);
_focusManager.manage(semanticsObject.id, owner.element);
}
_focusManager.changeFocus(semanticsObject.hasFocus && (!semanticsObject.hasEnabledState || semanticsObject.isEnabled));
} else {

View File

@@ -49,14 +49,14 @@ class ImageRoleManager extends PrimaryRoleManager {
..height = '${semanticsObject.rect!.height}px';
}
_auxiliaryImageElement!.style.fontSize = '6px';
semanticsObject.element.append(_auxiliaryImageElement!);
append(_auxiliaryImageElement!);
}
_auxiliaryImageElement!.setAttribute('role', 'img');
_setLabel(_auxiliaryImageElement);
} else if (semanticsObject.isVisualOnly) {
semanticsObject.setAriaRole('img');
_setLabel(semanticsObject.element);
setAriaRole('img');
_setLabel(element);
_cleanUpAuxiliaryElement();
} else {
_cleanUpAuxiliaryElement();
@@ -78,7 +78,7 @@ class ImageRoleManager extends PrimaryRoleManager {
}
void _cleanupElement() {
semanticsObject.element.removeAttribute('aria-label');
removeAttribute('aria-label');
}
@override

View File

@@ -29,7 +29,7 @@ class Incrementable extends PrimaryRoleManager {
addRouteName();
addLabelAndValue();
semanticsObject.element.append(_element);
append(_element);
_element.type = 'range';
_element.setAttribute('role', 'slider');

View File

@@ -2,7 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import '../dom.dart';
import 'semantics.dart';
/// Renders [SemanticsObject.label] and/or [SemanticsObject.value] to the semantics DOM.
@@ -26,8 +25,8 @@ import 'semantics.dart';
/// This role manager does not manage images and text fields. See
/// [ImageRoleManager] and [TextField].
class LabelAndValue extends RoleManager {
LabelAndValue(SemanticsObject semanticsObject)
: super(Role.labelAndValue, semanticsObject);
LabelAndValue(SemanticsObject semanticsObject, PrimaryRoleManager owner)
: super(Role.labelAndValue, semanticsObject, owner);
@override
void update() {
@@ -62,12 +61,11 @@ class LabelAndValue extends RoleManager {
combinedValue.write(semanticsObject.value);
}
semanticsObject.element
.setAttribute('aria-label', combinedValue.toString());
owner.setAttribute('aria-label', combinedValue.toString());
}
void _cleanUpDom() {
semanticsObject.element.removeAttribute('aria-label');
owner.removeAttribute('aria-label');
}
@override

View File

@@ -0,0 +1,21 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import '../dom.dart';
import '../semantics.dart';
/// Provides accessibility for links.
class Link extends PrimaryRoleManager {
Link(SemanticsObject semanticsObject) : super.withBasics(PrimaryRole.link, semanticsObject);
@override
DomElement createElement() {
final DomElement element = domDocument.createElement('a');
// TODO(chunhtai): Fill in the real link once the framework sends entire uri.
// https://github.com/flutter/flutter/issues/102535.
element.setAttribute('href', '#');
element.style.display = 'block';
return element;
}
}

View File

@@ -15,8 +15,8 @@ import 'semantics.dart';
/// label of the element. See [LabelAndValue]. If there is no label provided
/// no content will be read.
class LiveRegion extends RoleManager {
LiveRegion(SemanticsObject semanticsObject)
: super(Role.liveRegion, semanticsObject);
LiveRegion(SemanticsObject semanticsObject, PrimaryRoleManager owner)
: super(Role.liveRegion, semanticsObject, owner);
String? _lastAnnouncement;

View File

@@ -2,7 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import '../dom.dart';
import '../platform_views/slots.dart';
import 'semantics.dart';
@@ -30,13 +29,13 @@ class PlatformViewRoleManager extends PrimaryRoleManager {
if (semanticsObject.isPlatformView) {
if (semanticsObject.isPlatformViewIdDirty) {
semanticsObject.element.setAttribute(
setAttribute(
'aria-owns',
getPlatformViewDomId(semanticsObject.platformViewId),
);
}
} else {
semanticsObject.element.removeAttribute('aria-owns');
removeAttribute('aria-owns');
}
}
}

View File

@@ -30,7 +30,7 @@ class Scrollable extends PrimaryRoleManager {
..transformOrigin = '0 0 0'
// Ignore pointer events since this is a dummy element.
..pointerEvents = 'none';
semanticsObject.element.append(_scrollOverflowElement);
append(_scrollOverflowElement);
}
/// Disables browser-driven scrolling in the presence of pointer events.
@@ -112,7 +112,7 @@ class Scrollable extends PrimaryRoleManager {
// This is effective only in Chrome. Safari does not implement this
// CSS property. In Safari the `PointerBinding` uses `preventDefault`
// to prevent browser scrolling.
semanticsObject.element.style.touchAction = 'none';
element.style.touchAction = 'none';
_gestureModeDidChange();
// Memoize the tear-off because Dart does not guarantee that two
@@ -126,17 +126,17 @@ class Scrollable extends PrimaryRoleManager {
_scrollListener = createDomEventListener((_) {
_recomputeScrollPosition();
});
semanticsObject.element.addEventListener('scroll', _scrollListener);
addEventListener('scroll', _scrollListener);
}
}
/// The value of "scrollTop" or "scrollLeft", depending on the scroll axis.
int get _domScrollPosition {
if (semanticsObject.isVerticalScrollContainer) {
return semanticsObject.element.scrollTop.toInt();
return element.scrollTop.toInt();
} else {
assert(semanticsObject.isHorizontalScrollContainer);
return semanticsObject.element.scrollLeft.toInt();
return element.scrollLeft.toInt();
}
}
@@ -153,7 +153,6 @@ class Scrollable extends PrimaryRoleManager {
void _neutralizeDomScrollPosition() {
// This value is arbitrary.
const int canonicalNeutralScrollPosition = 10;
final DomElement element = semanticsObject.element;
final ui.Rect? rect = semanticsObject.rect;
if (rect == null) {
printWarning('Warning! the rect attribute of semanticsObject is null');
@@ -197,7 +196,6 @@ class Scrollable extends PrimaryRoleManager {
}
void _gestureModeDidChange() {
final DomElement element = semanticsObject.element;
switch (semanticsObject.owner.gestureMode) {
case GestureMode.browserGestures:
// overflow:scroll will cause the browser report "scroll" events when
@@ -227,13 +225,13 @@ class Scrollable extends PrimaryRoleManager {
@override
void dispose() {
super.dispose();
final DomCSSStyleDeclaration style = semanticsObject.element.style;
final DomCSSStyleDeclaration style = element.style;
assert(_gestureModeListener != null);
style.removeProperty('overflowY');
style.removeProperty('overflowX');
style.removeProperty('touch-action');
if (_scrollListener != null) {
semanticsObject.element.removeEventListener('scroll', _scrollListener);
removeEventListener('scroll', _scrollListener);
}
semanticsObject.owner.removeGestureModeListener(_gestureModeListener);
_gestureModeListener = null;

View File

@@ -24,6 +24,7 @@ import 'focusable.dart';
import 'image.dart';
import 'incrementable.dart';
import 'label_and_value.dart';
import 'link.dart';
import 'live_region.dart';
import 'platform_view.dart';
import 'scrollable.dart';
@@ -382,6 +383,9 @@ enum PrimaryRole {
///
/// Provides a label or a value.
generic,
/// Contains a link.
link,
}
/// Identifies one of the secondary [RoleManager]s of a [PrimaryRoleManager].
@@ -437,6 +441,8 @@ abstract class PrimaryRoleManager {
/// management intereferes with the widget's functionality.
PrimaryRoleManager.blank(this.role, this.semanticsObject);
late final DomElement element = _initElement(createElement(), semanticsObject);
/// The primary role identifier.
final PrimaryRole role;
@@ -453,29 +459,82 @@ abstract class PrimaryRoleManager {
@visibleForTesting
List<Role> get debugSecondaryRoles => _secondaryRoleManagers?.map((RoleManager manager) => manager.role).toList() ?? const <Role>[];
@protected
DomElement createElement() => domDocument.createElement('flt-semantics');
static DomElement _initElement(DomElement element, SemanticsObject semanticsObject) {
// DOM nodes created for semantics objects are positioned absolutely using
// transforms.
element.style.position = 'absolute';
element.setAttribute('id', 'flt-semantic-node-${semanticsObject.id}');
// The root node has some properties that other nodes do not.
if (semanticsObject.id == 0 && !configuration.debugShowSemanticsNodes) {
// Make all semantics transparent. Use `filter` instead of `opacity`
// attribute because `filter` is stronger. `opacity` does not apply to
// some elements, particularly on iOS, such as the slider thumb and track.
//
// Use transparency instead of "visibility:hidden" or "display:none"
// so that a screen reader does not ignore these elements.
element.style.filter = 'opacity(0%)';
// Make text explicitly transparent to signal to the browser that no
// rasterization needs to be done.
element.style.color = 'rgba(0,0,0,0)';
}
// Make semantic elements visible for debugging by outlining them using a
// green border. Do not use `border` attribute because it affects layout
// (`outline` does not).
if (configuration.debugShowSemanticsNodes) {
element.style.outline = '1px solid green';
}
return element;
}
/// Sets the `role` ARIA attribute.
void setAriaRole(String ariaRoleName) {
setAttribute('role', ariaRoleName);
}
/// Sets the `role` ARIA attribute.
void setAttribute(String name, Object value) {
element.setAttribute(name, value);
}
void append(DomElement child) {
element.append(child);
}
void removeAttribute(String name) => element.removeAttribute(name);
void addEventListener(String type, DomEventListener? listener, [bool? useCapture]) => element.addEventListener(type, listener, useCapture);
void removeEventListener(String type, DomEventListener? listener, [bool? useCapture]) => element.removeEventListener(type, listener, useCapture);
/// Adds generic focus management features.
void addFocusManagement() {
addSecondaryRole(Focusable(semanticsObject));
addSecondaryRole(Focusable(semanticsObject, this));
}
/// Adds generic live region features.
void addLiveRegion() {
addSecondaryRole(LiveRegion(semanticsObject));
addSecondaryRole(LiveRegion(semanticsObject, this));
}
/// Adds generic route name features.
void addRouteName() {
addSecondaryRole(RouteName(semanticsObject));
addSecondaryRole(RouteName(semanticsObject, this));
}
/// Adds generic label features.
void addLabelAndValue() {
addSecondaryRole(LabelAndValue(semanticsObject));
addSecondaryRole(LabelAndValue(semanticsObject, this));
}
/// Adds generic functionality for handling taps and clicks.
void addTappable() {
addSecondaryRole(Tappable(semanticsObject));
addSecondaryRole(Tappable(semanticsObject, this));
}
/// Adds a secondary role to this primary role manager.
@@ -525,7 +584,7 @@ abstract class PrimaryRoleManager {
/// gesture mode changes.
@mustCallSuper
void dispose() {
semanticsObject.element.removeAttribute('role');
removeAttribute('role');
_isDisposed = true;
}
}
@@ -566,11 +625,11 @@ final class GenericRole extends PrimaryRoleManager {
// Flutter renders into canvas, so the focus ring looks wrong.
// - Read out the same label multiple times.
if (semanticsObject.hasChildren) {
semanticsObject.setAriaRole('group');
setAriaRole('group');
} else if (semanticsObject.hasFlag(ui.SemanticsFlag.isHeader)) {
semanticsObject.setAriaRole('heading');
setAriaRole('heading');
} else {
semanticsObject.setAriaRole('text');
setAriaRole('text');
}
}
}
@@ -588,7 +647,7 @@ abstract class RoleManager {
/// Initializes a secondary role for [semanticsObject].
///
/// A single role object manages exactly one [SemanticsObject].
RoleManager(this.role, this.semanticsObject);
RoleManager(this.role, this.semanticsObject, this.owner);
/// Role identifier.
final Role role;
@@ -596,6 +655,8 @@ abstract class RoleManager {
/// The semantics object managed by this role.
final SemanticsObject semanticsObject;
final PrimaryRoleManager owner;
/// Called immediately after the [semanticsObject] updates some of its fields.
///
/// A concrete implementation of this method would typically use some of the
@@ -627,34 +688,7 @@ abstract class RoleManager {
/// information to the browser.
class SemanticsObject {
/// Creates a semantics tree node with the given [id] and [owner].
SemanticsObject(this.id, this.owner) {
// DOM nodes created for semantics objects are positioned absolutely using
// transforms.
element.style.position = 'absolute';
element.setAttribute('id', 'flt-semantic-node-$id');
// The root node has some properties that other nodes do not.
if (id == 0 && !configuration.debugShowSemanticsNodes) {
// Make all semantics transparent. Use `filter` instead of `opacity`
// attribute because `filter` is stronger. `opacity` does not apply to
// some elements, particularly on iOS, such as the slider thumb and track.
//
// Use transparency instead of "visibility:hidden" or "display:none"
// so that a screen reader does not ignore these elements.
element.style.filter = 'opacity(0%)';
// Make text explicitly transparent to signal to the browser that no
// rasterization needs to be done.
element.style.color = 'rgba(0,0,0,0)';
}
// Make semantic elements visible for debugging by outlining them using a
// green border. Do not use `border` attribute because it affects layout
// (`outline` does not).
if (configuration.debugShowSemanticsNodes) {
element.style.outline = '1px solid green';
}
}
SemanticsObject(this.id, this.owner);
/// See [ui.SemanticsUpdateBuilder.updateNode].
int get flags => _flags;
@@ -981,9 +1015,6 @@ class SemanticsObject {
/// Controls the semantics tree that this node participates in.
final EngineSemanticsOwner owner;
/// The DOM element used to convey semantics information to the browser.
final DomElement element = domDocument.createElement('flt-semantics');
/// Bitfield showing which fields have been updated but have not yet been
/// applied to the DOM.
///
@@ -996,6 +1027,9 @@ class SemanticsObject {
/// Whether the field corresponding to the [fieldIndex] has been updated.
bool _isDirty(int fieldIndex) => (_dirtyFields & fieldIndex) != 0;
/// The dom element of this semantics object.
DomElement get element => primaryRole!.element;
/// Returns the HTML element that contains the HTML elements of direct
/// children of this object.
///
@@ -1079,6 +1113,9 @@ class SemanticsObject {
/// Whether this object represents an editable text field.
bool get isTextField => hasFlag(ui.SemanticsFlag.isTextField);
/// Whether this object represents an editable text field.
bool get isLink => hasFlag(ui.SemanticsFlag.isLink);
/// Whether this object needs screen readers attention right away.
bool get isLiveRegion =>
hasFlag(ui.SemanticsFlag.isLiveRegion) &&
@@ -1456,11 +1493,6 @@ class SemanticsObject {
_currentChildrenInRenderOrder = childrenInRenderOrder;
}
/// Sets the `role` ARIA attribute.
void setAriaRole(String ariaRoleName) {
element.setAttribute('role', ariaRoleName);
}
/// The primary role of this node.
///
/// The primary role is assigned by [updateSelf] based on the combination of
@@ -1485,6 +1517,8 @@ class SemanticsObject {
return PrimaryRole.scrollable;
} else if (scopesRoute) {
return PrimaryRole.dialog;
} else if (isLink) {
return PrimaryRole.link;
} else {
return PrimaryRole.generic;
}
@@ -1500,6 +1534,7 @@ class SemanticsObject {
PrimaryRole.dialog => Dialog(this),
PrimaryRole.image => ImageRoleManager(this),
PrimaryRole.platformView => PlatformViewRoleManager(this),
PrimaryRole.link => Link(this),
PrimaryRole.generic => GenericRole(this),
};
}
@@ -1509,6 +1544,7 @@ class SemanticsObject {
void _updateRoles() {
PrimaryRoleManager? currentPrimaryRole = primaryRole;
final PrimaryRole roleId = _getPrimaryRoleIdentifier();
final DomElement? previousElement = primaryRole?.element;
if (currentPrimaryRole != null) {
if (currentPrimaryRole.role == roleId) {
@@ -1535,6 +1571,19 @@ class SemanticsObject {
primaryRole = currentPrimaryRole;
currentPrimaryRole.update();
}
// Reparent element.
if (previousElement != element) {
final DomElement? container = _childContainerElement;
if (container != null) {
element.append(container);
}
final DomElement? parent = previousElement?.parent;
if (parent != null) {
parent.insertBefore(element, previousElement);
previousElement!.remove();
}
}
}
/// Whether the object represents an UI element with "increase" or "decrease"

View File

@@ -11,7 +11,7 @@ import 'semantics.dart';
/// Sets the "button" ARIA role.
class Button extends PrimaryRoleManager {
Button(SemanticsObject semanticsObject) : super.withBasics(PrimaryRole.button, semanticsObject) {
semanticsObject.setAriaRole('button');
setAriaRole('button');
}
@override
@@ -19,9 +19,9 @@ class Button extends PrimaryRoleManager {
super.update();
if (semanticsObject.enabledState() == EnabledState.disabled) {
semanticsObject.element.setAttribute('aria-disabled', 'true');
setAttribute('aria-disabled', 'true');
} else {
semanticsObject.element.removeAttribute('aria-disabled');
removeAttribute('aria-disabled');
}
}
}
@@ -33,26 +33,28 @@ class Button extends PrimaryRoleManager {
/// the browser may not send us pointer events. In that mode we forward HTML
/// click as [ui.SemanticsAction.tap].
class Tappable extends RoleManager {
Tappable(SemanticsObject semanticsObject)
: super(Role.tappable, semanticsObject);
Tappable(SemanticsObject semanticsObject, PrimaryRoleManager owner)
: super(Role.tappable, semanticsObject, owner);
DomEventListener? _clickListener;
@override
void update() {
if (!semanticsObject.isTappable || semanticsObject.enabledState() == EnabledState.disabled) {
_stopListening();
} else {
if (_clickListener == null) {
_clickListener = createDomEventListener((DomEvent event) {
if (semanticsObject.owner.gestureMode != GestureMode.browserGestures) {
return;
}
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
semanticsObject.id, ui.SemanticsAction.tap, null);
});
semanticsObject.element.addEventListener('click', _clickListener);
}
if (_clickListener == null) {
_clickListener = createDomEventListener((DomEvent event) {
// Stop dom from reacting since it will be handled entirely on the
// flutter side.
event.preventDefault();
if (!semanticsObject.isTappable || semanticsObject.enabledState() == EnabledState.disabled) {
return;
}
if (semanticsObject.owner.gestureMode != GestureMode.browserGestures) {
return;
}
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
semanticsObject.id, ui.SemanticsAction.tap, null);
});
owner.addEventListener('click', _clickListener);
}
}
@@ -61,7 +63,7 @@ class Tappable extends RoleManager {
return;
}
semanticsObject.element.removeEventListener('click', _clickListener);
owner.removeEventListener('click', _clickListener);
_clickListener = null;
}

View File

@@ -275,7 +275,7 @@ class TextField extends PrimaryRoleManager {
..left = '0'
..width = '${semanticsObject.rect!.width}px'
..height = '${semanticsObject.rect!.height}px';
semanticsObject.element.append(activeEditableElement);
append(activeEditableElement);
}
void _setupDomElement() {
@@ -336,22 +336,21 @@ class TextField extends PrimaryRoleManager {
return;
}
semanticsObject.element
..setAttribute('role', 'textbox')
..setAttribute('contenteditable', 'false')
..setAttribute('tabindex', '0');
setAttribute('role', 'textbox');
setAttribute('contenteditable', 'false');
setAttribute('tabindex', '0');
num? lastPointerDownOffsetX;
num? lastPointerDownOffsetY;
semanticsObject.element.addEventListener('pointerdown',
addEventListener('pointerdown',
createDomEventListener((DomEvent event) {
final DomPointerEvent pointerEvent = event as DomPointerEvent;
lastPointerDownOffsetX = pointerEvent.clientX;
lastPointerDownOffsetY = pointerEvent.clientY;
}), true);
semanticsObject.element.addEventListener('pointerup',
addEventListener('pointerup',
createDomEventListener((DomEvent event) {
final DomPointerEvent pointerEvent = event as DomPointerEvent;
@@ -399,17 +398,17 @@ class TextField extends PrimaryRoleManager {
// represent the same text field. It will confuse VoiceOver, so `role` needs to
// be assigned and removed, based on whether or not editableElement exists.
activeEditableElement.focus();
semanticsObject.element.removeAttribute('role');
removeAttribute('role');
activeEditableElement.addEventListener('blur',
createDomEventListener((DomEvent event) {
semanticsObject.element.setAttribute('role', 'textbox');
setAttribute('role', 'textbox');
activeEditableElement.remove();
SemanticsTextEditingStrategy._instance?.deactivate(this);
// Focus on semantics element before removing the editable element, so that
// the user can continue navigating the page with the assistive technology.
semanticsObject.element.focus();
element.focus();
editableElement = null;
}));
}
@@ -447,7 +446,7 @@ class TextField extends PrimaryRoleManager {
}
}
final DomElement element = editableElement ?? semanticsObject.element;
final DomElement element = editableElement ?? this.element;
if (semanticsObject.hasLabel) {
element.setAttribute(
'aria-label',

View File

@@ -92,6 +92,9 @@ void runSemanticsTests() {
group('focusable', () {
_testFocusable();
});
group('link', () {
_testLink();
});
}
void _testRoleManagerLifecycle() {
@@ -337,7 +340,11 @@ void _testEngineSemanticsOwner() {
expect(placeholder.isConnected, isFalse);
});
void renderSemantics({String? label, String? tooltip}) {
void renderSemantics({String? label, String? tooltip, Set<ui.SemanticsFlag> flags = const <ui.SemanticsFlag>{}}) {
int flagValues = 0;
for (final ui.SemanticsFlag flag in flags) {
flagValues = flagValues | flag.index;
}
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
@@ -351,6 +358,7 @@ void _testEngineSemanticsOwner() {
id: 1,
label: label ?? '',
tooltip: tooltip ?? '',
flags: flagValues,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 20, 20),
);
@@ -404,6 +412,45 @@ void _testEngineSemanticsOwner() {
semantics().semanticsEnabled = false;
});
test('can switch role', () async {
semantics().semanticsEnabled = true;
// Create
renderSemantics(label: 'Hello');
Map<int, SemanticsObject> tree = semantics().debugSemanticsTree!;
expect(tree.length, 2);
expect(tree[1]!.element.tagName.toLowerCase(), 'flt-semantics');
expect(tree[1]!.id, 1);
expect(tree[1]!.label, 'Hello');
final DomElement existingParent = tree[1]!.element.parent!;
expectSemanticsTree('''
<sem style="$rootSemanticStyle">
<sem-c>
<sem aria-label="Hello"></sem>
</sem-c>
</sem>''');
// Update
renderSemantics(label: 'Hello', flags: <ui.SemanticsFlag>{ ui.SemanticsFlag.isLink });
tree = semantics().debugSemanticsTree!;
expect(tree.length, 2);
expect(tree[1]!.id, 1);
expect(tree[1]!.label, 'Hello');
expect(tree[1]!.element.tagName.toLowerCase(), 'a');
expectSemanticsTree('''
<sem style="$rootSemanticStyle">
<sem-c>
<a aria-label="Hello" role="button" style="display: block;"></a>
</sem-c>
</sem>''');
expect(existingParent, tree[1]!.element.parent);
semantics().semanticsEnabled = false;
});
test('tooltip is part of label', () async {
semantics().semanticsEnabled = true;
@@ -2892,6 +2939,28 @@ void _testFocusable() {
});
}
void _testLink() {
test('nodes with link: true creates anchor tag', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
SemanticsObject pumpSemantics() {
final SemanticsTester tester = SemanticsTester(semantics());
tester.updateNode(
id: 0,
isLink: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
return tester.getSemanticsObject(0);
}
final SemanticsObject object = pumpSemantics();
expect(object.element.tagName.toLowerCase(), 'a');
});
}
/// A facade in front of [ui.SemanticsUpdateBuilder.updateNode] that
/// supplies default values for semantics attributes.
void updateNode(