diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index e6e3308249404..c01aba892418a 100644 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -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 diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart index 1fda83ecc08fa..da7c3cdfddbe1 100644 --- a/lib/web_ui/lib/src/engine.dart +++ b/lib/web_ui/lib/src/engine.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'; diff --git a/lib/web_ui/lib/src/engine/semantics.dart b/lib/web_ui/lib/src/engine/semantics.dart index d1d58ce4a688e..0ba3230ee96a1 100644 --- a/lib/web_ui/lib/src/engine/semantics.dart +++ b/lib/web_ui/lib/src/engine/semantics.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'; diff --git a/lib/web_ui/lib/src/engine/semantics/checkable.dart b/lib/web_ui/lib/src/engine/semantics/checkable.dart index e6f5b2efcc223..d50f51c512c64 100644 --- a/lib/web_ui/lib/src/engine/semantics/checkable.dart +++ b/lib/web_ui/lib/src/engine/semantics/checkable.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'); } } diff --git a/lib/web_ui/lib/src/engine/semantics/dialog.dart b/lib/web_ui/lib/src/engine/semantics/dialog.dart index 8155ba4838415..9f64e42e7acff 100644 --- a/lib/web_ui/lib/src/engine/semantics/dialog.dart +++ b/lib/web_ui/lib/src/engine/semantics/dialog.dart @@ -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; diff --git a/lib/web_ui/lib/src/engine/semantics/focusable.dart b/lib/web_ui/lib/src/engine/semantics/focusable.dart index 4b3285dbdf399..4caf56f3f3eac 100644 --- a/lib/web_ui/lib/src/engine/semantics/focusable.dart +++ b/lib/web_ui/lib/src/engine/semantics/focusable.dart @@ -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 { diff --git a/lib/web_ui/lib/src/engine/semantics/image.dart b/lib/web_ui/lib/src/engine/semantics/image.dart index 0f0eb298b0ad3..efe1d7cdb414b 100644 --- a/lib/web_ui/lib/src/engine/semantics/image.dart +++ b/lib/web_ui/lib/src/engine/semantics/image.dart @@ -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 diff --git a/lib/web_ui/lib/src/engine/semantics/incrementable.dart b/lib/web_ui/lib/src/engine/semantics/incrementable.dart index f1de98d026982..7ad693b628584 100644 --- a/lib/web_ui/lib/src/engine/semantics/incrementable.dart +++ b/lib/web_ui/lib/src/engine/semantics/incrementable.dart @@ -29,7 +29,7 @@ class Incrementable extends PrimaryRoleManager { addRouteName(); addLabelAndValue(); - semanticsObject.element.append(_element); + append(_element); _element.type = 'range'; _element.setAttribute('role', 'slider'); diff --git a/lib/web_ui/lib/src/engine/semantics/label_and_value.dart b/lib/web_ui/lib/src/engine/semantics/label_and_value.dart index 23421e13590d1..3a5f7300734fb 100644 --- a/lib/web_ui/lib/src/engine/semantics/label_and_value.dart +++ b/lib/web_ui/lib/src/engine/semantics/label_and_value.dart @@ -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 diff --git a/lib/web_ui/lib/src/engine/semantics/link.dart b/lib/web_ui/lib/src/engine/semantics/link.dart new file mode 100644 index 0000000000000..00dcdfcad54c5 --- /dev/null +++ b/lib/web_ui/lib/src/engine/semantics/link.dart @@ -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; + } +} diff --git a/lib/web_ui/lib/src/engine/semantics/live_region.dart b/lib/web_ui/lib/src/engine/semantics/live_region.dart index c922cddd717b7..cea9c997e1d63 100644 --- a/lib/web_ui/lib/src/engine/semantics/live_region.dart +++ b/lib/web_ui/lib/src/engine/semantics/live_region.dart @@ -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; diff --git a/lib/web_ui/lib/src/engine/semantics/platform_view.dart b/lib/web_ui/lib/src/engine/semantics/platform_view.dart index 80321fc77e03e..7502694390dc3 100644 --- a/lib/web_ui/lib/src/engine/semantics/platform_view.dart +++ b/lib/web_ui/lib/src/engine/semantics/platform_view.dart @@ -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'); } } } diff --git a/lib/web_ui/lib/src/engine/semantics/scrollable.dart b/lib/web_ui/lib/src/engine/semantics/scrollable.dart index 82b9308816ab3..61f912304e1c0 100644 --- a/lib/web_ui/lib/src/engine/semantics/scrollable.dart +++ b/lib/web_ui/lib/src/engine/semantics/scrollable.dart @@ -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; diff --git a/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart index 316c867a5fef0..8a6ec7c0d35eb 100644 --- a/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -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 get debugSecondaryRoles => _secondaryRoleManagers?.map((RoleManager manager) => manager.role).toList() ?? const []; + @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" diff --git a/lib/web_ui/lib/src/engine/semantics/tappable.dart b/lib/web_ui/lib/src/engine/semantics/tappable.dart index 259a9c7d55669..cba0fafeb7650 100644 --- a/lib/web_ui/lib/src/engine/semantics/tappable.dart +++ b/lib/web_ui/lib/src/engine/semantics/tappable.dart @@ -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; } diff --git a/lib/web_ui/lib/src/engine/semantics/text_field.dart b/lib/web_ui/lib/src/engine/semantics/text_field.dart index 9d6a88a9ccb46..243e09cab6f8f 100644 --- a/lib/web_ui/lib/src/engine/semantics/text_field.dart +++ b/lib/web_ui/lib/src/engine/semantics/text_field.dart @@ -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', diff --git a/lib/web_ui/test/engine/semantics/semantics_test.dart b/lib/web_ui/test/engine/semantics/semantics_test.dart index 3d4876b0432ac..88cc01db8ce40 100644 --- a/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -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 flags = const {}}) { + 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 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(''' + + + + +'''); + + // Update + renderSemantics(label: 'Hello', flags: { 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(''' + + + + +'''); + 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(