Skip to content

Commit 31a6ddd

Browse files
committed
fix: Add visual invalid input handling beyond color
1 parent c733f7c commit 31a6ddd

4 files changed

Lines changed: 161 additions & 3 deletions

File tree

packages/blockly/core/field_input.ts

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {computeAriaLabel, getBeginStackLabel} from './block_aria_composer.js';
1515
import {BlockSvg} from './block_svg.js';
1616
import * as browserEvents from './browser_events.js';
1717
import * as bumpObjects from './bump_objects.js';
18+
import * as css from './css.js';
1819
import * as dialog from './dialog.js';
1920
import * as dropDownDiv from './dropdowndiv.js';
2021
import {EventType} from './events/type.js';
@@ -78,6 +79,11 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
7879
*/
7980
protected isTextValid_ = false;
8081

82+
/**
83+
* The warning icon to display on invalid input
84+
*/
85+
protected warningIcon: HTMLImageElement | null = null;
86+
8187
/**
8288
* The intial value of the field when the user opened an editor to change its
8389
* value. When the editor is disposed, an event will be fired that uses this
@@ -195,6 +201,34 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
195201
return this.fullBlockClickTarget_;
196202
}
197203

204+
/** Creates the DOM elements for the invalid input warning icon. */
205+
private createWarningIcon(parent?: Element | null): HTMLImageElement | null {
206+
const sourceBlock = this.sourceBlock_ as BlockSvg;
207+
if (!sourceBlock) {
208+
return null;
209+
}
210+
211+
const bBox = this.getScaledBBox();
212+
const bBoxHeight = bBox.bottom - bBox.top;
213+
const e = document.createElement('img');
214+
e.setAttribute('class', 'blocklyInputWarning');
215+
e.setAttribute(
216+
'src',
217+
`${sourceBlock.workspace.options.pathToMedia}input-warning-icon.svg`,
218+
);
219+
e.setAttribute('x', `0`);
220+
e.setAttribute('y', `0`);
221+
e.setAttribute('height', `${bBoxHeight}px`);
222+
e.setAttribute('width', `${bBoxHeight}px`);
223+
e.setAttribute('float', 'inline-start');
224+
225+
if (parent) {
226+
parent.appendChild(e);
227+
}
228+
229+
return e;
230+
}
231+
198232
/**
199233
* Called by setValue if the text input is not valid. If the field is
200234
* currently being edited it reverts value of the field to the previous
@@ -312,21 +346,52 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
312346
*/
313347
protected override render_() {
314348
super.render_();
349+
const block = this.getSourceBlock() as BlockSvg | null;
350+
if (!block) throw new UnattachedFieldError();
351+
315352
// This logic is done in render_ rather than doValueInvalid_ or
316353
// doValueUpdate_ so that the code is more centralized.
317354
if (this.isBeingEdited_) {
318355
const htmlInput = this.htmlInput_ as HTMLElement;
319356
if (!this.isTextValid_) {
320357
dom.addClass(htmlInput, 'blocklyInvalidInput');
321358
aria.setState(htmlInput, aria.State.INVALID, true);
359+
// insert the icon
360+
if (this.warningIcon && htmlInput) {
361+
const bBox = this.getScaledBBox();
362+
const hasBorder = !!this.borderRect_;
363+
const xPadding = hasBorder
364+
? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING
365+
: (bBox.bottom - bBox.top) / 2;
366+
dom.addClass(this.warningIcon, 'blocklyInputWarningInvalid');
367+
htmlInput.setAttribute(
368+
'width',
369+
`${htmlInput.offsetWidth + this.warningIcon.width}px`,
370+
);
371+
// If we pad by the whole icon width, it looks too far from the
372+
// text, and half the width looks too close.
373+
const iconPadding = this.warningIcon.width / 1.5;
374+
if (block.RTL) {
375+
htmlInput.style.paddingRight = `${iconPadding}px`;
376+
} else {
377+
htmlInput.style.paddingLeft = `${iconPadding}px`;
378+
}
379+
this.size_.width = this.warningIcon.width;
380+
const iconOffset = hasBorder
381+
? this.getConstants()!.FIELD_TEXT_HEIGHT -
382+
this.getConstants()!.FIELD_BORDER_RECT_X_PADDING
383+
: 0;
384+
this.updateSize_(xPadding + iconOffset);
385+
}
322386
} else {
323387
dom.removeClass(htmlInput, 'blocklyInvalidInput');
388+
if (this.warningIcon) {
389+
dom.removeClass(this.warningIcon, 'blocklyInputWarningInvalid');
390+
}
324391
aria.setState(htmlInput, aria.State.INVALID, false);
325392
}
326393
}
327394

328-
const block = this.getSourceBlock() as BlockSvg | null;
329-
if (!block) throw new UnattachedFieldError();
330395
// In general, do *not* let fields control the color of blocks. Having the
331396
// field control the color is unexpected, and could have performance
332397
// impacts.
@@ -462,6 +527,8 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
462527
'spellcheck',
463528
this.spellcheck_ as AnyDuringMigration,
464529
);
530+
this.warningIcon = this.createWarningIcon();
531+
465532
const scale = this.workspace_!.getAbsoluteScale();
466533
const fontSize = this.getConstants()!.FIELD_TEXT_FONTSIZE * scale + 'pt';
467534
div!.style.fontSize = fontSize;
@@ -484,9 +551,15 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
484551
div!.style.boxShadow =
485552
'rgba(255, 255, 255, 0.3) 0 0 0 ' + 4 * scale + 'px';
486553
}
554+
// Adjust invalid input warning icon for full block style
555+
const paddingX = (bBox.bottom - bBox.top) / 4;
556+
this.warningIcon!.style.paddingLeft = `${paddingX}px`;
557+
this.warningIcon!.style.paddingRight = `${paddingX}px`;
487558
}
488559
htmlInput.style.borderRadius = borderRadius;
489-
560+
if (this.warningIcon) {
561+
div!.appendChild(this.warningIcon);
562+
}
490563
div!.appendChild(htmlInput);
491564

