mirror of
https://github.com/google/blockly.git
synced 2026-01-11 10:57:07 +01:00
feat: add FocusManager
This is the bulk of the work for introducing the central logical unit for managing and sychronizing focus as a first-class Blockly concept with that of DOM focus. There's a lot to do yet, including: - Ensuring clicks within Blockly's scope correctly sync back to focus changes. - Adding support for, and testing, cases when focus is lost from all registered trees. - Testing nested tree propagation. - Testing the traverser utility class. - Adding implementations for IFocusableTree and IFocusableNode throughout Blockly.
This commit is contained in:
@@ -106,6 +106,7 @@ import {FlyoutItem} from './flyout_item.js';
|
||||
import {FlyoutMetricsManager} from './flyout_metrics_manager.js';
|
||||
import {FlyoutSeparator} from './flyout_separator.js';
|
||||
import {VerticalFlyout} from './flyout_vertical.js';
|
||||
import {FocusManager, getFocusManager} from './focus_manager.js';
|
||||
import {CodeGenerator} from './generator.js';
|
||||
import {Gesture} from './gesture.js';
|
||||
import {Grid} from './grid.js';
|
||||
@@ -521,6 +522,7 @@ export {
|
||||
FlyoutItem,
|
||||
FlyoutMetricsManager,
|
||||
FlyoutSeparator,
|
||||
FocusManager,
|
||||
CodeGenerator as Generator,
|
||||
Gesture,
|
||||
Grid,
|
||||
@@ -607,6 +609,7 @@ export {
|
||||
WorkspaceSvg,
|
||||
ZoomControls,
|
||||
config,
|
||||
getFocusManager,
|
||||
hasBubble,
|
||||
icons,
|
||||
inject,
|
||||
|
||||
@@ -484,4 +484,13 @@ input[type=number] {
|
||||
.blocklyDragging .blocklyIconGroup {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.blocklyActiveFocus {
|
||||
outline-color: #2ae;
|
||||
outline-width: 2px;
|
||||
}
|
||||
.blocklyPassiveFocus {
|
||||
outline-color: #3fdfff;
|
||||
outline-width: 1.5px;
|
||||
}
|
||||
`;
|
||||
|
||||
295
core/focus_manager.ts
Normal file
295
core/focus_manager.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
|
||||
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
|
||||
/**
|
||||
* Type declaration for returning focus to FocusManager upon completing an
|
||||
* ephemeral UI flow (such as a dialog).
|
||||
*
|
||||
* See FocusManager.takeEphemeralFocus for more details.
|
||||
*/
|
||||
export type ReturnEphemeralFocus = () => void;
|
||||
|
||||
/**
|
||||
* A per-page singleton that manages Blockly focus across one or more
|
||||
* IFocusableTrees, and bidirectionally synchronizes this focus with the DOM.
|
||||
*
|
||||
* Callers that wish to explicitly change input focus for select Blockly
|
||||
* components on the page should use the focus functions in this manager.
|
||||
*
|
||||
* The manager is responsible for handling focus events from the DOM (which may
|
||||
* may arise from users clicking on page elements) and ensuring that
|
||||
* corresponding IFocusableNodes are clearly marked as actively/passively
|
||||
* highlighted in the same way that this would be represented with calls to
|
||||
* focusNode().
|
||||
*/
|
||||
export class FocusManager {
|
||||
focusedNode: IFocusableNode | null = null;
|
||||
registeredTrees: Array<IFocusableTree> = [];
|
||||
|
||||
private currentlyHoldsEphemeralFocus: boolean = false;
|
||||
|
||||
constructor(
|
||||
addGlobalEventListener: (type: string, listener: EventListener) => void,
|
||||
) {
|
||||
// Register root document focus listeners for tracking when focus leaves all
|
||||
// tracked focusable trees.
|
||||
addGlobalEventListener('focusin', (event) => {
|
||||
if (!(event instanceof FocusEvent)) return;
|
||||
|
||||
// The target that now has focus.
|
||||
const activeElement = document.activeElement;
|
||||
let newNode: IFocusableNode | null = null;
|
||||
if (
|
||||
activeElement instanceof HTMLElement ||
|
||||
activeElement instanceof SVGElement
|
||||
) {
|
||||
// If the target losing focus maps to any tree, then it should be
|
||||
// updated. Per the contract of findFocusableNodeFor only one tree
|
||||
// should claim the element.
|
||||
const matchingNodes = this.registeredTrees.map((tree) =>
|
||||
tree.findFocusableNodeFor(activeElement),
|
||||
);
|
||||
newNode = matchingNodes.find((node) => !!node) ?? null;
|
||||
}
|
||||
|
||||
if (newNode) {
|
||||
this.focusNode(newNode);
|
||||
} else {
|
||||
// TODO: Set previous to passive if all trees are losing active focus.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new IFocusableTree for automatic focus management.
|
||||
*
|
||||
* If the tree currently has an element with DOM focus, it will not affect the
|
||||
* internal state in this manager until the focus changes to a new,
|
||||
* now-monitored element/node.
|
||||
*
|
||||
* This function throws if the provided tree is already currently registered
|
||||
* in this manager. Use isRegistered to check in cases when it can't be
|
||||
* certain whether the tree has been registered.
|
||||
*/
|
||||
registerTree(tree: IFocusableTree): void {
|
||||
if (this.isRegistered(tree)) {
|
||||
throw Error(`Attempted to re-register already registered tree: ${tree}.`);
|
||||
}
|
||||
this.registeredTrees.push(tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the specified tree has already been registered in this
|
||||
* manager using registerTree and hasn't yet been unregistered using
|
||||
* unregisterTree.
|
||||
*/
|
||||
isRegistered(tree: IFocusableTree): boolean {
|
||||
return this.registeredTrees.findIndex((reg) => reg == tree) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a IFocusableTree from automatic focus management.
|
||||
*
|
||||
* If the tree had a previous focused node, it will have its highlight
|
||||
* removed. This function does NOT change DOM focus.
|
||||
*
|
||||
* This function throws if the provided tree is not currently registered in
|
||||
* this manager.
|
||||
*/
|
||||
unregisterTree(tree: IFocusableTree): void {
|
||||
if (!this.isRegistered(tree)) {
|
||||
throw Error(`Attempted to unregister not registered tree: ${tree}.`);
|
||||
}
|
||||
const treeIndex = this.registeredTrees.findIndex((tree) => tree == tree);
|
||||
this.registeredTrees.splice(treeIndex, 1);
|
||||
|
||||
const focusedNode = tree.getFocusedNode();
|
||||
const root = tree.getRootFocusableNode();
|
||||
if (focusedNode != null) this.removeHighlight(focusedNode);
|
||||
if (this.focusedNode == focusedNode || this.focusedNode == root) {
|
||||
this.focusedNode = null;
|
||||
}
|
||||
this.removeHighlight(root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current IFocusableTree that has focus, or null if none
|
||||
* currently do.
|
||||
*
|
||||
* Note also that if ephemeral focus is currently captured (e.g. using
|
||||
* takeEphemeralFocus) then the returned tree here may not currently have DOM
|
||||
* focus.
|
||||
*/
|
||||
getFocusedTree(): IFocusableTree | null {
|
||||
return this.focusedNode?.getFocusableTree() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current IFocusableNode with focus (which is always tied to a
|
||||
* focused IFocusableTree), or null if there isn't one.
|
||||
*
|
||||
* Note that this function will maintain parity with
|
||||
* IFocusableTree.getFocusedNode(). That is, if a tree itself has focus but
|
||||
* none of its non-root children do, this will return null but
|
||||
* getFocusedTree() will not.
|
||||
*
|
||||
* Note also that if ephemeral focus is currently captured (e.g. using
|
||||
* takeEphemeralFocus) then the returned node here may not currently have DOM
|
||||
* focus.
|
||||
*/
|
||||
getFocusedNode(): IFocusableNode | null {
|
||||
return this.focusedNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses the specific IFocusableTree. This either means restoring active
|
||||
* focus to the tree's passively focused node, or focusing the tree's root
|
||||
* node.
|
||||
*
|
||||
* Note that if the specified tree already has a focused node then this will
|
||||
* not change any existing focus (unless that node has passive focus, then it
|
||||
* will be restored to active focus).
|
||||
*
|
||||
* See getFocusedNode for details on how other nodes are affected.
|
||||
*
|
||||
* @param focusableTree The tree that should receive active
|
||||
* focus.
|
||||
*/
|
||||
focusTree(focusableTree: IFocusableTree): void {
|
||||
if (!this.isRegistered(focusableTree)) {
|
||||
throw Error(`Attempted to focus unregistered tree: ${focusableTree}.`);
|
||||
}
|
||||
this.focusNode(
|
||||
focusableTree.getFocusedNode() ?? focusableTree.getRootFocusableNode(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses DOM input on the selected node, and marks it as actively focused.
|
||||
*
|
||||
* Any previously focused node will be updated to be passively highlighted (if
|
||||
* it's in a different focusable tree) or blurred (if it's in the same one).
|
||||
*
|
||||
* @param focusableNode The node that should receive active
|
||||
* focus.
|
||||
*/
|
||||
focusNode(focusableNode: IFocusableNode): void {
|
||||
const curTree = focusableNode.getFocusableTree();
|
||||
if (!this.isRegistered(curTree)) {
|
||||
throw Error(`Attempted to focus unregistered node: ${focusableNode}.`);
|
||||
}
|
||||
const prevNode = this.focusedNode;
|
||||
if (prevNode && prevNode.getFocusableTree() !== curTree) {
|
||||
this.setNodeToPassive(prevNode);
|
||||
}
|
||||
// If there's a focused node in the new node's tree, ensure it's reset.
|
||||
const prevNodeCurTree = curTree.getFocusedNode();
|
||||
const curTreeRoot = curTree.getRootFocusableNode();
|
||||
if (prevNodeCurTree) {
|
||||
this.removeHighlight(prevNodeCurTree);
|
||||
}
|
||||
// For caution, ensure that the root is always reset since getFocusedNode()
|
||||
// is expected to return null if the root was highlighted, if the root is
|
||||
// not the node now being set to active.
|
||||
if (curTreeRoot !== focusableNode) {
|
||||
this.removeHighlight(curTreeRoot);
|
||||
}
|
||||
if (!this.currentlyHoldsEphemeralFocus) {
|
||||
// Only change the actively focused node if ephemeral state isn't held.
|
||||
this.setNodeToActive(focusableNode);
|
||||
}
|
||||
this.focusedNode = focusableNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ephemerally captures focus for a selected element until the returned lambda
|
||||
* is called. This is expected to be especially useful for ephemeral UI flows
|
||||
* like dialogs.
|
||||
*
|
||||
* IMPORTANT: the returned lambda *must* be called, otherwise automatic focus
|
||||
* will no longer work anywhere on the page. It is highly recommended to tie
|
||||
* the lambda call to the closure of the corresponding UI so that if input is
|
||||
* manually changed to an element outside of the ephemeral UI, the UI should
|
||||
* close and automatic input restored. Note that this lambda must be called
|
||||
* exactly once and that subsequent calls will throw an error.
|
||||
*
|
||||
* Note that the manager will continue to track DOM input signals even when
|
||||
* ephemeral focus is active, but it won't actually change node state until
|
||||
* the returned lambda is called. Additionally, only 1 ephemeral focus context
|
||||
* can be active at any given time (attempting to activate more than one
|
||||
* simultaneously will result in an error being thrown).
|
||||
*/
|
||||
takeEphemeralFocus(
|
||||
focusableElement: HTMLElement | SVGElement,
|
||||
): ReturnEphemeralFocus {
|
||||
if (this.currentlyHoldsEphemeralFocus) {
|
||||
throw Error(
|
||||
`Attempted to take ephemeral focus when it's already held, ` +
|
||||
`with new element: ${focusableElement}.`,
|
||||
);
|
||||
}
|
||||
this.currentlyHoldsEphemeralFocus = true;
|
||||
|
||||
if (this.focusedNode) {
|
||||
this.setNodeToPassive(this.focusedNode);
|
||||
}
|
||||
focusableElement.focus();
|
||||
|
||||
let hasFinishedEphemeralFocus = false;
|
||||
return () => {
|
||||
if (hasFinishedEphemeralFocus) {
|
||||
throw Error(
|
||||
`Attempted to finish ephemeral focus twice for element: ` +
|
||||
`${focusableElement}.`,
|
||||
);
|
||||
}
|
||||
hasFinishedEphemeralFocus = true;
|
||||
this.currentlyHoldsEphemeralFocus = false;
|
||||
|
||||
if (this.focusedNode) {
|
||||
this.setNodeToActive(this.focusedNode);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private setNodeToActive(node: IFocusableNode): void {
|
||||
const element = node.getFocusableElement();
|
||||
dom.addClass(element, 'blocklyActiveFocus');
|
||||
dom.removeClass(element, 'blocklyPassiveFocus');
|
||||
element.focus();
|
||||
}
|
||||
|
||||
private setNodeToPassive(node: IFocusableNode): void {
|
||||
const element = node.getFocusableElement();
|
||||
dom.removeClass(element, 'blocklyActiveFocus');
|
||||
dom.addClass(element, 'blocklyPassiveFocus');
|
||||
}
|
||||
|
||||
private removeHighlight(node: IFocusableNode): void {
|
||||
const element = node.getFocusableElement();
|
||||
dom.removeClass(element, 'blocklyActiveFocus');
|
||||
dom.removeClass(element, 'blocklyPassiveFocus');
|
||||
}
|
||||
}
|
||||
|
||||
let focusManager: FocusManager | null = null;
|
||||
|
||||
/**
|
||||
* Returns the page-global FocusManager.
|
||||
*
|
||||
* The returned instance is guaranteed to not change across function calls, but
|
||||
* may change across page loads.
|
||||
*/
|
||||
export function getFocusManager(): FocusManager {
|
||||
if (!focusManager) {
|
||||
focusManager = new FocusManager(document.addEventListener);
|
||||
}
|
||||
return focusManager;
|
||||
}
|
||||
@@ -20,7 +20,10 @@ export interface IFocusableNode {
|
||||
* - blocklyPassiveFocus
|
||||
*
|
||||
* The returned element must also have a valid ID specified, and unique to the
|
||||
* element relative to its nearest IFocusableTree parent.
|
||||
* element relative to its nearest IFocusableTree parent. It must also have a
|
||||
* negative tabindex (since the focus manager itself will manage its tab index
|
||||
* and a tab index must be present in order for the element to be focusable in
|
||||
* the DOM).
|
||||
*
|
||||
* It's expected the return element will not change for the lifetime of the
|
||||
* node.
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface IFocusableTree {
|
||||
*
|
||||
* The provided element must have a non-null ID that conforms to the contract
|
||||
* mentioned in IFocusableNode.
|
||||
*
|
||||
* This function may match against the root node of the tree.
|
||||
*/
|
||||
findFocusableNodeFor(
|
||||
element: HTMLElement | SVGElement,
|
||||
|
||||
84
core/utils/focusable_tree_traverser.ts
Normal file
84
core/utils/focusable_tree_traverser.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
|
||||
|
||||
/**
|
||||
* A helper utility for IFocusableTree implementations to aid with common
|
||||
* tree traversals.
|
||||
*/
|
||||
export class FocusableTreeTraverser {
|
||||
/**
|
||||
* Returns the current IFocusableNode that either has the CSS class
|
||||
* 'blocklyActiveFocus' or 'blocklyPassiveFocus', only considering HTML and
|
||||
* SVG elements.
|
||||
*
|
||||
* This can match against the tree's root.
|
||||
*
|
||||
* @param tree The IFocusableTree in which to search for a focused node.
|
||||
* @returns The IFocusableNode currently with focus, or null if none.
|
||||
*/
|
||||
static findFocusedNode(tree: IFocusableTree): IFocusableNode | null {
|
||||
const root = tree.getRootFocusableNode().getFocusableElement();
|
||||
const activeElem = root.querySelector('.blocklyActiveFocus');
|
||||
let active: IFocusableNode | null = null;
|
||||
if (activeElem instanceof HTMLElement || activeElem instanceof SVGElement) {
|
||||
active = tree.findFocusableNodeFor(activeElem);
|
||||
}
|
||||
const passiveElems = Array.from(
|
||||
root.querySelectorAll('.blocklyPassiveFocus'),
|
||||
);
|
||||
const passive = passiveElems.map((elem) => {
|
||||
if (elem instanceof HTMLElement || elem instanceof SVGElement) {
|
||||
return tree.findFocusableNodeFor(elem);
|
||||
} else return null;
|
||||
});
|
||||
return active || passive.find((node) => !!node) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IFocusableNode corresponding to the specified HTML or SVG
|
||||
* element iff it's the root element or a descendent of the root element of
|
||||
* the specified IFocusableTree.
|
||||
*
|
||||
* If the tree contains another nested IFocusableTree, the nested tree may be
|
||||
* traversed but its nodes will never be returned here per the contract of
|
||||
* findChildById.
|
||||
*
|
||||
* findChildById is a provided callback that takes an element ID and maps it
|
||||
* back to the corresponding IFocusableNode within the provided
|
||||
* IFocusableTree. These IDs will match the contract specified in the
|
||||
* documentation for IFocusableNode. This function must not return any node
|
||||
* that doesn't directly belong to the node's nearest parent tree.
|
||||
*
|
||||
* @param element The HTML or SVG element being sought.
|
||||
* @param tree The tree under which the provided element may be a descendant.
|
||||
* @param findChildById The ID->IFocusableNode mapping callback that must
|
||||
* follow the contract mentioned above.
|
||||
* @returns The matching IFocusableNode, or null if there is no match.
|
||||
*/
|
||||
static findFocusableNodeFor(
|
||||
element: HTMLElement | SVGElement,
|
||||
tree: IFocusableTree,
|
||||
findChildById: (id: string) => IFocusableNode | null,
|
||||
): IFocusableNode | null {
|
||||
if (element === tree.getRootFocusableNode().getFocusableElement()) {
|
||||
return tree.getRootFocusableNode();
|
||||
}
|
||||
const matchedChildNode = findChildById(element.id);
|
||||
const elementParent = element.parentElement;
|
||||
if (!matchedChildNode && elementParent) {
|
||||
// Recurse up to find the nearest tree/node.
|
||||
return FocusableTreeTraverser.findFocusableNodeFor(
|
||||
elementParent,
|
||||
tree,
|
||||
findChildById,
|
||||
);
|
||||
}
|
||||
return matchedChildNode;
|
||||
}
|
||||
}
|
||||
3919
tests/mocha/focus_manager_test.js
Normal file
3919
tests/mocha/focus_manager_test.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,11 +13,82 @@
|
||||
visibility: hidden;
|
||||
width: 1000px;
|
||||
}
|
||||
|
||||
.blocklyActiveFocus {
|
||||
outline-color: #0f0;
|
||||
outline-width: 2px;
|
||||
}
|
||||
.blocklyPassiveFocus {
|
||||
outline-color: #00f;
|
||||
outline-width: 1.5px;
|
||||
}
|
||||
div.blocklyActiveFocus {
|
||||
color: #0f0;
|
||||
}
|
||||
div.blocklyPassiveFocus {
|
||||
color: #00f;
|
||||
}
|
||||
g.blocklyActiveFocus {
|
||||
fill: #0f0;
|
||||
}
|
||||
g.blocklyPassiveFocus {
|
||||
fill: #00f;
|
||||
}
|
||||
</style>
|
||||
<body>
|
||||
<div id="mocha"></div>
|
||||
<div id="failureCount" style="display: none" tests_failed="unset"></div>
|
||||
<div id="failureMessages" style="display: none"></div>
|
||||
<div id="testFocusableTree1" tabindex="-1">
|
||||
<div id="testFocusableTree1.node1" tabindex="-1">
|
||||
Tree 1 node 1
|
||||
<div id="testFocusableTree1.node1.child1" tabindex="-1">Tree 1 node 1 child 1</div>
|
||||
</div>
|
||||
<div id="testFocusableTree1.node2" tabindex="-1">
|
||||
Tree 1 node 2
|
||||
<div id="testFocusableTree1.node2.unregisteredChild1" tabindex="-1">Tree 1 node 2 child 2 (unregistered)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="testFocusableTree2" tabindex="-1">
|
||||
<div id="testFocusableTree2.node1" tabindex="-1">Tree 2 node 1</div>
|
||||
</div>
|
||||
<div id="testUnregisteredFocusableTree3" tabindex="-1">
|
||||
<div id="testUnregisteredFocusableTree3.node1" tabindex="-1">Tree 3 node 1 (unregistered)</div>
|
||||
</div>
|
||||
<div id="nonTreeElementForEphemeralFocus" tabindex="-1" />
|
||||
<svg width="250" height="250">
|
||||
<g id="testFocusableGroup1" tabindex="-1">
|
||||
<g id="testFocusableGroup1.node1" tabindex="-1">
|
||||
<rect x="0" y="0" width="250" height="30" fill="grey" />
|
||||
<text x="10" y="20" class="svgText">Group 1 node 1</text>
|
||||
<g id="testFocusableGroup1.node1.child1" tabindex="-1">
|
||||
<rect x="0" y="30" width="250" height="30" fill="lightgrey" />
|
||||
<text x="10" y="50" class="svgText">Tree 1 node 1 child 1</text>
|
||||
</g>
|
||||
</g>
|
||||
<g id="testFocusableGroup1.node2" tabindex="-1">
|
||||
<rect x="0" y="60" width="250" height="30" fill="grey" />
|
||||
<text x="10" y="80" class="svgText">Group 1 node 2</text>
|
||||
<g id="testFocusableGroup1.node2.unregisteredChild1" tabindex="-1">
|
||||
<rect x="0" y="90" width="250" height="30" fill="lightgrey" />
|
||||
<text x="10" y="110" class="svgText">Tree 1 node 2 child 2 (unregistered)</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="testFocusableGroup2" tabindex="-1">
|
||||
<g id="testFocusableGroup2.node1" tabindex="-1">
|
||||
<rect x="0" y="120" width="250" height="30" fill="grey" />
|
||||
<text x="10" y="140" class="svgText">Group 2 node 1</text>
|
||||
</g>
|
||||
</g>
|
||||
<g id="testUnregisteredFocusableGroup3" tabindex="-1">
|
||||
<g id="testUnregisteredFocusableGroup3.node1" tabindex="-1">
|
||||
<rect x="0" y="150" width="250" height="30" fill="grey" />
|
||||
<text x="10" y="170" class="svgText">Tree 3 node 1 (unregistered)</text>
|
||||
</g>
|
||||
</g>
|
||||
<g id="nonTreeGroupForEphemeralFocus" tabindex="-1"></g>
|
||||
</svg>
|
||||
<!-- Load mocha et al. before Blockly and the test modules so that
|
||||
we can safely import the test modules that make calls
|
||||
to (e.g.) suite() at the top level. -->
|
||||
@@ -90,6 +161,8 @@
|
||||
import './field_textinput_test.js';
|
||||
import './field_variable_test.js';
|
||||
import './flyout_test.js';
|
||||
import './focus_manager_test.js';
|
||||
// import './test_event_reduction.js';
|
||||
import './generator_test.js';
|
||||
import './gesture_test.js';
|
||||
import './icon_test.js';
|
||||
|
||||
Reference in New Issue
Block a user