Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 86 additions & 3 deletions packages/blockly/core/field_input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {computeAriaLabel, getBeginStackLabel} from './block_aria_composer.js';
import {BlockSvg} from './block_svg.js';
import * as browserEvents from './browser_events.js';
import * as bumpObjects from './bump_objects.js';
import * as css from './css.js';
import * as dialog from './dialog.js';
import * as dropDownDiv from './dropdowndiv.js';
import {EventType} from './events/type.js';
Expand Down Expand Up @@ -78,6 +79,11 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
*/
protected isTextValid_ = false;

/**
* The warning icon to display on invalid input
*/
protected warningIcon: HTMLImageElement | null = null;

/**
* The intial value of the field when the user opened an editor to change its
* value. When the editor is disposed, an event will be fired that uses this
Expand Down Expand Up @@ -195,6 +201,34 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
return this.fullBlockClickTarget_;
}

/** Creates the DOM elements for the invalid input warning icon. */
private createWarningIcon(parent?: Element | null): HTMLImageElement | null {
const sourceBlock = this.sourceBlock_ as BlockSvg;
if (!sourceBlock) {
return null;
}

const bBox = this.getScaledBBox();
const bBoxHeight = bBox.bottom - bBox.top;
const e = document.createElement('img');
e.setAttribute('class', 'blocklyInputWarning');
e.setAttribute(
'src',
`${sourceBlock.workspace.options.pathToMedia}input-warning-icon.svg`,
);
e.setAttribute('x', `0`);
e.setAttribute('y', `0`);
e.setAttribute('height', `${bBoxHeight}px`);
e.setAttribute('width', `${bBoxHeight}px`);
e.setAttribute('float', 'inline-start');

if (parent) {
parent.appendChild(e);
}

return e;
}

/**
* Called by setValue if the text input is not valid. If the field is
* currently being edited it reverts value of the field to the previous
Expand Down Expand Up @@ -312,21 +346,52 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
*/
protected override render_() {
super.render_();
const block = this.getSourceBlock() as BlockSvg | null;
if (!block) throw new UnattachedFieldError();

// This logic is done in render_ rather than doValueInvalid_ or
// doValueUpdate_ so that the code is more centralized.
if (this.isBeingEdited_) {
const htmlInput = this.htmlInput_ as HTMLElement;
if (!this.isTextValid_) {
dom.addClass(htmlInput, 'blocklyInvalidInput');
aria.setState(htmlInput, aria.State.INVALID, true);
// insert the icon
if (this.warningIcon && htmlInput) {
const bBox = this.getScaledBBox();
const hasBorder = !!this.borderRect_;
const xPadding = hasBorder
? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING
: (bBox.bottom - bBox.top) / 2;
dom.addClass(this.warningIcon, 'blocklyInputWarningInvalid');
htmlInput.setAttribute(
'width',
`${htmlInput.offsetWidth + this.warningIcon.width}px`,
);
// If we pad by the whole icon width, it looks too far from the
// text, and half the width looks too close.
const iconPadding = this.warningIcon.width / 1.5;
if (block.RTL) {
htmlInput.style.paddingRight = `${iconPadding}px`;
} else {
htmlInput.style.paddingLeft = `${iconPadding}px`;
}
this.size_.width = this.warningIcon.width;
const iconOffset = hasBorder
? this.getConstants()!.FIELD_TEXT_HEIGHT -
this.getConstants()!.FIELD_BORDER_RECT_X_PADDING
: 0;
this.updateSize_(xPadding + iconOffset);
}
} else {
dom.removeClass(htmlInput, 'blocklyInvalidInput');
if (this.warningIcon) {
dom.removeClass(this.warningIcon, 'blocklyInputWarningInvalid');
}
aria.setState(htmlInput, aria.State.INVALID, false);
}
}

const block = this.getSourceBlock() as BlockSvg | null;
if (!block) throw new UnattachedFieldError();
// In general, do *not* let fields control the color of blocks. Having the
// field control the color is unexpected, and could have performance
// impacts.
Expand Down Expand Up @@ -462,6 +527,8 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
'spellcheck',
this.spellcheck_ as AnyDuringMigration,
);
this.warningIcon = this.createWarningIcon();

const scale = this.workspace_!.getAbsoluteScale();
const fontSize = this.getConstants()!.FIELD_TEXT_FONTSIZE * scale + 'pt';
div!.style.fontSize = fontSize;
Expand All @@ -484,9 +551,15 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
div!.style.boxShadow =
'rgba(255, 255, 255, 0.3) 0 0 0 ' + 4 * scale + 'px';
}
// Adjust invalid input warning icon for full block style
const paddingX = (bBox.bottom - bBox.top) / 4;
this.warningIcon!.style.paddingLeft = `${paddingX}px`;
this.warningIcon!.style.paddingRight = `${paddingX}px`;
}
htmlInput.style.borderRadius = borderRadius;