492565
htmlInput.value = htmlInput.defaultValue = this.getEditorText_(this.value_);
@@ -921,3 +994,13 @@ export interface FieldInputConfig extends FieldConfig {
921994
export type FieldInputValidator<T extends InputTypes> = FieldValidator<
922995
string | T
923996
>;
997+
998+
css.register(`
999+
.blocklyInputWarning {
1000+
display: none;
1001+
position: absolute;
1002+
}
1003+
.blocklyInputWarning.blocklyInputWarningInvalid {
1004+
display: inline;
1005+
}
1006+
`);
Lines changed: 5 additions & 0 deletions
Loading

packages/blockly/tests/mocha/field_number_test.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,30 @@ suite('Number Fields', function () {
569569
assert.notInclude(label, 'dog');
570570
assert.include(label, '5');
571571
});
572+
test('Invalid input has ARIA invalid state', function () {
573+
this.field.setValue(5);
574+
this.field.showEditor();
575+
576+
this.field.htmlInput_.value = 'a';
577+
this.field.onHtmlInputChange(null);
578+
579+
// Move forward a few ticks to give the render_() a chance to run
580+
this.clock.tick(16);
581+
const ariaInvalid = this.field.htmlInput_.getAttribute('aria-invalid');
582+
assert.equal(ariaInvalid, 'true');
583+
});
584+
test('Invalid input displays warning icon', function () {
585+
this.field.setValue(5);
586+
this.field.showEditor();
587+
588+
this.field.htmlInput_.value = 'a';
589+
this.field.onHtmlInputChange(null);
590+
591+
// Move forward a few ticks to give the render_() a chance to run
592+
this.clock.tick(16);
593+
const warningIconDisplay = this.field.warningIcon.checkVisibility();
594+
assert.isTrue(warningIconDisplay);
595+
});
572596
suite('Full block fields', function () {
573597
setup(function () {
574598
this.workspace = Blockly.inject('blocklyDiv', {

packages/blockly/tests/mocha/field_textinput_test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ suite('Text Input Fields', function () {
224224
FIELD_TEXT_FONTFAMILY: 'sans-serif',
225225
};
226226
field.clickTarget_ = document.createElement('div');
227+
field.createWarningIcon = () => {};
227228
Blockly.common.setMainWorkspace(workspace);
228229
Blockly.WidgetDiv.createDom();
229230
this.stub = sinon.stub(field, 'resizeEditor_');
@@ -510,6 +511,29 @@ suite('Text Input Fields', function () {
510511
rightField.getFocusableElement(),
511512
);
512513
});
514+
test('Invalid input displays warning icon', async function () {
515+
const validator = function () {
516+
return null;
517+
};
518+
const block = this.workspace.getBlockById('left_input_block');
519+
const field = this.getFieldFromShadowBlock(block);
520+
field.setValidator(validator);
521+
const stub = sinon.stub(field, 'resizeEditor_');
522+
523+
Blockly.getFocusManager().focusNode(field);
524+
field.showEditor();
525+
// This must be called to avoid editor resize logic throwing an error.
526+
await Blockly.renderManagement.finishQueuedRenders();
527+
528+
// Change the value of the field's input through its editor.
529+
const fieldEditor = document.querySelector('.blocklyHtmlInput');
530+
this.simulateTypingIntoInput(fieldEditor, 'a');
531+
532+
// Move forward a few ticks to give the render_() a chance to run
533+
this.clock.tick(16);
534+
const warningIconDisplay = field.warningIcon.checkVisibility();
535+
assert.isTrue(warningIconDisplay);
536+
});
513537
});
514538

515539
// The block being tested uses full-block fields in Zelos.
@@ -645,5 +669,27 @@ suite('Text Input Fields', function () {
645669
const updatedLabel = this.focusableElement.getAttribute('aria-label');
646670
assert.isTrue(updatedLabel.includes('new value'));
647671
});
672+
test('Invalid input has ARIA invalid state', async function () {
673+
const validator = function () {
674+
return null;
675+
};
676+
this.field.setValidator(validator);
677+
const stub = sinon.stub(this.field, 'resizeEditor_');
678+
679+
Blockly.getFocusManager().focusNode(this.field);
680+
this.field.showEditor();
681+
// This must be called to avoid editor resize logic throwing an error.
682+
await Blockly.renderManagement.finishQueuedRenders();
683+
684+
// Change the value of the field's input through its editor.
685+
const fieldEditor = document.querySelector('.blocklyHtmlInput');
686+
fieldEditor.value = 'a';
687+
fieldEditor.dispatchEvent(new InputEvent('input'));
688+
689+
// Move forward a few ticks to give the render_() a chance to run
690+
this.clock.tick(16);
691+
const ariaInvalid = fieldEditor.getAttribute('aria-invalid');
692+
assert.equal(ariaInvalid, 'true');
693+
});
648694
});
649695
});

0 commit comments

Comments
 (0)