feat: Expand single field block labeling (experimental) (#9484)

## The basics

- [ ] ~I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change)~ (I can't really due to the nature of this change--I have to rely on tests and external testing)

## The details
### Resolves

Fixes part of #9456 (remainder will be in a change in the keyboard navigation plugin)

### Proposed Changes

Introduce and use new `Block` function for retrieving a configurably constrained singleton field for a given block. The constraints allow for some level of configuring (such as whether to isolate to only full or editable blocks). The existing simple reporter function has been retrofitted to use this new function, instead.

### Reason for Changes

This expanded support fixes the underlying use case.

Separately, this change reveals two noteworthy details:
1. There's inconsistency in the codebase as to when the singleton field needs to be editable, a full-block field, both, and neither. It would be ideal to make this consistent. Interestingly, the documentation for `isSimpleReporter` seems to have been wrong since it wasn't actually fulfilling its contract of returning an editable field (this has been retained for callsites except where the check was already happening).
2. There's a possible recursion case now possible between `getSingletonFullBlockField` and `isFullBlockField` due to `FieldInput`'s `isFullBlockField` depending on `isSimpleReporter`. Ideally this would be changed in the future to avoid that potential recursion risk (possibly as part of #9307).

### Test Coverage

No new automated tests are needed for this experimental work. Manual testing mainly comprised of cursory navigation and readout checks for single-field blocks to make sure nothing breaks. More thorough testing is difficult in core since the specific situation of multiple fields don't have a corresponding block to use in the playground to verify.

Automated tests are also being heavily relied on for correctness since all of the nuance behind the simple reporter cases would require a deeper testing pass.

### Documentation

No new documentation needed for this experimental work.

### Additional Information

None.
This commit is contained in:
Ben Henning
2025-12-02 20:32:15 +00:00
committed by GitHub
parent 0aa176e6e9
commit 38e30fa052
5 changed files with 42 additions and 35 deletions

View File

@@ -962,16 +962,43 @@ export class Block {
}
/**
* @returns True if this block is a value block with a single editable field.
* @returns True if this block is a value block with a full block field.
* @param mustBeFullBlock Whether the evaluated field must be 'full-block'.
* @param mustBeEditable Whether the evaluated field must be editable.
* @internal
*/
isSimpleReporter(): boolean {
if (!this.outputConnection) return false;
isSimpleReporter(
mustBeFullBlock: boolean = false,
mustBeEditable: boolean = false,
): boolean {
return (
this.getSingletonFullBlockField(mustBeFullBlock, mustBeEditable) !== null
);
}
for (const input of this.inputList) {
if (input.connection || input.fieldRow.length > 1) return false;
}
return true;
/**
* Determines and returns the only field of this block, or null if there isn't
* one and this block can't be considered a simple reporter. Null will also be
* returned if the singleton block doesn't match additional criteria, if set,
* such as being full-block or editable.
*
* @param mustBeFullBlock Whether the returned field must be 'full-block'.
* @param mustBeEditable Whether the returned field must be editable.
* @returns The only full-block, maybe editable field of this block, or null.
* @internal
*/
getSingletonFullBlockField(
mustBeFullBlock: boolean,
mustBeEditable: boolean,
): Field<any> | null {
if (!this.outputConnection) return null;
for (const input of this.inputList) if (input.connection) return null;
const matchingFields = Array.from(this.getFields()).filter((field) => {
if (mustBeFullBlock && !field.isFullBlockField()) return false;
if (mustBeEditable && !field.isCurrentlyEditable()) return false;
return true;
});
return matchingFields.length === 1 ? matchingFields[0] : null;
}
/**

View File

@@ -233,10 +233,7 @@ export class BlockSvg
* @internal
*/
recomputeAriaLabel() {
if (this.isSimpleReporter()) {
const field = Array.from(this.getFields())[0];
if (field.isFullBlockField() && field.isCurrentlyEditable()) return;
}
if (this.isSimpleReporter(true, true)) return;
aria.setState(
this.getFocusableElement(),
@@ -1964,13 +1961,8 @@ export class BlockSvg
/** See IFocusableNode.getFocusableElement. */
getFocusableElement(): HTMLElement | SVGElement {
if (this.isSimpleReporter()) {
const field = Array.from(this.getFields())[0];
if (field && field.isFullBlockField() && field.isCurrentlyEditable()) {
return field.getFocusableElement();
}
}
return this.pathObject.svgPath;
const singletonField = this.getSingletonFullBlockField(true, true);
return singletonField?.getFocusableElement() ?? this.pathObject.svgPath;
}
/** See IFocusableNode.getFocusableTree. */

View File

@@ -26,12 +26,6 @@ import {Coordinate} from './utils/coordinate.js';
import * as svgMath from './utils/svg_math.js';
import type {WorkspaceSvg} from './workspace_svg.js';
function isFullBlockField(block?: BlockSvg) {
if (!block || !block.isSimpleReporter()) return false;
const firstField = block.getFields().next().value;
return firstField?.isFullBlockField();
}
/**
* Option to undo previous action.
*/
@@ -377,7 +371,7 @@ export function registerComment() {
// Either block already has a comment so let us remove it,
// or the block isn't just one full-block field block, which
// shouldn't be allowed to have comments as there's no way to read them.
(block.hasIcon(CommentIcon.TYPE) || !isFullBlockField(block))
(block.hasIcon(CommentIcon.TYPE) || !block.isSimpleReporter(true))
) {
return 'enabled';
}

View File

@@ -330,12 +330,9 @@ export abstract class Field<T = any>
this.initModel();
this.applyColour();
const id =
this.isFullBlockField() &&
this.isCurrentlyEditable() &&
this.sourceBlock_?.isSimpleReporter()
? idGenerator.getNextUniqueId()
: `${this.sourceBlock_?.id}_field_${idGenerator.getNextUniqueId()}`;
const id = this.sourceBlock_?.isSimpleReporter(true, true)
? idGenerator.getNextUniqueId()
: `${this.sourceBlock_?.id}_field_${idGenerator.getNextUniqueId()}`;
this.fieldGroup_.setAttribute('id', id);
}

View File

@@ -65,10 +65,7 @@ export class FieldNavigationPolicy implements INavigationPolicy<Field<any>> {
current.canBeFocused() &&
current.isVisible() &&
(current.isClickable() || current.isCurrentlyEditable()) &&
!(
current.getSourceBlock()?.isSimpleReporter() &&
current.isFullBlockField()
) &&
!current.getSourceBlock()?.isSimpleReporter(true, true) &&
current.getParentInput().isVisible()
);
}