From 3632d848e008c2484d4affb3db6c57807c59ad23 Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Tue, 7 Apr 2026 17:07:33 +0200 Subject: [PATCH] feat(menu): enable keeping the menu open after selection --- etc/lime-elements.api.md | 4 + .../menu/examples/menu-keep-open.tsx | 168 ++++++++++++++++++ src/components/menu/menu.tsx | 30 ++++ 3 files changed, 202 insertions(+) create mode 100644 src/components/menu/examples/menu-keep-open.tsx diff --git a/etc/lime-elements.api.md b/etc/lime-elements.api.md index 1a17045588..a36df2b77a 100644 --- a/etc/lime-elements.api.md +++ b/etc/lime-elements.api.md @@ -634,6 +634,7 @@ export namespace Components { "emptyResultMessage"?: string; "gridLayout": boolean; "items": Array; + "keepOpenOnSelect": boolean; "loading": boolean; "open": boolean; "openDirection": OpenDirection; @@ -2834,6 +2835,7 @@ export namespace JSX { "emptyResultMessage"?: string; "gridLayout"?: boolean; "items"?: Array; + "keepOpenOnSelect"?: boolean; "onCancel"?: (event: LimelMenuCustomEvent) => void; "onNavigateMenu"?: (event: LimelMenuCustomEvent) => void; "onSelect"?: (event: LimelMenuCustomEvent) => void; @@ -2856,6 +2858,8 @@ export namespace JSX { // (undocumented) "gridLayout": boolean; // (undocumented) + "keepOpenOnSelect": boolean; + // (undocumented) "open": boolean; // (undocumented) "openDirection": OpenDirection; diff --git a/src/components/menu/examples/menu-keep-open.tsx b/src/components/menu/examples/menu-keep-open.tsx new file mode 100644 index 0000000000..31f332a475 --- /dev/null +++ b/src/components/menu/examples/menu-keep-open.tsx @@ -0,0 +1,168 @@ +import { + MenuItem, + ListSeparator, + LimelMenuCustomEvent, +} from '@limetech/lime-elements'; +import { Component, Host, State, h } from '@stencil/core'; + +const isApple = /Mac|iPhone|iPad|iPod/i.test( + (navigator as any).userAgentData?.platform ?? navigator.platform ?? '' +); + +type SuggestionValue = { + propertyName?: string; + searchTerm?: string; + applyAll?: boolean; +}; + +const ALL_PROPERTIES = [ + 'Name', + 'Company', + 'City', + 'Country', + 'Email', + 'Phone', + 'Website', +]; + +/** + * Keeping the menu open after selection + * + * By default, a menu closes as soon as the user selects an item. + * This is the expected behavior in most cases. + * + * But in some workflows, the design may require a different interaction + * pattern: the menu stays open after selection, allowing the user to make + * multiple selections in a row. + * + * For instance, when applying multiple filters + * to a dataset: the user types a keyword, selects an item to filter on, + * then selects another item — all without leaving the keyboard. + * + * Setting `keepOpenOnSelect` to `true` keeps the menu open after + * each selection. When a `searcher` is provided, the search query + * is preserved and the `searcher` is called again, so the consumer + * can update the result list (e.g. remove the item that was just applied). + */ +@Component({ + tag: 'limel-example-menu-keep-open', + shadow: true, +}) +export class MenuKeepOpenExample { + @State() + private appliedFilters: string[] = []; + + private get primaryHotkey() { + return isApple ? 'meta+enter' : 'ctrl+enter'; + } + + private get applyAllHotkey() { + return isApple ? 'meta+shift+enter' : 'ctrl+shift+enter'; + } + + public render() { + return ( + + + + + + + ); + } + + private readonly handleSearch = async ( + queryString: string + ): Promise> => { + const searchTerm = queryString?.trim() ?? ''; + if (searchTerm.length === 0) { + return []; + } + + const menuItems = ALL_PROPERTIES.filter((item) => { + return ( + item.toLowerCase().includes(searchTerm.toLowerCase()) && + !this.appliedFilters.includes(item) + ); + }).map((propertyName) => { + return { + text: propertyName, + value: { + propertyName, + searchTerm, + } satisfies SuggestionValue, + } as MenuItem; + }); + + if (menuItems.length === 0) { + return []; + } + + menuItems[0] = { + ...menuItems[0], + hotkey: this.primaryHotkey, + }; + + if (menuItems.length <= 1) { + return menuItems; + } + + const applyAllMenuItem: MenuItem = { + text: 'Apply all', + hotkey: this.applyAllHotkey, + value: { + applyAll: true, + searchTerm, + } satisfies SuggestionValue, + }; + + return [...menuItems, { separator: true }, applyAllMenuItem]; + }; + + private handleSelect = ( + event: LimelMenuCustomEvent> + ) => { + const selectedItem = event.detail; + if (!selectedItem?.value) { + return; + } + + if (selectedItem.value.applyAll) { + const searchTerm = selectedItem.value.searchTerm ?? ''; + const remaining = ALL_PROPERTIES.filter((item) => { + return ( + item.toLowerCase().includes(searchTerm.toLowerCase()) && + !this.appliedFilters.includes(item) + ); + }); + + this.appliedFilters = [...this.appliedFilters, ...remaining]; + + return; + } + + if (selectedItem.value.propertyName) { + this.appliedFilters = [ + ...this.appliedFilters, + selectedItem.value.propertyName, + ]; + } + }; +} diff --git a/src/components/menu/menu.tsx b/src/components/menu/menu.tsx index d644e0e0ef..48e7ac4384 100644 --- a/src/components/menu/menu.tsx +++ b/src/components/menu/menu.tsx @@ -70,6 +70,7 @@ const DEFAULT_ROOT_BREADCRUMBS_ITEM: BreadcrumbsItem = { * @exampleComponent limel-example-menu-searchable * @exampleComponent limel-example-menu-hotkeys * @exampleComponent limel-example-menu-searchable-hotkeys + * @exampleComponent limel-example-menu-keep-open * @exampleComponent limel-example-menu-composite */ @Component({ @@ -188,6 +189,12 @@ export class Menu { @Prop() public emptyResultMessage?: string; + /** + * When `true`, the menu stays open after an item is selected. + */ + @Prop({ reflect: true }) + public keepOpenOnSelect = false; + /** * Is emitted when a menu item with a sub-menu is selected. */ @@ -771,6 +778,20 @@ export class Menu { } }; + private readonly refreshSearch = async () => { + const query = this.searchValue; + this.loadingSubItems = true; + + const result = await this.searcher(query); + if (this.searchValue !== query) { + return; + } + + this.searchResults = result; + this.loadingSubItems = false; + this.setFocus(); + }; + private readonly clearSearch = () => { this.searchValue = ''; this.searchResults = null; @@ -913,6 +934,15 @@ export class Menu { this.loadingSubItems = false; this.select.emit(menuItem); + + if (this.keepOpenOnSelect) { + if (isFunction(this.searcher) && this.searchValue) { + this.refreshSearch(); + } + + return; + } + this.open = false; this.currentSubMenu = null; setTimeout(this.focusTrigger, 0);