forked from firka/flutter
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:
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,7 +29,7 @@ class Incrementable extends PrimaryRoleManager {
|
||||
addRouteName();
|
||||
addLabelAndValue();
|
||||
|
||||
semanticsObject.element.append(_element);
|
||||
append(_element);
|
||||
_element.type = 'range';
|
||||
_element.setAttribute('role', 'slider');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user