Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions etc/lime-elements.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,8 @@ export namespace Components {
export interface LimelMenuItemMeta {
"badge"?: string | number;
"commandText"?: string;
"disabled": boolean;
"hotkey"?: string;
"showChevron": boolean;
}
// @internal (undocumented)
Expand Down Expand Up @@ -2850,6 +2852,8 @@ export namespace JSX {
export interface LimelMenuItemMeta {
"badge"?: string | number;
"commandText"?: string;
"disabled"?: boolean;
"hotkey"?: string;
"showChevron"?: boolean;
}

Expand All @@ -2860,6 +2864,10 @@ export namespace JSX {
// (undocumented)
"commandText": string;
// (undocumented)
"disabled": boolean;
// (undocumented)
"hotkey": string;
// (undocumented)
"showChevron": boolean;
}

Expand Down Expand Up @@ -4176,6 +4184,7 @@ interface MenuItem<T = any> {
badge?: number | string;
commandText?: string;
disabled?: boolean;
hotkey?: string;
icon?: string | Icon;
// @deprecated
iconColor?: Color;
Expand Down
22 changes: 21 additions & 1 deletion src/components/list-item/menu-item-meta/menu-item-meta.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Component, Host, Prop, h } from '@stencil/core';
import { normalizeHotkeyString } from '../../../util/hotkeys';

/**
* Meta content for menu list items
Expand All @@ -19,9 +20,21 @@ export class MenuItemMeta {
/**
* Use to display optional keyboard shortcut or command hint, e.g. `⌘ + K`
*/
@Prop()
@Prop({ reflect: true })
public commandText?: string;

/**
* Hotkey to display. When provided, `commandText` is ignored.
*/
@Prop({ reflect: true })
public hotkey?: string;

/**
* Will be set to `true` when the menu item is disabled.
*/
@Prop({ reflect: true })
public disabled = false;

/**
* Optional badge value
*/
Expand All @@ -45,6 +58,13 @@ export class MenuItemMeta {
}

private renderCommandText() {
if (this.hotkey) {
const hotkey = normalizeHotkeyString(this.hotkey);
if (hotkey) {
return <limel-hotkey value={hotkey} disabled={this.disabled} />;
}
}

if (!this.commandText) {
return;
}
Expand Down
9 changes: 6 additions & 3 deletions src/components/menu-list/menu-list-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,17 @@ export class MenuListRenderer {
const hasMeta =
hasSubMenu ||
item.badge !== undefined ||
(!!('commandText' in item) && !!item.commandText);
!!item.hotkey ||
!!item.commandText;

const primaryComponent = hasMeta
? {
name: 'limel-menu-item-meta',
props: {
commandText: (item as any).commandText,
badge: (item as any).badge,
commandText: item.commandText,
hotkey: item.hotkey,
disabled: !!item.disabled,
badge: item.badge,
showChevron: hasSubMenu,
},
}
Expand Down
89 changes: 74 additions & 15 deletions src/components/menu/examples/menu-hotkeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,40 @@ import {
ListSeparator,
LimelMenuCustomEvent,
} from '@limetech/lime-elements';
import { Component, h, State } from '@stencil/core';
import { Component, h, Host, State } from '@stencil/core';

/**
* Menu with supporting hotkeys
*
* Use `hotkey` to bind actual keyboard interaction while the menu is open.
*
* :::note
* 1. `commandText` is ignored and won't render, when `hotkey` is defined.
* 2. Hotkeys only work while the menu is open. They do not register
* global keyboard shortcuts — the shortcut has no effect when the
* menu is closed.
* 3. Some keys are reserved for menu interaction and will be ignored as
* hotkeys: `tab`, `enter`, `escape`, `space`, and arrow keys are reserved
* unless used with a modifier (e.g. `ctrl+enter`, `meta+arrowdown`).
* :::
*
* :::important
* 1. `meta` means the Meta key.
* It is rendered as <kbd>⌘</kbd> on Apple devices, and <kbd>⊞ Win</kbd> on
* Windows/Linux. (`cmd`, `command`, `win`, `windows` are aliases for `meta`.)
*
* If you want "primary modifier" hotkeys (e.g. <kbd>⌘</kbd> on macOS and
* <kbd>Ctrl</kbd> on Windows/Linux), detect OS in your application and
* provide different `hotkey` values.
*
* `ctrl` means the Control key on all platforms.
*
* 2. All browsers and operating systems have some built-in hotkeys
* that may conflict with your defined hotkeys.
* For example, `cmd+p` is often used to print the current page.
* Make sure to choose hotkeys that do not conflict with common browser
* hotkeys, and user's expected behavior.
* :::
*/
@Component({
tag: 'limel-example-menu-hotkeys',
Expand All @@ -18,24 +47,54 @@ export class MenuHotkeysExample {
private lastSelectedItem: string;

private items: Array<ListSeparator | MenuItem> = [
{ text: 'Copy', commandText: 'alt + C' },
{ text: 'Cut', commandText: 'alt + X' },
{ text: 'Edit message', commandText: 'Is ignored', hotkey: 'e' },
{ text: 'Mark unread', hotkey: 'u', disabled: true },
{
text: 'Remind me',
items: [
{ text: 'Later today' },
{ text: 'Tomorrow' },
{ text: 'Next week' },
],
},
{ separator: true },
{ text: 'Copy link', hotkey: 'l' },
{ text: 'Copy message', hotkey: 'meta+c' },
{ separator: true },
{ text: 'Paste', commandText: 'alt + V' },
{
text: 'Organize',
hotkey: 'o',
items: [
{ text: 'Move to', hotkey: 'm' },
{ text: 'Label as', hotkey: 'shift+l' },
{ text: 'Mute', hotkey: 'alt+m' },
],
},
{
text: 'Connect to apps',
items: [{ text: 'Trello' }, { text: 'Asana' }],
},
{ separator: true },
{ text: 'Delete message…', hotkey: 'backspace' },
];

public render() {
console.log(this.items);

return [
<limel-menu items={this.items} onSelect={this.handleSelect}>
<limel-button label="Menu" slot="trigger" />
</limel-menu>,
<limel-example-value
label="Last selected item"
value={this.lastSelectedItem}
/>,
];
return (
<Host>
<limel-menu items={this.items} onSelect={this.handleSelect}>
<limel-icon-button
label="Menu"
icon="menu_2"
elevated={true}
slot="trigger"
/>
</limel-menu>
<limel-example-value
label="Last selected item"
value={this.lastSelectedItem}
/>
</Host>
);
}

private handleSelect = (event: LimelMenuCustomEvent<MenuItem>) => {
Expand Down
160 changes: 160 additions & 0 deletions src/components/menu/examples/menu-searchable-hotkeys.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
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 ?? ''

Check warning on line 9 in src/components/menu/examples/menu-searchable-hotkeys.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'platform' is deprecated.

See more on https://sonarcloud.io/project/issues?id=Lundalogik_lime-elements&issues=AZ0hq9upshhUnMiqDkqF&open=AZ0hq9upshhUnMiqDkqF&pullRequest=3978
);
Comment thread
adrianschmidt marked this conversation as resolved.

type SuggestionValue = {
propertyName?: string;
searchTerm?: string;
applyAll?: boolean;
};

const FILTERABLE_PROPERTIES = [
'Name',
'Company',
'City',
'Country',
'Email',
'Phone',
'Website',
];

/**
* Searchable menu with hotkeys
*
* When it comes to menus that have a search functionality,
* hotkeys can be a great way to speed up selection, but it can also be
* tricky to implement in a way that feels intuitive and doesn't interfere with
* the search experience.
*
* Consider that when the menu is opened, they focus is on the search input,
* and the user is either already typing something to filter the results
* or has typed something and is seeing the filtered results, while the focus is
* still in the input field.
*
* Therefore, using a single hotkey for each item in the search results is not
* a good idea, as it can interfere with the user's ability to type their search query.
* For such scenarios, hotkey combinations that require a modifier
* key (e.g. Ctrl or Cmd) can be a good solution,
* as they allow the user to quickly select an item from the search results
* without interfering with their ability to type.
*
* In this example, the first search result gets:
* - Apple devices: <kbd>⌘</kbd> + <kbd>↩</kbd>
* - Others: <kbd>Ctrl</kbd> + <kbd>↩</kbd>
*
* The final “Apply all” item (shown only when there are many results) gets:
* - Apple devices: <kbd>⌘</kbd> + <kbd>⌥</kbd> + <kbd>↩</kbd>
* - Others: <kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>↩</kbd>
*/
@Component({
tag: 'limel-example-menu-searchable-hotkeys',
shadow: true,
})
export class MenuSearchableHotkeysExample {
@State()
private lastSelectedItem = '';

private get primaryHotkey() {
return isApple ? 'meta+enter' : 'ctrl+enter';
}

private get applyAllHotkey() {
return isApple ? 'meta+alt+enter' : 'ctrl+alt+enter';
}

public render() {
return (
<Host>
<limel-menu
searcher={this.handleSearch}
searchPlaceholder="Find property…"
onSelect={this.handleSelect}
emptyResultMessage="No matching properties"
>
<limel-chip
text="Filter"
icon={{
name: 'plus_math',
color: 'rgb(var(--contrast-800))',
title: 'Add',
}}
slot="trigger"
/>
</limel-menu>
<limel-example-value
label="Last selected item"
value={this.lastSelectedItem}
/>
</Host>
);
}

private readonly handleSearch = async (
queryString: string
): Promise<Array<MenuItem | ListSeparator>> => {
const searchTerm = queryString?.trim() ?? '';
if (searchTerm.length === 0) {
return [];
}

const menuItems = FILTERABLE_PROPERTIES.filter((item) => {
return item.toLowerCase().includes(searchTerm.toLowerCase());
}).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 = (

Check warning on line 144 in src/components/menu/examples/menu-searchable-hotkeys.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'handleSelect' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=Lundalogik_lime-elements&issues=AZ0hq9upshhUnMiqDkqH&open=AZ0hq9upshhUnMiqDkqH&pullRequest=3978
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Mark handleSelect as readonly for consistency.

The handleSearch callback is already marked as readonly. Apply the same pattern here for consistency and to prevent accidental reassignment.

♻️ Proposed fix
-    private handleSelect = (
+    private readonly handleSelect = (
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private handleSelect = (
private readonly handleSelect = (
🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[warning] 144-144: Member 'handleSelect' is never reassigned; mark it as readonly.

See more on https://sonarcloud.io/project/issues?id=Lundalogik_lime-elements&issues=AZ0hq9upshhUnMiqDkqH&open=AZ0hq9upshhUnMiqDkqH&pullRequest=3978

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/menu/examples/menu-searchable-hotkeys.tsx` at line 144, Mark
the private class field handleSelect as readonly to match handleSearch and
prevent accidental reassignment; change the declaration from the current
"private handleSelect = (…)" to "private readonly handleSelect = (…)" so the
callback is immutable and consistent with handleSearch's pattern in the same
component.

event: LimelMenuCustomEvent<MenuItem<SuggestionValue>>
) => {
const selectedItem = event.detail;
if (!selectedItem?.value) {
return;
}

if (selectedItem.value.applyAll) {
this.lastSelectedItem = `Apply all (${selectedItem.value.searchTerm})`;

return;
}

this.lastSelectedItem = `${selectedItem.value.propertyName}`;
};
}
Loading
Loading