release: merge v13 into main

release: merge v13 into main
Merge pull request #9979 from RaspberryPiFoundation/v13
This commit is contained in:
Maribeth Moffatt
2026-06-11 15:53:07 -04:00
committed by GitHub
249 changed files with 26237 additions and 11161 deletions
+3 -1
View File
@@ -20,9 +20,11 @@ changelog:
- title: Bug fixes 🐛
labels:
- 'PR: fix'
- title: Cleanup ♻️
- title: Documentation updates 📄
labels:
- 'PR: docs'
- title: Cleanup ♻️
labels:
- 'PR: refactor'
- title: Reverted changes ⎌
labels:
+2 -2
View File
@@ -21,14 +21,14 @@ jobs:
# TODO (#2114): re-enable osx build.
# os: [ubuntu-latest, macos-latest]
os: [macos-latest]
node-version: [18.x, 20.x]
node-version: [22.x, 24.x]
# See supported Node.js release schedule at
# https://nodejs.org/en/about/releases/
defaults:
run:
working-directory: ./packages/blockly
steps:
- uses: actions/checkout@v5
with:
+9 -5
View File
@@ -20,7 +20,7 @@ jobs:
# TODO (#2114): re-enable osx build.
# os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest]
node-version: [18.x, 20.x, 22.x, 24.x]
node-version: [22.x, 24.x]
# See supported Node.js release schedule at
# https://nodejs.org/en/about/releases/
@@ -43,6 +43,10 @@ jobs:
- name: Npm Clean Install
run: npm ci
- name: Setup Chrome
if: runner.os == 'Linux'
uses: browser-actions/setup-chrome@v1
- name: Linux Test Setup
if: runner.os == 'Linux'
run: source ./tests/scripts/setup_linux_env.sh
@@ -62,10 +66,10 @@ jobs:
with:
ref: ${{ github.ref }}
- name: Use Node.js 20.x
- name: Use Node.js 24.x
uses: actions/setup-node@v5
with:
node-version: 20.x
node-version: 24.x
- name: Npm Install
run: npm install
@@ -81,10 +85,10 @@ jobs:
with:
ref: ${{ github.ref }}
- name: Use Node.js 20.x
- name: Use Node.js 24.x
uses: actions/setup-node@v5
with:
node-version: 20.x
node-version: 24.x
- name: Npm Install
run: npm install
+3
View File
@@ -154,6 +154,9 @@ jobs:
git add packages/blockly/package.json package-lock.json
git commit -m "release: v${{ needs.version.outputs.version }}"
git push
TAG="blockly-v${{ needs.version.outputs.version }}"
git tag "$TAG"
git push origin "refs/tags/$TAG"
- name: Setup Node.js
uses: actions/setup-node@v5
+5805 -5325
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -20,12 +20,12 @@
"packages/*"
],
"devDependencies": {
"@commitlint/cli": "^20.1.0",
"@commitlint/config-conventional": "^20.0.0"
"@commitlint/cli": "^21.0.1",
"@commitlint/config-conventional": "^21.0.1"
},
"overrides": {
"eslint": "9.36.0",
"prettier": "3.6.2"
"eslint": "10.3.0",
"prettier": "3.8.3"
},
"scripts": {
"test": "npm run test --ws --if-present",
@@ -34,4 +34,4 @@
"format:check": "npm run format:check --ws --if-present",
"postinstall": "patch-package"
}
}
}
+50 -11
View File
@@ -48,11 +48,13 @@ export const blocks = createBlockDefinitionsFromJsonArray([
{
'type': 'input_value',
'name': 'ITEM',
'ariaLabelText': '%{BKY_INPUT_LABEL_LISTS_REPEAT_ITEM}',
},
{
'type': 'input_value',
'name': 'NUM',
'check': 'Number',
'ariaLabelText': '%{BKY_INPUT_LABEL_LISTS_REPEAT_NUM}',
},
],
'output': 'Array',
@@ -69,6 +71,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'LIST',
'check': 'Array',
'ariaLabelText': '%{BKY_INPUT_LABEL_LISTS_TO_CHANGE}',
},
],
'output': 'Array',
@@ -86,6 +89,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'VALUE',
'check': ['String', 'Array'],
'ariaLabelText': '%{BKY_INPUT_LABEL_LISTS_TO_CHECK}',
},
],
'output': 'Boolean',
@@ -102,6 +106,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'VALUE',
'check': ['String', 'Array'],
'ariaLabelText': '%{BKY_INPUT_LABEL_LISTS_TO_CHECK}',
},
],
'output': 'Number',
@@ -274,7 +279,14 @@ const LISTS_CREATE_WITH = {
// Add new inputs.
for (let i = 0; i < this.itemCount_; i++) {
if (!this.getInput('ADD' + i)) {
const input = this.appendValueInput('ADD' + i).setAlign(Align.RIGHT);
const input = this.appendValueInput('ADD' + i)
.setAlign(Align.RIGHT)
.setAriaLabelProvider(
Msg['INPUT_LABEL_LISTS_CREATE_WITH_ITEM'].replace(
'%1',
String(i + 1),
),
);
if (i === 0) {
input.appendField(Msg['LISTS_CREATE_WITH_INPUT_WITH']);
}
@@ -350,7 +362,8 @@ const LISTS_INDEXOF = {
this.setOutput(true, 'Number');
this.appendValueInput('VALUE')
.setCheck('Array')
.appendField(Msg['LISTS_INDEX_OF_INPUT_IN_LIST']);
.appendField(Msg['LISTS_INDEX_OF_INPUT_IN_LIST'])
.setAriaLabelProvider(Msg['INPUT_LABEL_LISTS_TO_CHECK']);
const operatorsDropdown = fieldRegistry.fromJson({
type: 'field_dropdown',
options: OPERATORS,
@@ -408,7 +421,8 @@ const LISTS_GETINDEX = {
);
this.appendValueInput('VALUE')
.setCheck('Array')
.appendField(Msg['LISTS_GET_INDEX_INPUT_IN_LIST']);
.appendField(Msg['LISTS_GET_INDEX_INPUT_IN_LIST'])
.setAriaLabelProvider(Msg['INPUT_LABEL_LISTS_TO_CHECK']);
this.appendDummyInput()
.appendField(modeMenu, 'MODE')
.appendField('', 'SPACE');
@@ -586,7 +600,9 @@ const LISTS_GETINDEX = {
this.removeInput('ORDINAL', true);
// Create either a value 'AT' input or a dummy input.
if (isAt) {
this.appendValueInput('AT').setCheck('Number');
this.appendValueInput('AT')
.setCheck('Number')
.setAriaLabelProvider(Msg['INPUT_LABEL_LISTS_POSITION']);
if (Msg['ORDINAL_NUMBER_SUFFIX']) {
this.appendDummyInput('ORDINAL').appendField(
Msg['ORDINAL_NUMBER_SUFFIX'],
@@ -629,7 +645,8 @@ const LISTS_SETINDEX = {
this.setStyle('list_blocks');
this.appendValueInput('LIST')
.setCheck('Array')
.appendField(Msg['LISTS_SET_INDEX_INPUT_IN_LIST']);
.appendField(Msg['LISTS_SET_INDEX_INPUT_IN_LIST'])
.setAriaLabelProvider(Msg['INPUT_LABEL_LISTS_TO_CHECK']);
const operationDropdown = fieldRegistry.fromJson({
type: 'field_dropdown',
options: MODE,
@@ -656,7 +673,9 @@ const LISTS_SETINDEX = {
);
this.appendDummyInput().appendField(menu, 'WHERE');
this.appendDummyInput('AT');
this.appendValueInput('TO').appendField(Msg['LISTS_SET_INDEX_INPUT_TO']);
this.appendValueInput('TO')
.appendField(Msg['LISTS_SET_INDEX_INPUT_TO'])
.setAriaLabelProvider(Msg['INPUT_LABEL_LISTS_VALUE_TO_SET']);
this.setInputsInline(true);
this.setPreviousStatement(true);
this.setNextStatement(true);
@@ -758,7 +777,9 @@ const LISTS_SETINDEX = {
this.removeInput('ORDINAL', true);
// Create either a value 'AT' input or a dummy input.
if (isAt) {
this.appendValueInput('AT').setCheck('Number');
this.appendValueInput('AT')
.setCheck('Number')
.setAriaLabelProvider(Msg['INPUT_LABEL_LISTS_POSITION']);
if (Msg['ORDINAL_NUMBER_SUFFIX']) {
this.appendDummyInput('ORDINAL').appendField(
Msg['ORDINAL_NUMBER_SUFFIX'],
@@ -802,7 +823,8 @@ const LISTS_GETSUBLIST = {
this.setStyle('list_blocks');
this.appendValueInput('LIST')
.setCheck('Array')
.appendField(Msg['LISTS_GET_SUBLIST_INPUT_IN_LIST']);
.appendField(Msg['LISTS_GET_SUBLIST_INPUT_IN_LIST'])
.setAriaLabelProvider(Msg['INPUT_LABEL_LISTS_TO_CHECK']);
const createMenu = (n: 1 | 2): FieldDropdown => {
const menu = fieldRegistry.fromJson({
type: 'field_dropdown',
@@ -895,7 +917,13 @@ const LISTS_GETSUBLIST = {
this.removeInput('ORDINAL' + n, true);
// Create either a value 'AT' input or a dummy input.
if (isAt) {
this.appendValueInput('AT' + n).setCheck('Number');
this.appendValueInput('AT' + n)
.setCheck('Number')
.setAriaLabelProvider(
n === 1
? Msg['INPUT_LABEL_LISTS_START_POSITION']
: Msg['INPUT_LABEL_LISTS_END_POSITION'],
);
if (Msg['ORDINAL_NUMBER_SUFFIX']) {
this.appendDummyInput('ORDINAL' + n).appendField(
Msg['ORDINAL_NUMBER_SUFFIX'],
@@ -948,6 +976,7 @@ blocks['lists_sort'] = {
'type': 'input_value',
'name': 'LIST',
'check': 'Array',
'ariaLabelText': Msg['INPUT_LABEL_LISTS_TO_CHANGE'],
},
],
'output': 'Array',
@@ -972,6 +1001,14 @@ blocks['lists_split'] = {
[Msg['LISTS_SPLIT_TEXT_FROM_LIST'], 'JOIN'],
],
});
const inputAriaLabelProvider = () => {
const mode = this.getFieldValue('MODE');
if (mode === 'SPLIT') {
return Msg['INPUT_LABEL_LISTS_LIST_FROM_TEXT'];
} else if (mode === 'JOIN') {
return Msg['INPUT_LABEL_LISTS_TEXT_FROM_LIST'];
}
};
if (!dropdown) throw new Error('field_dropdown not found');
dropdown.setValidator((newMode) => {
this.updateType_(newMode);
@@ -980,10 +1017,12 @@ blocks['lists_split'] = {
this.setStyle('list_blocks');
this.appendValueInput('INPUT')
.setCheck('String')
.appendField(dropdown, 'MODE');
.appendField(dropdown, 'MODE')
.setAriaLabelProvider(inputAriaLabelProvider);
this.appendValueInput('DELIM')
.setCheck('String')
.appendField(Msg['LISTS_SPLIT_WITH_DELIMITER']);
.appendField(Msg['INPUT_LABEL_LISTS_DELIMITER'])
.setAriaLabelProvider(Msg['INPUT_LABEL_LISTS_SPLIT_WITH_DELIMITER']);
this.setInputsInline(true);
this.setOutput(true, 'Array');
this.setTooltip(() => {
+11 -8
View File
@@ -100,10 +100,9 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'previousStatement': null,
'nextStatement': null,
'style': 'logic_blocks',
'tooltip': '%{BKYCONTROLS_IF_TOOLTIP_2}',
'tooltip': '%{BKY_CONTROLS_IF_TOOLTIP_2}',
'helpUrl': '%{BKY_CONTROLS_IF_HELPURL}',
'suppressPrefixSuffix': true,
'extensions': ['controls_if_tooltip'],
},
// Block for comparison operator.
{
@@ -113,22 +112,24 @@ export const blocks = createBlockDefinitionsFromJsonArray([
{
'type': 'input_value',
'name': 'A',
'ariaLabelText': '%{BKY_INPUT_LABEL_VALUE_A}',
},
{
'type': 'field_dropdown',
'name': 'OP',
'options': [
['=', 'EQ'],
['\u2260', 'NEQ'],
['\u200F<', 'LT'],
['\u200F\u2264', 'LTE'],
['\u200F>', 'GT'],
['\u200F\u2265', 'GTE'],
['=', 'EQ', '%{BKY_LOGIC_COMPARE_EQ_ARIA}'],
['\u2260', 'NEQ', '%{BKY_LOGIC_COMPARE_NEQ_ARIA}'],
['\u200F<', 'LT', '%{BKY_LOGIC_COMPARE_LT_ARIA}'],
['\u200F\u2264', 'LTE', '%{BKY_LOGIC_COMPARE_LTE_ARIA}'],
['\u200F>', 'GT', '%{BKY_LOGIC_COMPARE_GT_ARIA}'],
['\u200F\u2265', 'GTE', '%{BKY_LOGIC_COMPARE_GTE_ARIA}'],
],
},
{
'type': 'input_value',
'name': 'B',
'ariaLabelText': '%{BKY_INPUT_LABEL_VALUE_B}',
},
],
'inputsInline': true,
@@ -146,6 +147,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'A',
'check': 'Boolean',
'ariaLabelText': '%{BKY_INPUT_LABEL_CONDITION_A}',
},
{
'type': 'field_dropdown',
@@ -159,6 +161,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'B',
'check': 'Boolean',
'ariaLabelText': '%{BKY_INPUT_LABEL_CONDITION_B}',
},
],
'inputsInline': true,
+7
View File
@@ -42,6 +42,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'TIMES',
'check': 'Number',
'ariaLabelText': '%{BKY_INPUT_LABEL_LOOP_TIMES}',
},
],
'message1': '%{BKY_CONTROLS_REPEAT_INPUT_DO} %1',
@@ -69,6 +70,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'value': 10,
'min': 0,
'precision': 1,
'ariaLabelText': '%{BKY_INPUT_LABEL_LOOP_TIMES}',
},
],
'message1': '%{BKY_CONTROLS_REPEAT_INPUT_DO} %1',
@@ -101,6 +103,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'BOOL',
'check': 'Boolean',
'ariaLabelText': '%{BKY_INPUT_LABEL_CONDITION}',
},
],
'message1': '%{BKY_CONTROLS_REPEAT_INPUT_DO} %1',
@@ -131,18 +134,21 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'name': 'FROM',
'check': 'Number',
'align': 'RIGHT',
'ariaLabelText': '%{BKY_INPUT_LABEL_LOOP_FROM}',
},
{
'type': 'input_value',
'name': 'TO',
'check': 'Number',
'align': 'RIGHT',
'ariaLabelText': '%{BKY_INPUT_LABEL_LOOP_TO}',
},
{
'type': 'input_value',
'name': 'BY',
'check': 'Number',
'align': 'RIGHT',
'ariaLabelText': '%{BKY_INPUT_LABEL_LOOP_BY}',
},
],
'message1': '%{BKY_CONTROLS_REPEAT_INPUT_DO} %1',
@@ -173,6 +179,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'LIST',
'check': 'Array',
'ariaLabelText': '%{BKY_INPUT_LABEL_LOOP_LIST}',
},
],
'message1': '%{BKY_CONTROLS_REPEAT_INPUT_DO} %1',
+72 -25
View File
@@ -50,22 +50,44 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'A',
'check': 'Number',
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER_A}',
},
{
'type': 'field_dropdown',
'name': 'OP',
'options': [
['%{BKY_MATH_ADDITION_SYMBOL}', 'ADD'],
['%{BKY_MATH_SUBTRACTION_SYMBOL}', 'MINUS'],
['%{BKY_MATH_MULTIPLICATION_SYMBOL}', 'MULTIPLY'],
['%{BKY_MATH_DIVISION_SYMBOL}', 'DIVIDE'],
['%{BKY_MATH_POWER_SYMBOL}', 'POWER'],
[
'%{BKY_MATH_ADDITION_SYMBOL}',
'ADD',
'%{BKY_MATH_ADDITION_SYMBOL_ARIA}',
],
[
'%{BKY_MATH_SUBTRACTION_SYMBOL}',
'MINUS',
'%{BKY_MATH_SUBTRACTION_SYMBOL_ARIA}',
],
[
'%{BKY_MATH_MULTIPLICATION_SYMBOL}',
'MULTIPLY',
'%{BKY_MATH_MULTIPLICATION_SYMBOL_ARIA}',
],
[
'%{BKY_MATH_DIVISION_SYMBOL}',
'DIVIDE',
'%{BKY_MATH_DIVISION_SYMBOL_ARIA}',
],
[
'%{BKY_MATH_POWER_SYMBOL}',
'POWER',
'%{BKY_MATH_POWER_SYMBOL_ARIA}',
],
],
},
{
'type': 'input_value',
'name': 'B',
'check': 'Number',
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER_B}',
},
],
'inputsInline': true,
@@ -85,18 +107,23 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'name': 'OP',
'options': [
['%{BKY_MATH_SINGLE_OP_ROOT}', 'ROOT'],
['%{BKY_MATH_SINGLE_OP_ABSOLUTE}', 'ABS'],
['-', 'NEG'],
['ln', 'LN'],
['log10', 'LOG10'],
['e^', 'EXP'],
['10^', 'POW10'],
[
'%{BKY_MATH_SINGLE_OP_ABSOLUTE}',
'ABS',
'%{BKY_MATH_SINGLE_OP_ABSOLUTE_ARIA}',
],
['-', 'NEG', '%{BKY_MATH_SINGLE_OP_NEG_ARIA}'],
['ln', 'LN', '%{BKY_MATH_SINGLE_OP_LN_ARIA}'],
['log10', 'LOG10', '%{BKY_MATH_SINGLE_OP_LOG10_ARIA}'],
['e^', 'EXP', '%{BKY_MATH_SINGLE_OP_EXP_ARIA}'],
['10^', 'POW10', '%{BKY_MATH_SINGLE_OP_POW10_ARIA}'],
],
},
{
'type': 'input_value',
'name': 'NUM',
'check': 'Number',
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER}',
},
],
'output': 'Number',
@@ -114,18 +141,19 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'field_dropdown',
'name': 'OP',
'options': [
['%{BKY_MATH_TRIG_SIN}', 'SIN'],
['%{BKY_MATH_TRIG_COS}', 'COS'],
['%{BKY_MATH_TRIG_TAN}', 'TAN'],
['%{BKY_MATH_TRIG_ASIN}', 'ASIN'],
['%{BKY_MATH_TRIG_ACOS}', 'ACOS'],
['%{BKY_MATH_TRIG_ATAN}', 'ATAN'],
['%{BKY_MATH_TRIG_SIN}', 'SIN', '%{BKY_MATH_TRIG_SIN_ARIA}'],
['%{BKY_MATH_TRIG_COS}', 'COS', '%{BKY_MATH_TRIG_COS_ARIA}'],
['%{BKY_MATH_TRIG_TAN}', 'TAN', '%{BKY_MATH_TRIG_TAN_ARIA}'],
['%{BKY_MATH_TRIG_ASIN}', 'ASIN', '%{BKY_MATH_TRIG_ASIN_ARIA}'],
['%{BKY_MATH_TRIG_ACOS}', 'ACOS', '%{BKY_MATH_TRIG_ACOS_ARIA}'],
['%{BKY_MATH_TRIG_ATAN}', 'ATAN', '%{BKY_MATH_TRIG_ATAN_ARIA}'],
],
},
{
'type': 'input_value',
'name': 'NUM',
'check': 'Number',
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER}',
},
],
'output': 'Number',
@@ -143,12 +171,12 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'field_dropdown',
'name': 'CONSTANT',
'options': [
['\u03c0', 'PI'],
['e', 'E'],
['\u03c6', 'GOLDEN_RATIO'],
['sqrt(2)', 'SQRT2'],
['sqrt(\u00bd)', 'SQRT1_2'],
['\u221e', 'INFINITY'],
['\u03c0', 'PI', '%{BKY_MATH_CONSTANT_PI_ARIA}'],
['e', 'E', '%{BKY_MATH_CONSTANT_E_ARIA}'],
['\u03c6', 'GOLDEN_RATIO', '%{BKY_MATH_CONSTANT_GOLDEN_RATIO_ARIA}'],
['sqrt(2)', 'SQRT2', '%{BKY_MATH_CONSTANT_SQRT2_ARIA}'],
['sqrt(\u00bd)', 'SQRT1_2', '%{BKY_MATH_CONSTANT_SQRT1_2_ARIA}'],
['\u221e', 'INFINITY', '%{BKY_MATH_CONSTANT_INFINITY_ARIA}'],
],
},
],
@@ -168,6 +196,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'NUMBER_TO_CHECK',
'check': 'Number',
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER_TO_CHECK}',
},
{
'type': 'field_dropdown',
@@ -204,6 +233,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'DELTA',
'check': 'Number',
'ariaLabelText': '%{BKY_INPUT_LABEL_MATH_CHANGE_BY}',
},
],
'previousStatement': null,
@@ -231,6 +261,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'NUM',
'check': 'Number',
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER}',
},
],
'output': 'Number',
@@ -250,8 +281,16 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'name': 'OP',
'options': [
['%{BKY_MATH_ONLIST_OPERATOR_SUM}', 'SUM'],
['%{BKY_MATH_ONLIST_OPERATOR_MIN}', 'MIN'],
['%{BKY_MATH_ONLIST_OPERATOR_MAX}', 'MAX'],
[
'%{BKY_MATH_ONLIST_OPERATOR_MIN}',
'MIN',
'%{BKY_MATH_ONLIST_OPERATOR_MIN_ARIA}',
],
[
'%{BKY_MATH_ONLIST_OPERATOR_MAX}',
'MAX',
'%{BKY_MATH_ONLIST_OPERATOR_MAX_ARIA}',
],
['%{BKY_MATH_ONLIST_OPERATOR_AVERAGE}', 'AVERAGE'],
['%{BKY_MATH_ONLIST_OPERATOR_MEDIAN}', 'MEDIAN'],
['%{BKY_MATH_ONLIST_OPERATOR_MODE}', 'MODE'],
@@ -263,6 +302,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'LIST',
'check': 'Array',
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER_LIST}',
},
],
'output': 'Number',
@@ -281,11 +321,13 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'DIVIDEND',
'check': 'Number',
'ariaLabelText': '%{BKY_INPUT_LABEL_MATH_DIVIDEND}',
},
{
'type': 'input_value',
'name': 'DIVISOR',
'check': 'Number',
'ariaLabelText': '%{BKY_INPUT_LABEL_MATH_DIVISOR}',
},
],
'inputsInline': true,
@@ -304,6 +346,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'VALUE',
'check': 'Number',
'ariaLabelText': '%{BKY_INPUT_LABEL_MATH_CONSTRAIN_VALUE}',
},
{
'type': 'input_value',
@@ -332,11 +375,13 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'FROM',
'check': 'Number',
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER_MIN}',
},
{
'type': 'input_value',
'name': 'TO',
'check': 'Number',
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER_MAX}',
},
],
'inputsInline': true,
@@ -365,11 +410,13 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'X',
'check': 'Number',
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER_ATAN2_X}',
},
{
'type': 'input_value',
'name': 'Y',
'check': 'Number',
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER_ATAN2_Y}',
},
],
'inputsInline': true,
+7 -17
View File
@@ -344,14 +344,6 @@ const PROCEDURE_DEF_COMMON = {
}
}
},
/**
* Return all variables referenced by this block.
*
* @returns List of variable names.
*/
getVars: function (this: ProcedureBlock): string[] {
return this.arguments_;
},
/**
* Return all variables referenced by this block.
*
@@ -507,6 +499,7 @@ blocks['procedures_defnoreturn'] = {
const nameField = fieldRegistry.fromJson({
type: 'field_input',
text: initName,
ariaTypeName: Msg['ARIA_TYPE_FIELD_TEXT_INPUT_PROCEDURE'],
}) as FieldTextInput;
nameField!.setValidator(Procedures.rename);
nameField.setSpellcheck(false);
@@ -555,6 +548,7 @@ blocks['procedures_defreturn'] = {
const nameField = fieldRegistry.fromJson({
type: 'field_input',
text: initName,
ariaTypeName: Msg['ARIA_TYPE_FIELD_TEXT_INPUT_PROCEDURE'],
}) as FieldTextInput;
nameField.setValidator(Procedures.rename);
nameField.setSpellcheck(false);
@@ -673,6 +667,9 @@ const PROCEDURES_MUTATORARGUMENT = {
const field = new ProcedureArgumentField(
Procedures.DEFAULT_ARG,
this.validator_,
{
ariaTypeName: Msg['ARIA_TYPE_FIELD_TEXT_INPUT_ARGUMENT'],
},
);
this.appendDummyInput()
@@ -1019,14 +1016,6 @@ const PROCEDURE_CALL_COMMON = {
this.setProcedureParameters_(params, ids);
}
},
/**
* Return all variables referenced by this block.
*
* @returns List of variable names.
*/
getVars: function (this: CallBlock): string[] {
return this.arguments_;
},
/**
* Return all variables referenced by this block.
*
@@ -1062,7 +1051,8 @@ const PROCEDURE_CALL_COMMON = {
if (
def &&
(def.type !== this.defType_ ||
JSON.stringify(def.getVars()) !== JSON.stringify(this.arguments_))
JSON.stringify(def.getVarModels().map((model) => model.getName())) !==
JSON.stringify(this.arguments_))
) {
// The signatures don't match.
def = null;
+72 -17
View File
@@ -93,6 +93,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
{
'type': 'input_value',
'name': 'TEXT',
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_APPEND}',
},
],
'previousStatement': null,
@@ -108,6 +109,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'VALUE',
'check': ['String', 'Array'],
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_CHECK}',
},
],
'output': 'Number',
@@ -123,6 +125,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'VALUE',
'check': ['String', 'Array'],
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_CHECK}',
},
],
'output': 'Boolean',
@@ -138,6 +141,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'VALUE',
'check': 'String',
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_CHECK}',
},
{
'type': 'field_dropdown',
@@ -151,6 +155,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'FIND',
'check': 'String',
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_FIND}',
},
],
'output': 'Number',
@@ -167,13 +172,22 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'input_value',
'name': 'VALUE',
'check': 'String',
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_CHECK}',
},
{
'type': 'field_dropdown',
'name': 'WHERE',
'options': [
['%{BKY_TEXT_CHARAT_FROM_START}', 'FROM_START'],
['%{BKY_TEXT_CHARAT_FROM_END}', 'FROM_END'],
[
'%{BKY_TEXT_CHARAT_FROM_START}',
'FROM_START',
'%{BKY_TEXT_FROM_START_ARIA}',
],
[
'%{BKY_TEXT_CHARAT_FROM_END}',
'FROM_END',
'%{BKY_TEXT_FROM_END_ARIA}',
],
['%{BKY_TEXT_CHARAT_FIRST}', 'FIRST'],
['%{BKY_TEXT_CHARAT_LAST}', 'LAST'],
['%{BKY_TEXT_CHARAT_RANDOM}', 'RANDOM'],
@@ -191,8 +205,8 @@ export const blocks = createBlockDefinitionsFromJsonArray([
/** Type of a 'text_get_substring' block. */
type GetSubstringBlock = Block & GetSubstringMixin;
interface GetSubstringMixin extends GetSubstringType {
WHERE_OPTIONS_1: Array<[string, string]>;
WHERE_OPTIONS_2: Array<[string, string]>;
WHERE_OPTIONS_1: Array<[string, string, string?]>;
WHERE_OPTIONS_2: Array<[string, string, string?]>;
}
type GetSubstringType = typeof GET_SUBSTRING_BLOCK;
@@ -202,20 +216,37 @@ const GET_SUBSTRING_BLOCK = {
*/
init: function (this: GetSubstringBlock) {
this['WHERE_OPTIONS_1'] = [
[Msg['TEXT_GET_SUBSTRING_START_FROM_START'], 'FROM_START'],
[Msg['TEXT_GET_SUBSTRING_START_FROM_END'], 'FROM_END'],
[
Msg['TEXT_GET_SUBSTRING_START_FROM_START'],
'FROM_START',
Msg['TEXT_FROM_START_ARIA'],
],
[
Msg['TEXT_GET_SUBSTRING_START_FROM_END'],
'FROM_END',
Msg['TEXT_FROM_END_ARIA'],
],
[Msg['TEXT_GET_SUBSTRING_START_FIRST'], 'FIRST'],
];
this['WHERE_OPTIONS_2'] = [
[Msg['TEXT_GET_SUBSTRING_END_FROM_START'], 'FROM_START'],
[Msg['TEXT_GET_SUBSTRING_END_FROM_END'], 'FROM_END'],
[
Msg['TEXT_GET_SUBSTRING_END_FROM_START'],
'FROM_START',
Msg['TEXT_FROM_START_ARIA'],
],
[
Msg['TEXT_GET_SUBSTRING_END_FROM_END'],
'FROM_END',
Msg['TEXT_FROM_END_ARIA'],
],
[Msg['TEXT_GET_SUBSTRING_END_LAST'], 'LAST'],
];
this.setHelpUrl(Msg['TEXT_GET_SUBSTRING_HELPURL']);
this.setStyle('text_blocks');
this.appendValueInput('STRING')
.setCheck('String')
.appendField(Msg['TEXT_GET_SUBSTRING_INPUT_IN_TEXT']);
.appendField(Msg['TEXT_GET_SUBSTRING_INPUT_IN_TEXT'])
.setAriaLabelProvider(Msg['INPUT_LABEL_TEXT_TO_CHECK']);
const createMenu = (n: 1 | 2): FieldDropdown => {
const menu = fieldRegistry.fromJson({
type: 'field_dropdown',
@@ -297,7 +328,13 @@ const GET_SUBSTRING_BLOCK = {
this.removeInput('ORDINAL' + n, true);
// Create either a value 'AT' input or a dummy input.
if (isAt) {
this.appendValueInput('AT' + n).setCheck('Number');
this.appendValueInput('AT' + n)
.setCheck('Number')
.setAriaLabelProvider(
n === 1
? Msg['INPUT_LABEL_TEXT_START_POSITION']
: Msg['INPUT_LABEL_TEXT_END_POSITION'],
);
if (Msg['ORDINAL_NUMBER_SUFFIX']) {
this.appendDummyInput('ORDINAL' + n).appendField(
Msg['ORDINAL_NUMBER_SUFFIX'],
@@ -342,7 +379,8 @@ blocks['text_changeCase'] = {
options: OPERATORS,
}) as FieldDropdown,
'CASE',
);
)
.setAriaLabelProvider(Msg['INPUT_LABEL_TEXT_TO_CHANGE']);
this.setOutput(true, 'String');
this.setTooltip(Msg['TEXT_CHANGECASE_TOOLTIP']);
},
@@ -368,7 +406,8 @@ blocks['text_trim'] = {
options: OPERATORS,
}) as FieldDropdown,
'MODE',
);
)
.setAriaLabelProvider(Msg['INPUT_LABEL_TEXT_TO_CHANGE']);
this.setOutput(true, 'String');
this.setTooltip(Msg['TEXT_TRIM_TOOLTIP']);
},
@@ -461,7 +500,9 @@ blocks['text_prompt_ext'] = {
this.updateType_(newOp);
return undefined; // FieldValidators can't be void. Use option as-is.
});
this.appendValueInput('TEXT').appendField(dropdown, 'TYPE');
this.appendValueInput('TEXT')
.appendField(dropdown, 'TYPE')
.setAriaLabelProvider(Msg['INPUT_LABEL_TEXT_PROMPT_MESSAGE']);
this.setOutput(true, 'String');
this.setTooltip(() => {
return this.getFieldValue('TYPE') === 'TEXT'
@@ -528,11 +569,13 @@ blocks['text_count'] = {
'type': 'input_value',
'name': 'SUB',
'check': 'String',
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_FIND}',
},
{
'type': 'input_value',
'name': 'TEXT',
'check': 'String',
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_CHECK}',
},
],
'output': 'Number',
@@ -556,16 +599,19 @@ blocks['text_replace'] = {
'type': 'input_value',
'name': 'FROM',
'check': 'String',
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_FIND}',
},
{
'type': 'input_value',
'name': 'TO',
'check': 'String',
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_REPLACE}',
},
{
'type': 'input_value',
'name': 'TEXT',
'check': 'String',
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_CHECK}',
},
],
'output': 'String',
@@ -589,6 +635,7 @@ blocks['text_reverse'] = {
'type': 'input_value',
'name': 'TEXT',
'check': 'String',
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_CHANGE}',
},
],
'output': 'String',
@@ -757,13 +804,15 @@ const JOIN_MUTATOR_MIXIN = {
'text_create_join_container',
) as BlockSvg;
containerBlock.initSvg();
let connection = containerBlock.getInput('STACK')!.connection!;
let connection = containerBlock.getInput('STACK')?.connection;
for (let i = 0; i < this.itemCount_; i++) {
const itemBlock = workspace.newBlock(
'text_create_join_item',
) as JoinItemBlock;
itemBlock.initSvg();
connection.connect(itemBlock.previousConnection);
if (itemBlock.previousConnection) {
connection?.connect(itemBlock.previousConnection);
}
connection = itemBlock.nextConnection;
}
return containerBlock;
@@ -836,7 +885,11 @@ const JOIN_MUTATOR_MIXIN = {
// Add new inputs.
for (let i = 0; i < this.itemCount_; i++) {
if (!this.getInput('ADD' + i)) {
const input = this.appendValueInput('ADD' + i).setAlign(Align.RIGHT);
const input = this.appendValueInput('ADD' + i)
.setAlign(Align.RIGHT)
.setAriaLabelProvider(
Msg['INPUT_LABEL_TEXT_JOIN_ITEM'].replace('%1', (i + 1).toString()),
);
if (i === 0) {
input.appendField(Msg['TEXT_JOIN_TITLE_CREATEWITH']);
}
@@ -931,7 +984,9 @@ const CHARAT_MUTATOR_MIXIN = {
this.removeInput('ORDINAL', true);
// Create either a value 'AT' input or a dummy input.
if (isAt) {
this.appendValueInput('AT').setCheck('Number');
this.appendValueInput('AT')
.setCheck('Number')
.setAriaLabelProvider(Msg['INPUT_LABEL_TEXT_POSITION']);
if (Msg['ORDINAL_NUMBER_SUFFIX']) {
this.appendDummyInput('ORDINAL').appendField(
Msg['ORDINAL_NUMBER_SUFFIX'],
+3 -2
View File
@@ -56,6 +56,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
{
'type': 'input_value',
'name': 'VALUE',
'ariaLabelText': '%{BKY_INPUT_LABEL_VARIABLES_SET}',
},
],
'previousStatement': null,
@@ -117,12 +118,12 @@ const CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN = {
this.type === 'variables_get' ||
this.type === 'variables_get_reporter'
) {
const name = this.getField('VAR')!.getText();
const renameOption = {
text: Msg['RENAME_VARIABLE'],
text: Msg['RENAME_VARIABLE'].replace('%1', name),
enabled: true,
callback: renameOptionCallbackFactory(this),
};
const name = this.getField('VAR')!.getText();
const deleteOption = {
text: Msg['DELETE_VARIABLE'].replace('%1', name),
enabled: true,
+2 -2
View File
@@ -117,12 +117,12 @@ const CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN = {
this.type === 'variables_get_dynamic' ||
this.type === 'variables_get_reporter_dynamic'
) {
const name = this.getField('VAR')!.getText();
const renameOption = {
text: Msg['RENAME_VARIABLE'],
text: Msg['RENAME_VARIABLE'].replace('%1', name),
enabled: true,
callback: renameOptionCallbackFactory(this),
};
const name = this.getField('VAR')!.getText();
const deleteOption = {
text: Msg['DELETE_VARIABLE'].replace('%1', name),
enabled: true,
+75 -17
View File
@@ -46,12 +46,14 @@ import type {
IVariableModel,
IVariableState,
} from './interfaces/i_variable_model.js';
import {Msg} from './msg.js';
import * as registry from './registry.js';
import * as Tooltip from './tooltip.js';
import * as arrayUtils from './utils/array.js';
import {Coordinate} from './utils/coordinate.js';
import * as idGenerator from './utils/idgenerator.js';
import * as parsing from './utils/parsing.js';
import {replaceMessageReferences} from './utils/parsing.js';
import {Size} from './utils/size.js';
import type {Workspace} from './workspace.js';
@@ -242,6 +244,9 @@ export class Block {
inputsInlineDefault?: boolean;
workspace: Workspace;
/** A custom provider for generating the aria role description for this block. */
private ariaRoleDescriptionProvider: string | (() => string) | undefined;
/**
* @param workspace The block's workspace.
* @param prototypeName Name of the language object containing type-specific
@@ -962,7 +967,30 @@ export class Block {
}
/**
* @returns True if this block is a value block with a single editable field.
* Determines and returns the full-block field for this block, or null if there isn't one
* and this block can't be considered a singleton field block.
*
* Note that this method is unreliable if a block contains a single field that
* hasn't been initialized/rendered yet.
*
* @returns The full-block field this block contains, or null if it doesn't contain one.
* @internal
*/
getFullBlockField(): Field<any> | null {
if (!this.isSimpleReporter()) return null;
const field = this.inputList[0]?.fieldRow[0];
return field?.isFullBlockField() ? field : null;
}
/**
* A block is a simple reporter if it has an output connection and exactly one field.
* In some renderers, simple reporters are rendered differently from other blocks.
* Being a simple reporter block is a prerequisite to the single field rendering itself
* as a "full-block field", but it is not sufficient, as not all fields or renderers use
* this special rendering. Use `getFullBlockField` to determine if the block is rendered
* as a "full-block field block".
*
* @returns True if this block is a value block with a single field.
* @internal
*/
isSimpleReporter(): boolean {
@@ -1136,26 +1164,10 @@ export class Block {
}
}
/**
* Return all variables referenced by this block.
*
* @returns List of variable ids.
*/
getVars(): string[] {
const vars: string[] = [];
for (const field of this.getFields()) {
if (field.referencesVariables()) {
vars.push(field.getValue());
}
}
return vars;
}
/**
* Return all variables referenced by this block.
*
* @returns List of variable models.
* @internal
*/
getVarModels(): IVariableModel<IVariableState>[] {
const vars = [];
@@ -1541,6 +1553,44 @@ export class Block {
}
}
/**
* Set a custom aria role description provider for this block. If not set,
* uses a default provider based on the block's properties (e.g. whether it has
* inputs, outputs, etc.).
*
* @param description The description or function to provide the description.
* If a string, we'll replace message references in the string, e.g.
* `%{BKY_CUSTOM_MESSAGE}` will be replaced with the value of
* `Blockly.Msg['CUSTOM_MESSAGE']`.}'
*/
setAriaRoleDescriptionProvider(description: string | (() => string)) {
this.ariaRoleDescriptionProvider = description;
}
/**
* @returns The string to use as the role description for this block. If a
* custom provider has been set, use that. Otherwise, return a default
* description based on the block's properties.
*/
getAriaRoleDescription(): string {
if (this.ariaRoleDescriptionProvider) {
if (typeof this.ariaRoleDescriptionProvider === 'function') {
return this.ariaRoleDescriptionProvider();
}
return replaceMessageReferences(this.ariaRoleDescriptionProvider);
}
let roleDescription: string;
if (this.statementInputCount) {
roleDescription = Msg['BLOCK_LABEL_CONTAINER'];
} else if (this.outputConnection) {
roleDescription = Msg['BLOCK_LABEL_VALUE'];
} else {
roleDescription = Msg['BLOCK_LABEL_STATEMENT'];
}
return roleDescription;
}
/**
* Create a human-readable text representation of this block and any children.
*
@@ -1795,6 +1845,11 @@ export class Block {
const localizedValue = parsing.replaceMessageReferences(rawValue);
this.setHelpUrl(localizedValue);
}
if (json['ariaRoleDescription'] !== undefined) {
this.setAriaRoleDescriptionProvider(json['ariaRoleDescription']);
}
if (typeof json['extensions'] === 'string') {
console.warn(
warningPrefix +
@@ -2123,6 +2178,9 @@ export class Block {
input.setAlign(alignment);
}
}
if (element['ariaLabelText']) {
input.setAriaLabelProvider(element['ariaLabelText']);
}
return input;
}
@@ -0,0 +1,703 @@
/**
* @license
* Copyright 2026 Raspberry Pi Foundation
* SPDX-License-Identifier: Apache-2.0
*/
import type {BlockSvg} from './block_svg.js';
import {ConnectionType} from './connection_type.js';
import {FieldLabel} from './field_label.js';
import type {Input} from './inputs/input.js';
import {inputTypes} from './inputs/input_types.js';
import {
ISelectableToolboxItem,
isSelectableToolboxItem,
} from './interfaces/i_selectable_toolbox_item.js';
import {Msg} from './msg.js';
import {RenderedConnection} from './rendered_connection.js';
import {Role, setRole, setState, State, Verbosity} from './utils/aria.js';
/**
* Prepositions to use when describing the relationship between two blocks based
* on their connection types.
*/
export enum ConnectionPreposition {
UNKNOWN,
BEFORE,
AFTER,
AROUND,
INSIDE,
TO,
}
/**
* Returns an ARIA representation of the specified block.
*
* The returned label will contain a complete context of the block, including:
* - Whether it begins a block stack or statement input stack.
* - Its constituent editable and non-editable fields.
* - Properties, including: disabled, collapsed, replaceable (a shadow), etc.
* - Its parent toolbox category.
* - Whether it has inputs.
*
* Beyond this, the returned label is specifically assembled with commas in
* select locations with the intention of better 'prosody' in the screen reader
* readouts since there's a lot of information being shared with the user. The
* returned label also places more important information earlier in the label so
* that the user gets the most important context as soon as possible in case
* they wish to stop readout early.
*
* The returned label will be specialized based on whether the block is part of a
* flyout.
*
* Custom input labels (from {@link Input.setAriaLabelProvider}) are not included
* here; they are used only in move-mode disambiguation and parent-input context
* via {@link Input.getAriaLabelText}.
*
* @internal
* @param block The block for which an ARIA representation should be created.
* @param verbosity How much detail to include in the description.
* @returns The ARIA representation for the specified block.
*/
export function computeAriaLabel(
block: BlockSvg,
verbosity = Verbosity.STANDARD,
) {
if (block.isSimpleReporter()) {
// special case for full-block field blocks.
const field = block.getFullBlockField();
if (field) {
return field.computeAriaLabel(verbosity >= Verbosity.STANDARD);
}
}
return [
verbosity >= Verbosity.STANDARD && getBeginStackLabel(block),
getParentInputLabel(block),
...getInputLabels(block, verbosity),
verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block),
verbosity >= Verbosity.STANDARD && getDisabledLabel(block),
verbosity >= Verbosity.STANDARD && getCollapsedLabel(block),
verbosity >= Verbosity.LOQUACIOUS && getShadowBlockLabel(block),
verbosity >= Verbosity.STANDARD && getInputCountLabel(block),
verbosity >= Verbosity.LOQUACIOUS && block.getAriaRoleDescription(),
]
.filter((label) => !!label)
.join(', ');
}
/**
* Sets the ARIA role and role description for the specified block, accounting
* for whether the block is part of a flyout.
*
* @internal
* @param block The block to set ARIA role and roledescription attributes on.
*/
export function configureAriaRole(block: BlockSvg) {
setRole(block.getSvgRoot(), Role.PRESENTATION);
const focusableElement = block.getFocusableElement();
if (!block.isInFlyout) {
// blocks in the flyout have their role set by the Flyout's block inflater
// don't overwrite it here
setRole(focusableElement, Role.FIGURE);
}
setState(
focusableElement,
State.ROLEDESCRIPTION,
block.getAriaRoleDescription(),
);
}
/**
* Returns a list of ARIA labels for the 'field row' for the specified Input.
*
* 'Field row' essentially means the horizontal run of readable fields that
* precede the Input. Together, these provide the domain context for the input,
* particularly in the context of connections. In some cases, there may not be
* any readable fields immediately prior to the Input. In that case, if the
* `lookback` attribute is specified, all of the fields on the row immediately
* above the Input will be used instead.
*
* If the input contains multiple adjacent FieldLabel fields, they will be
* combined together into a singular label string so that screenreaders can
* know to read them together as one piece of text.
*
* Empty field labels are excluded because they don't provide useful context.
* Fields should generally have a helpful label, but there are exceptions, such
* as when empty label fields are used to control the layout of a block.
*
* @internal
* @param input The Input to compute a description/context label for.
* @param lookback If true, will use labels for fields on the previous row if
* the given input's row has no fields itself.
* @returns A list of labels for fields on the same row (or previous row, if
* lookback is specified) as the given input.
*/
export function computeFieldRowLabel(
input: Input,
lookback: boolean,
verbosity = Verbosity.STANDARD,
): string[] {
const includeTypeInfo = verbosity >= Verbosity.LOQUACIOUS;
let adjacentFieldLabels: Array<string> = [];
const fieldRowLabel = input.fieldRow
.filter((field) => field.isVisible())
.flatMap((field, index, visibleFields) => {
const isFieldLabel = field instanceof FieldLabel;
if (isFieldLabel) {
if (
index < visibleFields.length - 1 &&
visibleFields[index + 1] instanceof FieldLabel
) {
// Both this item and the next item are FieldLabels. We want to
// combine these, so we add this one to the list for later handling.
adjacentFieldLabels.push(field.computeAriaLabel(includeTypeInfo));
return [];
} else if (adjacentFieldLabels.length >= 1) {
// There is at least one adjacent FieldLabel before this one but none
// after. Combine the FieldLabels into one string.
adjacentFieldLabels.push(field.computeAriaLabel(includeTypeInfo));
const label = adjacentFieldLabels.join(' ');
adjacentFieldLabels = [];
return label;
}
}
return field.computeAriaLabel(includeTypeInfo);
});
if (!fieldRowLabel.length && lookback) {
const inputs = input.getSourceBlock().inputList;
const index = inputs.indexOf(input);
if (index > 0) {
return computeFieldRowLabel(inputs[index - 1], lookback, verbosity);
}
}
return fieldRowLabel.filter((label) => !!label);
}
/**
* Returns a description of the parent input a block is attached to.
* When a block is connected to an input, the input's label will sometimes
* be prepended to the block's description.
*
* If an input has a custom label, the custom label will be prepended
* to the first child block connected to that input.
*
* If an input does not have a custom label, the input's fallback
* label determined from the field row will be prepended to the
* child block's label only if the following are true:
* - the parent block has at least one statement input
* - the child block in question is not attached to the first
* statement input of the parent block (in this case, the label
* would be redundant with the parent block's label)
*
* For statement inputs without their own field labels, labels from other
* inputs in the same statement section are included (via
* {@link getInputLabelsSubset}), consistent with move-target disambiguation.
*
* For statement inputs, the resolved label (whether custom or fallback) is
* wrapped in the "Begin %1" prefix so the readout indicates that the child
* block starts the body of the statement input.
*
* Labels for child blocks of inputs are excluded because they are included
* with {@link getInputLabels} already.
*
* @internal
* @param block The block to generate a parent input label for.
* @returns A description of the block's parent statement input, or undefined
* for blocks that do not have one.
*/
function getParentInputLabel(block: BlockSvg) {
const parentInput = (
block.outputConnection ?? block.previousConnection
)?.targetConnection?.getParentInput();
if (!parentInput) return undefined;
const parentBlock = parentInput.getSourceBlock();
if (parentBlock.isInsertionMarker()) return undefined;
// parentInput is only non-null when this block is directly attached to the
// input (i.e. it is the first child block in that input). A custom label
// is always prepended for the first child; a fallback label from the field
// row is only used in select circumstances.
let inputLabel: string | string[];
const customLabel = parentInput.getAriaLabelText();
if (customLabel) {
inputLabel = customLabel;
} else {
if (!parentBlock.statementInputCount) return undefined;
const firstStatementInput = parentBlock.inputList.find(
(i) => i.type === inputTypes.STATEMENT,
);
// The first statement input in a block has no field row label as it would
// be duplicative of the block's label.
if (parentInput === firstStatementInput) {
return undefined;
}
const sectionLabels = getInputLabelsSubset(
parentBlock as BlockSvg,
parentInput,
false, // Exclude labels from child blocks.
);
if (!sectionLabels.length) {
return undefined;
}
inputLabel = sectionLabels.join(', ');
}
if (parentInput.type === inputTypes.STATEMENT) {
const labelText = Array.isArray(inputLabel)
? inputLabel.join(' ')
: inputLabel;
return Msg['BLOCK_LABEL_BEGIN_PREFIX'].replace('%1', labelText);
}
return inputLabel;
}
/**
* Returns text indicating that a block is the root block of a stack.
*
* @internal
* @param block The block to retrieve a label for.
* @returns Text indicating that the block begins a stack, or undefined if it
* does not.
*/
function getBeginStackLabel(block: BlockSvg) {
// Don't include the "begin stack" label for blocks that are moving
// or blocks in the flyout
if (block.isInFlyout || block.isDragging()) return undefined;
return block.getRootBlock() === block
? Msg['BLOCK_LABEL_BEGIN_STACK']
: undefined;
}
/**
* Returns a list of accessibility labels for fields and inputs on a block.
* Each entry in the returned array corresponds to one of: (a) a label for a
* continuous run of non-interactable fields, (b) a label for an editable field,
* (c) a label for an input. When an input contains nested blocks/fields/inputs,
* their contents are returned as a single item in the array per top-level
* input.
*
* Uses derived labels only (field row text and connected block content via
* {@link Input.getLabel}). Custom input labels are not included; see
* {@link Input.getAriaLabelText} for move-mode and parent-input usage.
*
* @internal
* @param block The block to retrieve a list of field/input labels for.
* @param verbosity How much detail to include in each input label.
* @returns A list of field/input labels for the given block.
*/
export function getInputLabels(
block: BlockSvg,
verbosity = Verbosity.STANDARD,
): string[] {
const visibleInputs = block.inputList.filter((input) => input.isVisible());
let inputsToLabel = visibleInputs;
// For terse and standard verbosity levels, if there are multiple statement inputs,
// only include labels up to the first one.
if (verbosity <= Verbosity.STANDARD) {
const statementInputs = visibleInputs.filter(
(input) => input.type === inputTypes.STATEMENT,
);
if (statementInputs.length > 1) {
inputsToLabel = visibleInputs.slice(
0,
visibleInputs.indexOf(statementInputs[0]) + 1,
);
}
}
return inputsToLabel.map((input) => input.getLabel(verbosity));
}
/**
* Returns a subset of derived labels for inputs on the given block, ending at
* the specified input. Used to disambiguate move targets and connection
* highlights when no custom label is set.
*
* The subset is determined based on the input type:
* - For non-statement inputs, only the label for the given input is returned.
* - For statement inputs, labels are collected from the start of the current
* statement section up to and including the given input. A statement section
* begins immediately after the previous statement input, or at the start of
* the block if none exists.
*
* Label resolution (see also {@link computeMoveConnectionLabel}):
* 1. Custom labels ({@link Input.getAriaLabelText}) are handled by callers, not here.
* 2. Derived labels from {@link Input.getLabel} (field row + child blocks).
* 3. Numbered fallback ({@link Msg.INPUT_LABEL_INDEX}) when tier 2 is empty.
* For the statement target input, the fallback is omitted if any earlier
* input in the subset already produced a label.
*
* @internal
* @param block The block to retrieve a list of field/input labels for.
* @param endInput The input that defines the end of the subset.
* @param includeEndInputChildren Whether to include labels for child blocks
* connected to the end input.
* @returns A list of field/input labels for the given block.
*/
export function getInputLabelsSubset(
block: BlockSvg,
endInput: Input,
includeEndInputChildren: boolean,
): string[] {
const inputIndex = block.inputList.indexOf(endInput);
if (inputIndex === -1) {
throw new Error(
`Input with name "${endInput.name}" not found on block with id "${block.id}".`,
);
}
const isStatementTarget = endInput.type === inputTypes.STATEMENT;
const startIndex = isStatementTarget
? findStartOfStatementSection(block.inputList, inputIndex)
: inputIndex;
// For statement inputs, we include all visible inputs from the start
// of the current statement section up to and including the target input.
// For non-statement inputs, this will just be the target input itself.
const inputsInSubset = block.inputList
.slice(startIndex, inputIndex + 1)
.filter((subsetInput) => subsetInput.isVisible());
// The derived labels are based on the field row and any connected child
// blocks. Labels for child blocks are potentially skipped if they would be
// redundant within the overall block label.
const derivedLabels = inputsInSubset.map((subsetInput) =>
subsetInput.getLabel(
Verbosity.TERSE,
subsetInput !== endInput || includeEndInputChildren,
),
);
// For statement inputs, we only include the fallback label ("input %1")
// for the target input if no preceding input in the subset has a label.
// This prevents, e.g., "else" statement inputs from being read as "else, input 2".
const precedingLabelsProvideContext =
isStatementTarget && derivedLabels.slice(0, -1).some((label) => !!label);
return derivedLabels
.map((label, index) => {
if (label) {
return label;
}
const subsetInput = inputsInSubset[index];
// Dummy and end-row inputs are not connection inputs; getIndex() is -1
// and would produce a misleading "input 0" fallback label.
if (
subsetInput.type === inputTypes.DUMMY ||
subsetInput.type === inputTypes.END_ROW
) {
return undefined;
}
const isStatementTargetInput =
isStatementTarget && index === derivedLabels.length - 1;
if (isStatementTargetInput && precedingLabelsProvideContext) {
return undefined;
}
return Msg['INPUT_LABEL_INDEX'].replace(
'%1',
(subsetInput.getIndex() + 1).toString(),
);
})
.filter((label) => label !== undefined);
}
/**
* Finds the starting index of the current statement section within a list of inputs.
*
* A statement section is defined as the group of inputs that follow the most
* recent preceding statement input. If no prior statement input exists, the
* section starts at index 0.
*
* @param inputs The list of inputs to search.
* @param fromIndex The index of the current statement input.
* @returns The index of the first input in the current statement section.
*/
function findStartOfStatementSection(
inputs: Input[],
fromIndex: number,
): number {
// Find the first input after the previous statement input.
for (let i = fromIndex - 1; i >= 0; i--) {
if (inputs[i].type === inputTypes.STATEMENT) {
return i + 1;
}
}
return 0;
}
/**
* Returns the name of the toolbox category that the given block is part of.
* This is heuristic-based; each toolbox category's contents are enumerated, and
* if a block with the given block's type is encountered, that category is
* deemed to be its parent. As a fallback, a toolbox category with the same
* colour as the block may be returned. This is not comprehensive; blocks may
* exist on the workspace which are not part of any category, or a given block
* type may be part of multiple categories or belong to a dynamically-generated
* category, or there may not even be a toolbox at all. In these cases, either
* the first matching category or undefined will be returned.
*
* This method exists to attempt to provide similar context as block colour
* provides to sighted users, e.g. where a red block comes from a red category.
* It is inherently best-effort due to the above-mentioned constraints.
*
* @internal
* @param block The block to retrieve a category name for.
* @returns A description of the given block's parent toolbox category if any,
* otherwise undefined.
*/
function getParentToolboxCategoryLabel(block: BlockSvg) {
const toolbox = block.workspace.getToolbox();
if (!toolbox) return undefined;
let parentCategory: ISelectableToolboxItem | undefined = undefined;
for (const category of toolbox.getToolboxItems()) {
if (!isSelectableToolboxItem(category)) continue;
const contents = category.getContents();
if (
Array.isArray(contents) &&
contents.some(
(item) =>
item.kind.toLowerCase() === 'block' &&
'type' in item &&
item.type === block.type,
)
) {
parentCategory = category;
break;
}
if (
'getColour' in category &&
typeof category.getColour === 'function' &&
category.getColour() === block.getColour()
) {
parentCategory = category;
}
}
if (parentCategory) {
return Msg['BLOCK_LABEL_TOOLBOX_CATEGORY'].replace(
'%1',
parentCategory.getName(),
);
}
return undefined;
}
/**
* Returns the appropriate translated announcement template based on the connection type.
*
* @param preposition The relationship between the local and neighbour connections.
* @returns A translated string template to use for announcing a block move.
*/
function getAnnouncementTemplate(preposition: ConnectionPreposition): string {
switch (preposition) {
case ConnectionPreposition.BEFORE:
return Msg['ANNOUNCE_MOVE_BEFORE'];
case ConnectionPreposition.AFTER:
return Msg['ANNOUNCE_MOVE_AFTER'];
case ConnectionPreposition.INSIDE:
return Msg['ANNOUNCE_MOVE_INSIDE'];
case ConnectionPreposition.AROUND:
return Msg['ANNOUNCE_MOVE_AROUND'];
default:
return Msg['ANNOUNCE_MOVE_TO'];
}
}
/**
* Returns a label for a connection that includes either a block label, input
* label, or both.
*
* Input label resolution:
* 1. Custom label from {@link Input.getAriaLabelText} when set.
* 2. Otherwise derived labels from {@link getInputLabelsSubset} (field row,
* child blocks, and numbered fallbacks as needed).
*
* @param conn The connection to generate a label for.
* @param baseLabel An optional block label to include in the returned string.
* @returns A label describing the given connection
*/
function computeMoveConnectionLabel(
conn: RenderedConnection,
baseLabel: string,
): string {
const input = conn.getParentInput();
if (!input) return baseLabel;
let inputLabel = input.getAriaLabelText();
if (!inputLabel) {
const labels = getInputLabelsSubset(conn.getSourceBlock(), input, true);
if (!labels.length) return baseLabel;
inputLabel = labels.join(', ');
}
return baseLabel
? Msg['ANNOUNCE_MOVE_OF'].replace('%1', inputLabel).replace('%2', baseLabel)
: inputLabel;
}
/**
* Returns a translated string describing an in-progress move of a block to a new
* connection, suitable for announcement on the ARIA live region. The returned string
* will be assembled based on the types of the local and neighbour connections and
* the presence of any readable fields on the block's inputs. If multiple potential
* candidate connections are present, additional context will be included in the
* returned string to help disambiguate between them.
*
* @param local The moving side of the candidate connection pair
* @param neighbour The target side of the candidate connection pair
* @param disambiguationPolicy A function that determines whether it's useful to
* include parent input labels for disambiguation.
* @param isMoveStart Whether this announcement is for the start of a move. If false,
* skip announcing the block label since it should have already been announced.
*/
export function computeMoveLabel(
local: RenderedConnection,
neighbour: RenderedConnection,
disambiguationPolicy: (forLocal: boolean) => boolean,
isMoveStart = false,
): string {
const preposition = getConnectionPreposition(local, neighbour);
const template = getAnnouncementTemplate(preposition);
const needsDisambiguation = ![
ConnectionPreposition.BEFORE,
ConnectionPreposition.AFTER,
].includes(preposition);
const includeLocalContext = needsDisambiguation && disambiguationPolicy(true);
const includeNeighbourContext =
needsDisambiguation && disambiguationPolicy(false);
let blockLabel = isMoveStart
? local.getSourceBlock().getStackBlocksCountLabel()
: '';
let neighbourLabel = neighbour.getSourceBlock().getAriaLabel(Verbosity.TERSE);
if (includeLocalContext) {
blockLabel = computeMoveConnectionLabel(local, blockLabel);
}
if (includeNeighbourContext) {
neighbourLabel = computeMoveConnectionLabel(neighbour, neighbourLabel);
}
return template.replace('%1', blockLabel).replace('%2', neighbourLabel);
}
/**
* Returns the appropriate preposition to use in the move announcement based on the
* relationship between the local and neighbour connections.
*/
function getConnectionPreposition(
local: RenderedConnection,
neighbour: RenderedConnection,
): ConnectionPreposition {
switch (local.type) {
case ConnectionType.INPUT_VALUE:
case ConnectionType.OUTPUT_VALUE:
return ConnectionPreposition.TO;
case ConnectionType.NEXT_STATEMENT:
if (local === local.getSourceBlock().nextConnection) {
return ConnectionPreposition.BEFORE;
} else {
return ConnectionPreposition.AROUND;
}
case ConnectionType.PREVIOUS_STATEMENT:
if (neighbour === neighbour.getSourceBlock().nextConnection) {
return ConnectionPreposition.AFTER;
} else {
return ConnectionPreposition.INSIDE;
}
}
// Not normally reachable since all valid connection types are covered.
// Satisfies the return type.
return ConnectionPreposition.UNKNOWN;
}
/**
* Returns a label indicating that the block is disabled.
*
* @internal
* @param block The block to generate a label for.
* @returns A label indicating that the block is disabled (if it is), otherwise
* undefined.
*/
function getDisabledLabel(block: BlockSvg) {
return block.isEnabled() ? undefined : Msg['BLOCK_LABEL_DISABLED'];
}
/**
* Returns a label indicating that the block is collapsed.
*
* @internal
* @param block The block to generate a label for.
* @returns A label indicating that the block is collapsed (if it is), otherwise
* undefined.
*/
function getCollapsedLabel(block: BlockSvg) {
return block.isCollapsed() ? Msg['BLOCK_LABEL_COLLAPSED'] : undefined;
}
/**
* Returns a label indicating that the block is a shadow block.
*
* @internal
* @param block The block to generate a label for.
* @returns A label indicating that the block is a shadow (if it is), otherwise
* undefined.
*/
function getShadowBlockLabel(block: BlockSvg) {
return block.isShadow() ? Msg['BLOCK_LABEL_REPLACEABLE'] : undefined;
}
/**
* Returns a label indicating whether the block has one or multiple inputs.
*
* @internal
* @param block The block to generate a label for.
* @returns A label indicating that the block has one or multiple inputs,
* otherwise undefined.
*/
function getInputCountLabel(block: BlockSvg) {
const branchCount = block.inputList.filter(
(input) => input.type === inputTypes.STATEMENT,
).length;
if (branchCount > 1) {
return Msg['BLOCK_LABEL_HAS_BRANCHES'].replace(
'%1',
branchCount.toString(),
);
}
const valueInputCount = block.inputList.reduce((totalSum, input) => {
return (
input.fieldRow.reduce((fieldCount, field) => {
return field.EDITABLE && !field.isFullBlockField()
? ++fieldCount
: fieldCount;
}, totalSum) +
(input.connection?.type === ConnectionType.INPUT_VALUE ? 1 : 0)
);
}, 0);
switch (valueInputCount) {
case 0:
return undefined;
case 1:
return Msg['BLOCK_LABEL_HAS_INPUT'];
default:
return Msg['BLOCK_LABEL_HAS_INPUTS'];
}
}
+14 -1
View File
@@ -15,6 +15,7 @@ import type {IFlyout} from './interfaces/i_flyout.js';
import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js';
import * as registry from './registry.js';
import * as blocks from './serialization/blocks.js';
import {aria} from './utils.js';
import type {BlockInfo} from './utils/toolbox.js';
import * as utilsXml from './utils/xml.js';
import type {WorkspaceSvg} from './workspace_svg.js';
@@ -67,7 +68,19 @@ export class BlockFlyoutInflater implements IFlyoutInflater {
// Mark blocks as being inside a flyout. This is used to detect and
// prevent the closure of the flyout if the user right-clicks on such
// a block.
block.getDescendants(false).forEach((b) => (b.isInFlyout = true));
block.getDescendants(false).forEach((b) => {
b.isInFlyout = true;
const focusableElement = b.getFocusableElement();
// blocks can't be focused if they're in a flyout and not top-level
// nonfocusable blocks should be hidden from the aria tree
aria.setState(focusableElement, aria.State.HIDDEN, true);
aria.setRole(focusableElement, aria.Role.PRESENTATION);
});
// Since getDescencdants includes the root block, we need
// to correct the role and hidden state for it.
const focusableElement = block.getFocusableElement();
aria.clearState(focusableElement, aria.State.HIDDEN);
aria.setRole(focusableElement, aria.Role.LISTITEM);
this.addBlockListeners(block);
return new FlyoutItem(block, BLOCK_TYPE);
+212 -58
View File
@@ -16,6 +16,7 @@ import './events/events_selected.js';
import {Block} from './block.js';
import * as blockAnimations from './block_animations.js';
import {computeAriaLabel, configureAriaRole} from './block_aria_composer.js';
import * as browserEvents from './browser_events.js';
import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js';
import * as common from './common.js';
@@ -35,6 +36,7 @@ import {EventType} from './events/type.js';
import * as eventUtils from './events/utils.js';
import {FieldLabel} from './field_label.js';
import {getFocusManager} from './focus_manager.js';
import * as hints from './hints.js';
import {IconType} from './icons/icon_types.js';
import {MutatorIcon} from './icons/mutator_icon.js';
import {WarningIcon} from './icons/warning_icon.js';
@@ -43,11 +45,16 @@ import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import {IContextMenu} from './interfaces/i_contextmenu.js';
import type {ICopyable} from './interfaces/i_copyable.js';
import {IDeletable} from './interfaces/i_deletable.js';
import type {IDragStrategy, IDraggable} from './interfaces/i_draggable.js';
import type {
DragDisposition,
IDragStrategy,
IDraggable,
} from './interfaces/i_draggable.js';
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
import {IIcon} from './interfaces/i_icon.js';
import * as internalConstants from './internal_constants.js';
import {KeyboardMover} from './keyboard_nav/keyboard_mover.js';
import {Msg} from './msg.js';
import * as renderManagement from './render_management.js';
import {RenderedConnection} from './rendered_connection.js';
@@ -56,6 +63,7 @@ import * as blocks from './serialization/blocks.js';
import type {BlockStyle} from './theme.js';
import * as Tooltip from './tooltip.js';
import {idGenerator} from './utils.js';
import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import {Rect} from './utils/rect.js';
@@ -156,12 +164,9 @@ export class BlockSvg
private visuallyDisabled = false;
override workspace: WorkspaceSvg;
// TODO(b/109816955): remove '!', see go/strict-prop-init-fix.
override outputConnection!: RenderedConnection;
// TODO(b/109816955): remove '!', see go/strict-prop-init-fix.
override nextConnection!: RenderedConnection;
// TODO(b/109816955): remove '!', see go/strict-prop-init-fix.
override previousConnection!: RenderedConnection;
override outputConnection: RenderedConnection | null = null;
override nextConnection: RenderedConnection | null = null;
override previousConnection: RenderedConnection | null = null;
private translation = '';
@@ -241,6 +246,7 @@ export class BlockSvg
if (!svg.parentNode) {
this.workspace.getCanvas().appendChild(svg);
}
this.recomputeAriaContext();
this.initialized = true;
}
@@ -603,6 +609,7 @@ export class BlockSvg
this.getInput(collapsedInputName) ||
this.appendDummyInput(collapsedInputName);
input.appendField(new FieldLabel(text), collapsedFieldName);
this.recomputeAriaContext();
}
/**
@@ -791,6 +798,13 @@ export class BlockSvg
}
}
/**
* Returns whether or not this block is currently being dragged.
*/
isDragging() {
return this.dragging;
}
/**
* Set whether this block is movable or not.
*
@@ -832,6 +846,7 @@ export class BlockSvg
override setShadow(shadow: boolean) {
super.setShadow(shadow);
this.applyColour();
this.recomputeAriaContext();
}
/**
@@ -903,14 +918,21 @@ export class BlockSvg
Tooltip.dispose();
ContextMenu.hide();
if (animate) {
this.unplug(healStack);
blockAnimations.disposeUiEffect(this);
}
const focusManager = getFocusManager();
const focusedElement =
focusManager.getFocusedNode()?.getFocusableElement() ?? null;
super.dispose(!!healStack);
dom.removeNode(this.svgGroup);
// If this block (or a descendant) was focused, focus its parent or
// workspace instead.
const focusManager = getFocusManager();
if (
this.getSvgRoot().contains(
focusManager.getFocusedNode()?.getFocusableElement() ?? null,
)
) {
if (this.getSvgRoot().contains(focusedElement)) {
let parent: BlockSvg | undefined | null = this.getParent();
if (!parent) {
// In some cases, blocks are disconnected from their parents before
@@ -927,28 +949,21 @@ export class BlockSvg
parent = targetConnection?.getSourceBlock();
}
}
if (parent) {
focusManager.focusNode(parent);
} else {
const nearestNeighbour = this.getNearestNeighbour();
if (nearestNeighbour) {
focusManager.focusNode(nearestNeighbour);
setTimeout(() => {
if (!this.workspace.rendered) return;
if (parent) {
focusManager.focusNode(parent);
} else {
setTimeout(() => {
if (!this.workspace.rendered) return;
const nearestNeighbour = this.getNearestNeighbour();
if (nearestNeighbour) {
focusManager.focusNode(nearestNeighbour);
} else {
focusManager.focusTree(this.workspace);
}, 0);
}
}
}
}, 0);
}
if (animate) {
this.unplug(healStack);
blockAnimations.disposeUiEffect(this);
}
super.dispose(!!healStack);
dom.removeNode(this.svgGroup);
}
/**
@@ -1052,6 +1067,7 @@ export class BlockSvg
for (const child of this.getChildren(false)) {
child.updateDisabled();
}
this.recomputeAriaContext();
}
/**
@@ -1599,6 +1615,7 @@ export class BlockSvg
if (
this.isDeadOrDying() ||
this.workspace.isDragging() ||
this.isDragging() ||
root.isInFlyout
) {
return;
@@ -1620,7 +1637,7 @@ export class BlockSvg
if (conn.isSuperior()) {
neighbour.bumpAwayFrom(conn, /* initiatedByThis = */ false);
} else {
} else if (!neighbour.getSourceBlock().isDragging()) {
conn.bumpAwayFrom(neighbour, /* initiatedByThis = */ true);
}
}
@@ -1774,24 +1791,7 @@ export class BlockSvg
* @internal
*/
fadeForReplacement(add: boolean) {
// TODO (7204): Remove these internal methods.
(this.pathObject as AnyDuringMigration).updateReplacementFade(add);
}
/**
* Visual effect to show that if the dragging block is dropped it will connect
* to this input.
*
* @param conn The connection on the input to highlight.
* @param add True if highlighting should be added.
* @internal
*/
highlightShapeForInput(conn: RenderedConnection, add: boolean) {
// TODO (7204): Remove these internal methods.
(this.pathObject as AnyDuringMigration).updateShapeForInputHighlight(
conn,
add,
);
this.pathObject.updateReplacing?.(add);
}
/**
@@ -1820,18 +1820,21 @@ export class BlockSvg
}
/** Starts a drag on the block. */
startDrag(e?: PointerEvent): void {
this.dragStrategy.startDrag(e);
startDrag(e?: PointerEvent | KeyboardEvent) {
return this.dragStrategy.startDrag(e);
}
/** Drags the block to the given location. */
drag(newLoc: Coordinate, e?: PointerEvent): void {
drag(newLoc: Coordinate, e?: PointerEvent | KeyboardEvent): void {
this.dragStrategy.drag(newLoc, e);
}
/** Ends the drag on the block. */
endDrag(e?: PointerEvent): void {
this.dragStrategy.endDrag(e);
endDrag(
e: PointerEvent | KeyboardEvent | undefined,
disposition: DragDisposition,
): void {
this.dragStrategy.endDrag(e, disposition);
}
/** Moves the block back to where it was at the start of a drag. */
@@ -1877,8 +1880,23 @@ export class BlockSvg
}
}
/**
* Returns the number of blocks that this block is nested inside of.
*
* @internal
*/
getNestingLevel(): number {
const surroundParent = this.getSurroundParent();
return surroundParent ? surroundParent.getNestingLevel() + 1 : 0;
}
/** See IFocusableNode.getFocusableElement. */
getFocusableElement(): HTMLElement | SVGElement {
// For full-block fields, we focus the field itself
const fullBlockField = this.getFullBlockField();
if (fullBlockField) {
return fullBlockField.getFocusableElement();
}
return this.pathObject.svgPath;
}
@@ -1889,10 +1907,16 @@ export class BlockSvg
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {
this.recomputeAriaContext();
this.select();
this.workspace.scrollBoundsIntoView(
this.getBoundingRectangleWithoutChildren(),
);
const focusedNode = getFocusManager().getFocusedNode();
if (focusedNode && focusedNode !== this) {
renderManagement.finishQueuedRenders().then(() => {
this.workspace.scrollBoundsIntoView(
this.getBoundingRectangleWithoutChildren(),
);
});
}
}
/** See IFocusableNode.onNodeBlur. */
@@ -1904,4 +1928,134 @@ export class BlockSvg
canBeFocused(): boolean {
return true;
}
/**
* Handles the user acting on this block via keyboard navigation.
* If this block is in the flyout, a new copy is spawned in move mode on the
* main workspace. If this block has a single full-block field, that field
* will be focused. Otherwise, this is a no-op.
*/
performAction(e?: KeyboardEvent) {
if (this.workspace.isFlyout) {
KeyboardMover.mover.startMove(this, e);
return;
} else if (this.isSimpleReporter()) {
for (const input of this.inputList) {
for (const field of input.fieldRow) {
if (field.isClickable() && field.isFullBlockField()) {
field.showEditor();
return;
}
}
}
}
if (this.workspace.getNavigator().getInNode(this)) {
hints.showBlockNavigationHint(this.workspace);
} else {
hints.showHelpHint(this.workspace);
}
}
/**
* Returns a set of all of the parent blocks of the given block.
*
* @internal
* @returns A set of the parents of the given block.
*/
getParents(): Set<BlockSvg> {
const parents = new Set<BlockSvg>();
let parent = this.getParent();
while (parent) {
parents.add(parent);
parent = parent.getParent();
}
return parents;
}
/**
* Returns a set of all of the parent blocks connected to an output of the
* given block or one of its parents. Also includes the given block.
*
* @internal
* @returns A set of the output-connected parents of the given block.
*/
getOutputParents(): Set<BlockSvg> {
const parents = new Set<BlockSvg>();
parents.add(this);
let parent = this.outputConnection?.targetBlock();
while (parent) {
parents.add(parent);
parent = parent.outputConnection?.targetBlock();
}
return parents;
}
/**
* Returns an ID for the logical "row" this block is part of. A "row" is
* bounded by a previous/next connection, a statement input, or a block stack
* boundary; all blocks/inputs nested inside of one of those are conceptually
* part of its same row.
*
* @internal
*/
getRowId(): string {
const connectedInput =
this.outputConnection?.targetConnection?.getParentInput();
// Blocks with an output value have the same ID as the input they're
// connected to.
if (connectedInput) {
return connectedInput.getRowId();
}
// All other blocks are their own row.
return this.id;
}
/**
* Updates the ARIA label, role and roledescription for this block.
*/
private recomputeAriaContext() {
if (this.getFullBlockField()) return;
aria.setState(
this.getFocusableElement(),
aria.State.LABEL,
this.getAriaLabel(aria.Verbosity.STANDARD),
);
configureAriaRole(this);
}
/**
* Returns a description of this block suitable for screenreaders or use in
* ARIA attributes.
*
* @param verbosity How much detail to include in the description.
* @returns An accessibility description of this block.
*/
getAriaLabel(verbosity: aria.Verbosity) {
return computeAriaLabel(this, verbosity);
}
/**
* Count the number of blocks in this stack (connected by next connections)
* and return a label to describe it. Uses the standard label if there is only one block.
*
* @internal
*/
getStackBlocksCountLabel(): string {
let count = 1;
let block = this.getNextBlock();
while (block) {
count++;
block = block.getNextBlock();
}
if (count <= 1) {
return computeAriaLabel(this, aria.Verbosity.TERSE);
}
const labelTemplate = Msg['BLOCK_LABEL_STACK_BLOCKS'];
return labelTemplate.replace('%1', count.toString());
}
}
+28 -18
View File
@@ -118,6 +118,8 @@ import * as icons from './icons.js';
import {inject} from './inject.js';
import * as inputs from './inputs.js';
import {IFlyoutInflater} from './interfaces/i_flyout_inflater.js';
import {Direction, KeyboardMover} from './keyboard_nav/keyboard_mover.js';
import {MoveIndicator} from './keyboard_nav/move_indicator.js';
import {LabelFlyoutInflater} from './label_flyout_inflater.js';
import {SeparatorFlyoutInflater} from './separator_flyout_inflater.js';
import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
@@ -125,7 +127,10 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
import {Input} from './inputs/input.js';
import {InsertionMarkerPreviewer} from './insertion_marker_previewer.js';
import {IAutoHideable} from './interfaces/i_autohideable.js';
import {IBoundedElement} from './interfaces/i_bounded_element.js';
import {
IBoundedElement,
isBoundedElement,
} from './interfaces/i_bounded_element.js';
import {IBubble} from './interfaces/i_bubble.js';
import {ICollapsibleToolboxItem} from './interfaces/i_collapsible_toolbox_item.js';
import {IComponent} from './interfaces/i_component.js';
@@ -137,6 +142,7 @@ import {IDeletable, isDeletable} from './interfaces/i_deletable.js';
import {IDeleteArea} from './interfaces/i_delete_area.js';
import {IDragTarget} from './interfaces/i_drag_target.js';
import {
DragDisposition,
IDragStrategy,
IDraggable,
isDraggable,
@@ -171,15 +177,13 @@ import {
import {IVariableMap} from './interfaces/i_variable_map.js';
import {IVariableModel, IVariableState} from './interfaces/i_variable_model.js';
import * as internalConstants from './internal_constants.js';
import {LineCursor} from './keyboard_nav/line_cursor.js';
import {Marker} from './keyboard_nav/marker.js';
import {ToolboxNavigator} from './keyboard_nav/navigators/toolbox_navigator.js';
import {
KeyboardNavigationController,
keyboardNavigationController,
} from './keyboard_navigation_controller.js';
import type {LayerManager} from './layer_manager.js';
import * as layers from './layers.js';
import {MarkerManager} from './marker_manager.js';
import {Menu} from './menu.js';
import {MenuItem} from './menuitem.js';
import {MetricsManager} from './metrics_manager.js';
@@ -230,8 +234,6 @@ import {ZoomControls} from './zoom_controls.js';
* This constant is overridden by the build script (npm run build) to the value
* of the version in package.json. This is done by the Closure Compiler in the
* buildCompressed gulp task.
* For local builds, you can pass --define='Blockly.VERSION=X.Y.Z' to the
* compiler to override this constant.
*
* @define {string}
*/
@@ -433,16 +435,21 @@ Names.prototype.populateProcedures = function (
};
// clang-format on
export * from './flyout_navigator.js';
export * from './interfaces/i_navigation_policy.js';
export * from './keyboard_nav/block_navigation_policy.js';
export * from './keyboard_nav/connection_navigation_policy.js';
export * from './keyboard_nav/field_navigation_policy.js';
export * from './keyboard_nav/flyout_button_navigation_policy.js';
export * from './keyboard_nav/flyout_navigation_policy.js';
export * from './keyboard_nav/flyout_separator_navigation_policy.js';
export * from './keyboard_nav/workspace_navigation_policy.js';
export * from './navigator.js';
export * from './keyboard_nav/navigation_policies/block_navigation_policy.js';
export * from './keyboard_nav/navigation_policies/bubble_navigation_policy.js';
export * from './keyboard_nav/navigation_policies/comment_bar_button_navigation_policy.js';
export * from './keyboard_nav/navigation_policies/comment_editor_navigation_policy.js';
export * from './keyboard_nav/navigation_policies/connection_navigation_policy.js';
export * from './keyboard_nav/navigation_policies/field_navigation_policy.js';
export * from './keyboard_nav/navigation_policies/flyout_button_navigation_policy.js';
export * from './keyboard_nav/navigation_policies/flyout_separator_navigation_policy.js';
export * from './keyboard_nav/navigation_policies/icon_navigation_policy.js';
export * from './keyboard_nav/navigation_policies/toolbox_item_navigation_policy.js';
export * from './keyboard_nav/navigation_policies/workspace_comment_navigation_policy.js';
export * from './keyboard_nav/navigation_policies/workspace_navigation_policy.js';
export * from './keyboard_nav/navigators/flyout_navigator.js';
export * from './keyboard_nav/navigators/navigator.js';
export * from './toast.js';
// Re-export submodules that no longer declareLegacyNamespace.
@@ -465,7 +472,6 @@ export {
DragTarget,
Events,
Extensions,
LineCursor,
Procedures,
ShortcutItems,
Themes,
@@ -500,6 +506,8 @@ export {
BlockFlyoutInflater,
ButtonFlyoutInflater,
CodeGenerator,
Direction,
DragDisposition,
Field,
FieldCheckbox,
FieldCheckboxConfig,
@@ -584,17 +592,17 @@ export {
ImageProperties,
Input,
InsertionMarkerPreviewer,
KeyboardMover,
KeyboardNavigationController,
LabelFlyoutInflater,
LayerManager,
Marker,
MarkerManager,
Menu,
MenuGenerator,
MenuGeneratorFunction,
MenuItem,
MenuOption,
MetricsManager,
MoveIndicator,
Msg,
Names,
Options,
@@ -609,6 +617,7 @@ export {
Toolbox,
ToolboxCategory,
ToolboxItem,
ToolboxNavigator,
ToolboxSeparator,
Trashcan,
UnattachedFieldError,
@@ -626,6 +635,7 @@ export {
icons,
inject,
inputs,
isBoundedElement,
isCopyable,
isDeletable,
isDraggable,
+109 -4
View File
@@ -8,13 +8,16 @@ import * as browserEvents from '../browser_events.js';
import * as common from '../common.js';
import {BubbleDragStrategy} from '../dragging/bubble_drag_strategy.js';
import {getFocusManager} from '../focus_manager.js';
import {IBoundedElement} from '../interfaces/i_bounded_element.js';
import {IBubble} from '../interfaces/i_bubble.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import {ISelectable} from '../interfaces/i_selectable.js';
import {ContainerRegion} from '../metrics_manager.js';
import {Msg} from '../msg.js';
import {Scrollbar} from '../scrollbar.js';
import {aria} from '../utils.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
import * as idGenerator from '../utils/idgenerator.js';
@@ -24,12 +27,21 @@ import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import {WorkspaceSvg} from '../workspace_svg.js';
/**
* Represents a either a string or a function that, when called, can provide a
* custom ARIA string to represent a bubble, or null if the default fallback
* should be used. See setAriaLabelProvider for more context.
*/
export type AriaLabelProvider = string | ((bubble: Bubble) => string | null);
/**
* The abstract pop-up bubble class. This creates a UI that looks like a speech
* bubble, where it has a "tail" that points to the block, and a "head" that
* displays arbitrary svg elements.
*/
export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
export abstract class Bubble
implements IBubble, ISelectable, IFocusableNode, IBoundedElement
{
/** The width of the border around the bubble. */
static readonly BORDER_WIDTH = 6;
@@ -88,10 +100,15 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
/** The position of the left of the bubble realtive to its anchor. */
private relativeLeft = 0;
private dragStrategy = new BubbleDragStrategy(this, this.workspace);
private dragStrategy: BubbleDragStrategy = new BubbleDragStrategy(
this,
this.workspace,
);
private focusableElement: SVGElement | HTMLElement;
private ariaLabelProvider: AriaLabelProvider | null = null;
/**
* @param workspace The workspace this bubble belongs to.
* @param anchor The anchor location of the thing this bubble is attached to.
@@ -156,6 +173,7 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
this,
this.onKeyDown,
);
this.recomputeAriaContext();
}
/** Dispose of this bubble. */
@@ -274,6 +292,18 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
this.svgRoot.setAttribute('transform', `translate(${x}, ${y})`);
}
/**
* Moves the bubble by the given amounts in the x and y directions.
*
* @param dx The distance to move along the x axis.
* @param dy The distance to move along the y axis.
* @param _reason A description of why this move is happening.
*/
moveBy(dx: number, dy: number, _reason?: string[]) {
const origin = this.getRelativeToSurfaceXY();
this.moveTo(origin.x + dx, origin.y + dy);
}
/**
* Positions the bubble "optimally" so that the most of it is visible and
* it does not overlap the rect (if provided).
@@ -617,6 +647,21 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
);
}
/**
* Returns the bounds of this bubble.
*
* @returns A bounding box for this bubble.
*/
getBoundingRectangle(): Rect {
const origin = this.getRelativeToSurfaceXY();
return new Rect(
origin.y,
origin.y + this.size.height,
origin.x,
origin.x + this.size.width,
);
}
/** @internal */
getSvgRoot(): SVGElement {
return this.svgRoot;
@@ -664,8 +709,8 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
}
/** Starts a drag on the bubble. */
startDrag(): void {
this.dragStrategy.startDrag();
startDrag() {
return this.dragStrategy.startDrag();
}
/** Drags the bubble to the given location. */
@@ -729,4 +774,64 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
getOwner(): (IHasBubble & IFocusableNode) | undefined {
return this.owner;
}
/**
* Recomputes the ARIA label and role for this bubble. This is automatically called
* during initialization, but implementations may find it useful to call this if
* the bubble's label should be changed.
*
* Bubbles use a default non-specific label unless they're customized otherwise
* which is the responsibility of the bubble's owner rather than bubble
* implementations. Customization can be done via setAriaLabelProvider.
*/
protected recomputeAriaContext(): void {
const element = this.getFocusableElement();
if (!element) return;
aria.setRole(element, aria.Role.GROUP);
const label = this.getAriaLabel()?.trim();
aria.setState(
element,
aria.State.LABEL,
label ? label : Msg['BUBBLE_LABEL_DEFAULT'],
);
}
/**
* Sets a custom ARIA label provider for this bubble, or null if it should be reset
* to use the default method.
*
* Bubbles do not compute ARIA labels specifically to their implementation since
* they can be rather general-purpose. Instead, owners of the specific bubble
* instance (such as an icon) are responsible for defining custom label providers
* for their bubbles.
*
* Note that calling this isn't sufficient for it to actually be used.
* recomputeAriaContext will likely also need to be called to actually apply the
* custom label to the bubble's focusable element.
*/
setAriaLabelProvider(provider: AriaLabelProvider | null): void {
this.ariaLabelProvider = provider;
this.recomputeAriaContext();
}
/**
* Returns the ARIA label to use for this bubble based on the provider set via
* setAriaLabelProvider. This will return null if the provider is absent or
* returns null.
*
* @returns The ARIA label to use for this bubble, or null if one is not provided.
*/
getAriaLabel(): string | null {
if (this.ariaLabelProvider) {
if (typeof this.ariaLabelProvider === 'string') {
return this.ariaLabelProvider;
} else if (typeof this.ariaLabelProvider === 'function') {
return this.ariaLabelProvider(this);
}
}
return null;
}
}
@@ -6,6 +6,10 @@
import type {BlocklyOptions} from '../blockly_options.js';
import {Abstract as AbstractEvent} from '../events/events_abstract.js';
import {getFocusManager} from '../focus_manager.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import {KeyboardMover} from '../keyboard_nav/keyboard_mover.js';
import {Options} from '../options.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
@@ -50,8 +54,9 @@ export class MiniWorkspaceBubble extends Bubble {
public readonly workspace: WorkspaceSvg,
protected anchor: Coordinate,
protected ownerRect?: Rect,
protected owner?: IHasBubble & IFocusableNode,
) {
super(workspace, anchor, ownerRect);
super(workspace, anchor, ownerRect, undefined, owner);
const options = new Options(workspaceOptions);
this.validateWorkspaceOptions(options);
@@ -153,11 +158,10 @@ export class MiniWorkspaceBubble extends Bubble {
* are dealt with by resizing the workspace to show them.
*/
private bumpBlocksIntoBounds() {
if (
this.miniWorkspace.isDragging() &&
!this.miniWorkspace.keyboardMoveInProgress
)
// Only bump for mouse-driven drags.
if (this.miniWorkspace.isDragging() && !KeyboardMover.mover.isMoving()) {
return;
}
const MARGIN = 20;
@@ -189,15 +193,13 @@ export class MiniWorkspaceBubble extends Bubble {
* mini workspace.
*/
private updateBubbleSize() {
if (
this.miniWorkspace.isDragging() &&
!this.miniWorkspace.keyboardMoveInProgress
)
if (this.miniWorkspace.isDragging() && !KeyboardMover.mover.isMoving()) {
return;
}
// Disable autolayout if a keyboard move is in progress to prevent the
// mutator bubble from jumping around.
this.autoLayout &&= !this.miniWorkspace.keyboardMoveInProgress;
this.autoLayout &&= !KeyboardMover.mover.isMoving();
const currSize = this.getSize();
const newSize = this.calculateWorkspaceSize();
@@ -289,4 +291,12 @@ export class MiniWorkspaceBubble extends Bubble {
'monkey-patched in by blockly.ts',
);
}
/**
* Handles the user acting on this bubble via keyboard navigation by focusing
* the mutator workspace.
*/
performAction() {
getFocusManager().focusTree(this.getWorkspace());
}
}
+4 -1
View File
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
import {Rect} from '../utils/rect.js';
@@ -23,8 +25,9 @@ export class TextBubble extends Bubble {
public readonly workspace: WorkspaceSvg,
protected anchor: Coordinate,
protected ownerRect?: Rect,
protected owner?: IHasBubble & IFocusableNode,
) {
super(workspace, anchor, ownerRect);
super(workspace, anchor, ownerRect, undefined, owner);
this.paragraph = this.stringToSvg(text, this.contentContainer);
this.updateBubbleSize();
dom.addClass(this.svgRoot, 'blocklyTextBubble');
@@ -63,6 +63,9 @@ export class TextInputBubble extends Bubble {
/** View responsible for supporting text editing. */
private editor: CommentEditor;
private readonly textChangeListener = () => {
this.recomputeAriaContext();
};
/**
* @param workspace The workspace this bubble belongs to.
* @param anchor The anchor location of the thing this bubble is attached to.
@@ -85,6 +88,7 @@ export class TextInputBubble extends Bubble {
this.contentContainer.appendChild(this.editor.getDom());
this.resizeGroup = this.createResizeHandle(this.svgRoot, workspace);
this.setSize(this.DEFAULT_SIZE, true);
this.addTextChangeListener(this.textChangeListener);
}
/** @returns the text of this bubble. */
@@ -279,6 +283,22 @@ export class TextInputBubble extends Bubble {
getEditor() {
return this.editor;
}
/**
* Handles the user acting on this bubble via keyboard navigation by focusing
* the comment editor.
*/
performAction() {
getFocusManager().focusNode(this.getEditor());
}
/**
* Dispose of this bubble.
*/
dispose() {
super.dispose();
this.editor.removeTextChangeListener(this.textChangeListener);
}
}
Css.register(`
@@ -5,12 +5,12 @@
*/
import {BlockSvg} from '../block_svg.js';
import {IFocusableNode} from '../blockly.js';
import {config} from '../config.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import {getFocusManager} from '../focus_manager.js';
import {ICopyData} from '../interfaces/i_copyable.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import {IPaster} from '../interfaces/i_paster.js';
import * as renderManagement from '../render_management.js';
import {State, append} from '../serialization/blocks.js';
@@ -15,9 +15,10 @@ import {Coordinate} from '../utils/coordinate.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import * as registry from './registry.js';
export class WorkspaceCommentPaster
implements IPaster<WorkspaceCommentCopyData, RenderedWorkspaceComment>
{
export class WorkspaceCommentPaster implements IPaster<
WorkspaceCommentCopyData,
RenderedWorkspaceComment
> {
static TYPE = 'workspace-comment';
paste(
@@ -5,6 +5,7 @@
*/
import * as browserEvents from '../browser_events.js';
import {Msg} from '../msg.js';
import * as touch from '../touch.js';
import * as dom from '../utils/dom.js';
import {Svg} from '../utils/svg.js';
@@ -56,6 +57,7 @@ export class CollapseCommentBarButton extends CommentBarButton {
},
this.container,
);
this.recomputeAriaContext();
this.bindId = browserEvents.conditionalBind(
this.icon,
'pointerdown',
@@ -95,8 +97,22 @@ export class CollapseCommentBarButton extends CommentBarButton {
}
this.getCommentView().setCollapsed(!this.getCommentView().isCollapsed());
this.recomputeAriaContext();
this.workspace.hideChaff();
e?.stopPropagation();
}
/**
* Returns the ARIA label to use for this button (defaults to null). Note that this
* method will only be called and apply when recomputeAriaContext is called.
*
* @returns The ARIA label to use for this button, or null to use a default.
*/
protected getAriaLabel(): string {
const isCollapsed = this.getCommentView().isCollapsed();
return isCollapsed
? Msg['ARIA_LABEL_COMMENT_EXPAND']
: Msg['ARIA_LABEL_COMMENT_COLLAPSE'];
}
}
@@ -5,6 +5,8 @@
*/
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import {Msg} from '../msg.js';
import * as aria from '../utils/aria.js';
import {Rect} from '../utils/rect.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import type {CommentView} from './comment_view.js';
@@ -102,4 +104,33 @@ export abstract class CommentBarButton implements IFocusableNode {
canBeFocused() {
return this.isVisible();
}
/**
* Recomputes the ARIA label and role for this button. Note that this is not
* automatically called during initialization and must be called once a button's
* focusable element (icon) is initialized. Implementations may also find it useful
* to call this if the button's label should be changed.
*/
protected recomputeAriaContext(): void {
if (!this.icon) return;
aria.setRole(this.icon, aria.Role.BUTTON);
const label = this.getAriaLabel();
aria.setState(
this.icon,
aria.State.LABEL,
label || Msg['ARIA_LABEL_BUTTON'],
);
}
/**
* Returns the ARIA label to use for this button (defaults to null). Note that this
* method will only be called and apply when recomputeAriaContext is called.
*
* @returns The ARIA label to use for this button, or null to use a default.
*/
protected getAriaLabel(): string | null {
return null;
}
}
@@ -4,18 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {BlockSvg} from '../block_svg.js';
import * as browserEvents from '../browser_events.js';
import {getFocusManager} from '../focus_manager.js';
import {IFocusableNode} from '../interfaces/i_focusable_node.js';
import {IFocusableTree} from '../interfaces/i_focusable_tree.js';
import {Msg} from '../msg.js';
import * as touch from '../touch.js';
import * as aria from '../utils/aria.js';
import * as dom from '../utils/dom.js';
import {Rect} from '../utils/rect.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import * as svgMath from '../utils/svg_math.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import {RenderedWorkspaceComment} from './rendered_workspace_comment.js';
/**
* String added to the ID of a workspace comment to identify
@@ -25,7 +28,7 @@ export const COMMENT_EDITOR_FOCUS_IDENTIFIER = '_comment_textarea_';
/** The part of a comment that can be typed into. */
export class CommentEditor implements IFocusableNode {
id?: string;
id: string;
/** The foreignObject containing the HTML text area. */
private foreignObject: SVGForeignObjectElement;
@@ -40,9 +43,12 @@ export class CommentEditor implements IFocusableNode {
/** The current text of the comment. Updates on text area change. */
private text: string = '';
/** The parent object that owns this comment editor. */
private parent?: BlockSvg | RenderedWorkspaceComment;
constructor(
public workspace: WorkspaceSvg,
commentId?: string,
commentId: string,
private onFinishEditing?: () => void,
) {
this.foreignObject = dom.createSvgElement(Svg.FOREIGNOBJECT, {
@@ -57,6 +63,7 @@ export class CommentEditor implements IFocusableNode {
) as HTMLTextAreaElement;
this.textArea.setAttribute('tabindex', '-1');
this.textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR');
aria.setRole(this.textArea, aria.Role.TEXTBOX);
this.textArea.setAttribute(
'placeholder',
Msg['WORKSPACE_COMMENT_DEFAULT_TEXT'],
@@ -67,10 +74,8 @@ export class CommentEditor implements IFocusableNode {
body.appendChild(this.textArea);
this.foreignObject.appendChild(body);
if (commentId) {
this.id = commentId + COMMENT_EDITOR_FOCUS_IDENTIFIER;
this.textArea.setAttribute('id', this.id);
}
this.id = commentId + COMMENT_EDITOR_FOCUS_IDENTIFIER;
this.textArea.setAttribute('id', this.id);
// Register browser event listeners for the user typing in the textarea.
browserEvents.conditionalBind(
@@ -131,6 +136,23 @@ export class CommentEditor implements IFocusableNode {
this.onTextChange();
}
/**
* Sets the parent object that owns this comment editor.
*
* @param newParent The parent of this comment editor.
* @internal
*/
setParent(newParent: BlockSvg | RenderedWorkspaceComment): void {
this.parent = newParent;
}
/**
* Returns the parent object that owns this comment editor, if any.
*/
getParent(): BlockSvg | RenderedWorkspaceComment | undefined {
return this.parent;
}
/**
* Triggers listeners when the text of the comment changes, either
* programmatically or manually by the user.
+14 -2
View File
@@ -6,10 +6,11 @@
import * as browserEvents from '../browser_events.js';
import * as css from '../css.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node';
import {IRenderedElement} from '../interfaces/i_rendered_element.js';
import * as layers from '../layers.js';
import {Msg} from '../msg.js';
import * as touch from '../touch.js';
import * as aria from '../utils/aria.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
import * as drag from '../utils/drag.js';
@@ -107,6 +108,12 @@ export class CommentView implements IRenderedElement {
this.svgRoot = dom.createSvgElement(Svg.G, {
'class': 'blocklyComment blocklyEditable blocklyDraggable',
});
aria.setRole(this.svgRoot, aria.Role.BUTTON);
aria.setState(
this.svgRoot,
aria.State.ROLEDESCRIPTION,
Msg['ARIA_LABEL_COMMENT'],
);
this.highlightRect = this.createHighlightRect(this.svgRoot);
@@ -120,6 +127,11 @@ export class CommentView implements IRenderedElement {
} = this.createTopBar(this.svgRoot));
this.commentEditor = this.createTextArea();
aria.setState(
this.svgRoot,
aria.State.LABELLEDBY,
this.commentEditor.getFocusableElement().id,
);
this.resizeHandle = this.createResizeHandle(this.svgRoot, workspace);
@@ -235,7 +247,7 @@ export class CommentView implements IRenderedElement {
*
* @returns The FocusableNode representing the editor portion of this comment.
*/
getEditorFocusableNode(): IFocusableNode {
getEditorFocusableNode(): CommentEditor {
return this.commentEditor;
}
@@ -6,6 +6,7 @@
import * as browserEvents from '../browser_events.js';
import {getFocusManager} from '../focus_manager.js';
import {Msg} from '../msg.js';
import * as touch from '../touch.js';
import * as dom from '../utils/dom.js';
import {Svg} from '../utils/svg.js';
@@ -56,6 +57,7 @@ export class DeleteCommentBarButton extends CommentBarButton {
},
container,
);
this.recomputeAriaContext();
this.bindId = browserEvents.conditionalBind(
this.icon,
'pointerdown',
@@ -102,5 +104,16 @@ export class DeleteCommentBarButton extends CommentBarButton {
this.getCommentView().dispose();
e?.stopPropagation();
getFocusManager().focusNode(this.workspace);
this.workspace.getAudioManager().play('delete');
}
/**
* Returns the ARIA label to use for this button (defaults to null). Note that this
* method will only be called and apply when recomputeAriaContext is called.
*
* @returns The ARIA label to use for this button, or null to use a default.
*/
protected getAriaLabel(): string {
return Msg['REMOVE_COMMENT'];
}
}
@@ -59,6 +59,7 @@ export class RenderedWorkspaceComment
this.workspace = workspace;
this.view = new CommentView(workspace, this.id);
this.view.getEditorFocusableNode().setParent(this);
// Set the size to the default size as defined in the superclass.
this.view.setSize(this.getSize());
this.view.setEditable(this.isEditable());
@@ -239,8 +240,8 @@ export class RenderedWorkspaceComment
}
/** Starts a drag on the comment. */
startDrag(): void {
this.dragStrategy.startDrag();
startDrag() {
return this.dragStrategy.startDrag();
}
/** Drags the comment to the given location. */
@@ -358,4 +359,13 @@ export class RenderedWorkspaceComment
canBeFocused(): boolean {
return true;
}
/**
* Handles the user acting on this comment via keyboard navigation.
* Expands the comment and focuses its editor.
*/
performAction() {
this.setCollapsed(false);
getFocusManager().focusNode(this.getEditorFocusableNode());
}
}
@@ -221,7 +221,7 @@ export class WorkspaceComment {
/** Returns the position of the comment in workspace coordinates. */
getRelativeToSurfaceXY(): Coordinate {
return this.location;
return this.location.clone();
}
/** Disposes of this comment. */
+33 -3
View File
@@ -15,6 +15,7 @@ import * as eventUtils from './events/utils.js';
import {getFocusManager} from './focus_manager.js';
import {ISelectable, isSelectable} from './interfaces/i_selectable.js';
import {ShortcutRegistry} from './shortcut_registry.js';
import * as deprecation from './utils/deprecation.js';
import type {Workspace} from './workspace.js';
import type {WorkspaceSvg} from './workspace_svg.js';
@@ -58,10 +59,27 @@ export function registerWorkspace(workspace: Workspace) {
*
* @param workspace
*/
export function unregisterWorkpace(workspace: Workspace) {
export function unregisterWorkspace(workspace: Workspace) {
delete WorkspaceDB_[workspace.id];
}
/**
* Unregister a workspace from the workspace db.
*
* @deprecated v13: use Blockly.common.unregisterWorkspace
* @param workspace
*/
export function unregisterWorkpace(workspace: Workspace) {
deprecation.warn(
'Blockly.common.unregisterWorkpace',
'v13',
'v14',
'Blockly.common.unregisterWorkspace',
);
unregisterWorkspace(workspace);
}
/**
* The main workspace most recently used.
* Set by Blockly.WorkspaceSvg.prototype.markFocused
@@ -86,6 +104,11 @@ export function getMainWorkspace(): Workspace {
*/
export function setMainWorkspace(workspace: Workspace) {
mainWorkspace = workspace;
if (workspace.rendered) {
getFocusManager().setPopoverFocusRoot(
(workspace as WorkspaceSvg).getInjectionDiv(),
);
}
}
/**
@@ -141,8 +164,15 @@ let parentContainer: Element | null;
*
* @returns The parent container.
*/
export function getParentContainer(): Element | null {
return parentContainer;
export function getParentContainer(
workspace = getMainWorkspace(),
): Element | null {
if (parentContainer) return parentContainer;
if (workspace && workspace.rendered) {
return (workspace as WorkspaceSvg).getInjectionDiv();
}
return null;
}
/**
+48 -1
View File
@@ -11,6 +11,7 @@ import type {BlockSvg} from './block_svg.js';
import * as browserEvents from './browser_events.js';
import {config} from './config.js';
import type {
ActionContextMenuOption,
ContextMenuOption,
LegacyContextMenuOption,
} from './contextmenu_registry.js';
@@ -25,6 +26,7 @@ import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import {Rect} from './utils/rect.js';
import {getShortcutKeysShort} from './utils/shortcut_formatting.js';
import * as svgMath from './utils/svg_math.js';
import * as WidgetDiv from './widgetdiv.js';
import type {WorkspaceSvg} from './workspace_svg.js';
@@ -134,7 +136,7 @@ function populate_(
continue;
}
const menuItem = new MenuItem(option.text);
const menuItem = new MenuItem(makeMenuitem(option));
menuItem.setRightToLeft(rtl);
menuItem.setRole(aria.Role.MENUITEM);
menu.addChild(menuItem);
@@ -302,3 +304,48 @@ export function callbackFactory(
export function getMenu(): Menu | null {
return menu_;
}
/**
* Creates a menu item to represent the given context menu option.
* For text-based menu options, this wraps the text in a container with its
* corresponding keyboard shortcut, if any. HTML-based menu options are displayed
* as-is.
*
* @param option The context menu option to generate a menu item for.
* @returns A `MenuItem` representing the given context menu option.
*/
function makeMenuitem(
option: ActionContextMenuOption | LegacyContextMenuOption,
) {
const text = option.text;
if (text && !(text instanceof HTMLElement)) {
const container = document.createElement('div');
container.className = 'blocklyShortcutContainer';
const label = document.createElement('span');
label.textContent = text;
const shortcut = document.createElement('span');
shortcut.className = 'blocklyShortcut';
shortcut.textContent = ` ${getKeyboardShortcut(option)}`;
container.appendChild(label);
container.appendChild(shortcut);
return container;
}
return option.text;
}
/**
* Returns a textual representation of the keyboard shortcut for the given
* context menu item, if any.
*
* @param option The context menu item to retrieve a keyboard shortcut for.
* @returns A textual representation of the keyboard shortcut registered under
* the name stored in the menu option's `associatedKeyboardShortcut` field,
* if any.
*/
function getKeyboardShortcut(
option: ContextMenuOption | LegacyContextMenuOption,
): string {
if (!('id' in option) || !option.associatedKeyboardShortcut) return '';
return getShortcutKeysShort(option.associatedKeyboardShortcut);
}
@@ -52,6 +52,7 @@ export function registerUndo() {
scopeType: ContextMenuRegistry.ScopeType.WORKSPACE,
id: 'undoWorkspace',
weight: 1,
associatedKeyboardShortcut: 'undo',
};
ContextMenuRegistry.registry.register(undoOption);
}
@@ -76,6 +77,7 @@ export function registerRedo() {
scopeType: ContextMenuRegistry.ScopeType.WORKSPACE,
id: 'redoWorkspace',
weight: 2,
associatedKeyboardShortcut: 'redo',
};
ContextMenuRegistry.registry.register(redoOption);
}
@@ -103,6 +105,7 @@ export function registerCleanup() {
scopeType: ContextMenuRegistry.ScopeType.WORKSPACE,
id: 'cleanWorkspace',
weight: 3,
associatedKeyboardShortcut: 'cleanup',
};
ContextMenuRegistry.registry.register(cleanOption);
}
@@ -349,6 +352,7 @@ export function registerDuplicate() {
scopeType: ContextMenuRegistry.ScopeType.BLOCK,
id: 'blockDuplicate',
weight: 1,
associatedKeyboardShortcut: 'duplicate',
};
ContextMenuRegistry.registry.register(duplicateOption);
}
@@ -541,12 +545,14 @@ export function registerDelete() {
},
callback(scope: Scope) {
if (scope.block) {
getFocusManager().focusNode(scope.block);
scope.block.checkAndDelete();
}
},
scopeType: ContextMenuRegistry.ScopeType.BLOCK,
id: 'blockDelete',
weight: 6,
associatedKeyboardShortcut: 'delete',
};
ContextMenuRegistry.registry.register(deleteOption);
}
@@ -591,10 +597,12 @@ export function registerCommentDelete() {
eventUtils.setGroup(true);
scope.comment?.dispose();
eventUtils.setGroup(false);
scope.comment?.workspace.getAudioManager().play('delete');
},
scopeType: ContextMenuRegistry.ScopeType.COMMENT,
id: 'commentDelete',
weight: 6,
associatedKeyboardShortcut: 'delete',
};
ContextMenuRegistry.registry.register(deleteOption);
}
@@ -615,6 +623,7 @@ export function registerCommentDuplicate() {
scopeType: ContextMenuRegistry.ScopeType.COMMENT,
id: 'commentDuplicate',
weight: 1,
associatedKeyboardShortcut: 'duplicate',
};
ContextMenuRegistry.registry.register(duplicateOption);
}
@@ -100,6 +100,7 @@ export class ContextMenuRegistry {
| ContextMenuRegistry.SeparatorContextMenuOption
| ContextMenuRegistry.ActionContextMenuOption;
menuOption = {
id: item.id,
scope,
weight: item.weight,
};
@@ -122,6 +123,7 @@ export class ContextMenuRegistry {
text: displayText,
callback: item.callback,
enabled: precondition === 'enabled',
associatedKeyboardShortcut: item.associatedKeyboardShortcut,
};
}
@@ -188,6 +190,13 @@ export namespace ContextMenuRegistry {
displayText: ((p1: Scope) => string | HTMLElement) | string | HTMLElement;
separator?: never;
preconditionFn: (p1: Scope, menuOpenEvent: Event) => string;
/**
* Identifier used to associate this context menu item with a keyboard
* shortcut which will be displayed in the menu as a hint. Should
* correspond to the name under which a keyboard shortcut that performs the
* same action as this menu item is registered.
*/
associatedKeyboardShortcut?: string;
}
/**
@@ -208,8 +217,10 @@ export namespace ContextMenuRegistry {
* Fields common to all context menu items as used by contextmenu.ts.
*/
export interface CoreContextMenuOption {
id: string;
scope: Scope;
weight: number;
associatedKeyboardShortcut?: string;
}
/**
@@ -276,3 +287,5 @@ export type RegistryItem = ContextMenuRegistry.RegistryItem;
export type ContextMenuOption = ContextMenuRegistry.ContextMenuOption;
export type LegacyContextMenuOption =
ContextMenuRegistry.LegacyContextMenuOption;
export type ActionContextMenuOption =
ContextMenuRegistry.ActionContextMenuOption;
+209 -28
View File
@@ -5,8 +5,9 @@
*/
// Former goog.module ID: Blockly.Css
/** Has CSS already been injected? */
let injected = false;
const injectionSites = new WeakSet<Document | ShadowRoot>();
const registeredCss: Array<string> = [];
import * as userAgent from './utils/useragent.js';
/**
* Add some CSS to the blob that will be injected later. Allows optional
@@ -15,10 +16,7 @@ let injected = false;
* @param cssContent Multiline CSS string or an array of single lines of CSS.
*/
export function register(cssContent: string) {
if (injected) {
throw Error('CSS already injected');
}
content += '\n' + cssContent;
registeredCss.push(cssContent);
}
/**
@@ -28,37 +26,51 @@ export function register(cssContent: string) {
* b) It speeds up loading by not blocking on a separate HTTP transfer.
* c) The CSS content may be made dynamic depending on init options.
*
* @param container The div or other HTML element into which Blockly was injected.
* @param hasCss If false, don't inject CSS (providing CSS becomes the
* document's responsibility).
* @param pathToMedia Path from page to the Blockly media directory.
*/
export function inject(hasCss: boolean, pathToMedia: string) {
// Only inject the CSS once.
if (injected) {
return;
}
injected = true;
if (!hasCss) {
return;
}
export function inject(
container: HTMLElement,
hasCss: boolean,
pathToMedia: string,
) {
if (!hasCss || typeof window === 'undefined') return;
const root = container.getRootNode() as Document | ShadowRoot;
if (injectionSites.has(root)) return;
injectionSites.add(root);
// Strip off any trailing slash (either Unix or Windows).
const mediaPath = pathToMedia.replace(/[\\/]$/, '');
const cssContent = content.replace(/<<<PATH>>>/g, mediaPath);
// Cleanup the collected css content after injecting it to the DOM.
content = '';
const cssText = [content, ...registeredCss]
.join('\n')
.replace(/<<<PATH>>>/g, mediaPath);
// Inject CSS tag at start of head.
const cssNode = document.createElement('style');
cssNode.id = 'blockly-common-style';
const cssTextNode = document.createTextNode(cssContent);
cssNode.appendChild(cssTextNode);
document.head.insertBefore(cssNode, document.head.firstChild);
const styleEl = document.createElement('style');
styleEl.id = 'blockly-common-style';
styleEl.textContent = cssText;
// Prepend so Blockly's rules sit at the start of the cascade; any user
// stylesheet declared later wins by document order. Style elements appended
// to the light DOM don't apply inside shadow roots, so for the shadow DOM
// case we prepend the style element to the shadow root itself.
(root instanceof ShadowRoot ? root : document.head).prepend(styleEl);
}
/**
* The CSS content for Blockly.
*/
let content = `
const content = `
:is(
.injectionDiv,
.blocklyWidgetDiv,
.blocklyDropdownDiv,
.blocklyTooltipDiv,
) * {
box-sizing: border-box;
}
.blocklySvg {
background-color: #fff;
outline: none;
@@ -109,7 +121,7 @@ let content = `
left: 0;
top: 0;
z-index: 1000;
display: none;
visibility: hidden;
border: 1px solid;
border-color: #dadce0;
background-color: #fff;
@@ -365,6 +377,7 @@ input[type=number] {
.blocklyContextMenu {
border-radius: 4px;
max-height: 100%;
box-sizing: content-box;
}
.blocklyDropdownMenu {
@@ -446,7 +459,7 @@ input[type=number] {
/* State: selected/checked. */
.blocklyMenuItemSelected .blocklyMenuItemCheckbox {
background: url(<<<PATH>>>/sprites.png) no-repeat -48px -16px;
background: url(<<<PATH>>>/sprites.svg) no-repeat -48px -16px;
float: left;
margin-left: -24px;
width: 16px;
@@ -468,6 +481,19 @@ input[type=number] {
margin-right: 4px;
}
.blocklyRTL .blocklyMenuItemContent .blocklyShortcutContainer {
flex-direction: row-reverse;
}
.blocklyMenuItemContent .blocklyShortcutContainer {
width: 100%;
display: flex;
justify-content: space-between;
gap: 16px;
}
.blocklyMenuItemContent .blocklyShortcutContainer .blocklyShortcut {
color: #ccc;
}
.blocklyBlockDragSurface, .blocklyAnimationLayer {
position: absolute;
top: 0;
@@ -501,8 +527,163 @@ input[type=number] {
.blocklyComment,
.blocklyBubble,
.blocklyIconGroup,
.blocklyTextarea
.blocklyTextarea,
.blocklyZoom,
.blocklyTrash,
) {
outline: none;
}
.hiddenForAria {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.injectionDiv {
--blockly-active-node-color: #fff200;
--blockly-active-tree-color: #1379f6;
--blockly-selection-width: 3px;
}
/* Active focus cases: */
/* Blocks with active focus. */
.blocklyKeyboardNavigation
.blocklyActiveFocus:is(.blocklyPath, .blocklyHighlightedConnectionPath),
/* Fields with active focus, */
.blocklyKeyboardNavigation
.blocklyActiveFocus.blocklyField
> .blocklyFieldRect,
/* Icons with active focus. */
.blocklyKeyboardNavigation
.blocklyActiveFocus.blocklyIconGroup
> .blocklyIconShape:first-child,
.blocklyKeyboardNavigation
.blocklyActiveFocus
> .blocklyFocusRing {
stroke: var(--blockly-active-node-color);
stroke-width: var(--blockly-selection-width);
}
/* Passive focus cases: */
/* Blocks with passive focus except when widget/dropdown div in use. */
.blocklyPassiveFocus:is(
.blocklyPath:not(.blocklyFlyout .blocklyPath),
.blocklyHighlightedConnectionPath
),
/* Fields with passive focus except when widget/dropdown div in use. */
.blocklyKeyboardNavigation:not(
:has(
.blocklyDropDownDiv:focus-within,
.blocklyWidgetDiv:focus-within
)
)
.blocklyPassiveFocus.blocklyField
> .blocklyFieldRect,
/* Icons with passive focus except when widget/dropdown div in use. */
.blocklyKeyboardNavigation:not(
:has(
.blocklyDropDownDiv:focus-within,
.blocklyWidgetDiv:focus-within
)
)
.blocklyPassiveFocus.blocklyIconGroup
> .blocklyIconShape:first-child {
stroke: var(--blockly-active-node-color);
stroke-dasharray: 5px 3px;
stroke-width: var(--blockly-selection-width);
}
/* Workaround for unexpectedly hidden connection path due to core style. */
.blocklyKeyboardNavigation
.blocklyPassiveFocus.blocklyHighlightedConnectionPath {
display: unset !important;
}
/* Different ways for toolbox/flyout to be the active tree: */
/* Active focus in the flyout. */
.blocklyKeyboardNavigation .blocklyFlyout:has(.blocklyActiveFocus),
/* Active focus in the toolbox. */
.blocklyKeyboardNavigation .blocklyToolbox:has(.blocklyActiveFocus),
/* Active focus on the toolbox/flyout. */
.blocklyKeyboardNavigation
.blocklyActiveFocus:is(.blocklyFlyout, .blocklyToolbox) {
outline-offset: calc(var(--blockly-selection-width) * -1);
outline: var(--blockly-selection-width) solid
var(--blockly-active-tree-color);
}
/* Suppress default outline. */
.blocklyKeyboardNavigation
.blocklyToolboxCategoryContainer:focus-visible {
outline: none;
}
/* Different ways for the workspace to be the active tree: */
/* Active focus within workspace. */
.blocklyKeyboardNavigation
.blocklyWorkspace:has(.blocklyActiveFocus)
.blocklyWorkspaceFocusRing,
/* Active focus within drag layer. */
.blocklyKeyboardNavigation
.blocklySvg:has(~ .blocklyBlockDragSurface .blocklyActiveFocus)
.blocklyWorkspaceFocusRing,
/* Active focus on workspace. */
.blocklyKeyboardNavigation
.blocklyWorkspace.blocklyActiveFocus
.blocklyWorkspaceFocusRing,
/* Focus in widget/dropdown div considered to be in workspace. */
.blocklyKeyboardNavigation
.blocklyWorkspace.blocklyShowingDropDownDiv
.blocklyWorkspaceFocusRing,
.blocklyKeyboardNavigation
.blocklyWorkspace.blocklyShowingWidgetDiv
.blocklyWorkspaceFocusRing {
stroke: var(--blockly-active-tree-color);
stroke-width: calc(var(--blockly-selection-width) * 2);
}
/* The workspace itself is the active node. */
.blocklyKeyboardNavigation
.blocklyWorkspace.blocklyActiveFocus
.blocklyWorkspaceSelectionRing {
stroke: var(--blockly-active-node-color);
stroke-width: var(--blockly-selection-width);
}
/* The workspace itself is the active node. */
.blocklyKeyboardNavigation
.blocklyBubble.blocklyActiveFocus
.blocklyEmboss .blocklyDraggable {
stroke: var(--blockly-active-node-color);
stroke-width: var(--blockly-selection-width);
}
/* Flyout buttons and labels */
.blocklyKeyboardNavigation .blocklyFlyout .blocklyFlyoutLabel.blocklyActiveFocus,
.blocklyKeyboardNavigation .blocklyFlyout .blocklyFlyoutButton.blocklyActiveFocus {
outline: none;
}
.blocklyKeyboardNavigation .blocklyFlyout .blocklyFlyoutLabel.blocklyActiveFocus > .blocklyFlyoutLabelText,
.blocklyKeyboardNavigation .blocklyFlyout .blocklyFlyoutButton.blocklyActiveFocus > .blocklyFlyoutButtonBackground {
outline-offset: 2px;
outline: var(--blockly-selection-width) solid var(--blockly-active-node-color);
border-radius: 2px;
}
.blocklyDialog {
min-width: 300px;
border-radius: 16px;
box-shadow: 0 8px 8px rgba(0, 0, 0, 0.2);
border: 1px solid #999;
}
.blocklyDialogForm {
display: flex;
flex-direction: column;
row-gap: 8px;
}
.blocklyDialogButtonRow {
display: flex;
flex-direction: ${userAgent.MOBILE || userAgent.APPLE ? 'row-reverse' : 'row'};
column-gap: 8px;
}
`;
+5 -1
View File
@@ -17,6 +17,7 @@ import {DragTarget} from './drag_target.js';
import {isDeletable} from './interfaces/i_deletable.js';
import type {IDeleteArea} from './interfaces/i_delete_area.js';
import type {IDraggable} from './interfaces/i_draggable.js';
import {KeyboardMover} from './keyboard_nav/keyboard_mover.js';
/**
* Abstract class for a component that can delete a block or bubble that is
@@ -56,7 +57,10 @@ export class DeleteArea extends DragTarget implements IDeleteArea {
* area.
*/
wouldDelete(element: IDraggable): boolean {
if (element instanceof BlockSvg) {
// don't delete things if we're doing a keyboard move
if (KeyboardMover.mover.isMoving()) {
this.updateWouldDelete_(false);
} else if (element instanceof BlockSvg) {
const block = element;
const couldDeleteBlock = !block.getParent() && block.isDeletable();
this.updateWouldDelete_(couldDeleteBlock);
+120 -8
View File
@@ -6,15 +6,24 @@
// Former goog.module ID: Blockly.dialog
import {getFocusManager} from './focus_manager.js';
import {Msg} from './msg.js';
import type {ToastOptions} from './toast.js';
import {Toast} from './toast.js';
import type {WorkspaceSvg} from './workspace_svg.js';
/** Supported types of dialogs. */
enum DialogType {
ALERT = 1,
CONFIRM,
PROMPT,
}
/** Tally of the number of dialogs currently on screen. */
let activeDialogCount = 0;
const defaultAlert = function (message: string, opt_callback?: () => void) {
window.alert(message);
if (opt_callback) {
opt_callback();
}
displayDialog(DialogType.ALERT, message, opt_callback, undefined);
};
let alertImplementation = defaultAlert;
@@ -23,7 +32,7 @@ const defaultConfirm = function (
message: string,
callback: (result: boolean) => void,
) {
callback(window.confirm(message));
displayDialog(DialogType.CONFIRM, message, callback, undefined);
};
let confirmImplementation = defaultConfirm;
@@ -33,9 +42,7 @@ const defaultPrompt = function (
defaultValue: string,
callback: (result: string | null) => void,
) {
// NOTE TO DEVELOPER: Ephemeral focus doesn't need to be taken for the native
// window prompt since it prevents focus from changing while open.
callback(window.prompt(message, defaultValue));
displayDialog(DialogType.PROMPT, message, callback, defaultValue);
};
let promptImplementation = defaultPrompt;
@@ -165,3 +172,108 @@ export function setToast(
) {
toastImplementation = toastFunction;
}
/**
* Displays a dialog, potentially prompting for user input, and invokes the
* provided callback with the response.
*/
function displayDialog(
...[type, message, callback, defaultValue]:
| [
type: DialogType.PROMPT,
message: string,
callback: (result: string | null) => void,
defaultValue: string | undefined,
]
| [
type: DialogType.CONFIRM,
message: string,
callback: (result: boolean) => void,
defaultValue: undefined,
]
| [
type: DialogType.ALERT,
message: string,
callback: (() => void) | undefined,
defaultValue: undefined,
]
): void {
const OK = 'ok';
const CANCEL = 'cancel';
const dialog = document.createElement('dialog');
const form = document.createElement('form');
const label = document.createElement('label');
const input = document.createElement('input');
const buttonRow = document.createElement('div');
const ok = document.createElement('button');
dialog.className = 'blocklyDialog';
form.className = 'blocklyDialogForm';
label.className = 'blocklyDialogPrompt';
buttonRow.className = 'blocklyDialogButtonRow';
ok.className = 'blocklyDialogConfirmButton';
form.setAttribute('method', 'dialog');
label.textContent = message;
label.setAttribute('for', 'blockly-form-input');
ok.textContent = Msg['DIALOG_OK'];
ok.value = OK;
dialog.appendChild(form);
form.appendChild(label);
if (type === DialogType.PROMPT) {
input.id = 'blockly-form-input';
input.className = 'blocklyDialogInput';
input.type = 'text';
input.name = 'input';
input.autofocus = true;
if (defaultValue) {
input.value = defaultValue;
}
form.appendChild(input);
}
buttonRow.appendChild(ok);
if (type === DialogType.CONFIRM || type === DialogType.PROMPT) {
const cancel = document.createElement('button');
cancel.className = 'blocklyDialogCancelButton';
cancel.textContent = Msg['DIALOG_CANCEL'];
cancel.value = CANCEL;
buttonRow.appendChild(cancel);
}
form.appendChild(buttonRow);
let restoreFocus: (() => void) | undefined;
if (activeDialogCount === 0) {
restoreFocus = getFocusManager().takeEphemeralFocus(dialog);
}
activeDialogCount++;
dialog.addEventListener('close', () => {
activeDialogCount--;
if (!activeDialogCount) {
restoreFocus?.();
}
dialog.remove();
switch (type) {
case DialogType.CONFIRM:
callback(dialog.returnValue === OK);
break;
case DialogType.PROMPT:
callback(dialog.returnValue === OK ? input.value : null);
break;
case DialogType.ALERT:
callback?.();
}
});
document.body.appendChild(dialog);
dialog.showModal();
}
File diff suppressed because it is too large Load Diff
@@ -4,10 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {IBubble, WorkspaceSvg} from '../blockly.js';
import {IDragStrategy} from '../interfaces/i_draggable.js';
import type {IBubble} from '../interfaces/i_bubble.js';
import type {IDragStrategy} from '../interfaces/i_draggable.js';
import * as layers from '../layers.js';
import {Coordinate} from '../utils.js';
import type {Coordinate} from '../utils.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
export class BubbleDragStrategy implements IDragStrategy {
private startLoc: Coordinate | null = null;
@@ -21,13 +22,15 @@ export class BubbleDragStrategy implements IDragStrategy {
return true;
}
startDrag(): void {
startDrag() {
this.startLoc = this.bubble.getRelativeToSurfaceXY();
this.workspace.setResizesEnabled(false);
this.workspace.getLayerManager()?.moveToDragLayer(this.bubble);
if (this.bubble.setDragging) {
this.bubble.setDragging(true);
}
return this.bubble;
}
drag(newLoc: Coordinate): void {
@@ -4,14 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {RenderedWorkspaceComment} from '../comments.js';
import {CommentMove} from '../events/events_comment_move.js';
import type {RenderedWorkspaceComment} from '../comments.js';
import type {CommentMove} from '../events/events_comment_move.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import {IDragStrategy} from '../interfaces/i_draggable.js';
import type {IDragStrategy} from '../interfaces/i_draggable.js';
import * as layers from '../layers.js';
import {Coordinate} from '../utils.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import type {Coordinate} from '../utils.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
export class CommentDragStrategy implements IDragStrategy {
private startLoc: Coordinate | null = null;
@@ -30,12 +30,13 @@ export class CommentDragStrategy implements IDragStrategy {
);
}
startDrag(): void {
startDrag() {
this.fireDragStartEvent();
this.startLoc = this.comment.getRelativeToSurfaceXY();
this.workspace.setResizesEnabled(false);
this.workspace.getLayerManager()?.moveToDragLayer(this.comment);
this.comment.setDragging(true);
return this.comment;
}
drag(newLoc: Coordinate): void {
@@ -49,6 +50,7 @@ export class CommentDragStrategy implements IDragStrategy {
this.workspace
.getLayerManager()
?.moveOffDragLayer(this.comment, layers.BLOCK);
this.workspace.getAudioManager().play('drop');
this.comment.setDragging(false);
this.comment.snapToGrid();
+75 -54
View File
@@ -4,39 +4,37 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as blockAnimations from '../block_animations.js';
import {BlockSvg} from '../block_svg.js';
import {ComponentManager} from '../component_manager.js';
import * as eventUtils from '../events/utils.js';
import {getFocusManager} from '../focus_manager.js';
import {IDeletable, isDeletable} from '../interfaces/i_deletable.js';
import {IDeleteArea} from '../interfaces/i_delete_area.js';
import {IDragTarget} from '../interfaces/i_drag_target.js';
import {IDraggable} from '../interfaces/i_draggable.js';
import {IDragger} from '../interfaces/i_dragger.js';
import type {IDeletable} from '../interfaces/i_deletable.js';
import {isDeletable} from '../interfaces/i_deletable.js';
import type {IDeleteArea} from '../interfaces/i_delete_area.js';
import type {IDragTarget} from '../interfaces/i_drag_target.js';
import {DragDisposition, type IDraggable} from '../interfaces/i_draggable.js';
import type {IDragger} from '../interfaces/i_dragger.js';
import {isFocusableNode} from '../interfaces/i_focusable_node.js';
import * as registry from '../registry.js';
import {Coordinate} from '../utils/coordinate.js';
import {WorkspaceSvg} from '../workspace_svg.js';
export class Dragger implements IDragger {
protected startLoc: Coordinate;
protected dragTarget: IDragTarget | null = null;
constructor(
protected draggable: IDraggable,
protected workspace: WorkspaceSvg,
) {
constructor(protected draggable: IDraggable) {
this.startLoc = draggable.getRelativeToSurfaceXY();
}
/** Handles any drag startup. */
onDragStart(e: PointerEvent) {
onDragStart(e?: PointerEvent | KeyboardEvent) {
if (!eventUtils.getGroup()) {
eventUtils.setGroup(true);
}
this.draggable.startDrag(e);
this.draggable = this.draggable.startDrag(e);
this.startLoc = this.draggable.getRelativeToSurfaceXY();
return this.draggable;
}
/**
@@ -45,27 +43,30 @@ export class Dragger implements IDragger {
* @param totalDelta The total amount in pixel coordinates the mouse has moved
* since the start of the drag.
*/
onDrag(e: PointerEvent, totalDelta: Coordinate) {
onDrag(e: PointerEvent | KeyboardEvent | undefined, totalDelta: Coordinate) {
this.moveDraggable(e, totalDelta);
const root = this.getRoot(this.draggable);
// Must check `wouldDelete` before calling other hooks on drag targets
// since we have documented that we would do so.
if (isDeletable(root)) {
root.setDeleteStyle(this.wouldDeleteDraggable(e, root));
if (isDeletable(this.draggable)) {
this.draggable.setDeleteStyle(
this.wouldDeleteDraggable(
this.draggable.getRelativeToSurfaceXY(),
this.draggable,
),
);
}
this.updateDragTarget(e);
this.updateDragTarget(this.draggable.getRelativeToSurfaceXY());
}
/** Updates the drag target under the pointer (if there is one). */
protected updateDragTarget(e: PointerEvent) {
const newDragTarget = this.workspace.getDragTarget(e);
const root = this.getRoot(this.draggable);
protected updateDragTarget(coordinate: Coordinate) {
const newDragTarget = this.draggable.workspace.getDragTarget(coordinate);
if (this.dragTarget !== newDragTarget) {
this.dragTarget?.onDragExit(root);
newDragTarget?.onDragEnter(root);
this.dragTarget?.onDragExit(this.draggable);
newDragTarget?.onDragEnter(this.draggable);
}
newDragTarget?.onDragOver(root);
newDragTarget?.onDragOver(this.draggable);
this.dragTarget = newDragTarget;
}
@@ -73,7 +74,10 @@ export class Dragger implements IDragger {
* Calculates the correct workspace coordinate for the movable and tells
* the draggable to go to that location.
*/
private moveDraggable(e: PointerEvent, totalDelta: Coordinate) {
private moveDraggable(
e: PointerEvent | KeyboardEvent | undefined,
totalDelta: Coordinate,
) {
const delta = this.pixelsToWorkspaceUnits(totalDelta);
const newLoc = Coordinate.sum(this.startLoc, delta);
this.draggable.drag(newLoc, e);
@@ -84,13 +88,13 @@ export class Dragger implements IDragger {
* at the current location.
*/
protected wouldDeleteDraggable(
e: PointerEvent,
coordinate: Coordinate,
rootDraggable: IDraggable & IDeletable,
) {
const dragTarget = this.workspace.getDragTarget(e);
const dragTarget = this.draggable.workspace.getDragTarget(coordinate);
if (!dragTarget) return false;
const componentManager = this.workspace.getComponentManager();
const componentManager = this.draggable.workspace.getComponentManager();
const isDeleteArea = componentManager.hasCapability(
dragTarget.id,
ComponentManager.Capability.DELETE_AREA,
@@ -101,34 +105,46 @@ export class Dragger implements IDragger {
}
/** Handles any drag cleanup. */
onDragEnd(e: PointerEvent) {
onDragEnd(e?: PointerEvent | KeyboardEvent) {
const origGroup = eventUtils.getGroup();
const dragTarget = this.workspace.getDragTarget(e);
const root = this.getRoot(this.draggable);
const dragTarget = this.draggable.workspace.getDragTarget(
this.draggable.getRelativeToSurfaceXY(),
);
if (dragTarget) {
this.dragTarget?.onDrop(root);
this.dragTarget?.onDrop(this.draggable);
}
if (this.shouldReturnToStart(e, root)) {
let reverted = false;
if (
this.shouldReturnToStart(
this.draggable.getRelativeToSurfaceXY(),
this.draggable,
)
) {
reverted = true;
this.draggable.revertDrag();
}
const wouldDelete = isDeletable(root) && this.wouldDeleteDraggable(e, root);
const wouldDelete =
isDeletable(this.draggable) &&
this.wouldDeleteDraggable(
this.draggable.getRelativeToSurfaceXY(),
this.draggable,
);
// TODO(#8148): use a generalized API instead of an instanceof check.
if (wouldDelete && this.draggable instanceof BlockSvg) {
blockAnimations.disposeUiEffect(this.draggable.getRootBlock());
}
this.draggable.endDrag(e);
if (wouldDelete && isDeletable(root)) {
if (wouldDelete && isDeletable(this.draggable)) {
this.draggable.endDrag(e, DragDisposition.DELETE);
// We want to make sure the delete gets grouped with any possible move
// event. In core Blockly this shouldn't happen, but due to a change
// in behavior older custom draggables might still clear the group.
eventUtils.setGroup(origGroup);
root.dispose();
this.draggable.dispose();
} else {
this.draggable.endDrag(
e,
reverted ? DragDisposition.REVERT : DragDisposition.COMMIT,
);
}
eventUtils.setGroup(false);
@@ -139,32 +155,37 @@ export class Dragger implements IDragger {
}
}
// We need to special case blocks for now so that we look at the root block
// instead of the one actually being dragged in most cases.
private getRoot(draggable: IDraggable): IDraggable {
return draggable instanceof BlockSvg ? draggable.getRootBlock() : draggable;
/** Handles a drag being reverted. */
onDragRevert() {
this.draggable.revertDrag();
if (isFocusableNode(this.draggable)) {
getFocusManager().focusNode(this.draggable);
}
}
/**
* Returns true if we should return the draggable to its original location
* at the end of the drag.
*/
protected shouldReturnToStart(e: PointerEvent, rootDraggable: IDraggable) {
const dragTarget = this.workspace.getDragTarget(e);
protected shouldReturnToStart(
coordinate: Coordinate,
rootDraggable: IDraggable,
) {
const dragTarget = this.draggable.workspace.getDragTarget(coordinate);
if (!dragTarget) return false;
return dragTarget.shouldPreventMove(rootDraggable);
}
protected pixelsToWorkspaceUnits(pixelCoord: Coordinate): Coordinate {
const result = new Coordinate(
pixelCoord.x / this.workspace.scale,
pixelCoord.y / this.workspace.scale,
pixelCoord.x / this.draggable.workspace.scale,
pixelCoord.y / this.draggable.workspace.scale,
);
if (this.workspace.isMutator) {
if (this.draggable.workspace.isMutator) {
// If we're in a mutator, its scale is always 1, purely because of some
// oddities in our rendering optimizations. The actual scale is the same
// as the scale on the parent workspace. Fix that for dragging.
const mainScale = this.workspace.options.parentWorkspace!.scale;
const mainScale = this.draggable.workspace.options.parentWorkspace!.scale;
result.scale(1 / mainScale);
}
return result;
+70 -7
View File
@@ -17,7 +17,9 @@ import * as browserEvents from './browser_events.js';
import * as common from './common.js';
import type {Field} from './field.js';
import {ReturnEphemeralFocus, getFocusManager} from './focus_manager.js';
import * as aria from './utils/aria.js';
import * as dom from './utils/dom.js';
import * as idGenerator from './utils/idgenerator.js';
import * as math from './utils/math.js';
import {Rect} from './utils/rect.js';
import type {Size} from './utils/size.js';
@@ -48,6 +50,12 @@ export const PADDING_Y = 16;
/** Length of animations in seconds. */
export const ANIMATION_TIME = 0.25;
/**
* Class applied to the element that is displaying the DropDownDiv, used to
* apply focus styles.
*/
const SHOWING_DROPDOWNDIV_SELECTOR = 'blocklyShowingDropDownDiv';
/**
* Timer for animation out, to be cleared if we need to immediately hide
* without disrupting new shows.
@@ -127,6 +135,7 @@ export function createDom() {
div = document.createElement('div');
div.className = 'blocklyDropDownDiv';
div.tabIndex = -1;
div.id = idGenerator.getNextUniqueId();
const parentDiv = common.getParentContainer() || document.body;
parentDiv.appendChild(div);
@@ -151,6 +160,19 @@ export function createDom() {
'transform ' + ANIMATION_TIME + 's, ' + 'opacity ' + ANIMATION_TIME + 's';
}
/**
* Deals with the root element that contains this and other popovers losing
* focus by returning ephemeral focus if we hold it and hiding the DropDownDiv.
*/
function handleFocusLoss() {
if (returnEphemeralFocus) {
returnEphemeralFocus(false);
returnEphemeralFocus = null;
}
hide();
}
/**
* Set an element to maintain bounds within. Drop-downs will appear
* within the box of this element if possible.
@@ -370,6 +392,11 @@ export function show<T>(
manageEphemeralFocus: boolean,
opt_onHide?: () => void,
): boolean {
getFocusManager().registerPopoverFocusLossHandler(handleFocusLoss);
const parentDiv = common.getParentContainer();
parentDiv?.appendChild(div);
owner = newOwner as Field;
onHide = opt_onHide || null;
// Set direction.
@@ -381,6 +408,19 @@ export function show<T>(
dom.addClass(div, renderedClassName);
dom.addClass(div, themeClassName);
const existingOwnership = aria.getState(
mainWorkspace.getFocusableElement(),
aria.State.OWNS,
);
aria.setState(
mainWorkspace.getFocusableElement(),
aria.State.OWNS,
existingOwnership ? [existingOwnership, div.id] : div.id,
);
mainWorkspace
.getFocusableElement()
.classList.add(SHOWING_DROPDOWNDIV_SELECTOR);
// When we change `translate` multiple times in close succession,
// Chrome may choose to wait and apply them all at once.
// Since we want the translation to initial X, Y to be immediate,
@@ -666,6 +706,7 @@ export function hideIfOwner<T>(
/** Hide the menu, triggering animation. */
export function hide() {
getFocusManager().unregisterPopoverFocusLossHandler(handleFocusLoss);
// Start the animation by setting the translation and fading out.
// Reset to (initialX, initialY) - i.e., no translation.
div.style.transform = 'translate(0, 0)';
@@ -693,15 +734,28 @@ export function hideWithoutAnimation() {
onHide();
onHide = null;
}
clearContent();
owner = null;
(common.getMainWorkspace() as WorkspaceSvg).markFocused();
const workspace = common.getMainWorkspace() as WorkspaceSvg;
const existingOwnership =
aria.getState(workspace.getFocusableElement(), aria.State.OWNS) ?? '';
aria.setState(
workspace.getFocusableElement(),
aria.State.OWNS,
existingOwnership.replace(div.id, ''),
);
workspace
.getFocusableElement()
.classList.remove(SHOWING_DROPDOWNDIV_SELECTOR);
workspace.markFocused();
if (returnEphemeralFocus) {
returnEphemeralFocus();
returnEphemeralFocus = null;
}
clearContent();
}
/**
@@ -738,17 +792,26 @@ function positionInternal(
arrow.style.display = 'none';
}
const initialX = Math.floor(metrics.initialX);
const initialY = Math.floor(metrics.initialY);
const finalX = Math.floor(metrics.finalX);
const finalY = Math.floor(metrics.finalY);
let initialX = Math.floor(metrics.initialX);
let initialY = Math.floor(metrics.initialY);
let finalX = Math.floor(metrics.finalX);
let finalY = Math.floor(metrics.finalY);
const parentElement = div.parentElement;
if (parentElement) {
const bounds = parentElement.getBoundingClientRect();
initialX -= bounds.left + window.scrollX;
finalX -= bounds.left + window.scrollX;
initialY -= bounds.top + window.scrollY;
finalY -= bounds.top + window.scrollY;
}
// First apply initial translation.
div.style.left = initialX + 'px';
div.style.top = initialY + 'px';
// Show the div.
div.style.display = 'block';
div.style.visibility = 'visible';
div.style.opacity = '1';
// Add final translate, animated through `transition`.
// Coordinates are relative to (initialX, initialY),
@@ -14,9 +14,7 @@
import type {Block} from '../block.js';
import * as registry from '../registry.js';
import * as blocks from '../serialization/blocks.js';
import * as utilsXml from '../utils/xml.js';
import {Workspace} from '../workspace.js';
import * as Xml from '../xml.js';
import {BlockBase, BlockBaseJson} from './events_block_base.js';
import {EventType} from './type.js';
import * as eventUtils from './utils.js';
@@ -28,9 +26,6 @@ import * as eventUtils from './utils.js';
export class BlockCreate extends BlockBase {
override type = EventType.BLOCK_CREATE;
/** The XML representation of the created block(s). */
xml?: Element | DocumentFragment;
/** The JSON respresentation of the created block(s). */
json?: blocks.State;
@@ -50,7 +45,6 @@ export class BlockCreate extends BlockBase {
this.recordUndo = false;
}
this.xml = Xml.blockToDomWithXY(opt_block);
this.ids = eventUtils.getDescendantIds(opt_block);
this.json = blocks.save(opt_block, {addCoordinates: true}) as blocks.State;
@@ -63,12 +57,6 @@ export class BlockCreate extends BlockBase {
*/
override toJson(): BlockCreateJson {
const json = super.toJson() as BlockCreateJson;
if (!this.xml) {
throw new Error(
'The block XML is undefined. Either pass a block to ' +
'the constructor, or call fromJson',
);
}
if (!this.ids) {
throw new Error(
'The block IDs are undefined. Either pass a block to ' +
@@ -81,7 +69,6 @@ export class BlockCreate extends BlockBase {
'the constructor, or call fromJson',
);
}
json['xml'] = Xml.domToText(this.xml);
json['ids'] = this.ids;
json['json'] = this.json;
if (!this.recordUndo) {
@@ -109,7 +96,6 @@ export class BlockCreate extends BlockBase {
workspace,
event ?? new BlockCreate(),
) as BlockCreate;
newEvent.xml = utilsXml.textToDom(json['xml']);
newEvent.ids = json['ids'];
newEvent.json = json['json'] as blocks.State;
if (json['recordUndo'] !== undefined) {
@@ -176,7 +162,6 @@ const allShadowBlocks = function (
};
export interface BlockCreateJson extends BlockBaseJson {
xml: string;
ids: string[];
json: object;
recordUndo?: boolean;
@@ -14,9 +14,7 @@
import type {Block} from '../block.js';
import * as registry from '../registry.js';
import * as blocks from '../serialization/blocks.js';
import * as utilsXml from '../utils/xml.js';
import {Workspace} from '../workspace.js';
import * as Xml from '../xml.js';
import {BlockBase, BlockBaseJson} from './events_block_base.js';
import {EventType} from './type.js';
import * as eventUtils from './utils.js';
@@ -26,9 +24,6 @@ import * as eventUtils from './utils.js';
* deleted.
*/
export class BlockDelete extends BlockBase {
/** The XML representation of the deleted block(s). */
oldXml?: Element | DocumentFragment;
/** The JSON respresentation of the deleted block(s). */
oldJson?: blocks.State;
@@ -56,7 +51,6 @@ export class BlockDelete extends BlockBase {
this.recordUndo = false;
}
this.oldXml = Xml.blockToDomWithXY(opt_block);
this.ids = eventUtils.getDescendantIds(opt_block);
this.wasShadow = opt_block.isShadow();
this.oldJson = blocks.save(opt_block, {
@@ -71,12 +65,6 @@ export class BlockDelete extends BlockBase {
*/
override toJson(): BlockDeleteJson {
const json = super.toJson() as BlockDeleteJson;
if (!this.oldXml) {
throw new Error(
'The old block XML is undefined. Either pass a block ' +
'to the constructor, or call fromJson',
);
}
if (!this.ids) {
throw new Error(
'The block IDs are undefined. Either pass a block to ' +
@@ -95,7 +83,6 @@ export class BlockDelete extends BlockBase {
'to the constructor, or call fromJson',
);
}
json['oldXml'] = Xml.domToText(this.oldXml);
json['ids'] = this.ids;
json['wasShadow'] = this.wasShadow;
json['oldJson'] = this.oldJson;
@@ -124,10 +111,8 @@ export class BlockDelete extends BlockBase {
workspace,
event ?? new BlockDelete(),
) as BlockDelete;
newEvent.oldXml = utilsXml.textToDom(json['oldXml']);
newEvent.ids = json['ids'];
newEvent.wasShadow =
json['wasShadow'] || newEvent.oldXml.tagName.toLowerCase() === 'shadow';
newEvent.wasShadow = json['wasShadow'];
newEvent.oldJson = json['oldJson'];
if (json['recordUndo'] !== undefined) {
newEvent.recordUndo = json['recordUndo'];
@@ -172,7 +157,6 @@ export class BlockDelete extends BlockBase {
}
export interface BlockDeleteJson extends BlockBaseJson {
oldXml: string;
ids: string[];
wasShadow: boolean;
oldJson: blocks.State;
@@ -14,9 +14,7 @@
import type {WorkspaceComment} from '../comments/workspace_comment.js';
import * as registry from '../registry.js';
import * as comments from '../serialization/workspace_comments.js';
import * as utilsXml from '../utils/xml.js';
import type {Workspace} from '../workspace.js';
import * as Xml from '../xml.js';
import {CommentBase, CommentBaseJson} from './events_comment_base.js';
import {EventType} from './type.js';
@@ -26,9 +24,6 @@ import {EventType} from './type.js';
export class CommentCreate extends CommentBase {
override type = EventType.COMMENT_CREATE;
/** The XML representation of the created workspace comment. */
xml?: Element | DocumentFragment;
/** The JSON representation of the created workspace comment. */
json?: comments.State;
@@ -43,7 +38,6 @@ export class CommentCreate extends CommentBase {
return; // Blank event to be populated by fromJson.
}
this.xml = Xml.saveWorkspaceComment(opt_comment);
this.json = comments.save(opt_comment, {addCoordinates: true});
}
@@ -55,19 +49,12 @@ export class CommentCreate extends CommentBase {
*/
override toJson(): CommentCreateJson {
const json = super.toJson() as CommentCreateJson;
if (!this.xml) {
throw new Error(
'The comment XML is undefined. Either pass a comment to ' +
'the constructor, or call fromJson',
);
}
if (!this.json) {
throw new Error(
'The comment JSON is undefined. Either pass a block to ' +
'the constructor, or call fromJson',
);
}
json['xml'] = Xml.domToText(this.xml);
json['json'] = this.json;
return json;
}
@@ -91,7 +78,6 @@ export class CommentCreate extends CommentBase {
workspace,
event ?? new CommentCreate(),
) as CommentCreate;
newEvent.xml = utilsXml.textToDom(json['xml']);
newEvent.json = json['json'];
return newEvent;
}
@@ -107,7 +93,6 @@ export class CommentCreate extends CommentBase {
}
export interface CommentCreateJson extends CommentBaseJson {
xml: string;
json: object;
}
@@ -14,9 +14,7 @@
import type {WorkspaceComment} from '../comments/workspace_comment.js';
import * as registry from '../registry.js';
import * as comments from '../serialization/workspace_comments.js';
import * as utilsXml from '../utils/xml.js';
import type {Workspace} from '../workspace.js';
import * as Xml from '../xml.js';
import {CommentBase, CommentBaseJson} from './events_comment_base.js';
import {EventType} from './type.js';
@@ -26,9 +24,6 @@ import {EventType} from './type.js';
export class CommentDelete extends CommentBase {
override type = EventType.COMMENT_DELETE;
/** The XML representation of the deleted workspace comment. */
xml?: Element;
/** The JSON representation of the created workspace comment. */
json?: comments.State;
@@ -43,7 +38,6 @@ export class CommentDelete extends CommentBase {
return; // Blank event to be populated by fromJson.
}
this.xml = Xml.saveWorkspaceComment(opt_comment);
this.json = comments.save(opt_comment, {addCoordinates: true});
}
@@ -63,19 +57,12 @@ export class CommentDelete extends CommentBase {
*/
override toJson(): CommentDeleteJson {
const json = super.toJson() as CommentDeleteJson;
if (!this.xml) {
throw new Error(
'The comment XML is undefined. Either pass a comment to ' +
'the constructor, or call fromJson',
);
}
if (!this.json) {
throw new Error(
'The comment JSON is undefined. Either pass a block to ' +
'the constructor, or call fromJson',
);
}
json['xml'] = Xml.domToText(this.xml);
json['json'] = this.json;
return json;
}
@@ -99,14 +86,12 @@ export class CommentDelete extends CommentBase {
workspace,
event ?? new CommentDelete(),
) as CommentDelete;
newEvent.xml = utilsXml.textToDom(json['xml']);
newEvent.json = json['json'];
return newEvent;
}
}
export interface CommentDeleteJson extends CommentBaseJson {
xml: string;
json: object;
}
+190 -19
View File
@@ -28,9 +28,11 @@ 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';
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';
@@ -64,7 +66,7 @@ export type FieldValidator<T = any> = (newValue: T) => T | null | undefined;
/**
* Abstract class for an editable field.
*
* @typeParam T - The value stored on the field.
* @template T - The value stored on the field.
*/
export abstract class Field<T = any>
implements IKeyboardAccessible, IRegistrable, ISerializable, IFocusableNode
@@ -98,6 +100,9 @@ export abstract class Field<T = any>
/** Validation function called when user edits an editable field. */
protected validator_: FieldValidator<T> | null = null;
/** The ARIA-friendly label representation of this field's type. */
protected ariaTypeName: string | null = null;
/**
* Used to cache the field's tooltip value if setTooltip is called when the
* field is not yet initialized. Is *not* guaranteed to be accurate.
@@ -250,6 +255,9 @@ export abstract class Field<T = any>
if (config.tooltip) {
this.setTooltip(parsing.replaceMessageReferences(config.tooltip));
}
if (config.ariaTypeName) {
this.ariaTypeName = config.ariaTypeName;
}
}
/**
@@ -268,7 +276,6 @@ export abstract class Field<T = any>
`problems with focus: ${block.id}.`,
);
}
this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`;
}
/**
@@ -300,6 +307,87 @@ export abstract class Field<T = any>
return this.sourceBlock_;
}
/**
* Gets an ARIA-friendly label representation of this field's type.
*
* Implementations are responsible for, and encouraged to, return a localized
* version of the ARIA representation of the field's type.
*
* @returns An ARIA representation of the field's type or null if it is
* unspecified.
*/
getAriaTypeName(): string | null {
return this.ariaTypeName || null;
}
/**
* Gets an ARIA-friendly label representation of this field's value.
*
* Note that implementations should generally always override this value to
* ensure a non-null value is returned. The default implementation relies on
* 'getText' which may return an empty string. A null return value from this
* function will prompt ARIA label generation to skip the field's value
* entirely when there may be a better contextual placeholder to use isstead.
*
* For example, to avoid hiding an empty text input field from screen reader,
* implementations should ensure that if the text is an empty string, this
* function would return an appropriate, localized value such as "empty text".
*
* Implementations are responsible for, and encouraged to, return a localized
* version of the ARIA representation of the field's value.
*
* @returns An ARIA representation of the field's text, or null if no text is
* currently defined or known for the field.
*/
getAriaValue(): string | null {
if (this.getValue() == null) {
return null;
} else {
return this.getText();
}
}
/**
* Computes a descriptive ARIA label to represent this field with configurable
* verbosity.
*
* A 'verbose' label includes type information, if available, whereas a
* non-verbose label only contains the field's value.
*
* Note that this will always return the latest representation of the field's
* label which may differ from any previously set ARIA label for the field
* itself. Implementations are largely responsible for ensuring that the
* field's ARIA label is set correctly at relevant moments in the field's
* lifecycle (such as when its value changes).
*
* Finally, it is never guaranteed that implementations use the label returned
* by this method for their actual ARIA label. Some implementations may rely
* on other contexts to convey information like the field's value. Example:
* checkboxes represent their checked/non-checked status (i.e. value) through
* a separate ARIA property.
*
* If the field's value is empty then it will return a localized placeholder
* indicating that its value is empty. If this method returns an empty string,
* the output will be ignored when composing the block-level ARIA label. Make
* sure you want your label hidden from screenreaders before returning an
* empty string.
*
* @param includeTypeInfo Whether to include the field's type information in
* the returned label, if available.
*/
computeAriaLabel(includeTypeInfo: boolean = true): string {
const ariaTypeName = includeTypeInfo ? this.getAriaTypeName() : null;
let ariaValue = this.getAriaValue();
if (ariaValue === null || ariaValue === '') {
ariaValue = Msg['FIELD_LABEL_EMPTY'];
}
if (ariaTypeName) {
return `${ariaTypeName}: ${ariaValue}`;
}
return ariaValue;
}
/**
* Initialize everything to render this field. Override
* methods initModel and initView rather than this method.
@@ -312,11 +400,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';
}
@@ -328,6 +412,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_);
}
/**
@@ -350,15 +443,19 @@ export abstract class Field<T = any>
/**
* Defines whether this field should take up the full block or not.
*
* Be cautious when overriding this function. It may not work as you expect /
* intend because the behavior was kind of hacked in. If you are thinking
* about overriding this function, post on the forum with your intended
* behavior to see if there's another approach.
* This is typically only done for certain kinds of fields and in certain
* renderers. You should only override this if you're sure your field will
* render correctly in zelos and other renderers that support full-block
* fields.
*
* @internal
* Blocks that contain only a single field that is a full-block-field
* have a special appearance in some renderers and their behavior is
* unique, because we pretend that the field is a whole block in some cases.
* This is hacky and you should use caution when attempting to do anything
* with this method.
*/
isFullBlockField(): boolean {
return !this.borderRect_;
return false;
}
/**
@@ -383,15 +480,18 @@ export abstract class Field<T = any>
}
/**
* Create a field text element. Not to be overridden by subclasses. Instead
* Create a field text element. Not to be overridden by subclasses. Instead,
* modify the result of the function inside initView, or create a separate
* function to call.
* function to call. Aria state is hidden; use the aria label for the field
* and/or containing block to expose content to screen readers. Text content
* for custom blocks can be set after creation.
*/
protected createTextElement_() {
this.textElement_ = dom.createSvgElement(
Svg.TEXT,
{
'class': 'blocklyText blocklyFieldText',
'aria-hidden': 'true',
},
this.fieldGroup_,
);
@@ -834,7 +934,7 @@ export abstract class Field<T = any>
const xOffset =
margin !== undefined
? margin
: !this.isFullBlockField()
: this.borderRect_
? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING
: 0;
let totalWidth = xOffset * 2;
@@ -845,7 +945,7 @@ export abstract class Field<T = any>
contentWidth = dom.getTextWidth(this.textElement_);
totalWidth += contentWidth;
}
if (!this.isFullBlockField()) {
if (this.borderRect_) {
totalHeight = Math.max(totalHeight, constants!.FIELD_BORDER_RECT_HEIGHT);
}
@@ -942,7 +1042,7 @@ export abstract class Field<T = any>
throw new UnattachedFieldError();
}
if (this.isFullBlockField()) {
if (!this.borderRect_) {
// Browsers are inconsistent in what they return for a bounding box.
// - Webkit / Blink: fill-box / object bounding box
// - Gecko: stroke-box
@@ -960,7 +1060,7 @@ export abstract class Field<T = any>
xy.y -= 0.5 * scale;
}
} else {
const bBox = this.borderRect_!.getBoundingClientRect();
const bBox = this.borderRect_.getBoundingClientRect();
xy = style.getPageOffset(this.borderRect_!);
scaledWidth = bBox.width;
scaledHeight = bBox.height;
@@ -1395,6 +1495,76 @@ export abstract class Field<T = any>
return true;
}
/**
* Handles the user acting on this field via keyboard navigation.
* Shows and focuses the field editor.
*/
performAction() {
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 initialized yet.
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.
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.
@@ -1417,6 +1587,7 @@ export abstract class Field<T = any>
*/
export interface FieldConfig {
tooltip?: string;
ariaTypeName?: string;
}
/**
+60
View File
@@ -16,6 +16,8 @@ import './events/events_block_change.js';
import {Field, FieldConfig, FieldValidator} from './field.js';
import * as fieldRegistry from './field_registry.js';
import {Msg} from './msg.js';
import {aria} from './utils.js';
import * as dom from './utils/dom.js';
type BoolString = 'TRUE' | 'FALSE';
@@ -111,6 +113,7 @@ export class FieldCheckbox extends Field<CheckboxBool> {
const textElement = this.getTextElement();
dom.addClass(this.fieldGroup_!, 'blocklyCheckboxField');
textElement.style.display = this.value_ ? 'block' : 'none';
this.recomputeAriaContext();
}
override render_() {
@@ -170,6 +173,7 @@ export class FieldCheckbox extends Field<CheckboxBool> {
if (this.textElement_) {
this.textElement_.style.display = this.value_ ? 'block' : 'none';
}
this.recomputeAriaContext();
}
/**
@@ -213,6 +217,39 @@ export class FieldCheckbox extends Field<CheckboxBool> {
return !!value;
}
/**
* Gets an ARIA-friendly label representation of this field's type.
*
* Implementations are responsible for, and encouraged to, return a localized
* version of the ARIA representation of the field's type.
*
* @returns An ARIA representation of the field's type or a default if it is
* unspecified.
*/
override getAriaTypeName(): string {
return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_CHECKBOX'];
}
/**
* Gets an ARIA-friendly label representation of this field's value.
*
* Implementations are responsible for, and encouraged to, return a localized
* version of the ARIA representation of the field's value.
*
* The FieldCheckbox implementation is not used for the actual ARIA label of
* the field, since the checked state is already included in the ARIA checked
* state, but it is used for the ARIA label of its source block.
*
* @returns An ARIA representation of the field's text.
*/
override getAriaValue(): string | null {
// return null;
const checked = this.convertValueToBool(this.value_);
return checked
? Msg['FIELD_LABEL_CHECKBOX_CHECKED']
: Msg['FIELD_LABEL_CHECKBOX_UNCHECKED'];
}
/**
* Construct a FieldCheckbox from a JSON arg object.
*
@@ -228,6 +265,29 @@ export class FieldCheckbox extends Field<CheckboxBool> {
// 'override' the static fromJson method.
return new this(options.checked, undefined, options);
}
/**
* Customizes the label and sets additional aria state.
*/
override recomputeAriaContext(): boolean {
const shouldCustomize = super.recomputeAriaContext();
if (!shouldCustomize) return false;
const focusableElement = this.getFocusableElement();
aria.setState(focusableElement, aria.State.HIDDEN, false);
aria.setRole(focusableElement, aria.Role.CHECKBOX);
const checked = this.convertValueToBool(this.value_);
aria.setState(focusableElement, aria.State.CHECKED, checked);
// Checkbox fields do not use this.computeAriaLabel(), because the
// included 'checked' or 'not checked' state in the ARIA label would
// be redundant with the ARIA checked state.
const label = this.getAriaTypeName();
aria.setState(focusableElement, aria.State.LABEL, label);
return true;
}
}
fieldRegistry.register('field_checkbox', FieldCheckbox);
+185 -12
View File
@@ -25,6 +25,7 @@ import * as fieldRegistry from './field_registry.js';
import {Menu} from './menu.js';
import {MenuSeparator} from './menu_separator.js';
import {MenuItem} from './menuitem.js';
import {Msg} from './msg.js';
import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
@@ -98,6 +99,12 @@ export class FieldDropdown extends Field<string> {
/** The total vertical padding above and below an image. */
protected static IMAGE_Y_PADDING = FieldDropdown.IMAGE_Y_OFFSET * 2;
/**
* True once the fields DOM has been created and it is safe to run ARIA
* updates in response to value changes.
*/
isInitialized: boolean = false;
/**
* @param menuGenerator A non-empty array of options for a dropdown list, or a
* function which generates these options. Also accepts Field.SKIP_SETUP
@@ -197,6 +204,23 @@ export class FieldDropdown extends Field<string> {
dom.addClass(this.fieldGroup_, 'blocklyField');
dom.addClass(this.fieldGroup_, 'blocklyDropdownField');
}
this.recomputeAriaContext();
this.isInitialized = true;
}
/**
* This is hacky way of determining if a dropdown field is a full-block field or not.
* The constants that control the border rect are the same ones that determine how we
* render full-block dropdown fields. It's a full-block field if it doesn't have the
* border rect (and it's a simple reporter block).
*
* @returns true if this field should be treated as a full-block field
*/
override isFullBlockField(): boolean {
return (
!this.shouldAddBorderRect_() &&
!!this.getSourceBlock()?.isSimpleReporter()
);
}
/**
@@ -295,6 +319,7 @@ export class FieldDropdown extends Field<string> {
}
this.applyColour();
aria.setState(this.getFocusableElement(), aria.State.EXPANDED, true);
}
/** Create the dropdown editor. */
@@ -306,6 +331,11 @@ export class FieldDropdown extends Field<string> {
const menu = new Menu();
menu.setRole(aria.Role.LISTBOX);
this.menu_ = menu;
aria.setState(
this.getFocusableElement(),
aria.State.CONTROLS,
this.menu_.getId(),
);
const options = this.getOptions(false);
this.selectedMenuItem = null;
@@ -317,6 +347,7 @@ export class FieldDropdown extends Field<string> {
}
const [label, value] = option;
const ariaLabel = this.computeOptionAriaLabel(option, i);
const content = (() => {
if (isImageProperties(label)) {
// Convert ImageProperties to an HTMLImageElement.
@@ -327,7 +358,7 @@ export class FieldDropdown extends Field<string> {
}
return label;
})();
const menuItem = new MenuItem(content, value);
const menuItem = new MenuItem(content, value, ariaLabel);
menuItem.setRole(aria.Role.OPTION);
menuItem.setRightToLeft(block.RTL);
menuItem.setCheckable(true);
@@ -350,6 +381,7 @@ export class FieldDropdown extends Field<string> {
this.menu_ = null;
this.selectedMenuItem = null;
this.applyColour();
aria.setState(this.getFocusableElement(), aria.State.EXPANDED, false);
}
/**
@@ -472,6 +504,9 @@ export class FieldDropdown extends Field<string> {
this.selectedOption = option;
}
}
if (this.isInitialized) {
this.recomputeAriaContext();
}
}
/**
@@ -653,7 +688,7 @@ export class FieldDropdown extends Field<string> {
typeof HTMLElement !== 'undefined' &&
option instanceof HTMLElement
) {
return option.title ?? option.ariaLabel ?? option.innerText;
return option.title || (option.ariaLabel ?? option.innerText);
} else if (typeof option === 'string') {
return option;
}
@@ -705,9 +740,16 @@ export class FieldDropdown extends Field<string> {
return option;
}
const [label, value] = option;
const [label, value, ariaLabel] = option;
if (typeof label === 'string') {
return [parsing.replaceMessageReferences(label), value];
const trimmedLabelOption: MenuOption = [
parsing.replaceMessageReferences(label),
value,
];
if (ariaLabel) {
trimmedLabelOption.push(parsing.replaceMessageReferences(ariaLabel));
}
return trimmedLabelOption;
}
hasNonTextContent = true;
@@ -716,14 +758,18 @@ export class FieldDropdown extends Field<string> {
const imageLabel = isImageProperties(label)
? {...label, alt: parsing.replaceMessageReferences(label.alt)}
: label;
return [imageLabel, value];
const imageOptions: MenuOption = [imageLabel, value];
if (ariaLabel) {
imageOptions.push(parsing.replaceMessageReferences(ariaLabel));
}
return imageOptions;
});
if (hasNonTextContent || options.length < 2) {
return {options: trimmedOptions};
}
const stringOptions = trimmedOptions as [string, string][];
const stringOptions = trimmedOptions as [string, string, string][];
const stringLabels = stringOptions.map(([label]) => label);
const shortest = utilsString.shortestStringLength(stringLabels);
@@ -762,14 +808,20 @@ export class FieldDropdown extends Field<string> {
* @returns A new array with all of the option text trimmed.
*/
private applyTrim(
options: [string, string][],
options: [string, string, string?][],
prefixLength: number,
suffixLength: number,
): MenuOption[] {
return options.map(([text, value]) => [
text.substring(prefixLength, text.length - suffixLength),
value,
]);
return options.map(([text, value, ariaLabel]) => {
const trimmedText = text.substring(
prefixLength,
text.length - suffixLength,
);
return ariaLabel !== undefined
? [trimmedText, value, ariaLabel]
: [trimmedText, value];
});
}
/**
@@ -813,12 +865,132 @@ export class FieldDropdown extends Field<string> {
`Invalid option[${i}]: Each FieldDropdown option must have a string
label, image description, or HTML element. Found ${option[0]} in: ${option}`,
);
} else if (option[2] && typeof option[2] !== 'string') {
foundError = true;
console.error(
`Invalid option[${i}]: Each FieldDropdown option ARIA label must be a string.
Found ${option[2]} in: ${option}`,
);
}
}
if (foundError) {
throw TypeError('Found invalid FieldDropdown options.');
}
}
/**
* Gets an ARIA-friendly label representation of this field's type.
*
* Implementations are responsible for, and encouraged to, return a localized
* version of the ARIA representation of the field's type.
*
* @returns An ARIA representation of the field's type or a default if it is
* unspecified.
*/
override getAriaTypeName(): string | null {
return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_DROPDOWN'];
}
/**
* Gets an ARIA-friendly label representation of this field's value.
*
* Implementations are responsible for, and encouraged to, return a localized
* version of the ARIA representation of the field's value.
*
* @returns An ARIA representation of the field's text.
*/
override getAriaValue(): string {
// Note: This fallback is effectively unreachable since computeOptionAriaLabel
// always returns a non-empty string for non-separator options. It exists as a
// defensive safeguard.
return (
this.getSelectedAriaLabel() || this.getText() || Msg['FIELD_LABEL_EMPTY']
);
}
/**
* Returns the ARIA label for the currently selected dropdown option.
*
* @returns The computed ARIA label for the selected option, or `null` if no
* option is selected.
*/
private getSelectedAriaLabel(): string | null {
if (!this.selectedOption) {
return null;
}
const option = this.selectedOption;
const ariaLabel = this.computeOptionAriaLabel(
option,
this.getOptions(false).indexOf(option),
);
if (typeof ariaLabel === 'string') {
return ariaLabel;
}
return null;
}
/**
* Overrides the default label and sets additional aria state.
*/
override recomputeAriaContext(): boolean {
const shouldCustomize = super.recomputeAriaContext();
if (!shouldCustomize) return false;
const focusableElement = this.getFocusableElement();
const label = this.computeAriaLabel(true);
aria.setState(focusableElement, aria.State.LABEL, label);
aria.setState(focusableElement, aria.State.HASPOPUP, 'listbox');
aria.setState(focusableElement, aria.State.EXPANDED, !!this.menu_);
return true;
}
/**
* Computes an ARIA-friendly label for a dropdown option.
*
* The label is derived using a prioritized set of sources.
*
* Returned values are guaranteed to be non-empty strings for all non-separator
* options. Whitespace-only values are ignored when determining a usable label.
*
* @param option The dropdown option for which to compute the ARIA label.
* @param index The index of the option within the dropdown (0-based).
* @returns A string suitable for use as an ARIA label. Returns an empty string
* only if the option is a separator.
*/
private computeOptionAriaLabel(option: MenuOption, index: number): string {
if (option === FieldDropdown.SEPARATOR) return '';
const [label, , explicitAriaLabel] = option;
if (typeof explicitAriaLabel === 'string' && explicitAriaLabel.trim()) {
return explicitAriaLabel;
}
let text: string | null = null;
if (isImageProperties(label)) {
text = label.ariaLabel ?? label.alt;
} else if (
typeof HTMLElement !== 'undefined' &&
label instanceof HTMLElement
) {
// This chain is similar to getText_, but prioritizes ariaLabel over title.
text = label.ariaLabel ?? (label.title || label.innerText);
} else if (typeof label === 'string') {
text = label;
}
if (text && text.trim()) {
return text;
}
// If we can't find any text to use for the ARIA label, use the option index.
return Msg['FIELD_LABEL_OPTION_INDEX'].replace('%1', String(index + 1));
}
}
/**
@@ -850,6 +1022,7 @@ export interface ImageProperties {
alt: string;
width: number;
height: number;
ariaLabel?: string;
}
/**
@@ -860,7 +1033,7 @@ export interface ImageProperties {
* the language-neutral value.
*/
export type MenuOption =
| [string | ImageProperties | HTMLElement, string]
| [string | ImageProperties | HTMLElement, string, string?]
| 'separator';
/**
+113 -1
View File
@@ -13,6 +13,8 @@
import {Field, FieldConfig} from './field.js';
import * as fieldRegistry from './field_registry.js';
import {Msg} from './msg.js';
import {aria} from './utils.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js';
import {Size} from './utils/size.js';
@@ -111,9 +113,18 @@ export class FieldImage extends Field<string> {
if (config) {
this.configure_(config);
} else if (isFieldImageConfig(alt)) {
// Block Factory and some hand-written blocks pass a config object as the
// fourth argument instead of using the seventh `config` parameter.
// This is wrong, and typescript will complain about it, but handle it
// for backwards compatibility.
this.configure_(alt);
} else {
this.flipRtl = !!flipRtl;
this.altText = parsing.replaceMessageReferences(alt) || '';
this.altText =
typeof alt === 'string'
? parsing.replaceMessageReferences(alt) || ''
: '';
}
this.setValue(parsing.replaceMessageReferences(src));
}
@@ -157,6 +168,8 @@ export class FieldImage extends Field<string> {
if (this.clickHandler) {
this.imageElement.style.cursor = 'pointer';
}
this.recomputeAriaContext();
}
override updateSize_() {}
@@ -186,6 +199,7 @@ export class FieldImage extends Field<string> {
if (this.imageElement) {
this.imageElement.setAttributeNS(dom.XLINK_NS, 'xlink:href', this.value_);
}
this.recomputeAriaContext();
}
/**
@@ -283,6 +297,104 @@ export class FieldImage extends Field<string> {
options,
);
}
/**
* Gets an ARIA-friendly label representation of this field's type.
*
* Implementations are responsible for, and encouraged to, return a localized
* version of the ARIA representation of the field's type.
*
* @returns An ARIA representation of the field's type or a default if it is
* unspecified.
*/
override getAriaTypeName(): string | null {
return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_IMAGE'];
}
/**
* Gets an ARIA-friendly label representation of this field's value.
*
* Implementations are responsible for, and encouraged to, return a localized
* version of the ARIA representation of the field's value.
*
* @returns An ARIA representation of the field's text, or null if no text is
* currently defined or known for the field.
*/
override getAriaValue(): string | null {
return this.altText || null;
}
/**
* Computes a descriptive ARIA label to represent this field with configurable
* verbosity.
*
* A 'verbose' label includes type information, if available, whereas a
* non-verbose label only contains the field's value.
*
* Note that this will always return the latest representation of the field's
* label which may differ from any previously set ARIA label for the field
* itself. Implementations are largely responsible for ensuring that the
* field's ARIA label is set correctly at relevant moments in the field's
* lifecycle (such as when its value changes).
*
* Finally, it is never guaranteed that implementations use the label returned
* by this method for their actual ARIA label. Some implementations may rely
* on other contexts to convey information like the field's value. Example:
* checkboxes represent their checked/non-checked status (i.e. value) through
* a separate ARIA property.
*
* Returns an empty string on clickable images (buttons), as we do not want to
* include image buttons on the block-level ARIA label. When the button is
* focused the label is set in recomputeAriaContext below.
*
* @param includeTypeInfo Whether to include the field's type information in
* the returned label, if available.
*/
override computeAriaLabel(includeTypeInfo: boolean): string {
return this.isClickable() ? '' : super.computeAriaLabel(includeTypeInfo);
}
/**
* Customizes label and sets additional aria state.
*/
override recomputeAriaContext(): boolean {
const shouldCustomize = super.recomputeAriaContext();
if (!shouldCustomize) return false;
const focusableElement = this.getFocusableElement();
// 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.
if (!this.isClickable()) {
aria.setRole(focusableElement, aria.Role.PRESENTATION);
aria.clearState(focusableElement, aria.State.LABEL);
return false;
}
// For clickable images we need to set the label to the alt text here as
// we have overridden the computeAriaLabel to return an empty string. This
// will set it at the element level.
const label = this.getAriaValue() || '';
aria.setState(focusableElement, aria.State.LABEL, label);
return true;
}
}
/**
* Returns whether a value is a FieldImage config object passed in place of alt
* text (e.g. `{alt: '*', flipRtl: false}`). You shouldn't do this on purpose,
* but the block factory generates block definitions in this format.
*
* @param value The value to test.
*/
function isFieldImageConfig(value: unknown): value is FieldImageConfig {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
('alt' in value || 'flipRtl' in value)
);
}
fieldRegistry.register('field_image', FieldImage);
+75 -11
View File
@@ -53,7 +53,7 @@ const MINIMUM_WIDTH = 14;
/**
* Abstract class for an editable input field.
*
* @typeParam T - The value stored on the field.
* @template T - The value stored on the field.
* @internal
*/
export abstract class FieldInput<T extends InputTypes> extends Field<
@@ -166,7 +166,15 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
override initView() {
const block = this.getSourceBlock();
if (!block) throw new UnattachedFieldError();
super.initView();
if (!this.isFullBlockField()) {
// Full-block fields don't get the border-rect element.
this.createBorderRect_();
}
this.createTextElement_();
if (this.fieldGroup_) {
dom.addClass(this.fieldGroup_, 'blocklyField');
}
if (this.isFullBlockField()) {
this.clickTarget_ = (this.sourceBlock_ as BlockSvg).getSvgRoot();
@@ -175,6 +183,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
if (this.fieldGroup_) {
dom.addClass(this.fieldGroup_, 'blocklyInputField');
}
this.recomputeAriaContext();
}
override isFullBlockField(): boolean {
@@ -224,6 +233,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
);
}
}
this.recomputeAriaContext();
}
/**
@@ -238,6 +248,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
this.isDirty_ = true;
this.isTextValid_ = true;
this.value_ = newValue;
this.recomputeAriaContext();
}
/**
@@ -251,10 +262,9 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
if (!this.fieldGroup_) return;
if (!this.isFullBlockField() && this.borderRect_) {
this.borderRect_!.style.display = 'block';
this.borderRect_.style.display = 'block';
this.borderRect_.setAttribute('stroke', block.getColourTertiary());
} else {
this.borderRect_!.style.display = 'none';
// In general, do *not* let fields control the color of blocks. Having the
// field control the color is unexpected, and could have performance
// impacts.
@@ -600,16 +610,20 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
dropDownDiv.hideWithoutAnimation();
} else if (e.key === 'Tab') {
e.preventDefault();
const cursor = this.workspace_?.getCursor();
const navigator = this.workspace_?.getNavigator();
const isValidDestination = (node: IFocusableNode | null) =>
(node instanceof FieldInput ||
(node instanceof BlockSvg && node.isSimpleReporter())) &&
node !== this.getSourceBlock();
let target = e.shiftKey
? cursor?.getPreviousNode(this, isValidDestination, false)
: cursor?.getNextNode(this, isValidDestination, false);
// eslint-disable-next-line @typescript-eslint/no-this-alias
let target: IFocusableNode | null | undefined = this;
do {
target = e.shiftKey
? navigator?.getOutNode(target)
: navigator?.getInNode(target);
} while (target && !isValidDestination(target));
target =
target instanceof BlockSvg && target.isSimpleReporter()
? target.getFields().next().value
@@ -625,7 +639,9 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
targetSourceBlock instanceof BlockSvg
) {
getFocusManager().focusNode(targetSourceBlock);
} else getFocusManager().focusNode(target);
} else {
getFocusManager().focusNode(target);
}
target.showEditor();
}
}
@@ -702,8 +718,15 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
// In RTL mode block fields and LTR input fields the left edge moves,
// whereas the right edge is fixed. Reposition the editor.
const x = block.RTL ? bBox.right - div!.offsetWidth : bBox.left;
const y = bBox.top;
let x = block.RTL ? bBox.right - div!.offsetWidth : bBox.left;
let y = bBox.top;
const parentElement = div?.parentElement;
if (parentElement) {
const bounds = parentElement.getBoundingClientRect();
x -= bounds.left + window.scrollX;
y -= bounds.top + window.scrollY;
}
div!.style.left = `${x}px`;
div!.style.top = `${y}px`;
@@ -794,6 +817,47 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
protected getValueFromEditorText_(text: string): AnyDuringMigration {
return text;
}
/**
* Gets an ARIA-friendly label representation of this field's type.
*
* Implementations are responsible for, and encouraged to, return a localized
* version of the ARIA representation of the field's type.
*
* @returns An ARIA representation of the field's type or a default if it is
* unspecified.
*/
override getAriaTypeName(): string | null {
return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_INPUT'];
}
/**
* Gets an ARIA-friendly label representation of this field's value.
*
* Implementations are responsible for, and encouraged to, return a localized
* version of the ARIA representation of the field's value.
*
* @returns An ARIA representation of the field's text.
*/
override getAriaValue(): string | null {
return this.getText() || Msg['FIELD_LABEL_EMPTY'];
}
/**
* Customizes the label for this field to include "editable" if it applies.
*/
override recomputeAriaContext(): boolean {
const shouldCustomize = super.recomputeAriaContext();
if (!shouldCustomize) return false;
const focusableElement = this.getFocusableElement();
let label = this.computeAriaLabel(true);
if (this.isCurrentlyEditable() && !this.getSourceBlock()?.isInFlyout) {
label = Msg['FIELD_LABEL_EDIT_PREFIX'].replace('%1', label);
}
aria.setState(focusableElement, aria.State.LABEL, label);
return true;
}
}
/**
+38
View File
@@ -14,6 +14,7 @@
import {Field, FieldConfig} from './field.js';
import * as fieldRegistry from './field_registry.js';
import {aria} from './utils.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js';
@@ -76,9 +77,46 @@ export class FieldLabel extends Field<string> {
}
if (this.fieldGroup_) {
dom.addClass(this.fieldGroup_, 'blocklyLabelField');
aria.setState(this.fieldGroup_, aria.State.HIDDEN, true);
}
}
/**
* Computes a descriptive ARIA label to represent this field with configurable
* verbosity.
*
* A 'verbose' label includes type information, if available, whereas a
* non-verbose label only contains the field's value.
*
* Note that this will always return the latest representation of the field's
* label which may differ from any previously set ARIA label for the field
* itself. Implementations are largely responsible for ensuring that the
* field's ARIA label is set correctly at relevant moments in the field's
* lifecycle (such as when its value changes).
*
* Finally, it is never guaranteed that implementations use the label returned
* by this method for their actual ARIA label. Some implementations may rely
* on other contexts to convey information like the field's value. Example:
* checkboxes represent their checked/non-checked status (i.e. value) through
* a separate ARIA property.
*
* Unlike other built-in fields, FieldLabel does return an empty string when its
* value is empty. This is because empty labels are sometimes used for layout
* purposes.
*
* @param includeTypeInfo Whether to include the field's type information in
* the returned label, if available.
*/
override computeAriaLabel(includeTypeInfo: boolean = true): string {
const ariaTypeName = includeTypeInfo ? this.getAriaTypeName() : null;
const ariaValue = this.getAriaValue() ?? '';
if (ariaTypeName) {
return `${ariaTypeName}: ${ariaValue}`;
}
return ariaValue;
}
/**
* Ensure that the input value casts to a valid string.
*
+14 -3
View File
@@ -18,7 +18,7 @@ import {
FieldInputValidator,
} from './field_input.js';
import * as fieldRegistry from './field_registry.js';
import * as aria from './utils/aria.js';
import {Msg} from './msg.js';
import * as dom from './utils/dom.js';
/**
@@ -299,11 +299,9 @@ export class FieldNumber extends FieldInput<number> {
// Set the accessibility state
if (this.min_ > -Infinity) {
htmlInput.min = `${this.min_}`;
aria.setState(htmlInput, aria.State.VALUEMIN, this.min_);
}
if (this.max_ < Infinity) {
htmlInput.max = `${this.max_}`;
aria.setState(htmlInput, aria.State.VALUEMAX, this.max_);
}
return htmlInput;
}
@@ -341,6 +339,19 @@ export class FieldNumber extends FieldInput<number> {
options,
);
}
/**
* Gets an ARIA-friendly label representation of this field's type.
*
* Implementations are responsible for, and encouraged to, return a localized
* version of the ARIA representation of the field's type.
*
* @returns An ARIA representation of the field's type or a default if it is
* unspecified.
*/
override getAriaTypeName(): string | null {
return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_NUMBER'];
}
}
fieldRegistry.register('field_number', FieldNumber);
+14
View File
@@ -21,6 +21,7 @@ import {
FieldInputValidator,
} from './field_input.js';
import * as fieldRegistry from './field_registry.js';
import {Msg} from './msg.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js';
@@ -89,6 +90,19 @@ export class FieldTextInput extends FieldInput<string> {
// override the static fromJson method.
return new this(text, undefined, options);
}
/**
* Gets an ARIA-friendly label representation of this field's type.
*
* Implementations are responsible for, and encouraged to, return a localized
* version of the ARIA representation of the field's type.
*
* @returns An ARIA representation of the field's type or a default if it is
* unspecified.
*/
override getAriaTypeName(): string | null {
return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_TEXT_INPUT'];
}
}
fieldRegistry.register('field_input', FieldTextInput);
+13 -1
View File
@@ -605,7 +605,7 @@ export class FieldVariable extends FieldDropdown {
];
}
options.push([
Msg['RENAME_VARIABLE'],
Msg['RENAME_VARIABLE'].replace('%1', name),
internalConstants.RENAME_VARIABLE_ID,
]);
if (Msg['DELETE_VARIABLE']) {
@@ -617,6 +617,18 @@ export class FieldVariable extends FieldDropdown {
return options;
}
/**
* Gets an ARIA-friendly label representation of this field's value.
*
* Implementations are responsible for, and encouraged to, return a localized
* version of the ARIA representation of the field's value.
*
* @returns An ARIA representation of the field's text.
*/
override getAriaValue(): string {
// Example: 'Variable "i"'
return Msg['FIELD_LABEL_VARIABLE'].replace('%1', super.getAriaValue());
}
}
fieldRegistry.register('field_variable', FieldVariable);
+76 -217
View File
@@ -11,7 +11,6 @@
*/
// Former goog.module ID: Blockly.Flyout
import {BlockSvg} from './block_svg.js';
import * as browserEvents from './browser_events.js';
import {ComponentManager} from './component_manager.js';
import {DeleteArea} from './delete_area.js';
@@ -20,20 +19,20 @@ import {EventType} from './events/type.js';
import * as eventUtils from './events/utils.js';
import {FlyoutItem} from './flyout_item.js';
import {FlyoutMetricsManager} from './flyout_metrics_manager.js';
import {FlyoutNavigator} from './flyout_navigator.js';
import {FlyoutSeparator, SeparatorAxis} from './flyout_separator.js';
import {getFocusManager} from './focus_manager.js';
import {IAutoHideable} from './interfaces/i_autohideable.js';
import type {IFlyout} from './interfaces/i_flyout.js';
import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js';
import {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
import {isSelectableToolboxItem} from './interfaces/i_selectable_toolbox_item.js';
import {FlyoutNavigator} from './keyboard_nav/navigators/flyout_navigator.js';
import {Msg} from './msg.js';
import type {Options} from './options.js';
import * as registry from './registry.js';
import * as renderManagement from './render_management.js';
import {ScrollbarPair} from './scrollbar_pair.js';
import {SEPARATOR_TYPE} from './separator_flyout_inflater.js';
import * as blocks from './serialization/blocks.js';
import {Coordinate} from './utils/coordinate.js';
import * as aria from './utils/aria.js';
import * as dom from './utils/dom.js';
import * as idGenerator from './utils/idgenerator.js';
import {Svg} from './utils/svg.js';
@@ -45,24 +44,13 @@ import {WorkspaceSvg} from './workspace_svg.js';
*/
export abstract class Flyout
extends DeleteArea
implements IAutoHideable, IFlyout, IFocusableNode
implements IAutoHideable, IFlyout
{
/**
* Position the flyout.
*/
abstract position(): void;
/**
* Determine if a drag delta is toward the workspace, based on the position
* and orientation of the flyout. This is used in determineDragIntention_ to
* determine if a new block should be created or if the flyout should scroll.
*
* @param currentDragDeltaXY How far the pointer has
* moved from the position at mouse down, in pixel units.
* @returns True if the drag is toward the workspace.
*/
abstract isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean;
/**
* Sets the translation of the flyout to match the scrollbars.
*
@@ -191,29 +179,6 @@ export abstract class Flyout
* Height of flyout.
*/
protected height_ = 0;
// clang-format off
/**
* Range of a drag angle from a flyout considered "dragging toward
* workspace". Drags that are within the bounds of this many degrees from
* the orthogonal line to the flyout edge are considered to be "drags toward
* the workspace".
*
* @example
*
* ```
* Flyout Edge Workspace
* [block] / <-within this angle, drags "toward workspace" |
* [block] ---- orthogonal to flyout boundary ---- |
* [block] \ |
* ```
*
* The angle is given in degrees from the orthogonal.
*
* This is used to know when to create a new block and when to scroll the
* flyout. Setting it to 360 means that all drags create a new block.
*/
// clang-format on
protected dragAngleRange_ = 70;
/**
* The path around the background of the flyout, which will be filled with a
@@ -327,6 +292,17 @@ export abstract class Flyout
.getThemeManager()
.subscribe(this.svgBackground_, 'flyoutOpacity', 'fill-opacity');
this.svgGroup_.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') {
getFocusManager().focusTree(this.targetWorkspace);
if (!this.targetWorkspace.isMutator) {
this.autoHide(false);
}
e.preventDefault();
e.stopPropagation();
}
});
return this.svgGroup_;
}
@@ -339,6 +315,7 @@ export abstract class Flyout
init(targetWorkspace: WorkspaceSvg) {
this.targetWorkspace = targetWorkspace;
this.workspace_.targetWorkspace = targetWorkspace;
this.workspace_.setInitialAriaContext();
this.workspace_.scrollbar = new ScrollbarPair(
this.workspace_,
@@ -622,6 +599,16 @@ export abstract class Flyout
* toolbox definition, or a string with the name of the dynamic category.
*/
show(flyoutDef: toolbox.FlyoutDefinition | string) {
// As part of showing, the existing contents of the flyout will be cleared.
// If the focused element is a flyout item, i.e. a child of the workspace
// and not the workspace itself, move focus to the workspace to prevent
// focus from being lost when the currently focused element is destroyed.
if (
getFocusManager().getFocusedTree() === this.workspace_ &&
getFocusManager().getFocusedNode() !== this.workspace_
) {
getFocusManager().focusNode(this.workspace_);
}
this.workspace_.setResizesEnabled(false);
eventUtils.setRecordUndo(false);
this.hide();
@@ -649,6 +636,7 @@ export abstract class Flyout
this.width_ = 0;
}
this.reflow();
this.updateAriaContext();
eventUtils.setRecordUndo(true);
this.workspace_.setResizesEnabled(true);
@@ -667,6 +655,53 @@ export abstract class Flyout
this.workspace_.addChangeListener(this.reflowWrapper);
}
/**
* Updates the aria attributes for the entire flyout dom.
* This needs to do two things:
* 1. Set aria-owns on the flyout's workspace canvas to include the ids of all
* focusable elements in the flyout.
* 2. Update the aria attributes on the flyout's workspace. This can't be done at workspace
* creation because the workspace may not have all required information until the flyout
* is fully shown.
*/
protected updateAriaContext() {
// Set aria-owns on the flyout's workspace canvas to include the ids of all focusable elements in the flyout.
// This is probably not necessary if the listitems are all direct descendants of the canvas, but
// we can't know the dom structure of the flyout contents, so it's best to be explicit.
const focusableIds = this.getContents()
.map((item) => item.getElement())
.filter((item) => item.canBeFocused())
.map((item) => item.getFocusableElement().id);
aria.setState(
this.getWorkspace().getCanvas(),
aria.State.OWNS,
focusableIds.join(' '),
);
// Update aria attributes on the flyout's workspace.
// Only call a flyout's workspace a region if it's not auto-closing and not a mutator
if (!this.targetWorkspace.isMutator && !this.autoClose) {
aria.setRole(this.getWorkspace().svgGroup_, aria.Role.REGION);
} else {
aria.setRole(this.getWorkspace().svgGroup_, aria.Role.PRESENTATION);
}
// the label for a flyout includes the category name if it's available
const selectedItem = this.targetWorkspace.getToolbox()?.getSelectedItem();
const selectedItemName =
selectedItem && isSelectableToolboxItem(selectedItem)
? selectedItem.getName()
: '';
const ariaLabel = Msg['WORKSPACE_LABEL_FLYOUT_WORKSPACE']
.replace('%1', selectedItemName)
.trim();
aria.setState(this.getWorkspace().getCanvas(), aria.State.LABEL, ariaLabel);
// The block canvas is a list. The list items must be direct descendants of the list,
// and the flyout may or may not be a region, so we set the role on the block canvas rather than the svgGroup_.
aria.setRole(this.getWorkspace().getCanvas(), aria.Role.LIST);
}
/**
* Create the contents array and gaps array necessary to create the layout for
* the flyout.
@@ -790,47 +825,6 @@ export abstract class Flyout
}
}
/**
* Does this flyout allow you to create a new instance of the given block?
* Used for deciding if a block can be "dragged out of" the flyout.
*
* @param block The block to copy from the flyout.
* @returns True if you can create a new instance of the block, false
* otherwise.
* @internal
*/
isBlockCreatable(block: BlockSvg): boolean {
return block.isEnabled() && !this.getTargetWorkspace().isReadOnly();
}
/**
* Create a copy of this block on the workspace.
*
* @param originalBlock The block to copy from the flyout.
* @returns The newly created block.
* @throws {Error} if something went wrong with deserialization.
* @internal
*/
createBlock(originalBlock: BlockSvg): BlockSvg {
const targetWorkspace = this.targetWorkspace;
const svgRootOld = originalBlock.getSvgRoot();
if (!svgRootOld) {
throw Error('oldBlock is not rendered');
}
// Clone the block.
const json = this.serializeBlock(originalBlock);
// Normally this resizes leading to weird jumps. Save it for terminateDrag.
targetWorkspace.setResizesEnabled(false);
const block = blocks.appendInternal(json, targetWorkspace, {
recordUndo: true,
}) as BlockSvg;
this.positionNewBlock(originalBlock, block);
targetWorkspace.hideChaff();
return block;
}
/**
* Reflow flyout contents.
*/
@@ -851,59 +845,6 @@ export abstract class Flyout
: false;
}
/**
* Serialize a block to JSON.
*
* @param block The block to serialize.
* @returns A serialized representation of the block.
*/
protected serializeBlock(block: BlockSvg): blocks.State {
return blocks.save(block) as blocks.State;
}
/**
* Positions a block on the target workspace.
*
* @param oldBlock The flyout block being copied.
* @param block The block to posiiton.
*/
private positionNewBlock(oldBlock: BlockSvg, block: BlockSvg) {
const targetWorkspace = this.targetWorkspace;
// The offset in pixels between the main workspace's origin and the upper
// left corner of the injection div.
const mainOffsetPixels = targetWorkspace.getOriginOffsetInPixels();
// The offset in pixels between the flyout workspace's origin and the upper
// left corner of the injection div.
const flyoutOffsetPixels = this.workspace_.getOriginOffsetInPixels();
// The position of the old block in flyout workspace coordinates.
const oldBlockPos = oldBlock.getRelativeToSurfaceXY();
// The position of the old block in pixels relative to the flyout
// workspace's origin.
oldBlockPos.scale(this.workspace_.scale);
// The position of the old block in pixels relative to the upper left corner
// of the injection div.
const oldBlockOffsetPixels = Coordinate.sum(
flyoutOffsetPixels,
oldBlockPos,
);
// The position of the old block in pixels relative to the origin of the
// main workspace.
const finalOffset = Coordinate.difference(
oldBlockOffsetPixels,
mainOffsetPixels,
);
// The position of the old block in main workspace coordinates.
finalOffset.scale(1 / targetWorkspace.scale);
// No 'reason' provided since events are disabled.
block.moveTo(new Coordinate(finalOffset.x, finalOffset.y));
}
/**
* Returns the inflater responsible for constructing items of the given type.
*
@@ -928,86 +869,4 @@ export abstract class Flyout
return null;
}
/**
* See IFocusableNode.getFocusableElement.
*
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
*/
getFocusableElement(): HTMLElement | SVGElement {
throw new Error('Flyouts are not directly focusable.');
}
/**
* See IFocusableNode.getFocusableTree.
*
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
*/
getFocusableTree(): IFocusableTree {
throw new Error('Flyouts are not directly focusable.');
}
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {}
/** See IFocusableNode.onNodeBlur. */
onNodeBlur(): void {}
/** See IFocusableNode.canBeFocused. */
canBeFocused(): boolean {
return false;
}
/**
* See IFocusableNode.getRootFocusableNode.
*
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
*/
getRootFocusableNode(): IFocusableNode {
throw new Error('Flyouts are not directly focusable.');
}
/**
* See IFocusableNode.getRestoredFocusableNode.
*
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
*/
getRestoredFocusableNode(
_previousNode: IFocusableNode | null,
): IFocusableNode | null {
throw new Error('Flyouts are not directly focusable.');
}
/**
* See IFocusableNode.getNestedTrees.
*
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
*/
getNestedTrees(): Array<IFocusableTree> {
throw new Error('Flyouts are not directly focusable.');
}
/**
* See IFocusableNode.lookUpFocusableNode.
*
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
*/
lookUpFocusableNode(_id: string): IFocusableNode | null {
throw new Error('Flyouts are not directly focusable.');
}
/** See IFocusableTree.onTreeFocus. */
onTreeFocus(
_node: IFocusableNode,
_previousTree: IFocusableTree | null,
): void {}
/**
* See IFocusableNode.onTreeBlur.
*
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
*/
onTreeBlur(_nextTree: IFocusableTree | null): void {
throw new Error('Flyouts are not directly focusable.');
}
}
+36 -1
View File
@@ -13,11 +13,13 @@
import * as browserEvents from './browser_events.js';
import * as Css from './css.js';
import * as hints from './hints.js';
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
import type {IRenderedElement} from './interfaces/i_rendered_element.js';
import {idGenerator} from './utils.js';
import {Msg} from './msg.js';
import {aria, idGenerator} from './utils.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js';
@@ -134,6 +136,7 @@ export class FlyoutButton
},
this.svgGroup!,
);
aria.setRole(shadow, aria.Role.PRESENTATION);
}
// Background rectangle.
const rect = dom.createSvgElement(
@@ -147,6 +150,7 @@ export class FlyoutButton
},
this.svgGroup!,
);
aria.setRole(rect, aria.Role.PRESENTATION);
const svgText = dom.createSvgElement(
Svg.TEXT,
@@ -170,6 +174,13 @@ export class FlyoutButton
.getThemeManager()
.subscribe(this.svgText, 'flyoutForegroundColour', 'fill');
}
aria.setRole(svgText, aria.Role.PRESENTATION);
// We add the word "heading" or "button" to the label so that they give appropriate hints
// we can't use the corresponding roles because that overwrites the context of it being a list item.
const ariaLabel = `${text}, ${this.isFlyoutLabel ? Msg['ARIA_LABEL_HEADING'] : Msg['ARIA_LABEL_BUTTON']}`;
aria.setState(this.getFocusableElement(), aria.State.LABEL, ariaLabel);
aria.setRole(this.getFocusableElement(), aria.Role.LISTITEM);
const fontSize = style.getComputedStyle(svgText, 'fontSize');
const fontWeight = style.getComputedStyle(svgText, 'fontWeight');
@@ -414,6 +425,30 @@ export class FlyoutButton
canBeFocused(): boolean {
return true;
}
/**
* Returns the ID of this FlyoutButton.
*/
getId() {
return this.id;
}
/**
* Handles the user acting on this button via keyboard navigation.
* Invokes the click handler callback for buttons. For labels, which are not
* interactive, shows a toast directing the user to navigate using the arrow
* keys or the next-heading shortcut.
*/
performAction(): void {
if (this.isFlyoutLabel) {
hints.showFlyoutLabelActionHint(this.targetWorkspace);
return;
}
const callback = this.targetWorkspace.getButtonCallback(this.callbackKey);
if (callback) {
callback(this);
}
}
}
/** CSS for buttons and labels. See css.js for use. */
@@ -18,7 +18,6 @@ import type {FlyoutItem} from './flyout_item.js';
import type {Options} from './options.js';
import * as registry from './registry.js';
import {Scrollbar} from './scrollbar.js';
import type {Coordinate} from './utils/coordinate.js';
import {Rect} from './utils/rect.js';
import * as toolbox from './utils/toolbox.js';
import * as WidgetDiv from './widgetdiv.js';
@@ -271,32 +270,6 @@ export class HorizontalFlyout extends Flyout {
}
}
/**
* Determine if a drag delta is toward the workspace, based on the position
* and orientation of the flyout. This is used in determineDragIntention_ to
* determine if a new block should be created or if the flyout should scroll.
*
* @param currentDragDeltaXY How far the pointer has moved from the position
* at mouse down, in pixel units.
* @returns True if the drag is toward the workspace.
*/
override isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean {
const dx = currentDragDeltaXY.x;
const dy = currentDragDeltaXY.y;
// Direction goes from -180 to 180, with 0 toward the right and 90 on top.
const dragDirection = (Math.atan2(dy, dx) / Math.PI) * 180;
const range = this.dragAngleRange_;
// Check for up or down dragging.
if (
(dragDirection < 90 + range && dragDirection > 90 - range) ||
(dragDirection > -90 - range && dragDirection < -90 + range)
) {
return true;
}
return false;
}
/**
* Returns the bounding rectangle of the drag target area in pixel units
* relative to viewport.
-24
View File
@@ -1,24 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {IFlyout} from './interfaces/i_flyout.js';
import {FlyoutButtonNavigationPolicy} from './keyboard_nav/flyout_button_navigation_policy.js';
import {FlyoutNavigationPolicy} from './keyboard_nav/flyout_navigation_policy.js';
import {FlyoutSeparatorNavigationPolicy} from './keyboard_nav/flyout_separator_navigation_policy.js';
import {Navigator} from './navigator.js';
export class FlyoutNavigator extends Navigator {
constructor(flyout: IFlyout) {
super();
this.rules.push(
new FlyoutButtonNavigationPolicy(),
new FlyoutSeparatorNavigationPolicy(),
);
this.rules = this.rules.map(
(rule) => new FlyoutNavigationPolicy(rule, flyout),
);
}
}
-28
View File
@@ -18,7 +18,6 @@ import type {FlyoutItem} from './flyout_item.js';
import type {Options} from './options.js';
import * as registry from './registry.js';
import {Scrollbar} from './scrollbar.js';
import type {Coordinate} from './utils/coordinate.js';
import {Rect} from './utils/rect.js';
import * as toolbox from './utils/toolbox.js';
import * as WidgetDiv from './widgetdiv.js';
@@ -235,33 +234,6 @@ export class VerticalFlyout extends Flyout {
}
}
/**
* Determine if a drag delta is toward the workspace, based on the position
* and orientation of the flyout. This is used in determineDragIntention_ to
* determine if a new block should be created or if the flyout should scroll.
*
* @param currentDragDeltaXY How far the pointer has moved from the position
* at mouse down, in pixel units.
* @returns True if the drag is toward the workspace.
*/
override isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean {
const dx = currentDragDeltaXY.x;
const dy = currentDragDeltaXY.y;
// Direction goes from -180 to 180, with 0 toward the right and 90 on top.
const dragDirection = (Math.atan2(dy, dx) / Math.PI) * 180;
const range = this.dragAngleRange_;
// Check for left or right dragging.
if (
(dragDirection < range && dragDirection > -range) ||
dragDirection < -180 + range ||
dragDirection > 180 - range
) {
return true;
}
return false;
}
/**
* Returns the bounding rectangle of the drag target area in pixel units
* relative to viewport.
+79 -5
View File
@@ -11,11 +11,15 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
/**
* Type declaration for returning focus to FocusManager upon completing an
* ephemeral UI flow (such as a dialog).
* ephemeral UI flow (such as a dialog). Normally, the FocusManager will refocus
* the previously-focused element. If callers do not wish for the FocusManager
* to do so, they may call this method with `restoreFocus` set to false to
* prevent automatic refocusing and leave focus where it is.
*
*
* See FocusManager.takeEphemeralFocus for more details.
*/
export type ReturnEphemeralFocus = () => void;
export type ReturnEphemeralFocus = (restoreFocus?: boolean) => void;
/**
* Represents an IFocusableTree that has been registered for focus management in
@@ -83,6 +87,33 @@ export class FocusManager {
private recentlyLostAllFocus: boolean = false;
private isUpdatingFocusedNode: boolean = false;
/**
* Root element in which popovers (WidgetDiv, DropDownDiv) currently live.
*/
private popoverFocusRoot?: HTMLElement;
/**
* Set of callbacks to invoke if the popover focus root loses focus.
*/
private popoverFocusLossHandlers: Set<() => void> = new Set();
/**
* Handler for focusout in the popover focus root that selectively
* invokes the popover focus loss handlers if focus has truly transitioned
* outside of the focus root, and not e.g. to a different popover.
*/
private popoverFocusOutHandler = (e: FocusEvent) => {
const target = e.relatedTarget;
if (
target === null ||
(target instanceof Node && !this.popoverFocusRoot?.contains(target))
) {
for (const handler of this.popoverFocusLossHandlers) {
handler();
}
}
};
constructor(
addGlobalEventListener: (type: string, listener: EventListener) => void,
) {
@@ -446,7 +477,7 @@ export class FocusManager {
focusableElement.focus({preventScroll: true});
let hasFinishedEphemeralFocus = false;
return () => {
return (restoreFocus = true) => {
if (hasFinishedEphemeralFocus) {
throw Error(
`Attempted to finish ephemeral focus twice for element: ` +
@@ -455,8 +486,7 @@ export class FocusManager {
}
hasFinishedEphemeralFocus = true;
this.currentlyHoldsEphemeralFocus = false;
if (this.focusedNode) {
if (this.focusedNode && restoreFocus) {
this.activelyFocusNode(this.focusedNode, null);
// Even though focus was restored, check if it's lost again. It's
@@ -667,6 +697,50 @@ export class FocusManager {
}
return FocusManager.focusManager;
}
/**
* Sets the current popover focus root. Generally this is active
* workspace's injection div or the explicitly specified parent container for
* the WidgetDiv, DropDownDiv, etc.
*
* @internal
* @param newRoot The new element that contains all popovers.
*/
setPopoverFocusRoot(newRoot: HTMLElement) {
this.popoverFocusRoot?.removeEventListener(
'focusout',
this.popoverFocusOutHandler,
);
this.popoverFocusRoot = newRoot;
this.popoverFocusRoot.addEventListener(
'focusout',
this.popoverFocusOutHandler,
);
}
/**
* Registers a callback to be invoked if the popover focus root loses
* focus. This should only be called by popovers that need to react to
* focus changes by e.g. hiding themselves and resigning ephemeral focus.
*
* @internal
* @param handler A callback function.
*/
registerPopoverFocusLossHandler(handler: () => void) {
this.popoverFocusLossHandlers.add(handler);
}
/**
* Unregisters a previously-registered popover focus loss handler. This
* should only be invoked by popovers when they no longer need to be
* notified of focus loss, typically when they are hidden.
*
* @internal
* @param handler A previously-registered callback function.
*/
unregisterPopoverFocusLossHandler(handler: () => void) {
this.popoverFocusLossHandlers.delete(handler);
}
}
/** Convenience function for FocusManager.getFocusManager. */
+23 -54
View File
@@ -34,9 +34,11 @@ import type {IFlyout} from './interfaces/i_flyout.js';
import type {IIcon} from './interfaces/i_icon.js';
import {keyboardNavigationController} from './keyboard_navigation_controller.js';
import * as registry from './registry.js';
import * as blocks from './serialization/blocks.js';
import * as Tooltip from './tooltip.js';
import * as Touch from './touch.js';
import {Coordinate} from './utils/coordinate.js';
import * as svgMath from './utils/svg_math.js';
import {WorkspaceDragger} from './workspace_dragger.js';
import type {WorkspaceSvg} from './workspace_svg.js';
@@ -255,40 +257,6 @@ export class Gesture {
return false;
}
/**
* Update this gesture to record whether a block is being dragged from the
* flyout.
* This function should be called on a pointermove event the first time
* the drag radius is exceeded. It should be called no more than once per
* gesture. If a block should be dragged from the flyout this function creates
* the new block on the main workspace and updates targetBlock_ and
* startWorkspace_.
*
* @returns True if a block is being dragged from the flyout.
*/
private updateIsDraggingFromFlyout(): boolean {
if (!this.targetBlock || !this.flyout?.isBlockCreatable(this.targetBlock)) {
return false;
}
if (!this.flyout.targetWorkspace) {
throw new Error(`Cannot update dragging from the flyout because the ' +
'flyout's target workspace is undefined`);
}
this.startWorkspace_ = this.flyout.targetWorkspace;
this.startWorkspace_.updateScreenCalculationsIfScrolled();
// Start the event group now, so that the same event group is used for
// block creation and block dragging.
if (!eventUtils.getGroup()) {
eventUtils.setGroup(true);
}
// The start block is no longer relevant, because this is a drag.
this.startBlock = null;
this.targetBlock = this.flyout.createBlock(this.targetBlock);
getFocusManager().focusNode(this.targetBlock);
return true;
}
/**
* Check whether to start a workspace drag. If a workspace is being dragged,
* create the necessary WorkspaceDragger and start the drag.
@@ -335,14 +303,10 @@ export class Gesture {
}
this.calledUpdateIsDragging = true;
// If we drag a block out of the flyout, it updates `common.getSelected`
// to return the new block.
if (this.flyout) this.updateIsDraggingFromFlyout();
const selected = common.getSelected();
if (selected && isDraggable(selected) && selected.isMovable()) {
this.dragging = true;
this.dragger = this.createDragger(selected, this.startWorkspace_);
this.dragger = this.createDragger(selected);
this.dragger.onDragStart(e);
this.dragger.onDrag(e, this.currentDragDeltaXY);
} else {
@@ -350,16 +314,13 @@ export class Gesture {
}
}
private createDragger(
draggable: IDraggable,
workspace: WorkspaceSvg,
): IDragger {
private createDragger(draggable: IDraggable): IDragger {
const DraggerClass = registry.getClassFromOptions(
registry.Type.BLOCK_DRAGGER,
this.creatorWorkspace.options,
true,
);
return new DraggerClass!(draggable, workspace);
return new DraggerClass!(draggable);
}
/**
@@ -896,16 +857,24 @@ export class Gesture {
'Cannot do a block click because the target block is ' + 'undefined',
);
}
if (this.flyout.isBlockCreatable(this.targetBlock)) {
if (!eventUtils.getGroup()) {
eventUtils.setGroup(true);
}
const newBlock = this.flyout.createBlock(this.targetBlock);
newBlock.snapToGrid();
newBlock.bumpNeighbours();
// If a new block was added, make sure that it's correctly focused.
getFocusManager().focusNode(newBlock);
const json = blocks.save(this.targetBlock);
const targetWorkspace = this.flyout.targetWorkspace;
if (json && targetWorkspace) {
const screenCoordinate = svgMath.wsToScreenCoordinates(
this.flyout.getWorkspace(),
this.targetBlock.getRelativeToSurfaceXY(),
);
const workspaceCoordinates = svgMath.screenToWsCoordinates(
targetWorkspace,
screenCoordinate,
);
json.x = workspaceCoordinates.x;
json.y = workspaceCoordinates.y;
blocks.appendInternal(json, targetWorkspace, {
recordUndo: true,
});
targetWorkspace.hideChaff(false);
}
} else {
if (!this.startWorkspace_) {
@@ -1035,8 +1004,8 @@ export class Gesture {
this.setTargetBlock(block.getParent()!);
} else {
this.targetBlock = block;
getFocusManager().focusNode(this.targetBlock);
this.targetBlock.bringToFront();
getFocusManager().focusNode(this.targetBlock);
}
}
+193
View File
@@ -0,0 +1,193 @@
/**
* @license
* Copyright 2026 Raspberry Pi Foundation
* SPDX-License-Identifier: Apache-2.0
*/
import {Msg} from './msg.js';
import {names} from './shortcut_items.js';
import {Toast} from './toast.js';
import {getShortcutKeysShort} from './utils/shortcut_formatting.js';
import * as userAgent from './utils/useragent.js';
import type {WorkspaceSvg} from './workspace_svg.js';
const unconstrainedMoveHintId = 'unconstrainedMoveHint';
const constrainedMoveHintId = 'constrainedMoveHint';
const helpHintId = 'helpHint';
const blockNavigationHintId = 'blockNavigationHint';
const workspaceNavigationHintId = 'workspaceNavigationHint';
const flyoutLabelHintId = 'flyoutLabelHint';
const copiedHintId = 'copiedHint';
const cutHintId = 'cutHint';
const screenreaderHintId = 'screenreaderHint';
/**
* Nudge the user to use unconstrained movement.
*
* @param workspace Workspace.
* @param force Set to show it even if previously shown.
*/
export function showUnconstrainedMoveHint(
workspace: WorkspaceSvg,
force = false,
) {
const modifier =
userAgent.MAC || userAgent.IPAD || userAgent.IPHONE
? Msg['COMMAND_KEY']
: Msg['CONTROL_KEY'];
const message = Msg['KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT']
.replace('%1', modifier)
.replace('%2', Msg['ENTER_KEY']);
Toast.show(workspace, {
message,
id: unconstrainedMoveHintId,
oncePerSession: !force,
});
}
/**
* Nudge the user to move a block that's in move mode.
*
* @param workspace Workspace.
*/
export function showConstrainedMovementHint(workspace: WorkspaceSvg) {
const message = Msg['KEYBOARD_NAV_CONSTRAINED_MOVE_HINT'].replace(
'%1',
Msg['ENTER_KEY'],
);
Toast.show(workspace, {
message,
id: constrainedMoveHintId,
oncePerSession: true,
});
}
/**
* Clear active move-related hints, if any.
*
* @param workspace The workspace.
*/
export function clearMoveHints(workspace: WorkspaceSvg) {
Toast.hide(workspace, constrainedMoveHintId);
Toast.hide(workspace, unconstrainedMoveHintId);
}
/**
* Nudge the user to open the help.
*
* @param workspace The workspace.
*/
export function showHelpHint(workspace: WorkspaceSvg) {
const shortcut = getShortcutKeysShort('list_shortcuts');
if (!shortcut) return;
const message = Msg['HELP_PROMPT'].replace('%1', shortcut);
const id = helpHintId;
Toast.show(workspace, {message, id});
}
/**
* Tell the user how to navigate inside blocks.
*
* @param workspace The workspace.
*/
export function showBlockNavigationHint(workspace: WorkspaceSvg) {
const shortcut = getShortcutKeysShort(
workspace.RTL ? names.NAVIGATE_LEFT : names.NAVIGATE_RIGHT,
);
const message = Msg['KEYBOARD_NAV_BLOCK_NAVIGATION_HINT'].replace(
'%1',
shortcut,
);
const id = blockNavigationHintId;
Toast.show(workspace, {message, id});
}
/**
* Tell the user how to navigate inside the workspace.
*
* @param workspace The workspace.
*/
export function showWorkspaceNavigationHint(workspace: WorkspaceSvg) {
const message = Msg['KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT'];
const id = workspaceNavigationHintId;
Toast.show(workspace, {message, id});
}
/**
* Tell the user how to navigate away from a flyout label (heading) when they
* try to act on it. Labels are not interactive, so direct them to use the
* arrow keys to reach a block or the next-heading shortcut to skip ahead.
*
* @param workspace The workspace.
*/
export function showFlyoutLabelActionHint(workspace: WorkspaceSvg) {
const message = Msg['KEYBOARD_NAV_FLYOUT_LABEL_HINT'].replace(
'%1',
getShortcutKeysShort(names.NEXT_HEADING),
);
Toast.show(workspace, {message, id: flyoutLabelHintId});
}
/**
* Nudge the user to paste after a copy.
*
* @param workspace Workspace.
*/
export function showCopiedHint(workspace: WorkspaceSvg) {
Toast.show(workspace, {
message: Msg['KEYBOARD_NAV_COPIED_HINT'].replace(
'%1',
getShortcutKeysShort(names.PASTE),
),
duration: 7,
id: copiedHintId,
});
}
/**
* Nudge the user to paste after a cut.
*
* @param workspace Workspace.
*/
export function showCutHint(workspace: WorkspaceSvg) {
Toast.show(workspace, {
message: Msg['KEYBOARD_NAV_CUT_HINT'].replace(
'%1',
getShortcutKeysShort(names.PASTE),
),
duration: 7,
id: cutHintId,
});
}
/**
* Clear active paste-related hints, if any.
*
* @param workspace The workspace.
*/
export function clearPasteHints(workspace: WorkspaceSvg) {
Toast.hide(workspace, cutHintId);
Toast.hide(workspace, copiedHintId);
}
/**
* Inform the user about screenreader optimization mode being toggled, and how
* to undo it.
*
* @param workspace The workspace where screenreader mode was toggled.
* @param enabled True if screenreader mode is now enabled, otherwise false.
*/
export function showScreenreaderModeHint(
workspace: WorkspaceSvg,
enabled: boolean,
) {
Toast.show(workspace, {
message: (enabled
? Msg['SCREENREADER_MODE_ENABLED']
: Msg['SCREENREADER_MODE_DISABLED']
).replace('%1', getShortcutKeysShort(names.TOGGLE_SCREENREADER)),
duration: 7,
id: screenreaderHintId,
});
}
@@ -13,6 +13,7 @@ import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import type {ISerializable} from '../interfaces/i_serializable.js';
import {Msg} from '../msg.js';
import * as renderManagement from '../render_management.js';
import {Coordinate} from '../utils.js';
import * as dom from '../utils/dom.js';
@@ -336,6 +337,9 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
'comment',
),
);
if (this.svgRoot) {
this.recomputeAriaContext();
}
}
/** See IHasBubble.getBubble. */
@@ -366,6 +370,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
this.getBubbleOwnerRect(),
this,
);
this.textInputBubble.getEditor().setParent(this.sourceBlock as BlockSvg);
this.textInputBubble.setText(this.getText());
this.textInputBubble.setSize(this.bubbleSize, true);
if (this.bubbleLocation) {
@@ -376,6 +381,9 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
this.textInputBubble.addLocationChangeListener(() =>
this.onBubbleLocationChange(),
);
this.textInputBubble.setAriaLabelProvider(() =>
Msg['BUBBLE_LABEL_COMMENT'].replace('%1', this.getText()),
);
}
/** Hides any open bubbles owned by this comment. */
@@ -403,6 +411,19 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
private getBubbleOwnerRect(): Rect {
return (this.sourceBlock as BlockSvg).getBoundingRectangleWithoutChildren();
}
/**
* Returns the ARIA label to use for this icon (defaults to null). Note that this
* method will only be called during initialization by default, so dynamic changes
* to the icon's ARIA label need to be applied by calling recomputeAriaContext.
*
* @returns The ARIA label to use for this icon, or null to use a default.
*/
protected override getAriaLabel(): string | null {
return this.bubbleIsVisible()
? Msg['ICON_LABEL_COMMENT_OPEN']
: Msg['ICON_LABEL_COMMENT_CLOSED'];
}
}
/** The save state format for a comment icon. */
+45
View File
@@ -7,11 +7,15 @@
import type {Block} from '../block.js';
import type {BlockSvg} from '../block_svg.js';
import * as browserEvents from '../browser_events.js';
import {getFocusManager} from '../focus_manager.js';
import type {IContextMenu} from '../interfaces/i_contextmenu.js';
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
import {hasBubble} from '../interfaces/i_has_bubble.js';
import type {IIcon} from '../interfaces/i_icon.js';
import {Msg} from '../msg.js';
import * as renderManagement from '../render_management.js';
import * as tooltip from '../tooltip.js';
import {aria} from '../utils.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
import * as idGenerator from '../utils/idgenerator.js';
@@ -73,6 +77,7 @@ export abstract class Icon implements IIcon, IContextMenu {
);
(this.svgRoot as any).tooltip = this;
tooltip.bindMouseEvents(this.svgRoot);
this.recomputeAriaContext();
}
dispose(): void {
@@ -189,6 +194,22 @@ export abstract class Icon implements IIcon, IContextMenu {
return true;
}
/**
* Handles the user acting on this icon via keyboard navigation.
* Performs the same action as a click would, and focuses this icon's bubble
* if it has one.
*/
performAction() {
this.onClick();
renderManagement.finishQueuedRenders().then(() => {
if (hasBubble(this) && this.bubbleIsVisible()) {
const bubble = this.getBubble();
if (!bubble) return;
getFocusManager().focusNode(bubble);
}
});
}
/**
* Returns the block that this icon is attached to.
*
@@ -201,4 +222,28 @@ export abstract class Icon implements IIcon, IContextMenu {
showContextMenu(e: PointerEvent) {
(this.getSourceBlock() as BlockSvg).showContextMenu(e);
}
/**
* Recomputes the ARIA label and role for this icon. This is automatically called
* during initialization, but implementations may find it useful to call this if
* the icon's label should be changed.
*/
protected recomputeAriaContext(): void {
const element = this.getFocusableElement();
if (!element) return;
aria.setRole(element, aria.Role.BUTTON);
const label = this.getAriaLabel() ?? Msg['ICON_LABEL_DEFAULT'];
aria.setState(element, aria.State.LABEL, label);
}
/**
* Returns the ARIA label to use for this icon (defaults to null). Note that this
* method will only be called during initialization by default, so dynamic changes
* to the icon's ARIA label need to be applied by calling recomputeAriaContext.
*
* @returns The ARIA label to use for this icon, or null to use a default.
*/
protected getAriaLabel(): string | null {
return null;
}
}
@@ -15,6 +15,7 @@ import {isBlockChange, isBlockCreate} from '../events/predicates.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import {Msg} from '../msg.js';
import * as renderManagement from '../render_management.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
@@ -176,6 +177,7 @@ export class MutatorIcon extends Icon implements IHasBubble {
this.sourceBlock.workspace,
this.getAnchorLocation(),
this.getBubbleOwnerRect(),
this,
);
this.applyColour();
this.createRootBlock();
@@ -183,6 +185,9 @@ export class MutatorIcon extends Icon implements IHasBubble {
this.miniWorkspaceBubble?.addWorkspaceChangeListener(
this.createMiniWorkspaceChangeListener(),
);
this.miniWorkspaceBubble.setAriaLabelProvider(
Msg['WORKSPACE_LABEL_MUTATOR_WORKSPACE'],
);
} else {
this.miniWorkspaceBubble?.dispose();
this.miniWorkspaceBubble = null;
@@ -201,6 +206,7 @@ export class MutatorIcon extends Icon implements IHasBubble {
'mutator',
),
);
this.recomputeAriaContext();
}
/** See IHasBubble.getBubble. */
@@ -357,4 +363,17 @@ export class MutatorIcon extends Icon implements IHasBubble {
getWorkspace(): WorkspaceSvg | undefined {
return this.miniWorkspaceBubble?.getWorkspace();
}
/**
* Returns the ARIA label to use for this icon (defaults to null). Note that this
* method will only be called during initialization by default, so dynamic changes
* to the icon's ARIA label need to be applied by calling recomputeAriaContext.
*
* @returns The ARIA label to use for this icon, or null to use a default.
*/
protected override getAriaLabel(): string | null {
return this.bubbleIsVisible()
? Msg['ICON_LABEL_MUTATOR_OPEN']
: Msg['ICON_LABEL_MUTATOR_CLOSED'];
}
}
@@ -12,6 +12,7 @@ import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import type {IBubble} from '../interfaces/i_bubble.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import {Msg} from '../msg.js';
import * as renderManagement from '../render_management.js';
import {Size} from '../utils.js';
import {Coordinate} from '../utils/coordinate.js';
@@ -182,8 +183,12 @@ export class WarningIcon extends Icon implements IHasBubble {
this.sourceBlock.workspace,
this.getAnchorLocation(),
this.getBubbleOwnerRect(),
this,
);
this.applyColour();
this.textBubble.setAriaLabelProvider(() =>
Msg['BUBBLE_LABEL_WARNING'].replace('%1', this.getText()),
);
} else {
this.textBubble?.dispose();
this.textBubble = null;
@@ -196,6 +201,7 @@ export class WarningIcon extends Icon implements IHasBubble {
'warning',
),
);
this.recomputeAriaContext();
}
/** See IHasBubble.getBubble. */
@@ -223,4 +229,17 @@ export class WarningIcon extends Icon implements IHasBubble {
const bbox = this.sourceBlock.getSvgRoot().getBBox();
return new Rect(bbox.y, bbox.y + bbox.height, bbox.x, bbox.x + bbox.width);
}
/**
* Returns the ARIA label to use for this icon (defaults to null). Note that this
* method will only be called during initialization by default, so dynamic changes
* to the icon's ARIA label need to be applied by calling recomputeAriaContext.
*
* @returns The ARIA label to use for this icon, or null to use a default.
*/
protected override getAriaLabel(): string | null {
return this.bubbleIsVisible()
? Msg['ICON_LABEL_WARNING_OPEN']
: Msg['ICON_LABEL_WARNING_CLOSED'];
}
}
+13 -2
View File
@@ -13,10 +13,12 @@ import * as common from './common.js';
import * as Css from './css.js';
import * as dropDownDiv from './dropdowndiv.js';
import {Grid} from './grid.js';
import {keyboardNavigationController} from './keyboard_navigation_controller.js';
import {Options} from './options.js';
import {ScrollbarPair} from './scrollbar_pair.js';
import * as Tooltip from './tooltip.js';
import * as Touch from './touch.js';
import * as aria from './utils/aria.js';
import * as dom from './utils/dom.js';
import {Svg} from './utils/svg.js';
import * as WidgetDiv from './widgetdiv.js';
@@ -53,6 +55,7 @@ export function inject(
if (opt_options?.rtl) {
dom.addClass(subContainer, 'blocklyRTL');
}
aria.setRole(subContainer, aria.Role.APPLICATION);
containerElement!.appendChild(subContainer);
const svg = createDom(subContainer, options);
@@ -78,6 +81,8 @@ export function inject(
common.globalShortcutHandler,
);
aria.initializeGlobalAriaLiveRegion(subContainer);
return workspace;
}
@@ -95,7 +100,7 @@ function createDom(container: HTMLElement, options: Options): SVGElement {
container.setAttribute('dir', 'LTR');
// Load CSS.
Css.inject(options.hasCss, options.pathToMedia);
Css.inject(container, options.hasCss, options.pathToMedia);
// Build the SVG DOM.
/*
@@ -172,7 +177,7 @@ function createMainWorkspace(
if (!wsOptions.hasCategories && wsOptions.languageTree) {
// Add flyout as an <svg> that is a sibling of the workspace SVG.
const flyout = mainWorkspace.addFlyout(Svg.SVG);
dom.insertAfter(flyout, svg);
injectionDiv.insertBefore(flyout, svg);
}
if (wsOptions.hasTrashcan) {
mainWorkspace.addTrashcan();
@@ -314,6 +319,11 @@ function bindDocumentEvents() {
// should run regardless of what other touch event handlers have run.
browserEvents.bind(document, 'touchend', null, Touch.longStop);
browserEvents.bind(document, 'touchcancel', null, Touch.longStop);
browserEvents.bind(document, 'keydown', null, function (e: KeyboardEvent) {
if (e.key === 'Tab') {
keyboardNavigationController.setIsActive(true);
}
});
}
documentEventsBound = true;
}
@@ -329,4 +339,5 @@ function loadSounds(pathToMedia: string, workspace: WorkspaceSvg) {
audioMgr.load([`${pathToMedia}click.mp3`], 'click');
audioMgr.load([`${pathToMedia}disconnect.mp3`], 'disconnect');
audioMgr.load([`${pathToMedia}delete.mp3`], 'delete');
audioMgr.load([`${pathToMedia}drop.mp3`], 'drop');
}
+132 -1
View File
@@ -15,15 +15,25 @@
import '../field_label.js';
import type {Block} from '../block.js';
import {computeFieldRowLabel, getInputLabels} from '../block_aria_composer.js';
import type {BlockSvg} from '../block_svg.js';
import type {Connection} from '../connection.js';
import type {ConnectionType} from '../connection_type.js';
import {ConnectionType} from '../connection_type.js';
import type {Field} from '../field.js';
import * as fieldRegistry from '../field_registry.js';
import {RenderedConnection} from '../rendered_connection.js';
import {Verbosity} from '../utils/aria.js';
import * as parsing from '../utils/parsing.js';
import {Align} from './align.js';
import {inputTypes} from './input_types.js';
/**
* Represents a string or a function that returns a string which can be used as a
* custom ARIA string to represent an Input, or null if the default fallback should
* be used. See setAriaLabelProvider for more context.
*/
export type AriaLabelProvider = ((input: Input) => string | null) | string;
/** Class for an input with optional fields. */
export class Input {
fieldRow: Field[] = [];
@@ -33,6 +43,9 @@ export class Input {
/** Is the input visible? */
private visible = true;
/** The AriaLabelProvider */
private ariaLabelProvider: AriaLabelProvider | null = null;
public readonly type: inputTypes = inputTypes.CUSTOM;
public connection: Connection | null = null;
@@ -271,6 +284,42 @@ export class Input {
}
}
/**
* Sets a custom ARIA label provider for this input, or null if it should be reset
* to use the default method.
*
* Inputs do not compute ARIA contexts directly, so the set provider will be used
* in select cases when the Input needs to be represented (such as for parts of a
* block label or for connections). Note that overriding this provider will not
* recompute any already constructed ARIA labels, and it cannot be assumed that the
* provider will be called any particular number of times during label
* recomputation. As such, implementations should make sure to provide a
* deterministic and idempotent ARIA representation each time the provider is
* called for a given input. It's also fine to reuse providers across multiple
* Input implementations.
*
* @param provider The string or function to use to set the ARIA label for the input
* @returns The input being modified (to allow chaining).
*/
setAriaLabelProvider(provider: AriaLabelProvider | null): Input {
this.ariaLabelProvider = provider;
return this;
}
/**
* Returns the string from the custom ARIA label provider set, or null if the default label (from the field row) should
* be used. See setAriaLabelProvider for more context.
*/
getAriaLabelText(): string | null {
if (!this.ariaLabelProvider) {
return null;
} else if (typeof this.ariaLabelProvider === 'string') {
return parsing.replaceMessageReferences(this.ariaLabelProvider);
} else {
return this.ariaLabelProvider(this);
}
}
/**
* Initializes the fields on this input for a headless block.
*
@@ -314,4 +363,86 @@ export class Input {
protected makeConnection(type: ConnectionType): Connection {
return this.sourceBlock.makeConnection_(type);
}
/**
* Returns an ID for the logical "row" this input is part of. A "row" is
* bounded by a previous/next connection, a statement input, or a block stack
* boundary; all blocks/inputs nested inside of one of those are conceptually
* part of its same row.
*
* @internal
*/
getRowId(): string {
const inputs = this.getSourceBlock().inputList;
// The first visible input shares the block's row id; this also covers
// the collapsed-input placeholder, since every other input is hidden.
if (this === inputs.find((i) => i.isVisible())) {
return (this.getSourceBlock() as BlockSvg).getRowId();
}
// Fallback when inputs[0] itself is hidden.
if (this === inputs[0]) {
return (this.getSourceBlock() as BlockSvg).getRowId();
}
const inputIndex = inputs.indexOf(this);
const precedingStatementInput =
inputs[inputIndex - 1].connection?.type === ConnectionType.NEXT_STATEMENT;
// Each subsequent statement input or value input following a statement
// input is on its own row and has its own row ID.
if (
this.connection?.type === ConnectionType.NEXT_STATEMENT ||
precedingStatementInput
) {
return `${this.getSourceBlock().id}-input${inputIndex}`;
}
// Value inputs have the same row ID as their preceding input, since
// they're all on one row.
return inputs[inputIndex - 1].getRowId();
}
/**
* Returns a derived accessibility label for this input: field row text plus
* labels of any connected child blocks (unless excluded). Does not include
* custom labels from {@link getAriaLabelText}; those are used in move-mode
* and parent-input context only.
*
* @internal
*/
getLabel(verbosity = Verbosity.STANDARD, includeChildren = true): string {
if (!this.isVisible()) return '';
const labels = computeFieldRowLabel(this, false, verbosity);
if (
includeChildren &&
this.connection?.type === ConnectionType.INPUT_VALUE
) {
const childBlock = this.connection.targetBlock();
if (childBlock && !childBlock.isInsertionMarker()) {
labels.push(
getInputLabels(childBlock as BlockSvg, verbosity).join(', '),
);
}
}
return labels.join(', ');
}
/**
* Returns the index of this input, excluding inputs without connections, on its
* source block.
*
* @internal
*/
getIndex(): number {
const noConnectionInputTypes = [inputTypes.DUMMY, inputTypes.END_ROW];
const allInputs = this.getSourceBlock().inputList;
const allConnectionInputs = allInputs.filter(
(input) => !noConnectionInputTypes.includes(input.type),
);
return allConnectionInputs.indexOf(this);
}
}
@@ -92,6 +92,10 @@ export class InsertionMarkerPreviewer implements IConnectionPreviewer {
staticConn.highlight();
}
if (this.workspace.getRenderer().shouldHighlightConnection(draggedConn)) {
draggedConn.highlight();
}
this.draggedConn = draggedConn;
this.staticConn = staticConn;
} finally {
@@ -224,6 +228,10 @@ export class InsertionMarkerPreviewer implements IConnectionPreviewer {
this.staticConn.unhighlight();
this.staticConn = null;
}
if (this.draggedConn) {
this.draggedConn.unhighlight();
this.draggedConn = null;
}
if (this.fadedBlock) {
this.fadedBlock.fadeForReplacement(false);
this.fadedBlock = null;
@@ -29,3 +29,16 @@ export interface IBoundedElement {
*/
moveBy(dx: number, dy: number, reason?: string[]): void;
}
/**
* Returns whether or not the given object conforms to IBoundedElement.
*
* @param object The object to test for conformance.
* @returns True if the object conforms to IBoundedElement, otherwise false.
*/
export function isBoundedElement(object: any): object is IBoundedElement {
return (
typeof (object as IBoundedElement).getBoundingRectangle === 'function' &&
typeof (object as IBoundedElement).moveBy === 'function'
);
}
+4 -2
View File
@@ -7,14 +7,16 @@
// Former goog.module ID: Blockly.IBubble
import type {Coordinate} from '../utils/coordinate.js';
import {IBoundedElement} from './i_bounded_element.js';
import type {IContextMenu} from './i_contextmenu.js';
import type {IDraggable} from './i_draggable.js';
import {IFocusableNode} from './i_focusable_node.js';
import {ISelectable} from './i_selectable.js';
/**
* A bubble interface.
*/
export interface IBubble extends IDraggable, IContextMenu, IFocusableNode {
export interface IBubble
extends IDraggable, IContextMenu, ISelectable, IBoundedElement {
/**
* Return the coordinates of the top-left corner of this bubble's body
* relative to the drawing surface's origin (0,0), in workspace units.
@@ -6,7 +6,10 @@
// Former goog.module ID: Blockly.ICollapsibleToolboxItem
import type {ISelectableToolboxItem} from './i_selectable_toolbox_item.js';
import {
type ISelectableToolboxItem,
isSelectableToolboxItem,
} from './i_selectable_toolbox_item.js';
import type {IToolboxItem} from './i_toolbox_item.js';
/**
@@ -31,3 +34,19 @@ export interface ICollapsibleToolboxItem extends ISelectableToolboxItem {
/** Toggles whether or not the toolbox item is expanded. */
toggleExpanded(): void;
}
/**
* Type guard that checks whether an object is an ICollapsibleToolboxItem.
*/
export function isCollapsibleToolboxItem(
obj: any,
): obj is ICollapsibleToolboxItem {
return (
obj &&
typeof obj.getChildToolboxItems === 'function' &&
typeof obj.isExpanded === 'function' &&
typeof obj.toggleExpanded === 'function' &&
isSelectableToolboxItem(obj) &&
obj.isCollapsible()
);
}
+27 -17
View File
@@ -4,15 +4,23 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {Coordinate} from '../utils/coordinate';
import type {Coordinate} from '../utils/coordinate.js';
import type {IBoundedElement} from './i_bounded_element.js';
import type {ISelectable} from './i_selectable.js';
export enum DragDisposition {
COMMIT = 1,
DELETE = 2,
REVERT = 3,
}
/**
* Represents an object that can be dragged.
*/
export interface IDraggable extends IDragStrategy {
export interface IDraggable
extends IDragStrategy, IBoundedElement, ISelectable {
/**
* Returns the current location of the draggable in workspace
* coordinates.
* Returns the current location of the draggable in workspace coordinates.
*
* @returns Coordinate of current location on workspace.
*/
@@ -27,11 +35,11 @@ export interface IDragStrategy {
* Handles any drag startup (e.g moving elements to the front of the
* workspace).
*
* @param e PointerEvent that started the drag; can be used to
* check modifier keys, etc. May be missing when dragging is
* triggered programatically rather than by user.
* @param e Event that started the drag; can be used to check modifier keys,
* etc. May be missing when dragging is triggered programmatically rather
* than by user.
*/
startDrag(e?: PointerEvent): void;
startDrag(e?: PointerEvent | KeyboardEvent): IDraggable;
/**
* Handles moving elements to the new location, and updating any
@@ -39,21 +47,23 @@ export interface IDragStrategy {
*
* @param newLoc Workspace coordinate to which the draggable has
* been dragged.
* @param e PointerEvent that continued the drag. Can be
* used to check modifier keys, etc.
* @param e Event that continued the drag. Can be used to check modifier
* keys, etc.
*/
drag(newLoc: Coordinate, e?: PointerEvent): void;
drag(newLoc: Coordinate, e?: PointerEvent | KeyboardEvent): void;
/**
* Handles any drag cleanup, including e.g. connecting or deleting
* blocks.
* Handles any drag cleanup, including e.g. connecting or deleting blocks.
*
* @param newLoc Workspace coordinate at which the drag finished.
* been dragged.
* @param e PointerEvent that finished the drag. Can be
* used to check modifier keys, etc.
* @param e Event that finished the drag. Can be used to check modifier keys,
* etc.
* @param disposition The end result of the drag.
*/
endDrag(e?: PointerEvent): void;
endDrag(
e: PointerEvent | KeyboardEvent | undefined,
disposition: DragDisposition,
): void;
/** Moves the draggable back to where it was at the start of the drag. */
revertDrag(): void;
+29 -10
View File
@@ -4,32 +4,51 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {Coordinate} from '../utils/coordinate';
import type {Coordinate} from '../utils/coordinate';
import type {IDraggable} from './i_draggable';
export interface IDragger {
/**
* Handles any drag startup.
*
* @param e PointerEvent that started the drag.
* @param e Event that started the drag.
*/
onDragStart(e: PointerEvent): void;
onDragStart(e?: PointerEvent | KeyboardEvent): IDraggable;
/**
* Handles dragging, including calculating where the element should
* actually be moved to.
*
* @param e PointerEvent that continued the drag.
* @param totalDelta The total distance, in pixels, that the mouse
* @param e Event that continued the drag.
* @param totalDelta The total distance, in pixels, that the draggable
* has moved since the start of the drag.
*/
onDrag(e: PointerEvent, totalDelta: Coordinate): void;
onDrag(
e: PointerEvent | KeyboardEvent | undefined,
totalDelta: Coordinate,
): void;
/**
* Handles any drag cleanup.
* Handles any drag cleanup when a drag finishes normally.
*
* @param e PointerEvent that finished the drag.
* @param totalDelta The total distance, in pixels, that the mouse
* @param e Event that finished the drag.
* @param totalDelta The total distance, in pixels, that the draggable
* has moved since the start of the drag.
*/
onDragEnd(e: PointerEvent, totalDelta: Coordinate): void;
onDragEnd(
e: PointerEvent | KeyboardEvent | undefined,
totalDelta: Coordinate,
): void;
/**
* Handles any drag cleanup when a drag is reverted.
*
* @param e Event that finished the drag.
* @param totalDelta The total distance, in pixels, that the draggable
* has moved since the start of the drag.
*/
onDragRevert(
e: PointerEvent | KeyboardEvent | undefined,
totalDelta: Coordinate,
): void;
}
+1 -34
View File
@@ -6,19 +6,16 @@
// Former goog.module ID: Blockly.IFlyout
import type {BlockSvg} from '../block_svg.js';
import type {FlyoutItem} from '../flyout_item.js';
import type {Coordinate} from '../utils/coordinate.js';
import type {Svg} from '../utils/svg.js';
import type {FlyoutDefinition} from '../utils/toolbox.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import {IFocusableTree} from './i_focusable_tree.js';
import type {IRegistrable} from './i_registrable.js';
/**
* Interface for a flyout.
*/
export interface IFlyout extends IRegistrable, IFocusableTree {
export interface IFlyout extends IRegistrable {
/** Whether the flyout is laid out horizontally or not. */
horizontalLayout: boolean;
@@ -129,15 +126,6 @@ export interface IFlyout extends IRegistrable, IFocusableTree {
*/
getContents(): FlyoutItem[];
/**
* Create a copy of this block on the workspace.
*
* @param originalBlock The block to copy from the flyout.
* @returns The newly created block.
* @throws {Error} if something went wrong with deserialization.
*/
createBlock(originalBlock: BlockSvg): BlockSvg;
/** Reflow blocks and their mats. */
reflow(): void;
@@ -164,27 +152,6 @@ export interface IFlyout extends IRegistrable, IFocusableTree {
/** Position the flyout. */
position(): void;
/**
* Determine if a drag delta is toward the workspace, based on the position
* and orientation of the flyout. This is used in determineDragIntention_ to
* determine if a new block should be created or if the flyout should scroll.
*
* @param currentDragDeltaXY How far the pointer has moved from the position
* at mouse down, in pixel units.
* @returns True if the drag is toward the workspace.
*/
isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean;
/**
* Does this flyout allow you to create a new instance of the given block?
* Used for deciding if a block can be "dragged out of" the flyout.
*
* @param block The block to copy from the flyout.
* @returns True if you can create a new instance of the block, false
* otherwise.
*/
isBlockCreatable(block: BlockSvg): boolean;
/** Scroll the flyout to the beginning of its contents. */
scrollToStart(): void;
}
@@ -8,6 +8,15 @@ export interface IFlyoutInflater {
* Note that this method's interface is identical to that in ISerializer, to
* allow for code reuse.
*
* You must ensure that any item created by this method has the appropriate
* ARIA markup:
* - The role of the element's focusable element should be set to `listitem`.
* - The focusable element must have an `id` attribute.
* - Any DOM parents of the focusable element should set their role to
* `presentation` to avoid interfering with flyout list navigation.
* - If the element is not focusable, it must be hidden from the ARIA tree.
* Only do this if the content should be inaccessible to screenreaders.
*
* @param state A JSON representation of an element to inflate on the flyout.
* @param flyout The flyout on whose workspace the inflated element
* should be created. If the inflated element is an `IRenderedElement` it
@@ -99,6 +99,15 @@ export interface IFocusableNode {
* @returns Whether this node can be focused by FocusManager.
*/
canBeFocused(): boolean;
/**
* Optional method invoked when this node has focus and the user acts on it by
* pressing Enter or Space. Behavior should generally be similar to the node
* being clicked on.
*
* @param e The event that triggered this action, if any.
*/
performAction?(e?: Event): void;
}
/**
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {Navigator} from '../keyboard_nav/navigators/navigator';
import type {IFocusableNode} from './i_focusable_node.js';
/**
@@ -122,6 +123,14 @@ export interface IFocusableTree {
* as in the case that Blockly is entirely losing DOM focus).
*/
onTreeBlur(nextTree: IFocusableTree | null): void;
/**
* Returns a Navigator instance to be used to determine the navigation order
* between IFocusableNodes contained within this IFocusableTree. Generally
* this can just be an instance of Navigator, but trees may choose to return a
* subclass to customize navigation behavior within their context.
*/
getNavigator(): Navigator;
}
/**
@@ -42,6 +42,7 @@ export interface JsonBlockDefinition {
inputsInline?: boolean;
tooltip?: string;
helpUrl?: string;
ariaRoleDescription?: string;
extensions?: string[];
mutator?: string;
enableContextMenu?: boolean;
@@ -44,6 +44,19 @@ export interface INavigationPolicy<T> {
*/
getPreviousSibling(current: T): IFocusableNode | null;
/**
* Returns an ID corresponding to the visual "row" the given element is part
* of. All elements on the same visual row should share the same ID. For
* example, icons share their parent block's row ID, as do inline connected
* blocks or value inputs. Statement inputs, external inputs, or blocks
* connected to one another's previous or next connections form distinct
* visual rows and should have distinct row IDs.
*
* @param current The element to return the row ID of.
* @returns The row ID of the given element.
*/
getRowId(current: T): string;
/**
* Returns whether or not the given instance should be reachable via keyboard
* navigation.
@@ -6,7 +6,7 @@
// Former goog.module ID: Blockly.ISelectable
import type {Workspace} from '../workspace.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import {IFocusableNode, isFocusableNode} from './i_focusable_node.js';
/**
@@ -20,7 +20,7 @@ import {IFocusableNode, isFocusableNode} from './i_focusable_node.js';
export interface ISelectable extends IFocusableNode {
id: string;
workspace: Workspace;
workspace: WorkspaceSvg;
/** Select this. Highlight it visually. */
select(): void;
@@ -7,7 +7,7 @@
// Former goog.module ID: Blockly.ISelectableToolboxItem
import type {FlyoutItemInfoArray} from '../utils/toolbox';
import type {IToolboxItem} from './i_toolbox_item.js';
import {isToolboxItem, type IToolboxItem} from './i_toolbox_item.js';
/**
* Interface for an item in the toolbox that can be selected.
@@ -54,10 +54,17 @@ export interface ISelectableToolboxItem extends IToolboxItem {
}
/**
* Type guard that checks whether an IToolboxItem is an ISelectableToolboxItem.
* Type guard that checks whether an object is an ISelectableToolboxItem.
*/
export function isSelectableToolboxItem(
toolboxItem: IToolboxItem,
): toolboxItem is ISelectableToolboxItem {
return toolboxItem.isSelectable();
obj: any,
): obj is ISelectableToolboxItem {
return (
typeof obj.getName === 'function' &&
typeof obj.getContents === 'function' &&
typeof obj.setSelected === 'function' &&
typeof obj.onClick === 'function' &&
isToolboxItem(obj) &&
obj.isSelectable()
);
}
@@ -118,4 +118,9 @@ export interface IToolbox extends IRegistrable, IFocusableTree {
/** Disposes of this toolbox. */
dispose(): void;
/**
* Returns a list of items in this toolbox.
*/
getToolboxItems(): IToolboxItem[];
}
@@ -7,6 +7,7 @@
// Former goog.module ID: Blockly.IToolboxItem
import type {IFocusableNode} from './i_focusable_node.js';
import type {IToolbox} from './i_toolbox.js';
/**
* Interface for an item in the toolbox.
@@ -80,4 +81,26 @@ export interface IToolboxItem extends IFocusableNode {
* @param isVisible True if category should be visible.
*/
setVisible_(isVisible: boolean): void;
getParentToolbox(): IToolbox;
}
/**
* Type guard that checks whether an object is an IToolboxItem.
*/
export function isToolboxItem(obj: any): obj is IToolboxItem {
return (
obj &&
typeof obj.init === 'function' &&
typeof obj.getDiv === 'function' &&
typeof obj.getId === 'function' &&
typeof obj.getParent === 'function' &&
typeof obj.getLevel === 'function' &&
typeof obj.isSelectable === 'function' &&
typeof obj.isCollapsible === 'function' &&
typeof obj.dispose === 'function' &&
typeof obj.getClickTarget === 'function' &&
typeof obj.setVisible_ === 'function' &&
typeof obj.getParentToolbox === 'function'
);
}
@@ -1,76 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {TextInputBubble} from '../bubbles/textinput_bubble.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
/**
* Set of rules controlling keyboard navigation from an TextInputBubble.
*/
export class BlockCommentNavigationPolicy
implements INavigationPolicy<TextInputBubble>
{
/**
* Returns the first child of the given block comment.
*
* @param current The block comment to return the first child of.
* @returns The text editor of the given block comment bubble.
*/
getFirstChild(current: TextInputBubble): IFocusableNode | null {
return current.getEditor();
}
/**
* Returns the parent of the given block comment.
*
* @param current The block comment to return the parent of.
* @returns The parent block of the given block comment.
*/
getParent(current: TextInputBubble): IFocusableNode | null {
return current.getOwner() ?? null;
}
/**
* Returns the next peer node of the given block comment.
*
* @param _current The block comment to find the following element of.
* @returns Null.
*/
getNextSibling(_current: TextInputBubble): IFocusableNode | null {
return null;
}
/**
* Returns the previous peer node of the given block comment.
*
* @param _current The block comment to find the preceding element of.
* @returns Null.
*/
getPreviousSibling(_current: TextInputBubble): IFocusableNode | null {
return null;
}
/**
* Returns whether or not the given block comment can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given block comment can be focused.
*/
isNavigable(current: TextInputBubble): boolean {
return current.canBeFocused();
}
/**
* Returns whether the given object can be navigated from by this policy.
*
* @param current The object to check if this policy applies to.
* @returns True if the object is an TextInputBubble.
*/
isApplicable(current: any): current is TextInputBubble {
return current instanceof TextInputBubble;
}
}
@@ -1,213 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {BlockSvg} from '../block_svg.js';
import {ConnectionType} from '../connection_type.js';
import type {Field} from '../field.js';
import type {Icon} from '../icons/icon.js';
import type {IBoundedElement} from '../interfaces/i_bounded_element.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import {isFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
import type {ISelectable} from '../interfaces/i_selectable.js';
import {RenderedConnection} from '../rendered_connection.js';
import {WorkspaceSvg} from '../workspace_svg.js';
/**
* Set of rules controlling keyboard navigation from a block.
*/
export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
/**
* Returns the first child of the given block.
*
* @param current The block to return the first child of.
* @returns The first field or input of the given block, if any.
*/
getFirstChild(current: BlockSvg): IFocusableNode | null {
const candidates = getBlockNavigationCandidates(current, true);
return candidates[0];
}
/**
* Returns the parent of the given block.
*
* @param current The block to return the parent of.
* @returns The top block of the given block's stack, or the connection to
* which it is attached.
*/
getParent(current: BlockSvg): IFocusableNode | null {
if (current.previousConnection?.targetBlock()) {
const surroundParent = current.getSurroundParent();
if (surroundParent) return surroundParent;
} else if (current.outputConnection?.targetBlock()) {
return current.outputConnection.targetBlock();
}
return current.workspace;
}
/**
* Returns the next peer node of the given block.
*
* @param current The block to find the following element of.
* @returns The first node of the next input/stack if the given block is a terminal
* block, or its next connection.
*/
getNextSibling(current: BlockSvg): IFocusableNode | null {
if (current.nextConnection?.targetBlock()) {
return current.nextConnection?.targetBlock();
} else if (current.outputConnection?.targetBlock()) {
return navigateBlock(current, 1);
} else if (current.getSurroundParent()) {
return navigateBlock(current.getTopStackBlock(), 1);
} else if (this.getParent(current) instanceof WorkspaceSvg) {
return navigateStacks(current, 1);
}
return null;
}
/**
* Returns the previous peer node of the given block.
*
* @param current The block to find the preceding element of.
* @returns The block's previous/output connection, or the last
* connection/block of the previous block stack if it is a root block.
*/
getPreviousSibling(current: BlockSvg): IFocusableNode | null {
if (current.previousConnection?.targetBlock()) {
return current.previousConnection?.targetBlock();
} else if (current.outputConnection?.targetBlock()) {
return navigateBlock(current, -1);
} else if (this.getParent(current) instanceof WorkspaceSvg) {
return navigateStacks(current, -1);
}
return null;
}
/**
* Returns whether or not the given block can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given block can be focused.
*/
isNavigable(current: BlockSvg): boolean {
return current.canBeFocused();
}
/**
* Returns whether the given object can be navigated from by this policy.
*
* @param current The object to check if this policy applies to.
* @returns True if the object is a BlockSvg.
*/
isApplicable(current: any): current is BlockSvg {
return current instanceof BlockSvg;
}
}
/**
* Returns a list of the navigable children of the given block.
*
* @param block The block to retrieve the navigable children of.
* @returns A list of navigable/focusable children of the given block.
*/
function getBlockNavigationCandidates(
block: BlockSvg,
forward: boolean,
): IFocusableNode[] {
const candidates: IFocusableNode[] = block.getIcons();
for (const input of block.inputList) {
if (!input.isVisible()) continue;
candidates.push(...input.fieldRow);
if (input.connection?.targetBlock()) {
const connectedBlock = input.connection.targetBlock() as BlockSvg;
if (input.connection.type === ConnectionType.NEXT_STATEMENT && !forward) {
const lastStackBlock = connectedBlock
.lastConnectionInStack(false)
?.getSourceBlock();
if (lastStackBlock) {
candidates.push(lastStackBlock);
}
} else {
candidates.push(connectedBlock);
}
} else if (input.connection?.type === ConnectionType.INPUT_VALUE) {
candidates.push(input.connection as RenderedConnection);
}
}
return candidates;
}
/**
* Returns the next/previous stack relative to the given element's stack.
*
* @param current The element whose stack will be navigated relative to.
* @param delta The difference in index to navigate; positive values navigate
* to the nth next stack, while negative values navigate to the nth previous
* stack.
* @returns The first element in the stack offset by `delta` relative to the
* current element's stack, or the last element in the stack offset by
* `delta` relative to the current element's stack when navigating backwards.
*/
export function navigateStacks(current: ISelectable, delta: number) {
const stacks: IFocusableNode[] = (current.workspace as WorkspaceSvg)
.getTopBoundedElements(true)
.filter((element: IBoundedElement) => isFocusableNode(element));
const currentIndex = stacks.indexOf(
current instanceof BlockSvg ? current.getRootBlock() : current,
);
const targetIndex = currentIndex + delta;
let result: IFocusableNode | null = null;
if (targetIndex >= 0 && targetIndex < stacks.length) {
result = stacks[targetIndex];
} else if (targetIndex < 0) {
result = stacks[stacks.length - 1];
} else if (targetIndex >= stacks.length) {
result = stacks[0];
}
// When navigating to a previous block stack, our previous sibling is the last
// block in it.
if (delta < 0 && result instanceof BlockSvg) {
return result.lastConnectionInStack(false)?.getSourceBlock() ?? result;
}
return result;
}
/**
* Returns the next navigable item relative to the provided block child.
*
* @param current The navigable block child item to navigate relative to.
* @param delta The difference in index to navigate; positive values navigate
* forward by n, while negative values navigate backwards by n.
* @returns The navigable block child offset by `delta` relative to `current`.
*/
export function navigateBlock(
current: Icon | Field | RenderedConnection | BlockSvg,
delta: number,
): IFocusableNode | null {
const block =
current instanceof BlockSvg
? (current.outputConnection?.targetBlock() ?? current.getSurroundParent())
: current.getSourceBlock();
if (!(block instanceof BlockSvg)) return null;
const candidates = getBlockNavigationCandidates(block, delta > 0);
const currentIndex = candidates.indexOf(current);
if (currentIndex === -1) return null;
const targetIndex = currentIndex + delta;
if (targetIndex >= 0 && targetIndex < candidates.length) {
return candidates[targetIndex];
}
return null;
}
@@ -1,155 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {BlockSvg} from '../block_svg.js';
import {ConnectionType} from '../connection_type.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
import {RenderedConnection} from '../rendered_connection.js';
import {navigateBlock} from './block_navigation_policy.js';
/**
* Set of rules controlling keyboard navigation from a connection.
*/
export class ConnectionNavigationPolicy
implements INavigationPolicy<RenderedConnection>
{
/**
* Returns the first child of the given connection.
*
* @param current The connection to return the first child of.
* @returns The connection's first child element, or null if not none.
*/
getFirstChild(current: RenderedConnection): IFocusableNode | null {
if (current.getParentInput()) {
return current.targetConnection;
}
return null;
}
/**
* Returns the parent of the given connection.
*
* @param current The connection to return the parent of.
* @returns The given connection's parent connection or block.
*/
getParent(current: RenderedConnection): IFocusableNode | null {
return current.getSourceBlock();
}
/**
* Returns the next element following the given connection.
*
* @param current The connection to navigate from.
* @returns The field, input connection or block following this connection.
*/
getNextSibling(current: RenderedConnection): IFocusableNode | null {
if (current.getParentInput()) {
return navigateBlock(current, 1);
} else if (current.type === ConnectionType.NEXT_STATEMENT) {
const nextBlock = current.targetConnection;
// If this connection is the last one in the stack, our next sibling is
// the next block stack.
const sourceBlock = current.getSourceBlock();
if (
!nextBlock &&
sourceBlock.getRootBlock().lastConnectionInStack(false) === current
) {
const topBlocks = sourceBlock.workspace.getTopBlocks(true);
let targetIndex = topBlocks.indexOf(sourceBlock.getRootBlock()) + 1;
if (targetIndex >= topBlocks.length) {
targetIndex = 0;
}
const nextBlock = topBlocks[targetIndex];
return this.getParentConnection(nextBlock) ?? nextBlock;
}
return nextBlock;
}
return current.getSourceBlock();
}
/**
* Returns the element preceding the given connection.
*
* @param current The connection to navigate from.
* @returns The field, input connection or block preceding this connection.
*/
getPreviousSibling(current: RenderedConnection): IFocusableNode | null {
if (current.getParentInput()) {
return navigateBlock(current, -1);
} else if (
current.type === ConnectionType.PREVIOUS_STATEMENT ||
current.type === ConnectionType.OUTPUT_VALUE
) {
const previousConnection =
current.targetConnection && !current.targetConnection.getParentInput()
? current.targetConnection
: null;
// If this connection is a disconnected previous/output connection, our
// previous sibling is the previous block stack's last connection/block.
const sourceBlock = current.getSourceBlock();
if (
!previousConnection &&
this.getParentConnection(sourceBlock.getRootBlock()) === current
) {
const topBlocks = sourceBlock.workspace.getTopBlocks(true);
let targetIndex = topBlocks.indexOf(sourceBlock.getRootBlock()) - 1;
if (targetIndex < 0) {
targetIndex = topBlocks.length - 1;
}
const previousRootBlock = topBlocks[targetIndex];
return (
previousRootBlock.lastConnectionInStack(false) ?? previousRootBlock
);
}
return previousConnection;
} else if (current.type === ConnectionType.NEXT_STATEMENT) {
return current.getSourceBlock();
}
return null;
}
/**
* Gets the parent connection on a block.
* This is either an output connection, previous connection or undefined.
* If both connections exist return the one that is actually connected
* to another block.
*
* @param block The block to find the parent connection on.
* @returns The connection connecting to the parent of the block.
*/
protected getParentConnection(block: BlockSvg) {
if (!block.outputConnection || block.previousConnection?.isConnected()) {
return block.previousConnection;
}
return block.outputConnection;
}
/**
* Returns whether or not the given connection can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given connection can be focused.
*/
isNavigable(current: RenderedConnection): boolean {
return current.canBeFocused();
}
/**
* Returns whether the given object can be navigated from by this policy.
*
* @param current The object to check if this policy applies to.
* @returns True if the object is a RenderedConnection.
*/
isApplicable(current: any): current is RenderedConnection {
return current instanceof RenderedConnection;
}
}
@@ -1,111 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {IFlyout} from '../interfaces/i_flyout.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
/**
* Generic navigation policy that navigates between items in the flyout.
*/
export class FlyoutNavigationPolicy<T> implements INavigationPolicy<T> {
/**
* Creates a new FlyoutNavigationPolicy instance.
*
* @param policy The policy to defer to for parents/children.
* @param flyout The flyout this policy will control navigation in.
*/
constructor(
private policy: INavigationPolicy<T>,
private flyout: IFlyout,
) {}
/**
* Returns null to prevent navigating into flyout items.
*
* @param _current The flyout item to navigate from.
* @returns Null to prevent navigating into flyout items.
*/
getFirstChild(_current: T): IFocusableNode | null {
return null;
}
/**
* Returns the parent of the given flyout item.
*
* @param current The flyout item to navigate from.
* @returns The parent of the given flyout item.
*/
getParent(current: T): IFocusableNode | null {
return this.policy.getParent(current);
}
/**
* Returns the next item in the flyout relative to the given item.
*
* @param current The flyout item to navigate from.
* @returns The flyout item following the given one.
*/
getNextSibling(current: T): IFocusableNode | null {
const flyoutContents = this.flyout.getContents();
if (!flyoutContents) return null;
let index = flyoutContents.findIndex(
(flyoutItem) => flyoutItem.getElement() === current,
);
if (index === -1) return null;
index++;
if (index >= flyoutContents.length) {
index = 0;
}
return flyoutContents[index].getElement();
}
/**
* Returns the previous item in the flyout relative to the given item.
*
* @param current The flyout item to navigate from.
* @returns The flyout item preceding the given one.
*/
getPreviousSibling(current: T): IFocusableNode | null {
const flyoutContents = this.flyout.getContents();
if (!flyoutContents) return null;
let index = flyoutContents.findIndex(
(flyoutItem) => flyoutItem.getElement() === current,
);
if (index === -1) return null;
index--;
if (index < 0) {
index = flyoutContents.length - 1;
}
return flyoutContents[index].getElement();
}
/**
* Returns whether or not the given flyout item can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given flyout item can be focused.
*/
isNavigable(current: T): boolean {
return this.policy.isNavigable(current);
}
/**
* Returns whether the given object can be navigated from by this policy.
*
* @param current The object to check if this policy applies to.
* @returns True if the object is a BlockSvg.
*/
isApplicable(current: any): current is T {
return this.policy.isApplicable(current);
}
}
@@ -0,0 +1,388 @@
/**
* @license
* Copyright 2026 Raspberry Pi Foundation
* SPDX-License-Identifier: Apache-2.0
*/
import type {BlockSvg} from '../block_svg.js';
import type {IDraggable} from '../interfaces/i_draggable.js';
import type {IDragger} from '../interfaces/i_dragger.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import * as registry from '../registry.js';
import * as renderManagement from '../render_management.js';
import {ShortcutRegistry} from '../shortcut_registry.js';
import {Coordinate} from '../utils/coordinate.js';
import {KeyCodes} from '../utils/keycodes.js';
import {MoveIndicator} from './move_indicator.js';
/**
* Cardinal directions in which a move can proceed.
*/
export enum Direction {
NONE,
UP,
DOWN,
LEFT,
RIGHT,
}
/**
* Identifier for a keyboard shortcut that commits the in-progress move.
*/
const COMMIT_MOVE_SHORTCUT = 'commitMove';
/**
* Class responsible for coordinating keyboard-driven moves with the workspace
* and dragging system.
*/
export class KeyboardMover {
/**
* Object responsible for dragging workspace elements in response to move
* commands.
*/
private dragger?: IDragger;
/**
* The object that is currently being moved.
*/
private draggable?: IDraggable;
/**
* Workspace coordinate that the current move started from.
*/
private startLocation?: Coordinate;
/**
* The total distance, in workspace coordinates, that the element being moved
* has been moved since the movement process started.
*/
private totalDelta = new Coordinate(0, 0);
/**
* The distance to move an item in workspace coordinates.
*/
private stepDistance = 20;
/**
* Symbol attached to the item being moved to indicate it is in move mode.
*/
private moveIndicator?: MoveIndicator;
private allowedShortcuts: string[] = [];
// Set up a blur listener to end the move if the user clicks away
private readonly blurListener = () => {
this.abortMove();
};
static mover = new KeyboardMover();
// Constructor is private to keep this class a singleton.
private constructor() {}
/**
* Returns true iff the given draggable is allowed to be moved.
*
* @param draggable The draggable element to try to move.
* @returns True iff movement is allowed.
*/
canMove(draggable: IDraggable) {
return !draggable.workspace.isReadOnly() && draggable.isMovable();
}
/**
* Returns true iff this Mover is currently moving an element.
*
* @returns True iff a workspace element is being moved.
*/
isMoving() {
return !!this.draggable;
}
/**
* Start moving the currently-focused item on workspace, if possible.
*
* @param draggable The element to start moving.
* @param event The keyboard event that triggered this move.
* @returns True iff a move has successfully begun.
*/
startMove(draggable: IDraggable, event?: KeyboardEvent) {
if (!this.canMove(draggable) || this.isMoving()) return false;
const DraggerClass = registry.getClassFromOptions(
registry.Type.BLOCK_DRAGGER,
draggable.workspace.options,
true,
);
if (!DraggerClass) throw new Error('no Dragger registered');
this.dragger = new DraggerClass(draggable, draggable.workspace);
// Record that a move is in progress and start dragging.
this.draggable = this.dragger.onDragStart(event);
this.startLocation = this.draggable.getRelativeToSurfaceXY();
this.updateTotalDelta();
this.draggable
.getFocusableElement()
.addEventListener('blur', this.blurListener);
// Register a keyboard shortcut under the key combos of all existing
// keyboard shortcuts that commits the move before allowing the real
// shortcut to proceed. This avoids all kinds of fun brokenness when
// deleting/copying/otherwise acting on a element in move mode.
const shortcutKeys = Object.values(ShortcutRegistry.registry.getRegistry())
.flatMap((shortcut) => shortcut.keyCodes)
.filter((keyCode) => {
return (
keyCode &&
![
KeyCodes.RIGHT,
KeyCodes.LEFT,
KeyCodes.UP,
KeyCodes.DOWN,
KeyCodes.ENTER,
KeyCodes.SPACE,
KeyCodes.ESC,
KeyCodes.M,
].includes(
typeof keyCode === 'number'
? keyCode
: parseInt(`${keyCode.split('+').pop()}`),
)
);
})
// Convince TS there aren't undefined values.
.filter((keyCode): keyCode is string | number => !!keyCode);
const commitMoveShortcut = {
name: COMMIT_MOVE_SHORTCUT,
preconditionFn: () => {
return this.isMoving();
},
callback: () => {
this.finishMove();
return false;
},
keyCodes: shortcutKeys,
allowCollision: true,
};
ShortcutRegistry.registry.register(commitMoveShortcut, true);
this.scrollCurrentElementIntoView();
this.moveIndicator = new MoveIndicator(this.draggable.workspace);
this.repositionMoveIndicator();
return true;
}
/**
* Moves the current element in the given direction.
*
* @param direction The direction to move the currently-moving element.
* @param event The event that triggered this move, if any.
* @returns True iff this action applies and has been performed.
*/
move(direction: Direction, event?: KeyboardEvent | PointerEvent) {
switch (direction) {
case Direction.UP:
this.totalDelta.y -= this.stepDistance;
break;
case Direction.DOWN:
this.totalDelta.y += this.stepDistance;
break;
case Direction.LEFT:
this.totalDelta.x -= this.stepDistance;
break;
case Direction.RIGHT:
this.totalDelta.x += this.stepDistance;
break;
}
this.dragger?.onDrag(event, this.totalPixelDelta());
this.updateTotalDelta();
this.scrollCurrentElementIntoView();
this.repositionMoveIndicator();
return true;
}
/**
* Finish moving the item that is currently being moved.
*
* @param event The event that triggered the end of the move, if any.
* @returns True iff move successfully finished.
*/
finishMove(event?: KeyboardEvent | PointerEvent) {
this.preDragEndCleanup();
this.dragger?.onDragEnd(event, this.totalPixelDelta());
this.postDragEndCleanup();
return true;
}
/**
* Abort moving the currently-focused item on workspace.
*
* @param event The event that triggered the end of the move, if any.
* @returns True iff move successfully aborted.
*/
abortMove(event?: KeyboardEvent | PointerEvent) {
this.preDragEndCleanup();
this.dragger?.onDragRevert(event, this.totalPixelDelta());
this.postDragEndCleanup();
return true;
}
/**
* Sets the distance by which an object will be moved.
*
* @param stepDistance The distance in workspace coordinates that each move
* should move elements on the workspace by.
*/
setMoveDistance(stepDistance: number) {
this.stepDistance = stepDistance;
}
/**
* Returns a list of the names of shortcuts that are allowed to be run while
* a keyboard-driven move is in progress.
*/
getAllowedShortcuts() {
return this.allowedShortcuts;
}
/**
* Adds shortcuts with the given names to the list of shortcuts that are
* allowed to be run while a keyboard-driven move is in progress.
*/
setAllowedShortcuts(shortcutNames: string[]) {
this.allowedShortcuts = shortcutNames;
}
/**
* Repositions the move indicator to the corner of the item being moved.
*/
private repositionMoveIndicator() {
renderManagement.finishQueuedRenders().then(() => {
let bounds = this.draggable?.getBoundingRectangle();
if (
this.draggable &&
'getBoundingRectangleWithoutChildren' in this.draggable
) {
bounds = this.positionForBlockMoveIndicator(this.draggable as BlockSvg);
}
if (!bounds) return;
this.moveIndicator?.moveTo(
this.draggable?.workspace.RTL ? bounds.left : bounds.right,
bounds.top,
);
});
}
/**
* Returns a bounding box used for positioning the move indicator on a block.
* Blocks require special treatment because `BlockSvg.getBoundingRectangle()`
* includes the bounds of nested and subsequent blocks. Since the move
* indicator is positioned at the top right corner of the bounds, this can
* result in it appearing to float in empty space when e.g. a small block has
* a much wider block nested inside a statement input. BlockSvg also provides
* `getBoundingRectangleWithoutChildren()`, which addresses that case, but is
* insufficient because in the case of nested *value* blocks in a row, the
* child blocks' bounds should contribute to the bounding box.
*
* @param block The block to retrieve an adjusted bounding box for.
* @returns A bounding box for the given block whose top-right corner
* corresponds to the maximum visual extent of the given block's row.
*/
private positionForBlockMoveIndicator(block: BlockSvg) {
const navigator = block.workspace.getNavigator();
let rightmost: IFocusableNode = block;
let nextCandidate = null;
// Find the rightmost element on the same visual row as the starting block.
while ((nextCandidate = navigator.getInNode(rightmost))) {
rightmost = nextCandidate;
}
// Get the parent block of the rightmost element in the case where it is
// e.g. a field.
let targetBlock = navigator.getSourceBlockFromNode(rightmost);
// Work backwards from the rightmost block; deeply nested value blocks do
// not have the same y position as their parent because they are visually
// depicted as being inside of it. Keep working up the parent block
// hierarchy until one is found with the same y position as the starting
// block, meaning is is the rightmost top-level value block in the same row
// as the starting block.
const topline = block.getBoundingRectangleWithoutChildren().getOrigin().y;
while (
targetBlock?.getBoundingRectangleWithoutChildren().getOrigin().y !==
topline
) {
targetBlock = targetBlock?.getParent() ?? null;
}
return (
targetBlock?.getBoundingRectangleWithoutChildren() ??
block.getBoundingRectangleWithoutChildren()
);
}
/**
* Common clean-up for finish/abort run before terminating the move.
*/
private preDragEndCleanup() {
ShortcutRegistry.registry.unregister(COMMIT_MOVE_SHORTCUT);
// Remove the blur listener before ending the drag
this.draggable
?.getFocusableElement()
.removeEventListener('blur', this.blurListener);
}
/**
* Common clean-up for finish/abort run after terminating the move.
*/
private postDragEndCleanup() {
this.moveIndicator?.dispose();
this.moveIndicator = undefined;
this.draggable = undefined;
this.dragger = undefined;
this.startLocation = undefined;
this.totalDelta = new Coordinate(0, 0);
}
/**
* Returns the total distance current element has moved in pixels.
*/
private totalPixelDelta() {
const scale = this.draggable?.workspace.scale ?? 1;
return new Coordinate(this.totalDelta.x * scale, this.totalDelta.y * scale);
}
/**
* Scrolls the current element into view.
*/
private scrollCurrentElementIntoView() {
if (!this.draggable) return;
const bounds = this.draggable.getBoundingRectangle();
this.draggable.workspace.scrollBoundsIntoView(bounds);
}
/**
* Recalculates the total movement delta from the starting location and the
* current position of the item being moved.
*/
private updateTotalDelta() {
if (!this.draggable || !this.startLocation) return;
this.totalDelta = new Coordinate(
this.draggable.getRelativeToSurfaceXY().x - this.startLocation.x,
this.draggable.getRelativeToSurfaceXY().y - this.startLocation.y,
);
}
}
@@ -1,414 +0,0 @@
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview The class representing a line cursor.
* A line cursor tries to traverse the blocks and connections on a block as if
* they were lines of code in a text editor. Previous and next traverse previous
* connections, next connections and blocks, while in and out traverse input
* connections and fields.
* @author aschmiedt@google.com (Abby Schmiedt)
*/
import {BlockSvg} from '../block_svg.js';
import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js';
import {getFocusManager} from '../focus_manager.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import * as registry from '../registry.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import {Marker} from './marker.js';
/**
* Class for a line cursor.
*/
export class LineCursor extends Marker {
override type = 'cursor';
/** Locations to try moving the cursor to after a deletion. */
private potentialNodes: IFocusableNode[] | null = null;
/**
* @param workspace The workspace this cursor belongs to.
*/
constructor(protected readonly workspace: WorkspaceSvg) {
super();
}
/**
* Moves the cursor to the next block or workspace comment in the pre-order
* traversal.
*
* @returns The next node, or null if the current node is not set or there is
* no next value.
*/
next(): IFocusableNode | null {
const curNode = this.getCurNode();
if (!curNode) {
return null;
}
const newNode = this.getNextNode(
curNode,
(candidate: IFocusableNode | null) => {
return (
(candidate instanceof BlockSvg &&
!candidate.outputConnection?.targetBlock()) ||
candidate instanceof RenderedWorkspaceComment
);
},
true,
);
if (newNode) {
this.setCurNode(newNode);
}
return newNode;
}
/**
* Moves the cursor to the next input connection or field
* in the pre order traversal.
*
* @returns The next node, or null if the current node is
* not set or there is no next value.
*/
in(): IFocusableNode | null {
const curNode = this.getCurNode();
if (!curNode) {
return null;
}
const newNode = this.getNextNode(curNode, () => true, true);
if (newNode) {
this.setCurNode(newNode);
}
return newNode;
}
/**
* Moves the cursor to the previous block or workspace comment in the
* pre-order traversal.
*
* @returns The previous node, or null if the current node is not set or there
* is no previous value.
*/
prev(): IFocusableNode | null {
const curNode = this.getCurNode();
if (!curNode) {
return null;
}
const newNode = this.getPreviousNode(
curNode,
(candidate: IFocusableNode | null) => {
return (
(candidate instanceof BlockSvg &&
!candidate.outputConnection?.targetBlock()) ||
candidate instanceof RenderedWorkspaceComment
);
},
true,
);
if (newNode) {
this.setCurNode(newNode);
}
return newNode;
}
/**
* Moves the cursor to the previous input connection or field in the pre order
* traversal.
*
* @returns The previous node, or null if the current node
* is not set or there is no previous value.
*/
out(): IFocusableNode | null {
const curNode = this.getCurNode();
if (!curNode) {
return null;
}
const newNode = this.getPreviousNode(curNode, () => true, true);
if (newNode) {
this.setCurNode(newNode);
}
return newNode;
}
/**
* Returns true iff the node to which we would navigate if in() were
* called is the same as the node to which we would navigate if next() were
* called - in effect, if the LineCursor is at the end of the 'current
* line' of the program.
*/
atEndOfLine(): boolean {
const curNode = this.getCurNode();
if (!curNode) return false;
const inNode = this.getNextNode(curNode, () => true, true);
const nextNode = this.getNextNode(
curNode,
(candidate: IFocusableNode | null) => {
return (
candidate instanceof BlockSvg &&
!candidate.outputConnection?.targetBlock()
);
},
true,
);
return inNode === nextNode;
}
/**
* Uses pre order traversal to navigate the Blockly AST. This will allow
* a user to easily navigate the entire Blockly AST without having to go in
* and out levels on the tree.
*
* @param node The current position in the AST.
* @param isValid A function true/false depending on whether the given node
* should be traversed.
* @param visitedNodes A set of previously visited nodes used to avoid cycles.
* @returns The next node in the traversal.
*/
private getNextNodeImpl(
node: IFocusableNode | null,
isValid: (p1: IFocusableNode | null) => boolean,
visitedNodes: Set<IFocusableNode> = new Set<IFocusableNode>(),
): IFocusableNode | null {
if (!node || visitedNodes.has(node)) return null;
let newNode =
this.workspace.getNavigator().getFirstChild(node) ||
this.workspace.getNavigator().getNextSibling(node);
let target = node;
while (target && !newNode) {
const parent = this.workspace.getNavigator().getParent(target);
if (!parent) break;
newNode = this.workspace.getNavigator().getNextSibling(parent);
target = parent;
}
if (isValid(newNode)) return newNode;
if (newNode) {
visitedNodes.add(node);
return this.getNextNodeImpl(newNode, isValid, visitedNodes);
}
return null;
}
/**
* Get the next node in the AST, optionally allowing for loopback.
*
* @param node The current position in the AST.
* @param isValid A function true/false depending on whether the given node
* should be traversed.
* @param loop Whether to loop around to the beginning of the workspace if no
* valid node was found.
* @returns The next node in the traversal.
*/
getNextNode(
node: IFocusableNode | null,
isValid: (p1: IFocusableNode | null) => boolean,
loop: boolean,
): IFocusableNode | null {
if (!node || (!loop && this.getLastNode() === node)) return null;
return this.getNextNodeImpl(node, isValid);
}
/**
* Reverses the pre order traversal in order to find the previous node. This
* will allow a user to easily navigate the entire Blockly AST without having
* to go in and out levels on the tree.
*
* @param node The current position in the AST.
* @param isValid A function true/false depending on whether the given node
* should be traversed.
* @param visitedNodes A set of previously visited nodes used to avoid cycles.
* @returns The previous node in the traversal or null if no previous node
* exists.
*/
private getPreviousNodeImpl(
node: IFocusableNode | null,
isValid: (p1: IFocusableNode | null) => boolean,
visitedNodes: Set<IFocusableNode> = new Set<IFocusableNode>(),
): IFocusableNode | null {
if (!node || visitedNodes.has(node)) return null;
const newNode =
this.getRightMostChild(
this.workspace.getNavigator().getPreviousSibling(node),
node,
) || this.workspace.getNavigator().getParent(node);
if (isValid(newNode)) return newNode;
if (newNode) {
visitedNodes.add(node);
return this.getPreviousNodeImpl(newNode, isValid, visitedNodes);
}
return null;
}
/**
* Get the previous node in the AST, optionally allowing for loopback.
*
* @param node The current position in the AST.
* @param isValid A function true/false depending on whether the given node
* should be traversed.
* @param loop Whether to loop around to the end of the workspace if no valid
* node was found.
* @returns The previous node in the traversal or null if no previous node
* exists.
*/
getPreviousNode(
node: IFocusableNode | null,
isValid: (p1: IFocusableNode | null) => boolean,
loop: boolean,
): IFocusableNode | null {
if (!node || (!loop && this.getFirstNode() === node)) return null;
return this.getPreviousNodeImpl(node, isValid);
}
/**
* Get the right most child of a node.
*
* @param node The node to find the right most child of.
* @returns The right most child of the given node, or the node if no child
* exists.
*/
private getRightMostChild(
node: IFocusableNode | null,
stopIfFound: IFocusableNode,
): IFocusableNode | null {
if (!node) return node;
let newNode = this.workspace.getNavigator().getFirstChild(node);
if (!newNode || newNode === stopIfFound) return node;
for (
let nextNode: IFocusableNode | null = newNode;
nextNode;
nextNode = this.workspace.getNavigator().getNextSibling(newNode)
) {
if (nextNode === stopIfFound) break;
newNode = nextNode;
}
return this.getRightMostChild(newNode, stopIfFound);
}
/**
* Prepare for the deletion of a block by making a list of nodes we
* could move the cursor to afterwards and save it to
* this.potentialNodes.
*
* After the deletion has occurred, call postDelete to move it to
* the first valid node on that list.
*
* The locations to try (in order of preference) are:
*
* - The current location.
* - The connection to which the deleted block is attached.
* - The block connected to the next connection of the deleted block.
* - The parent block of the deleted block.
* - A location on the workspace beneath the deleted block.
*
* N.B.: When block is deleted, all of the blocks conneccted to that
* block's inputs are also deleted, but not blocks connected to its
* next connection.
*
* @param deletedBlock The block that is being deleted.
*/
preDelete(deletedBlock: BlockSvg) {
const curNode = this.getCurNode();
const nodes: IFocusableNode[] = curNode ? [curNode] : [];
// The connection to which the deleted block is attached.
const parentConnection =
deletedBlock.previousConnection?.targetConnection ??
deletedBlock.outputConnection?.targetConnection;
if (parentConnection) {
nodes.push(parentConnection);
}
// The block connected to the next connection of the deleted block.
const nextBlock = deletedBlock.getNextBlock();
if (nextBlock) {
nodes.push(nextBlock);
}
// The parent block of the deleted block.
const parentBlock = deletedBlock.getParent();
if (parentBlock) {
nodes.push(parentBlock);
}
// A location on the workspace beneath the deleted block.
// Move to the workspace.
nodes.push(this.workspace);
this.potentialNodes = nodes;
}
/**
* Move the cursor to the first valid location in
* this.potentialNodes, following a block deletion.
*/
postDelete() {
const nodes = this.potentialNodes;
this.potentialNodes = null;
if (!nodes) throw new Error('must call preDelete first');
for (const node of nodes) {
if (!this.getSourceBlockFromNode(node)?.disposed) {
this.setCurNode(node);
return;
}
}
throw new Error('no valid nodes in this.potentialNodes');
}
/**
* Get the current location of the cursor.
*
* Overrides normal Marker getCurNode to update the current node from the
* selected block. This typically happens via the selection listener but that
* is not called immediately when `Gesture` calls
* `Blockly.common.setSelected`. In particular the listener runs after showing
* the context menu.
*
* @returns The current field, connection, or block the cursor is on.
*/
getCurNode(): IFocusableNode | null {
return getFocusManager().getFocusedNode();
}
/**
* Set the location of the cursor and draw it.
*
* Overrides normal Marker setCurNode logic to call
* this.drawMarker() instead of this.drawer.draw() directly.
*
* @param newNode The new location of the cursor.
*/
setCurNode(newNode: IFocusableNode) {
getFocusManager().focusNode(newNode);
}
/**
* Get the first navigable node on the workspace, or null if none exist.
*
* @returns The first navigable node on the workspace, or null.
*/
getFirstNode(): IFocusableNode | null {
return this.workspace.getNavigator().getFirstChild(this.workspace);
}
/**
* Get the last navigable node on the workspace, or null if none exist.
*
* @returns The last navigable node on the workspace, or null.
*/
getLastNode(): IFocusableNode | null {
const first = this.getFirstNode();
return this.getPreviousNode(first, () => true, true);
}
}
registry.register(registry.Type.CURSOR, registry.DEFAULT, LineCursor);
@@ -1,86 +0,0 @@
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* The class representing a marker.
* Used primarily for keyboard navigation to show a marked location.
*
* @class
*/
// Former goog.module ID: Blockly.Marker
import {BlockSvg} from '../block_svg.js';
import {Field} from '../field.js';
import {Icon} from '../icons/icon.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import {RenderedConnection} from '../rendered_connection.js';
/**
* Class for a marker.
* This is used in keyboard navigation to save a location in the Blockly AST.
*/
export class Marker {
/** The colour of the marker. */
colour: string | null = null;
/** The current location of the marker. */
protected curNode: IFocusableNode | null = null;
/** The type of the marker. */
type = 'marker';
/**
* Gets the current location of the marker.
*
* @returns The current field, connection, or block the marker is on.
*/
getCurNode(): IFocusableNode | null {
return this.curNode;
}
/**
* Set the location of the marker and call the update method.
*
* @param newNode The new location of the marker, or null to remove it.
*/
setCurNode(newNode: IFocusableNode | null) {
this.curNode = newNode;
}
/** Dispose of this marker. */
dispose() {
this.curNode = null;
}
/**
* Returns the block that the given node is a child of.
*
* @returns The parent block of the node if any, otherwise null.
*/
getSourceBlockFromNode(node: IFocusableNode | null): BlockSvg | null {
if (node instanceof BlockSvg) {
return node;
} else if (node instanceof Field) {
return node.getSourceBlock() as BlockSvg;
} else if (node instanceof RenderedConnection) {
return node.getSourceBlock();
} else if (node instanceof Icon) {
return node.getSourceBlock() as BlockSvg;
}
return null;
}
/**
* Returns the block that this marker's current node is a child of.
*
* @returns The parent block of the marker's current node if any, otherwise
* null.
*/
getSourceBlock(): BlockSvg | null {
return this.getSourceBlockFromNode(this.getCurNode());
}
}
@@ -0,0 +1,84 @@
/**
* @license
* Copyright 2026 Raspberry Pi Foundation
* SPDX-License-Identifier: Apache-2.0
*/
import * as dom from '../utils/dom.js';
import {Svg} from '../utils/svg.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
/**
* Four-way arrow indicator attached to a workspace element to indicate that it
* is being moved.
*/
export class MoveIndicator {
/**
* Root SVG element for the indicator.
*/
svgRoot: SVGGElement;
/**
* Creates a new move indicator.
*
* @param workspace The workspace the indicator should be displayed on.
*/
constructor(private workspace: WorkspaceSvg) {
this.svgRoot = dom.createSvgElement(
Svg.G,
{},
// Mutator workspaces don't have a drag layer, so fall back to the block
// layer.
workspace.getLayerManager()?.getDragLayer() ??
workspace.getLayerManager()?.getBlockLayer(),
);
this.svgRoot.classList.add('blocklyMoveIndicator');
const rtl = workspace.RTL;
dom.createSvgElement(
Svg.CIRCLE,
{
'fill': 'white',
'fill-opacity': '0.8',
'stroke': 'grey',
'stroke-width': '1',
'r': 20,
'cx': 20 * (rtl ? -1 : 1),
'cy': 20,
},
this.svgRoot,
);
dom.createSvgElement(
Svg.PATH,
{
'fill': 'none',
'stroke': 'black',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
'd': 'm18 9l3 3l-3 3m-3-3h6M6 9l-3 3l3 3m-3-3h6m0 6l3 3l3-3m-3-3v6m3-15l-3-3l-3 3m3-3v6',
'transform': `translate(${(rtl ? -4 : 1) * 8} 8)`,
},
this.svgRoot,
);
}
/**
* Moves this indicator to the specified location.
*
* @param x The location on the X axis to move to.
* @param y The location on the Y axis to move to.
*/
moveTo(x: number, y: number) {
this.svgRoot.setAttribute(
'transform',
`translate(${x + (this.workspace.RTL ? 20 : -20)}, ${y - 20})`,
);
}
/**
* Disposes of this move indicator.
*/
dispose() {
dom.removeNode(this.svgRoot);
}
}
@@ -0,0 +1,195 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {BlockSvg} from '../../block_svg.js';
import type {IFocusableNode} from '../../interfaces/i_focusable_node.js';
import {hasBubble} from '../../interfaces/i_has_bubble.js';
import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js';
import {RenderedConnection} from '../../rendered_connection.js';
/**
* Set of rules controlling keyboard navigation from a block.
*/
export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
/**
* Returns the first child of the given block.
*
* @param current The block to return the first child of.
* @returns The first icon, field, or input of the given block, if any.
*/
getFirstChild(current: BlockSvg): IFocusableNode | null {
return getBlockNavigationCandidates(current)[0];
}
/**
* Returns the parent of the given block.
*
* @param current The block to return the parent of.
* @returns The top block of the given block's stack, or the connection to
* which it is attached.
*/
getParent(current: BlockSvg): IFocusableNode | null {
if (current.previousConnection?.targetBlock()) {
const surroundParent = current.getSurroundParent();
if (surroundParent) return surroundParent;
} else if (current.outputConnection?.targetBlock()) {
return current.outputConnection.targetBlock();
}
return current.workspace;
}
/**
* Returns the next peer node of the given block.
*
* @param current The block to find the following element of.
* @returns The block's next connection, or the next peer on its parent block,
* otherwise null.
*/
getNextSibling(current: BlockSvg): IFocusableNode | null {
if (current.nextConnection) {
return current.nextConnection.targetBlock() ?? current.nextConnection;
} else if (current.outputConnection?.targetConnection) {
const parent = this.getParent(current) as BlockSvg;
return navigateBlock(parent, current, 1);
}
return null;
}
/**
* Returns the previous peer node of the given block.
*
* @param current The block to find the preceding element of.
* @returns The block's previous connection, or the previous peer on its
* parent block, otherwise null.
*/
getPreviousSibling(current: BlockSvg): IFocusableNode | null {
if (current.previousConnection?.targetBlock()) {
return current.previousConnection;
} else if (current.outputConnection?.targetBlock()) {
return navigateBlock(
current.outputConnection.targetBlock()!,
current,
-1,
);
}
return null;
}
/**
* Returns the visual row ID of the given block.
*
* @param current The block to retrieve the row ID of.
* @returns The row ID of the given block.
*/
getRowId(current: BlockSvg) {
return current.getRowId();
}
/**
* Returns whether or not the given block can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given block can be focused.
*/
isNavigable(current: BlockSvg): boolean {
return current.canBeFocused();
}
/**
* Returns whether the given object can be navigated from by this policy.
*
* @param current The object to check if this policy applies to.
* @returns True if the object is a BlockSvg.
*/
isApplicable(current: any): current is BlockSvg {
return current instanceof BlockSvg;
}
}
/**
* Returns a list of the navigable children of the given block.
*
* @param block The block to retrieve the navigable children of.
* @returns A list of navigable/focusable children of the given block.
*/
function getBlockNavigationCandidates(block: BlockSvg): IFocusableNode[] {
const candidates: IFocusableNode[] = [];
// Icons and open bubbles are navigable.
for (const icon of block.getIcons()) {
// Icons hidden when the block is collapsed shouldn't be navigable.
if (block.isCollapsed() && !icon.isShownWhenCollapsed()) continue;
candidates.push(icon);
let bubble;
if (
hasBubble(icon) &&
icon.bubbleIsVisible() &&
(bubble = icon.getBubble())
) {
candidates.push(bubble);
}
}
for (const input of block.inputList) {
// Invisible inputs are not valid navigation candidates.
if (!input.isVisible()) continue;
// Fields are navigable.
candidates.push(...input.fieldRow);
// Connections on inputs are navigable.
const connection = input.connection;
if (!connection) continue;
candidates.push(connection as RenderedConnection);
// Child blocks attached to inputs are navigable.
const attachedBlock = connection.targetBlock();
if (!attachedBlock) continue;
candidates.push(attachedBlock as BlockSvg);
// The last (empty) next connection in a child statement block stack is
// navigable.
const lastConnection = attachedBlock.lastConnectionInStack(false);
if (!lastConnection) continue;
candidates.push(lastConnection as RenderedConnection);
}
// The block's next connection is navigable.
if (block.nextConnection && !block.isCollapsed()) {
candidates.push(block.nextConnection);
}
return candidates;
}
/**
* Returns the next navigable item relative to the provided block child.
*
* @param block The block whose children should be navigated.
* @param current The navigable block child item to navigate relative to.
* @param delta The difference in index to navigate; positive values navigate
* forward by n, while negative values navigate backwards by n.
* @returns The navigable block child offset by `delta` relative to `current`.
*/
export function navigateBlock(
block: BlockSvg,
current: IFocusableNode,
delta: number,
): IFocusableNode | null {
const candidates = getBlockNavigationCandidates(block);
const currentIndex = candidates.indexOf(current);
if (currentIndex === -1) return null;
const targetIndex = currentIndex + delta;
if (targetIndex >= 0 && targetIndex < candidates.length) {
return candidates[targetIndex];
}
return null;
}
@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {BlockSvg} from '../../block_svg.js';
import {Bubble} from '../../bubbles/bubble.js';
import type {Icon} from '../../icons/icon.js';
import type {IFocusableNode} from '../../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js';
import {navigateBlock} from './block_navigation_policy.js';
/**
* Set of rules controlling keyboard navigation from a Bubble.
*/
export class BubbleNavigationPolicy implements INavigationPolicy<Bubble> {
/**
* Returns the first child of the given bubble.
*
* @param _current The bubble to return the first child of.
* @returns Null.
*/
getFirstChild(_current: Bubble): IFocusableNode | null {
return null;
}
/**
* Returns the parent of the given bubble.
*
* @param current The bubble to return the parent of.
* @returns The parent block of the given bubble.
*/
getParent(current: Bubble): IFocusableNode | null {
return current.getOwner() ?? null;
}
/**
* Returns the next peer node of the given bubble.
*
* @param current The bubble to find the following element of.
* @returns The next navigable item on the bubble's icon's parent block.
*/
getNextSibling(current: Bubble): IFocusableNode | null {
return navigateBlock(
(current.getOwner() as Icon | undefined)?.getSourceBlock() as BlockSvg,
current,
1,
);
}
/**
* Returns the previous peer node of the given bubble.
*
* @param current The bubble to find the preceding element of.
* @returns The previous navigable item on the bubble's icon's parent block.
*/
getPreviousSibling(current: Bubble): IFocusableNode | null {
return navigateBlock(
(current.getOwner() as Icon | undefined)?.getSourceBlock() as BlockSvg,
current,
-1,
);
}
/**
* Returns the row ID of the given bubble.
*
* @param current The bubble to retrieve the row ID of.
* @returns The row ID of the given bubble.
*/
getRowId(current: Bubble) {
return (
(
(current.getOwner() as Icon | undefined)?.getSourceBlock() as
| BlockSvg
| undefined
)?.getRowId() ?? ''
);
}
/**
* Returns whether or not the given bubble can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given bubble can be focused.
*/
isNavigable(current: Bubble): boolean {
return current.canBeFocused();
}
/**
* Returns whether the given object can be navigated from by this policy.
*
* @param current The object to check if this policy applies to.
* @returns True if the object is an Bubble.
*/
isApplicable(current: any): current is Bubble {
return current instanceof Bubble;
}
}
@@ -4,16 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {CommentBarButton} from '../comments/comment_bar_button.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
import {CommentBarButton} from '../../comments/comment_bar_button.js';
import type {IFocusableNode} from '../../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js';
/**
* Set of rules controlling keyboard navigation from a CommentBarButton.
*/
export class CommentBarButtonNavigationPolicy
implements INavigationPolicy<CommentBarButton>
{
export class CommentBarButtonNavigationPolicy implements INavigationPolicy<CommentBarButton> {
/**
* Returns the first child of the given CommentBarButton.
*
@@ -66,6 +64,16 @@ export class CommentBarButtonNavigationPolicy
return null;
}
/**
* Returns the row ID of the given CommentBarButton.
*
* @param current The CommentBarButton to retrieve the row ID of.
* @returns The row ID of the given CommentBarButton.
*/
getRowId(current: CommentBarButton) {
return current.getCommentView().commentId;
}
/**
* Returns whether or not the given CommentBarButton can be navigated to.
*
@@ -4,18 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {CommentEditor} from '../comments/comment_editor.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
import {CommentEditor} from '../../comments/comment_editor.js';
import type {IFocusableNode} from '../../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js';
/**
* Set of rules controlling keyboard navigation from a comment editor.
* This is a no-op placeholder (other than isNavigable/isApplicable) since
* comment editors handle their own navigation when editing ends.
*/
export class CommentEditorNavigationPolicy
implements INavigationPolicy<CommentEditor>
{
export class CommentEditorNavigationPolicy implements INavigationPolicy<CommentEditor> {
getFirstChild(_current: CommentEditor): IFocusableNode | null {
return null;
}
@@ -32,6 +30,16 @@ export class CommentEditorNavigationPolicy
return null;
}
/**
* Returns the row ID of the given comment editor.
*
* @param current The comment editor to retrieve the row ID of.
* @returns The row ID of the given comment editor.
*/
getRowId(current: CommentEditor) {
return current.id;
}
/**
* Returns whether or not the given comment editor can be navigated to.
*
@@ -0,0 +1,149 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {BlockSvg} from '../../block_svg.js';
import {ConnectionType} from '../../connection_type.js';
import type {IFocusableNode} from '../../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js';
import {RenderedConnection} from '../../rendered_connection.js';
import {navigateBlock} from './block_navigation_policy.js';
/**
* Set of rules controlling keyboard navigation from a connection.
*/
export class ConnectionNavigationPolicy implements INavigationPolicy<RenderedConnection> {
/**
* Returns the first child of a connection.
*
* @returns Null, as connections do not have children.
*/
getFirstChild(): IFocusableNode | null {
return null;
}
/**
* Returns the parent of the given connection.
*
* @param current The connection to return the parent of.
* @returns The given connection's parent block.
*/
getParent(current: RenderedConnection): IFocusableNode | null {
return current.getSourceBlock();
}
/**
* Returns the next element following the given connection.
*
* @param current The connection to navigate from.
* @returns The field, input connection or block following this connection.
*/
getNextSibling(current: RenderedConnection): IFocusableNode | null {
if (current.getParentInput()) {
return navigateBlock(current.getSourceBlock(), current, 1);
} else if (
current.type === ConnectionType.NEXT_STATEMENT &&
current.getSourceBlock().getSurroundParent() &&
!current.targetConnection
) {
return navigateBlock(
current.getSourceBlock().getSurroundParent()!,
current,
1,
);
}
switch (current.type) {
case ConnectionType.NEXT_STATEMENT:
return current.targetConnection;
case ConnectionType.PREVIOUS_STATEMENT:
case ConnectionType.OUTPUT_VALUE:
return current.getSourceBlock();
}
return null;
}
/**
* Returns the element preceding the given connection.
*
* @param current The connection to navigate from.
* @returns The field, input connection or block preceding this connection.
*/
getPreviousSibling(current: RenderedConnection): IFocusableNode | null {
if (current.getParentInput()) {
return navigateBlock(
current.getParentInput()!.getSourceBlock() as BlockSvg,
current,
-1,
);
}
switch (current.type) {
case ConnectionType.NEXT_STATEMENT:
return current.getSourceBlock();
case ConnectionType.PREVIOUS_STATEMENT:
case ConnectionType.OUTPUT_VALUE:
return current.targetConnection;
}
return null;
}
/**
* Returns the row ID of the given connection.
*
* @param current The connection to retrieve the row ID of.
* @returns The row ID of the given connection.
*/
getRowId(current: RenderedConnection) {
switch (current.type) {
case ConnectionType.NEXT_STATEMENT:
case ConnectionType.PREVIOUS_STATEMENT:
return current.id;
case ConnectionType.INPUT_VALUE:
return current.getParentInput()!.getRowId();
case ConnectionType.OUTPUT_VALUE:
default:
return current.getSourceBlock().getRowId();
}
}
/**
* Returns whether or not the given connection can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given connection can be focused.
*/
isNavigable(current: RenderedConnection): boolean {
if (!current.canBeFocused()) return false;
// Empty next connections on block stacks inside of a C shaped block are
// navigable.
if (current.type === ConnectionType.NEXT_STATEMENT) {
if (current.targetBlock()) return false;
const rootBlock =
current.getSourceBlock().getRootBlock() ?? current.getSourceBlock();
if (current === rootBlock.lastConnectionInStack(false)) return false;
return true;
}
// Empty input connections are navigable.
return (
current.type === ConnectionType.INPUT_VALUE && !current.targetBlock()
);
}
/**
* Returns whether the given object can be navigated from by this policy.
*
* @param current The object to check if this policy applies to.
* @returns True if the object is a RenderedConnection.
*/
isApplicable(current: any): current is RenderedConnection {
return current instanceof RenderedConnection;
}
}

Some files were not shown because too many files have changed in this diff Show More