Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
41c8215
feat: Make WorkspaceSvg focusable.
BenHenning Apr 21, 2025
14b486e
chore: remove accidental 'test.only'.
BenHenning Apr 21, 2025
26cf8db
feat: Make Toolbox & Flyout focusable.
BenHenning Apr 22, 2025
5ef2d7e
Merge branch 'add-focus-manager-callbacks-and-improvements' into make…
BenHenning Apr 22, 2025
996208d
Merge branch 'make-workspace-focusable' into make-toolbox-and-flyout-…
BenHenning Apr 22, 2025
d3acbff
feat!: Force lifecycle methods for fields.
BenHenning Apr 22, 2025
ed0f140
feat: Make fields ephemerally focusable.
BenHenning Apr 22, 2025
94672d9
chore: Lint fixes.
BenHenning Apr 22, 2025
2430646
chore: Remove incorrect aria-label.
BenHenning Apr 22, 2025
4479b82
Merge branch 'add-focus-manager-callbacks-and-improvements' into make…
BenHenning Apr 23, 2025
2637736
fix: Ensure Block paths are focusable.
BenHenning Apr 23, 2025
49192ba
chore: Fix line comment.
BenHenning Apr 23, 2025
917c4b6
Merge branch 'make-workspace-focusable' into make-toolbox-and-flyout-…
BenHenning Apr 23, 2025
d276dbc
chore: reduce branching.
BenHenning Apr 24, 2025
c819130
Merge branch 'make-toolbox-and-flyout-focusable' into make-fields-foc…
BenHenning Apr 24, 2025
90fdde2
feat: make drop down & widget divs focusable.
BenHenning Apr 24, 2025
7c2f705
chore: undo breaking field changes.
BenHenning Apr 24, 2025
9726389
chore: some more clean-ups after removals.
BenHenning Apr 24, 2025
1094787
feat: fix field node retrieval.
BenHenning Apr 24, 2025
082a6ef
chore: lint fixes.
BenHenning Apr 24, 2025
a346a92
fix: remove unnecessary shadow check.
BenHenning Apr 24, 2025
4ed61bf
Merge branch 'make-workspace-focusable' into make-toolbox-and-flyout-…
BenHenning Apr 24, 2025
898c5a4
Merge branch 'make-toolbox-and-flyout-focusable' into make-fields-foc…
BenHenning Apr 24, 2025
80c8859
chore: add braces.
BenHenning Apr 24, 2025
b3bd5e7
Merge branch 'rc/v12.0.0' into make-workspace-focusable
BenHenning Apr 24, 2025
1f0cefc
Merge branch 'make-workspace-focusable' into make-toolbox-and-flyout-…
BenHenning Apr 24, 2025
c2384c6
chore: empty commit to make CI pass.
BenHenning Apr 24, 2025
57391a7
Merge branch 'rc/v12.0.0' into make-toolbox-and-flyout-focusable
BenHenning Apr 24, 2025
8057051
Merge branch 'make-toolbox-and-flyout-focusable' into make-fields-foc…
BenHenning Apr 24, 2025
c75aea7
feat: Make WorkspaceSvg and BlockSvg focusable (#8916)
BenHenning Apr 24, 2025
d4883f5
feat: Make toolbox and flyout focusable (#8920)
BenHenning Apr 24, 2025
2a1a8b3
Merge branch 'make-toolbox-and-flyout-focusable-roll-forward' into ma…
BenHenning Apr 29, 2025
e5abf72
fix: Remove CSS for active/passive focus.
BenHenning Apr 30, 2025
e849e0c
Merge branch 'make-workspace-focusable-roll-forward' into make-toolbo…
BenHenning Apr 30, 2025
585c950
feat: Make FlyoutButton focusable.
BenHenning Apr 30, 2025
a520554
Merge branch 'make-toolbox-and-flyout-focusable-roll-forward' into ma…
BenHenning Apr 30, 2025
d6dcc4b
fix: Actually make FlyoutButton focusable.
BenHenning Apr 30, 2025
a9cf3d7
chore: lint fixes.
BenHenning Apr 30, 2025
f18670a
chore: Use strict equals.
BenHenning Apr 30, 2025
34970cc
chore: Empty commit to re-trigger CI.
BenHenning Apr 30, 2025
b11aa43
Merge branch 'make-toolbox-and-flyout-focusable-roll-forward' into ma…
BenHenning Apr 30, 2025
3cf8fb8
chore: Add field doc.
BenHenning Apr 30, 2025
e3a6f98
Merge branch 'rc/v12.0.0' into make-workspace-focusable-roll-forward
BenHenning Apr 30, 2025
b738b4a
Merge branch 'make-workspace-focusable-roll-forward' into make-toolbo…
BenHenning Apr 30, 2025
ef6f661
Merge branch 'rc/v12.0.0' into make-toolbox-and-flyout-focusable-roll…
BenHenning Apr 30, 2025
b493147
Merge branch 'make-toolbox-and-flyout-focusable-roll-forward' into ma…
BenHenning Apr 30, 2025
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
8 changes: 6 additions & 2 deletions blocks/procedures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,8 +648,12 @@ class ProcedureArgumentField extends FieldTextInput {
*
* @param e The event that triggered display of the field editor.
*/
protected override showEditor_(e?: Event) {
super.showEditor_(e);
protected override showEditor_(
onEditorShown: () => void,
onEditorHidden: () => void,
e?: Event,
) {
super.showEditor_(onEditorShown, onEditorHidden, e);
this.editingInteractively = true;
this.editingVariable = undefined;
}
Expand Down
72 changes: 65 additions & 7 deletions core/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import * as eventUtils from './events/utils.js';
import type {Input} from './inputs/input.js';
import type {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js';
import type {IASTNodeLocationWithBlock} from './interfaces/i_ast_node_location_with_block.js';
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
import type {IKeyboardAccessible} from './interfaces/i_keyboard_accessible.js';
import type {IRegistrable} from './interfaces/i_registrable.js';
import {ISerializable} from './interfaces/i_serializable.js';
Expand All @@ -34,6 +36,7 @@ import type {KeyboardShortcut} from './shortcut_registry.js';
import * as Tooltip from './tooltip.js';
import type {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import * as idGenerator from './utils/idgenerator.js';
import * as parsing from './utils/parsing.js';
import {Rect} from './utils/rect.js';
import {Size} from './utils/size.js';
Expand All @@ -42,7 +45,7 @@ import {Svg} from './utils/svg.js';
import * as userAgent from './utils/useragent.js';
import * as utilsXml from './utils/xml.js';
import * as WidgetDiv from './widgetdiv.js';
import type {WorkspaceSvg} from './workspace_svg.js';
import {WorkspaceSvg} from './workspace_svg.js';

/**
* A function that is called to validate changes to the field's value before
Expand Down Expand Up @@ -72,7 +75,8 @@ export abstract class Field<T = any>
IASTNodeLocationWithBlock,
IKeyboardAccessible,
IRegistrable,
ISerializable
ISerializable,
IFocusableNode
{
/**
* To overwrite the default value which is set in **Field**, directly update
Expand Down Expand Up @@ -191,6 +195,8 @@ export abstract class Field<T = any>
*/
SERIALIZABLE = false;

private id_: string | null = null;

/**
* @param value The initial value of the field.
* Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by
Expand Down Expand Up @@ -255,6 +261,7 @@ export abstract class Field<T = any>
throw Error('Field already bound to a block');
}
this.sourceBlock_ = block;
this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`;
}

/**
Expand Down Expand Up @@ -298,7 +305,13 @@ export abstract class Field<T = any>
// Field has already been initialized once.
return;
}
this.fieldGroup_ = dom.createSvgElement(Svg.G, {});
const id = this.id_;
if (!id) throw new Error('Expected ID to be defined prior to init.');
this.fieldGroup_ = dom.createSvgElement(Svg.G, {
'tabindex': '-1',
'id': id,
'aria-label': 'Field ' + this.name,
});
if (!this.isVisible()) {
this.fieldGroup_.style.display = 'none';
}
Expand Down Expand Up @@ -770,19 +783,40 @@ export abstract class Field<T = any>
*/
showEditor(e?: Event) {
if (this.isClickable()) {
this.showEditor_(e);
this.showEditor_(
() => this.onShowEditor(),
() => this.onHideEditor(),
e,
);
}
}

/**
* A developer hook to create an editor for the field. This is no-op by
* default, and must be overriden to create an editor.
*
* @param _e Optional mouse event that triggered the field to open, or
* @param onEditorShown Callback that must be called when the editor is shown.
* @param onEditorHidden Callback that must be called when the editor hides.
* @param e Optional mouse event that triggered the field to open, or
* undefined if triggered programmatically.
*/
protected showEditor_(_e?: Event): void {}
// NOP
protected abstract showEditor_(
onEditorShown: () => void,
onEditorHidden: () => void,
e?: Event,
): void;

/**
* Called when an editor is shown. This is expected to be used for ensuring
* that the editor has proper focus.
*/
protected abstract onShowEditor(): void;

/**
* Called when an editor is hidden. This is expected to be used for ensuring
* that the editor no longer proper focus.
*/
protected abstract onHideEditor(): void;

/**
* A developer hook to reposition the WidgetDiv during a window resize. You
Expand Down Expand Up @@ -1401,6 +1435,30 @@ export abstract class Field<T = any>
}
}

/** See IFocusableNode.getFocusableElement. */
getFocusableElement(): HTMLElement | SVGElement {
if (!this.fieldGroup_) {
throw Error('This field currently has no representative DOM element.');
}
return this.fieldGroup_;
}

/** See IFocusableNode.getFocusableTree. */
getFocusableTree(): IFocusableTree {
const block = this.getSourceBlock();
if (!block) {
throw new UnattachedFieldError();
}
// TODO: Remove the latter two casts once WorkspaceSvg is a focusable tree.
return block.workspace as WorkspaceSvg as unknown as IFocusableTree;
}

/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {}

/** See IFocusableNode.onNodeBlur. */
onNodeBlur(): void {}

/**
* Subclasses should reimplement this method to construct their Field
* subclass from a JSON arg object.
Expand Down
11 changes: 10 additions & 1 deletion core/field_checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,19 @@ export class FieldCheckbox extends Field<CheckboxBool> {
}

/** Toggle the state of the checkbox on click. */
protected override showEditor_() {
protected override showEditor_(
_onEditorShown: () => void,
_onEditorHidden: () => void,
) {
// There's no explicit editor for checkboxes, so let DOM focus be handled
// normally.
this.setValue(!this.value_);
}

protected override onShowEditor(): void {}

protected override onHideEditor(): void {}

/**
* Ensure that the input value is valid ('TRUE' or 'FALSE').
*
Expand Down
31 changes: 27 additions & 4 deletions core/field_dropdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
UnattachedFieldError,
} from './field.js';
import * as fieldRegistry from './field_registry.js';
import {getFocusManager} from './focus_manager.js';
import {Menu} from './menu.js';
import {MenuSeparator} from './menu_separator.js';
import {MenuItem} from './menuitem.js';
Expand Down Expand Up @@ -88,6 +89,8 @@ export class FieldDropdown extends Field<string> {
private selectedOption!: MenuOption;
override clickTarget_: SVGElement | null = null;

private returnFocusCallback: (() => void) | null = null;

/**
* The y offset from the top of the field to the top of the image, if an image
* is selected.
Expand Down Expand Up @@ -269,14 +272,19 @@ export class FieldDropdown extends Field<string> {
* @param e Optional mouse event that triggered the field to open, or
* undefined if triggered programmatically.
*/
protected override showEditor_(e?: MouseEvent) {
protected override showEditor_(
onEditorShown: () => void,
onEditorHidden: () => void,
e?: MouseEvent,
) {
const block = this.getSourceBlock();
if (!block) {
throw new UnattachedFieldError();
}
this.dropdownCreate();
if (!this.menu_) return;

onEditorShown();
if (e && typeof e.clientX === 'number') {
this.menu_.openingCoords = new Coordinate(e.clientX, e.clientY);
} else {
Expand All @@ -295,7 +303,10 @@ export class FieldDropdown extends Field<string> {
dropDownDiv.setColour(primaryColour, borderColour);
}

dropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this));
dropDownDiv.showPositionedByField(this, () => {
this.dropdownDispose_.bind(this);
onEditorHidden();
});

dropDownDiv.getContentDiv().style.height = `${this.menu_.getSize().height}px`;

Expand All @@ -311,6 +322,18 @@ export class FieldDropdown extends Field<string> {
this.applyColour();
}

protected override onShowEditor(): void {
const menuElement = this.menu_?.getElement();
if (menuElement) {
this.returnFocusCallback =
getFocusManager().takeEphemeralFocus(menuElement);
}
}

protected override onHideEditor(): void {
if (this.returnFocusCallback) this.returnFocusCallback();
}

/** Create the dropdown editor. */
private dropdownCreate() {
const block = this.getSourceBlock();
Expand Down Expand Up @@ -769,7 +792,7 @@ export class FieldDropdown extends Field<string> {
} else if (typeof option[1] !== 'string') {
foundError = true;
console.error(
`Invalid option[${i}]: Each FieldDropdown option id must be a string.
`Invalid option[${i}]: Each FieldDropdown option id must be a string.
Found ${option[1]} in: ${option}`,
);
} else if (
Expand All @@ -780,7 +803,7 @@ export class FieldDropdown extends Field<string> {
) {
foundError = true;
console.error(
`Invalid option[${i}]: Each FieldDropdown option must have a string
`Invalid option[${i}]: Each FieldDropdown option must have a string
label, image description, or HTML element. Found ${option[0]} in: ${option}`,
);
}
Expand Down
11 changes: 10 additions & 1 deletion core/field_image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,12 +217,21 @@ export class FieldImage extends Field<string> {
* If field click is called, and click handler defined,
* call the handler.
*/
protected override showEditor_() {
protected override showEditor_(
_onEditorShown: () => void,
_onEditorHidden: () => void,
) {
// Note that an editor shouldn't be shown for this field, so the callbacks
// are ignored.
if (this.clickHandler) {
this.clickHandler(this);
}
}

protected override onShowEditor(): void {}

protected override onHideEditor(): void {}

/**
* Set the function that is called when this image is clicked.
*
Expand Down
45 changes: 39 additions & 6 deletions core/field_input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
FieldValidator,
UnattachedFieldError,
} from './field.js';
import {getFocusManager} from './focus_manager.js';
import {Msg} from './msg.js';
import * as renderManagement from './render_management.js';
import * as aria from './utils/aria.js';
Expand Down Expand Up @@ -100,6 +101,8 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
*/
override SERIALIZABLE = true;

private returnFocusCallback: (() => void) | null = null;

/**
* @param value The initial value of the field. Should cast to a string.
* Defaults to an empty string if null or undefined. Also accepts
Expand Down Expand Up @@ -327,30 +330,51 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
* Shows a prompt editor for mobile browsers if the modalInputs option is
* enabled.
*
* @param onEditorShown Callback that must be called when the editor is shown.
* @param onEditorHidden Callback that must be called when the editor hides.
* @param _e Optional mouse event that triggered the field to open, or
* undefined if triggered programmatically.
* @param quietInput True if editor should be created without focus.
* Defaults to false.
*/
protected override showEditor_(_e?: Event, quietInput = false) {
protected override showEditor_(
onEditorShown: () => void,
onEditorHidden: () => void,
_e?: Event,
quietInput: boolean = false,
) {
this.workspace_ = (this.sourceBlock_ as BlockSvg).workspace;
if (
!quietInput &&
this.workspace_.options.modalInputs &&
(userAgent.MOBILE || userAgent.ANDROID || userAgent.IPAD)
) {
this.showPromptEditor();
this.showPromptEditor(onEditorShown, onEditorHidden);
} else {
this.showInlineEditor(quietInput);
this.showInlineEditor(onEditorShown, onEditorHidden, quietInput);
}
}

protected override onShowEditor(): void {
this.returnFocusCallback = getFocusManager().takeEphemeralFocus(
document.body,
);
}

protected override onHideEditor(): void {
if (this.returnFocusCallback) this.returnFocusCallback();
}

/**
* Create and show a text input editor that is a prompt (usually a popup).
* Mobile browsers may have issues with in-line textareas (focus and
* keyboards).
*/
private showPromptEditor() {
private showPromptEditor(
onEditorShown: () => void,
onEditorHidden: () => void,
) {
onEditorShown();
dialog.prompt(
Msg['CHANGE_VALUE_TITLE'],
this.getText(),
Expand All @@ -360,6 +384,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
this.setValue(this.getValueFromEditorText_(text));
}
this.onFinishEditing_(this.value_);
onEditorHidden();
},
);
}
Expand All @@ -369,17 +394,25 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
*
* @param quietInput True if editor should be created without focus.
*/
private showInlineEditor(quietInput: boolean) {
private showInlineEditor(
onEditorShown: () => void,
onEditorHidden: () => void,
quietInput: boolean,
) {
const block = this.getSourceBlock();
if (!block) {
throw new UnattachedFieldError();
}
WidgetDiv.show(
this,
block.RTL,
this.widgetDispose_.bind(this),
() => {
this.widgetDispose_();
onEditorHidden();
},
this.workspace_,
);
onEditorShown();
this.htmlInput_ = this.widgetCreate_() as HTMLInputElement;
this.isBeingEdited_ = true;
this.valueWhenEditorWasOpened_ = this.value_;
Expand Down
Loading
Loading