if (this.warningIcon) {
div!.appendChild(this.warningIcon);
}
div!.appendChild(htmlInput);

htmlInput.value = htmlInput.defaultValue = this.getEditorText_(this.value_);
Expand Down Expand Up @@ -921,3 +994,13 @@ export interface FieldInputConfig extends FieldConfig {
export type FieldInputValidator<T extends InputTypes> = FieldValidator<
string | T
>;

css.register(`
.blocklyInputWarning {
display: none;
position: absolute;
}
.blocklyInputWarning.blocklyInputWarningInvalid {
display: inline;
}
`);
5 changes: 5 additions & 0 deletions packages/blockly/media/input-warning-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions packages/blockly/tests/mocha/field_number_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,30 @@ suite('Number Fields', function () {
assert.notInclude(label, 'dog');
assert.include(label, '5');
});
test('Invalid input has ARIA invalid state', function () {
this.field.setValue(5);
this.field.showEditor();

this.field.htmlInput_.value = 'a';
this.field.onHtmlInputChange(null);

// Move forward a few ticks to give the render_() a chance to run
this.clock.tick(16);
const ariaInvalid = this.field.htmlInput_.getAttribute('aria-invalid');
assert.equal(ariaInvalid, 'true');
});
test('Invalid input displays warning icon', function () {
this.field.setValue(5);
this.field.showEditor();

this.field.htmlInput_.value = 'a';
this.field.onHtmlInputChange(null);

// Move forward a few ticks to give the render_() a chance to run
this.clock.tick(16);
const warningIconDisplay = this.field.warningIcon.checkVisibility();
assert.isTrue(warningIconDisplay);
});
suite('Full block fields', function () {
setup(function () {
this.workspace = Blockly.inject('blocklyDiv', {
Expand Down
46 changes: 46 additions & 0 deletions packages/blockly/tests/mocha/field_textinput_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ suite('Text Input Fields', function () {
FIELD_TEXT_FONTFAMILY: 'sans-serif',
};
field.clickTarget_ = document.createElement('div');
field.createWarningIcon = () => {};
Blockly.common.setMainWorkspace(workspace);
Blockly.WidgetDiv.createDom();
this.stub = sinon.stub(field, 'resizeEditor_');
Expand Down Expand Up @@ -510,6 +511,29 @@ suite('Text Input Fields', function () {
rightField.getFocusableElement(),
);
});
test('Invalid input displays warning icon', async function () {
const validator = function () {
return null;
};
const block = this.workspace.getBlockById('left_input_block');
const field = this.getFieldFromShadowBlock(block);
field.setValidator(validator);
const stub = sinon.stub(field, 'resizeEditor_');

Blockly.getFocusManager().focusNode(field);
field.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();

// Change the value of the field's input through its editor.
const fieldEditor = document.querySelector('.blocklyHtmlInput');
this.simulateTypingIntoInput(fieldEditor, 'a');

// Move forward a few ticks to give the render_() a chance to run
this.clock.tick(16);
const warningIconDisplay = field.warningIcon.checkVisibility();
assert.isTrue(warningIconDisplay);
});
});

// The block being tested uses full-block fields in Zelos.
Expand Down Expand Up @@ -645,5 +669,27 @@ suite('Text Input Fields', function () {
const updatedLabel = this.focusableElement.getAttribute('aria-label');
assert.isTrue(updatedLabel.includes('new value'));
});
test('Invalid input has ARIA invalid state', async function () {
const validator = function () {
return null;
};
this.field.setValidator(validator);
const stub = sinon.stub(this.field, 'resizeEditor_');

Blockly.getFocusManager().focusNode(this.field);
this.field.showEditor();
// This must be called to avoid editor resize logic throwing an error.
await Blockly.renderManagement.finishQueuedRenders();

// Change the value of the field's input through its editor.
const fieldEditor = document.querySelector('.blocklyHtmlInput');
fieldEditor.value = 'a';
fieldEditor.dispatchEvent(new InputEvent('input'));

// Move forward a few ticks to give the render_() a chance to run
this.clock.tick(16);
const ariaInvalid = fieldEditor.getAttribute('aria-invalid');
assert.equal(ariaInvalid, 'true');
});
});
});
Loading