-
Notifications
You must be signed in to change notification settings - Fork 3.8k
fix: improve block labels and aria roles #9834
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v13
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,7 @@ import {Msg} from './msg.js'; | |
| import type {ConstantProvider} from './renderers/common/constants.js'; | ||
| import type {KeyboardShortcut} from './shortcut_registry.js'; | ||
| import * as Tooltip from './tooltip.js'; | ||
| import * as aria from './utils/aria.js'; | ||
| import type {Coordinate} from './utils/coordinate.js'; | ||
| import * as dom from './utils/dom.js'; | ||
| import * as idGenerator from './utils/idgenerator.js'; | ||
|
|
@@ -275,7 +276,6 @@ export abstract class Field<T = any> | |
| `problems with focus: ${block.id}.`, | ||
| ); | ||
| } | ||
| this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -398,11 +398,7 @@ export abstract class Field<T = any> | |
| // Field has already been initialized once. | ||
| return; | ||
| } | ||
| const id = this.id_; | ||
| if (!id) throw new Error('Expected ID to be defined prior to init.'); | ||
| this.fieldGroup_ = dom.createSvgElement(Svg.G, { | ||
| 'id': id, | ||
| }); | ||
| this.fieldGroup_ = dom.createSvgElement(Svg.G, {}); | ||
| if (!this.isVisible()) { | ||
| this.fieldGroup_.style.display = 'none'; | ||
| } | ||
|
|
@@ -414,6 +410,15 @@ export abstract class Field<T = any> | |
| this.bindEvents_(); | ||
| this.initModel(); | ||
| this.applyColour(); | ||
|
|
||
| // Since full-block fields can be focused from the workspace's tree, | ||
| // they need IDs in the format that the workspace is expecting. | ||
| if (this.isFullBlockField()) { | ||
| this.id_ = idGenerator.getNextUniqueId(); | ||
| } else { | ||
| this.id_ = `${sourceBlockSvg.id}_field_${idGenerator.getNextUniqueId()}`; | ||
| } | ||
| this.fieldGroup_.setAttribute('id', this.id_); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -1492,6 +1497,68 @@ export abstract class Field<T = any> | |
| this.showEditor(); | ||
| } | ||
|
|
||
| /** | ||
| * Recomputes the aria state and label for this field. Fields are generally hidden | ||
| * when in blocks in the flyout (except for top-level full-block fields), and | ||
| * otherwise set to a role of button (indicating they can be clicked to edit) | ||
| * and given the label returned from their `computeAriaLabel` method. | ||
| * | ||
| * Subclasses can override this in order to change the role or label, but they must | ||
| * ensure they keep the correct behavior for fields in flyout blocks. | ||
| * | ||
| * This method will return a boolean indicating if the element is displayed in the | ||
| * aria tree or not. This can be used by subclasses to determine whether or not | ||
| * to continue customizing the role and label (hidden elements should not have labels). | ||
| * | ||
| * @returns true if the element is in the accessibility tree, false if the aria state is hidden | ||
| */ | ||
| protected recomputeAriaContext(): boolean { | ||
| let focusableElement; | ||
| try { | ||
| focusableElement = this.getFocusableElement(); | ||
| } catch { | ||
| // just return because the field hasn't been init yet | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Capitalize and add period if this will be around long-term. |
||
| return false; | ||
| } | ||
|
|
||
| if (!focusableElement) return false; | ||
|
|
||
| if (this.getSourceBlock()?.isInFlyout) { | ||
| const isTopLevelFullBlockField = | ||
| this.getSourceBlock()?.getFullBlockField() && | ||
| !this.getSourceBlock()?.getParent(); | ||
| if (!isTopLevelFullBlockField) { | ||
| // Fields in the flyout are not generally focusable, so they should | ||
| // be hidden. An exception is full-block field blocks that don't have | ||
| // parents, since the block itself defers to the field's focusable element. | ||
| aria.setState(focusableElement, aria.State.HIDDEN, true); | ||
| return false; | ||
| } else { | ||
| // top-level full-block fields in the flyout need to have their | ||
| // roledescription set. this can't happen in the flyout code because | ||
| // the field hasn't been initialized yet then. | ||
| // these blocks should also have the rest of the state in this method set. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Add capital letters |
||
| const roleDescription = | ||
| this.getSourceBlock()?.getAriaRoleDescription() || | ||
| Msg['BLOCK_LABEL_VALUE']; | ||
| aria.setState( | ||
| focusableElement, | ||
| aria.State.ROLEDESCRIPTION, | ||
| roleDescription, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| aria.clearState(focusableElement, aria.State.HIDDEN); | ||
| // The button role is intended to indicate to users that the field has an | ||
| // editing mode that can be activated. | ||
| aria.setRole(focusableElement, aria.Role.BUTTON); | ||
|
|
||
| const label = this.computeAriaLabel(true); | ||
| aria.setState(focusableElement, aria.State.LABEL, label); | ||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * Subclasses should reimplement this method to construct their Field | ||
| * subclass from a JSON arg object. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -316,30 +316,24 @@ export class FieldImage extends Field<string> { | |
| } | ||
|
|
||
| /** | ||
| * Recomputes the ARIA role and label for this field. | ||
| * Customizes label and sets additional aria state. | ||
| */ | ||
| protected recomputeAriaContext(): void { | ||
| const focusableElement = this.getClickTarget_(); | ||
| if (!focusableElement) return; | ||
| override recomputeAriaContext(): boolean { | ||
| const shouldCustomize = super.recomputeAriaContext(); | ||
| if (!shouldCustomize) return false; | ||
|
|
||
| const isInFlyout = this.getSourceBlock()?.isInFlyout; | ||
| if (isInFlyout) { | ||
| aria.setState(focusableElement, aria.State.HIDDEN, true); | ||
| return; | ||
| } | ||
| const focusableElement = this.getFocusableElement(); | ||
|
|
||
| aria.setState(focusableElement, aria.State.HIDDEN, false); | ||
| // The button role is intended to indicate to users that the field has an | ||
| // editing mode that can be activated. The presentation role is used to | ||
| // prevent screen readers from reading the content or its descendants. | ||
| // Only clickable image fields are navigable. | ||
| aria.setRole( | ||
| focusableElement, | ||
| this.isClickable() ? aria.Role.BUTTON : aria.Role.PRESENTATION, | ||
| ); | ||
|
|
||
| const label = this.computeAriaLabel(true); | ||
| aria.setState(focusableElement, aria.State.LABEL, label); | ||
| if (!this.isClickable()) { | ||
| aria.setRole(focusableElement, aria.Role.PRESENTATION); | ||
| aria.clearState(focusableElement, aria.State.LABEL); | ||
| return false; | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are we no longer using the button role for clickable images? It possible for an image field to become clickable after it's made? If so, would the outdated 'presentation' role need to be cleared? |
||
| return true; | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -837,29 +837,19 @@ export abstract class FieldInput<T extends InputTypes> extends Field< | |
| } | ||
|
|
||
| /** | ||
| * Recomputes the ARIA role and label for this field. | ||
| * Customizes the label for this field to include "editable" if it applies. | ||
| */ | ||
| protected recomputeAriaContext(): void { | ||
| const focusableElement = this.getClickTarget_(); | ||
| if (!focusableElement) return; | ||
|
|
||
| if (this.getSourceBlock()?.isInFlyout) { | ||
| aria.setState(focusableElement, aria.State.HIDDEN, true); | ||
| return; | ||
| } | ||
|
|
||
| aria.setState(focusableElement, aria.State.HIDDEN, false); | ||
| // The button role is intended to indicate to users that the field has an | ||
| // editing mode that can be activated. | ||
| aria.setRole(focusableElement, aria.Role.BUTTON); | ||
| override recomputeAriaContext(): boolean { | ||
| const shouldCustomize = super.recomputeAriaContext(); | ||
| if (!shouldCustomize) return false; | ||
| const focusableElement = this.getFocusableElement(); | ||
|
|
||
| let label = this.computeAriaLabel(true); | ||
|
|
||
| if (this.isCurrentlyEditable?.()) { | ||
| if (this.isCurrentlyEditable() && !this.getSourceBlock()?.isInFlyout) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we're in a flyout, would we have already returned |
||
| label = Msg['FIELD_LABEL_EDIT_PREFIX'].replace('%1', label); | ||
| } | ||
|
|
||
| aria.setState(focusableElement, aria.State.LABEL, label); | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -425,25 +425,8 @@ suite('ARIA', function () { | |
| assert.include(label, 'collapsed'); | ||
| }); | ||
|
|
||
| test('Shadow blocks indicate that in their label', function () { | ||
| const block = this.makeBlock('text_print'); | ||
| const text = this.makeBlock('text'); | ||
| text.outputConnection.connect(block.inputList[0].connection); | ||
| let label = Blockly.utils.aria.getState( | ||
| text.getFocusableElement(), | ||
| Blockly.utils.aria.State.LABEL, | ||
| ); | ||
| assert.notInclude(label, 'replaceable'); | ||
| text.setShadow(true); | ||
| label = Blockly.utils.aria.getState( | ||
| text.getFocusableElement(), | ||
| Blockly.utils.aria.State.LABEL, | ||
| ); | ||
| assert.include(label, 'replaceable'); | ||
| }); | ||
|
|
||
| test('Blocks without inputs are properly labeled', function () { | ||
| const block = this.makeBlock('math_random_float'); | ||
| const block = this.makeBlock('logic_boolean'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any particular reason for this change? |
||
| const label = Blockly.utils.aria.getState( | ||
| block.getFocusableElement(), | ||
| Blockly.utils.aria.State.LABEL, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Elsewhere, these methods are called
recomputeAriaContext. Worth renaming?