diff --git a/packages/plugins/toolbox-search/README.md b/packages/plugins/toolbox-search/README.md index 0033309f126..1003a3c7cad 100644 --- a/packages/plugins/toolbox-search/README.md +++ b/packages/plugins/toolbox-search/README.md @@ -1,8 +1,30 @@ # @blockly/toolbox-search [![Built on Blockly](https://tinyurl.com/built-on-blockly)](https://github.com/google/blockly) A [Blockly](https://www.npmjs.com/package/blockly) plugin that adds a toolbox category for searching -blocks. The category displays a search field in the toolbox, and filters the available blocks as you -type. The Blockly docs have [more information about toolbox definitions and categories](https://docs.blockly.com/guides/configure/toolboxes/category/). +blocks. Selecting the category opens its flyout with a search field pinned to the top, and the +flyout fills with matching blocks as you type. The Blockly docs have +[more information about toolbox definitions and categories](https://docs.blockly.com/guides/configure/toolboxes/category/). + +## Keyboard + +The category behaves like any other, except that moving into its flyout lands in the search field +rather than on the first block. `Ctrl`/`Cmd`+`B` also opens the category and puts the cursor there +from anywhere. + +From inside the search field, in any toolbox: + +- `Enter`: go to the first result +- `Escape`: go to the workspace +- `Left`/`Right`: move the cursor, and never leave the field + +In a vertical toolbox: + +- `Up`/`Down`: move between the search field and the results, wrapping at either end + +In a horizontal toolbox: + +- `Up`: go back to the category, if the toolbox is at the top +- `Down`: go back to the category, if the toolbox is at the bottom ## Installation diff --git a/packages/plugins/toolbox-search/src/search_field_navigation_policy.ts b/packages/plugins/toolbox-search/src/search_field_navigation_policy.ts new file mode 100644 index 00000000000..88f2b772b7d --- /dev/null +++ b/packages/plugins/toolbox-search/src/search_field_navigation_policy.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as Blockly from 'blockly/core'; +import {SearchFieldNode} from './search_field_node'; + +/** + * Set of rules controlling keyboard navigation from the search field. + * + * In a vertical flyout the field is part of the arrow-key cycle, which also + * makes it the flyout's entry point. In a horizontal flyout it is kept out of + * that cycle so that left and right stay with the text cursor. It remains the + * entry point via SearchFlyoutNavigator.isHorizontalEntryPoint(). + */ +export class SearchFieldNavigationPolicy implements Blockly.INavigationPolicy { + /** + * @param flyout The flyout the field belongs to. + */ + constructor(private readonly flyout: Blockly.IFlyout) {} + + /** + * Returns the first child of the given search field. + * + * @returns Null. + */ + getFirstChild(): Blockly.IFocusableNode | null { + return null; + } + + /** + * Returns the parent of the given search field. + * + * @returns Null; SearchFlyoutNavigator places the field itself. + */ + getParent(): Blockly.IFocusableNode | null { + return null; + } + + /** + * Returns the next sibling of the given search field. + * + * @returns Null; its neighbours come from getTopLevelItems(). + */ + getNextSibling(): Blockly.IFocusableNode | null { + return null; + } + + /** + * Returns the previous sibling of the given search field. + * + * @returns Null; its neighbours come from getTopLevelItems(). + */ + getPreviousSibling(): Blockly.IFocusableNode | null { + return null; + } + + /** + * Returns the row ID of the given search field. + * + * @param current The search field to retrieve the row ID of. + * @returns The row ID of the given search field. + */ + getRowId(current: SearchFieldNode): string { + return current.id; + } + + /** + * Returns whether or not the given search field can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True in a vertical flyout, where the field is part of the + * arrow-key cycle; false in a horizontal one, where it is reached + * explicitly instead. + */ + isNavigable(current: SearchFieldNode): boolean { + return current.canBeFocused() && !this.flyout.horizontalLayout; + } + + /** + * 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 SearchFieldNode. + */ + isApplicable(current: Blockly.IFocusableNode): current is SearchFieldNode { + return current instanceof SearchFieldNode; + } +} diff --git a/packages/plugins/toolbox-search/src/search_field_node.ts b/packages/plugins/toolbox-search/src/search_field_node.ts new file mode 100644 index 00000000000..30b925ce6ee --- /dev/null +++ b/packages/plugins/toolbox-search/src/search_field_node.ts @@ -0,0 +1,262 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from 'blockly/core'; + +/** + * The field's box size, in CSS pixels. + */ +const FIELD_HEIGHT = 24; +const FIELD_WIDTH = 150; + +/** + * The search field that a search category pins to the top of its flyout. + * + * The field is deliberately not a flyout item. Typing into it rebuilds the + * flyout's contents, which would dispose the field along with everything else. + * It is positioned over the flyout instead, and reserves room for itself with + * a separator at the head of the flyout's contents. + * + * It is still a focusable node of the flyout's workspace, so focusing it does + * not read as leaving the toolbox and close the flyout. + */ +export class SearchFieldNode + implements Blockly.IFocusableNode, Blockly.IPositionable +{ + private readonly input: HTMLInputElement; + private readonly foreignObject: SVGForeignObjectElement; + private readonly boundEvents: Blockly.browserEvents.Data[] = []; + + /** + * @param id The DOM ID of the input, which the flyout's workspace matches + * against to resolve focus back to this node. IComponent requires this + * exact name. + * @param workspace The workspace whose injection div the field is added to. + * @param flyout The flyout the field sits above. + * @param category The category the field belongs to. + */ + constructor( + readonly id: string, + private readonly workspace: Blockly.WorkspaceSvg, + private readonly flyout: Blockly.Flyout, + private readonly category: Blockly.ISelectableToolboxItem, + ) { + const html = Blockly.utils.dom.HTML_NS; + this.foreignObject = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.FOREIGNOBJECT, + {}, + ); + const body = document.createElementNS(html, 'body'); + body.setAttribute('xmlns', html); + body.className = 'blocklyMinimalBody blocklyToolboxSearchField'; + this.input = document.createElementNS(html, 'input') as HTMLInputElement; + this.input.id = id; + this.input.type = 'search'; + this.input.placeholder = Blockly.Msg['TOOLBOX_SEARCH_PLACEHOLDER']; + Blockly.utils.aria.setState( + this.input, + Blockly.utils.aria.State.LABEL, + Blockly.Msg['TOOLBOX_SEARCH_PLACEHOLDER'], + ); + + body.appendChild(this.input); + this.foreignObject.appendChild(body); + this.setVisible(false); + + flyout.getWorkspace().getSvgGroup().appendChild(this.foreignObject); + + workspace.getComponentManager().addComponent({ + component: this, + weight: 0, + capabilities: [Blockly.ComponentManager.Capability.POSITIONABLE], + }); + flyout + .getWorkspace() + .getComponentManager() + .addComponent({ + component: this, + weight: 0, + capabilities: [Blockly.ComponentManager.Capability.FOCUSABLE], + }); + + this.boundEvents.push( + Blockly.browserEvents.conditionalBind( + flyout.getWorkspace().getParentSvg(), + 'focusin', + this, + (event: FocusEvent) => this.keepClearOfField(event.target), + ), + ); + } + + /** + * Scrolls the flyout on if a newly focused item would sit under the field. + * + * Blockly scrolls a focused item just far enough to reach the edge of the + * viewport, which in a horizontal flyout is the edge the field covers. + * + * @param target The element that just took focus. + */ + private keepClearOfField(target: EventTarget | null) { + if ( + !this.flyout.horizontalLayout || + !this.canBeFocused() || + !(target instanceof Element) + ) { + return; + } + + const flyoutWorkspace = this.flyout.getWorkspace(); + + // Focus lands on an element inside the item, so climb to the item itself. + const canvas = flyoutWorkspace.getCanvas(); + let root: Element | null = target; + while (root && root.parentNode !== canvas) root = root.parentElement; + if (!root) return; + + const item = root.getBoundingClientRect(); + if (!item.width) return; + const field = this.foreignObject.getBoundingClientRect(); + // Leave the same gap the flyout leaves before its first item, so that an + // item reached by wrapping lands where scrolling to the start would put it. + // The rects are CSS pixels and these two are workspace units. + const clearance = + (this.flyout.MARGIN + + flyoutWorkspace.getRenderer().getConstants().TAB_WIDTH) * + flyoutWorkspace.scale; + const overlap = this.flyout.RTL + ? item.right - (field.left - clearance) + : field.right + clearance - item.left; + if (overlap <= 0) return; + + const offset = this.flyout.RTL ? overlap : -overlap; + flyoutWorkspace.scrollbar?.setX( + flyoutWorkspace.getMetricsManager().getViewMetrics().left + offset, + ); + } + + /** @returns The search input, which the category binds its listeners to. */ + getInput(): HTMLInputElement { + return this.input; + } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + return this.input; + } + + /** + * See IFocusableNode.getFocusableTree. + * + * @returns The flyout's workspace, not the main one: that is what stops + * focusing the field reading as leaving the toolbox. + */ + getFocusableTree(): Blockly.IFocusableTree { + return this.flyout.getWorkspace(); + } + + /** + * See IFocusableNode.canBeFocused. + * + * @returns Whether its own category's flyout is the one on screen. This is + * what keeps the field out of other categories' flyouts, since the + * navigator it installs serves all of them. + */ + canBeFocused(): boolean { + return ( + this.flyout.isVisible() && + this.workspace.getToolbox()?.getSelectedItem() === this.category + ); + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void {} + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + + /** + * Shows or hides the field, which also governs whether it can be focused. + * + * @param visible Whether the field should be shown. + */ + setVisible(visible: boolean) { + this.foreignObject.style.display = visible ? 'block' : 'none'; + } + + /** + * Returns the bounding rectangle of the UI element in pixel units relative to + * the Blockly injection div. + * + * @returns The component's bounding box. Null in this case since we don't need + * other elements to avoid the toolbox search field. + */ + getBoundingRectangle(): Blockly.utils.Rect | null { + return null; + } + + /** + * Aligns the field with the top edge of the flyout. + */ + position() { + const flyout = this.flyout; + if (!flyout.isVisible()) return; + + const margin = flyout.MARGIN; + // The flyout's own group is the origin, so getX()/getY() drop out. + let left = margin; + let width = FIELD_WIDTH; + + if (flyout.horizontalLayout) { + if (this.workspace.RTL) left = flyout.getWidth() - margin - width; + } else { + // Fit the search field within the flyout, accounting for the scrollbar. + const scrollbarInset = + Blockly.Scrollbar.scrollbarThickness + flyout.SCROLLBAR_MARGIN; + if (this.workspace.RTL) left += scrollbarInset; + width = Math.max(flyout.getWidth() - 2 * margin - scrollbarInset, 0); + } + + this.foreignObject.setAttribute('x', `${left}`); + this.foreignObject.setAttribute('y', `${margin}`); + this.foreignObject.setAttribute('width', `${width}`); + this.foreignObject.setAttribute('height', `${FIELD_HEIGHT}`); + } + + /** + * Returns the room the flyout should reserve at its head for this field. + * + * @returns The size of the field along the flyout's axis, in workspace units. + */ + getRequiredGap(): number { + const flyout = this.flyout; + const scale = flyout.getWorkspace().scale || 1; + // Leave the flyout's own margin below the field as well as room for the + // field itself. + const clearance = flyout.MARGIN; + const size = flyout.horizontalLayout ? FIELD_WIDTH : FIELD_HEIGHT; + return (size + clearance) / scale; + } + + /** + * Removes the field and unregisters it from both component managers. + */ + dispose() { + for (const event of this.boundEvents) { + Blockly.browserEvents.unbind(event); + } + this.boundEvents.length = 0; + this.workspace.getComponentManager().removeComponent(this.id); + this.flyout.getWorkspace().getComponentManager().removeComponent(this.id); + this.foreignObject.remove(); + } +} + +Blockly.Css.register(` +.blocklyToolboxSearchField>input { + width: 100%; +} +`); diff --git a/packages/plugins/toolbox-search/src/search_flyout_navigator.ts b/packages/plugins/toolbox-search/src/search_flyout_navigator.ts new file mode 100644 index 00000000000..0237614e78a --- /dev/null +++ b/packages/plugins/toolbox-search/src/search_flyout_navigator.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from 'blockly/core'; +import type {SearchFieldNode} from './search_field_node'; +import {SearchFieldNavigationPolicy} from './search_field_navigation_policy'; + +/** + * Makes the search field the flyout's entry point, so that moving in from + * the category reaches the field rather than the first block. + * + * In a vertical flyout, the user can navigate from the field to the blocks + * using the up and down arrows. + * In a horizontal flyout, the left and right arrow keys are still only used + * for the field's cursor. The match results can be reached with enter instead. + */ +export class SearchFlyoutNavigator extends Blockly.FlyoutNavigator { + private readonly policy: SearchFieldNavigationPolicy; + + /** + * @param flyout The flyout being navigated. + * @param searchNode The search field's focusable node. + */ + constructor( + flyout: Blockly.IFlyout, + private readonly searchNode: SearchFieldNode, + ) { + super(flyout); + this.policy = new SearchFieldNavigationPolicy(flyout); + this.addNavigationPolicy(this.policy); + } + + /** Returns a list of top-level navigable flyout items, plus the search field. */ + protected override getTopLevelItems(): Blockly.IFocusableNode[] { + const items = super.getTopLevelItems(); + return this.policy.isNavigable(this.searchNode) + ? [this.searchNode, ...items] + : items; + } + + /** + * Returns whether or not the given node is navigable. + * + * @param node A focusable node to check the navigability of. + * @returns True if the node is navigable, otherwise false. + */ + protected override isNavigable(node: Blockly.IFocusableNode) { + // The field is never a flyout item, so we check its own policy. + if (node === this.searchNode) { + return this.policy.isNavigable(this.searchNode); + } + // The placeholder text label is not navigable. + if (node instanceof Blockly.FlyoutButton && node.isLabel()) return false; + return super.isNavigable(node); + } + + /** + * Returns whether the search field is where arrowing in from the toolbox + * should land, for whichever of the two methods below is asking. + * + * @param requestLastEntry Whether the caller is asking for the last entry + * point (true) or the first (false). + * @returns Whether that caller should return the search field. False + * whenever the field is hidden, since this navigator serves every + * category's flyout, not just the search category's. + */ + private isHorizontalEntryPoint(requestLastEntry: boolean): boolean { + // A vertical flyout puts the field first in getTopLevelItems(), so the + // inherited getFirstNode() returns it already, and getLastNode() keeps + // its ordinary meaning, which is what vertical ArrowUp relies on to reach + // the last result. + if (!this.flyout.horizontalLayout) return false; + if (!this.searchNode.canBeFocused()) return false; + + // getInNode() takes the flyout's last node in an RTL horizontal toolbox + // and its first node otherwise. + const entryPointIsLast = !!this.flyout.targetWorkspace?.RTL; + return requestLastEntry === entryPointIsLast; + } + + /** + * Gets the first navigable node on the flyout, or null if none exist. + * + * @returns The search field in an LTR horizontal flyout, where it is the + * entry point but not part of the arrow-key cycle; otherwise the first + * item, which in a vertical flyout is the field itself. + */ + override getFirstNode(): Blockly.IFocusableNode | null { + return this.isHorizontalEntryPoint(false) + ? this.searchNode + : super.getFirstNode(); + } + + /** + * Gets the last navigable node on the flyout, or null if none exist. + * + * @returns The search field in an RTL horizontal flyout, which is the entry + * point getInNode() asks for there; otherwise the last item. + */ + override getLastNode(): Blockly.IFocusableNode | null { + return this.isHorizontalEntryPoint(true) + ? this.searchNode + : super.getLastNode(); + } +} diff --git a/packages/plugins/toolbox-search/src/toolbox_search.ts b/packages/plugins/toolbox-search/src/toolbox_search.ts index 9b699041e57..594f7d1e557 100644 --- a/packages/plugins/toolbox-search/src/toolbox_search.ts +++ b/packages/plugins/toolbox-search/src/toolbox_search.ts @@ -10,6 +10,8 @@ */ import * as Blockly from 'blockly/core'; import {BlockSearcher} from './block_searcher'; +import {SearchFieldNode} from './search_field_node'; +import {SearchFlyoutNavigator} from './search_flyout_navigator'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -20,12 +22,15 @@ import {BlockSearcher} from './block_searcher'; export class ToolboxSearchCategory extends Blockly.ToolboxCategory { private static readonly START_SEARCH_SHORTCUT = 'startSearch'; static readonly SEARCH_CATEGORY_KIND = 'search'; - private readonly SEARCH_INPUT_ID = 'toolbox-search-input'; - private searchField?: HTMLInputElement; + private searchNode?: SearchFieldNode; + private flyoutNavigator?: SearchFlyoutNavigator; + /** The flyout this category searches into. */ + private readonly flyout: Blockly.Flyout; private blockSearcher: BlockSearcher; private onChangeWrapper?: (event: Blockly.Events.Abstract) => void; private indexedBlocks = ''; private boundEvents: Blockly.browserEvents.Data[] = []; + private resultCount = 0; /** * Initializes a ToolboxSearchCategory. @@ -42,6 +47,11 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { opt_parent?: Blockly.ICollapsibleToolboxItem, ) { super(categoryDef, parentToolbox, opt_parent); + const flyout = parentToolbox.getFlyout(); + if (!(flyout instanceof Blockly.Flyout)) { + throw Error('A search category needs a toolbox that has a flyout.'); + } + this.flyout = flyout; this.blockSearcher = new BlockSearcher(this.workspace_); this.initBlockSearcher(); this.registerShortcut(); @@ -50,79 +60,80 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { } /** - * Initializes the search field toolbox category. - * - * @returns The
that will be displayed in the toolbox. + * Initializes the category and creates its search field. */ - protected override createDom_(): HTMLDivElement { - const dom = super.createDom_(); - this.searchField = document.createElement('input'); - this.searchField.id = this.SEARCH_INPUT_ID; - this.searchField.type = 'search'; - this.searchField.placeholder = Blockly.Msg['TOOLBOX_SEARCH_PLACEHOLDER']; - this.workspace_.RTL - ? (this.searchField.style.marginRight = '8px') - : (this.searchField.style.marginLeft = '8px'); + override init() { + super.init(); + const node = new SearchFieldNode( + `toolbox-search-input-${this.getId()}`, + this.workspace_, + this.flyout, + this, + ); + this.searchNode = node; + // Make the field the node that arrowing into the flyout lands on. + this.flyoutNavigator = new SearchFlyoutNavigator(this.flyout, node); + this.flyout.getWorkspace().setNavigator(this.flyoutNavigator); + + const searchField = node.getInput(); this.boundEvents.push( Blockly.browserEvents.conditionalBind( - this.searchField, + searchField, 'keydown', this, + // Blockly suppresses its own keyboard shortcuts while a text input + // has focus, so every key that should move focus is handled here. + // Left and right are left alone in both layouts: inside the field + // they belong to the cursor. (event: KeyboardEvent) => { - if ( - event.key === 'ArrowUp' && - this.searchField?.selectionStart === 0 - ) { - const previous = this.parentToolbox_ - .getNavigator() - .getPreviousNode(); - if (previous) { - Blockly.getFocusManager().focusNode(previous); - } - return; - } else if ( - event.key === 'ArrowRight' && - this.searchField?.selectionStart === this.searchField?.value.length - ) { - const previous = this.parentToolbox_.getNavigator().getInNode(); - if (previous) { - Blockly.getFocusManager().focusNode(previous); - } - return; - } else if ( - event.key === 'ArrowDown' && - this.searchField?.selectionStart === this.searchField?.value.length - ) { - const next = this.parentToolbox_.getNavigator().getNextNode(); - if (next) { - Blockly.getFocusManager().focusNode(next); - } - return; - } else if (event.key === 'Escape' && this.searchField) { - if (this.searchField.value !== '') { - this.searchField.value = ''; - event.stopPropagation(); - // Removes matches from the flyout after programmatically clearing the search field. - this.matchBlocks(); - } + const isHorizontal = this.flyout.horizontalLayout; + const leaveKey = + this.workspace_.options.toolboxPosition === + Blockly.utils.toolbox.Position.BOTTOM + ? 'ArrowDown' + : 'ArrowUp'; + let handled = true; + if (event.key === 'Escape') { + Blockly.getFocusManager().focusTree(this.workspace_); + } else if (event.key === 'Enter') { + this.focusResult(); + } else if (!isHorizontal && event.key === 'ArrowDown') { + this.focusResult(); + } else if (!isHorizontal && event.key === 'ArrowUp') { + this.focusResult(true); + } else if (isHorizontal && event.key === leaveKey) { + Blockly.getFocusManager().focusNode(this); + } else { + handled = false; + } + + if (handled) { + event.preventDefault(); } }, ), // When the user types in the search field, update the flyout to show matching blocks. - Blockly.browserEvents.conditionalBind( - this.searchField, - 'input', - this, - () => this.matchBlocks(), + Blockly.browserEvents.conditionalBind(searchField, 'input', this, () => + this.matchBlocks(), ), ); - this.rowContents_?.replaceChildren(this.searchField); - return dom; } - /** The ID of the toolbox item must match the ID of the focusable node. */ - override getId(): string { - return this.SEARCH_INPUT_ID; + /** + * Moves focus to one end of the results. + * + * @param getLast Whether to focus the last item instead of the first. + */ + private focusResult(getLast = false) { + if (!this.flyoutNavigator) return; + const result = getLast + ? this.flyoutNavigator.getLastNode() + : this.flyoutNavigator.getFirstChild( + this.flyout.getWorkspace().getRootFocusableNode(), + ); + if (result) { + Blockly.getFocusManager().focusNode(result); + } } /** @@ -136,7 +147,14 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { Blockly.ShortcutRegistry.registry.register({ name: ToolboxSearchCategory.START_SEARCH_SHORTCUT, callback: () => { - Blockly.getFocusManager().focusNode(this); + if (this.parentToolbox_.getSelectedItem() !== this) { + // Shows the flyout and the field, so that the field can take focus. + this.parentToolbox_.setSelectedItem(this); + } + this.matchBlocks(); + if (this.searchNode?.canBeFocused()) { + Blockly.getFocusManager().focusNode(this.searchNode); + } return true; }, keyCodes: [shortcut], @@ -189,63 +207,110 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { this.blockSearcher.indexBlocks([...availableBlocks]); } - /** See IFocusableNode.getFocusableElement. */ - override getFocusableElement(): HTMLElement | SVGElement { - if (!this.searchField) { - throw Error('This field currently has no representative DOM element.'); - } - return this.searchField; - } - /** See IFocusableNode.onNodeFocus. */ override onNodeFocus(): void { super.onNodeFocus(); - if (!this.searchField?.value) { - Blockly.renderManagement.finishQueuedRenders().then(() => { - this.matchBlocks(); - }); - } + Blockly.renderManagement.finishQueuedRenders().then(() => { + this.matchBlocks(); + }); + } + + /** + * Shows the search field alongside this category's flyout. + * + * @param isSelected Whether this category is now the selected one. + */ + override setSelected(isSelected: boolean) { + super.setSelected(isSelected); + this.searchNode?.setVisible(isSelected); } /** * Filters the available blocks based on the current query string. */ private matchBlocks() { - const query = this.searchField?.value || ''; - - const oldCount = this.flyoutItems_.length; - this.flyoutItems_ = query - ? this.blockSearcher.blockTypesMatching(query) - : []; - - const newCount = this.flyoutItems_.length; + const node = this.searchNode; + const field = node?.getInput(); + const query = field?.value || ''; + const results = query ? this.blockSearcher.blockTypesMatching(query) : []; - if (!this.flyoutItems_.length) { - this.flyoutItems_.push({ - kind: 'label', - text: - query.length < 3 - ? Blockly.Msg['TOOLBOX_SEARCH_PROMPT'] - : Blockly.Msg['TOOLBOX_SEARCH_NO_RESULTS'], - }); - } + const oldCount = this.resultCount; + this.resultCount = results.length; + this.flyoutItems_ = this.generateFlyoutContent(query, results); if (this.parentToolbox_.getSelectedItem() !== this) { return; } - if (oldCount !== newCount) { + if (oldCount !== results.length) { Blockly.utils.aria.announceDynamicAriaState( - newCount === 1 + results.length === 1 ? Blockly.Msg['TOOLBOX_SEARCH_RESULT_COUNT_ONE'] : Blockly.Msg['TOOLBOX_SEARCH_RESULT_COUNT'].replace( '%1', - String(newCount), + String(results.length), ), ); } + // Prevent losing the cursor position in the search field when the flyout is rebuilt. + const cursorToRestore = + field && node && Blockly.getFocusManager().getFocusedNode() === node + ? { + field, + node, + start: field.selectionStart, + end: field.selectionEnd, + } + : null; + this.parentToolbox_.refreshSelection(); + // Start each new set of results from the best match rather than from + // wherever the previous query left the flyout scrolled. + this.flyout.scrollToStart(); + + if (cursorToRestore) { + Blockly.getFocusManager().focusNode(cursorToRestore.node); + cursorToRestore.field.setSelectionRange( + cursorToRestore.start, + cursorToRestore.end, + ); + } + // Showing the flyout may have changed its width, and that path doesn't + // run positionable components. + this.searchNode?.position(); + } + + /** + * Builds the flyout contents for a query. + * + * The list always starts with a separator tall enough to clear the search + * field, so that the first result appears below it rather than behind it. + * + * @param query The current search query. + * @param results The blocks matching the query. + * @returns The contents to show in the flyout. + */ + private generateFlyoutContent( + query: string, + results: Blockly.utils.toolbox.BlockInfo[], + ): Blockly.utils.toolbox.FlyoutItemInfoArray { + const items: Blockly.utils.toolbox.FlyoutItemInfoArray = [ + {kind: 'sep', gap: this.searchNode?.getRequiredGap() ?? 0}, + ]; + + if (results.length) { + items.push(...results); + } else { + items.push({ + kind: 'label', + text: + query.length < 3 + ? Blockly.Msg['TOOLBOX_SEARCH_PROMPT'] + : Blockly.Msg['TOOLBOX_SEARCH_NO_RESULTS'], + }); + } + return items; } /** @@ -257,6 +322,8 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { Blockly.browserEvents.unbind(event); } this.boundEvents.length = 0; + this.searchNode?.dispose(); + this.searchNode = undefined; if (this.onChangeWrapper) { this.workspace_.removeChangeListener(this.onChangeWrapper); this.onChangeWrapper = undefined; @@ -314,15 +381,6 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { } } -// Make the clear button clickable in Safari. -Blockly.Css.register(` -input[type="search"]::-webkit-search-cancel-button { - -webkit-appearance: searchfield-cancel-button; - pointer-events: auto !important; - position: relative; - z-index: 10; -}`); - Blockly.registry.register( Blockly.registry.Type.TOOLBOX_ITEM, ToolboxSearchCategory.SEARCH_CATEGORY_KIND, diff --git a/packages/plugins/toolbox-search/test/index.ts b/packages/plugins/toolbox-search/test/index.ts index fcff7dfa590..737d5e35837 100644 --- a/packages/plugins/toolbox-search/test/index.ts +++ b/packages/plugins/toolbox-search/test/index.ts @@ -239,7 +239,7 @@ const toolbox = { }, ], }, - {kind: 'search', name: 'Search', contents: []}, + {kind: 'search', name: 'Search', colour: '#000', contents: []}, ], }; diff --git a/packages/plugins/toolbox-search/test/tests.mocha.js b/packages/plugins/toolbox-search/test/tests.mocha.js index 5dfd8f224de..95bec6e128e 100644 --- a/packages/plugins/toolbox-search/test/tests.mocha.js +++ b/packages/plugins/toolbox-search/test/tests.mocha.js @@ -55,6 +55,7 @@ suite('ToolboxSearchCategory', () => { .getToolbox() .getToolboxItems() .find((item) => item instanceof ToolboxSearchCategory); + this.searchField = this.searchCategory.searchNode.getInput(); /** * Types into the search field the way a user would, so the category's own @@ -62,7 +63,7 @@ suite('ToolboxSearchCategory', () => { * @param {string} query The text to search for. */ this.search = (query) => { - const field = this.searchCategory.searchField; + const field = this.searchField; this.searchCategory .getParentToolbox() .setSelectedItem(this.searchCategory); @@ -113,6 +114,66 @@ suite('ToolboxSearchCategory', () => { assert.include(flyoutBlockTypes(this.workspace), 'variables_get'); }); + + test('reserves room for the search field above the results', function () { + this.search('controls if'); + + const contents = this.searchCategory.getContents(); + assert.equal(contents[0].kind, 'sep'); + assert.deepEqual( + contents.slice(1).map((item) => item.type), + ['controls_if'], + ); + }); + + test('explains itself in the flyout when nothing matches', function () { + const label = () => this.searchCategory.getContents().slice(1); + + // Too short to have matched anything yet, so it still asks for a query. + this.search('ab'); + assert.deepEqual(label(), [ + {kind: 'label', text: Blockly.Msg['TOOLBOX_SEARCH_PROMPT']}, + ]); + + this.search('zzz'); + + assert.deepEqual(label(), [ + {kind: 'label', text: Blockly.Msg['TOOLBOX_SEARCH_NO_RESULTS']}, + ]); + }); + + test('shows and hides the search field with the flyout', function () { + const isSearchFieldVisible = () => + this.searchField.parentElement.parentElement.style.display !== 'none'; + const toolbox = this.workspace.getToolbox(); + + assert.isFalse(isSearchFieldVisible()); + + toolbox.setSelectedItem(this.searchCategory); + assert.isTrue(isSearchFieldVisible()); + + toolbox.clearSelection(); + assert.isFalse(isSearchFieldVisible()); + + const firstCategory = toolbox.contents.get( + toolbox.contents.keys().next().value, + ); + toolbox.setSelectedItem(firstCategory); + assert.isFalse(isSearchFieldVisible()); + }); + + test('remembers its query while the flyout is hidden', function () { + const toolbox = this.workspace.getToolbox(); + this.search('controls if'); + + toolbox.clearSelection(); + this.clock.runAll(); + toolbox.setSelectedItem(this.searchCategory); + this.clock.runAll(); + + assert.equal(this.searchField.value, 'controls if'); + assert.deepEqual(flyoutBlockTypes(this.workspace), ['controls_if']); + }); }); suite('BlockSearcher', () => {