Skip to content

feat(select): add hotkey prop for items & improve keyboard navigation#3990

Open
Kiarokh wants to merge 1 commit intomainfrom
split/4-select-hotkeys
Open

feat(select): add hotkey prop for items & improve keyboard navigation#3990
Kiarokh wants to merge 1 commit intomainfrom
split/4-select-hotkeys

Conversation

@Kiarokh
Copy link
Copy Markdown
Contributor

@Kiarokh Kiarokh commented Mar 31, 2026

fix: https://github.com/Lundalogik/crm-client/issues/755

Summary by CodeRabbit

New Features

  • Added keyboard shortcut support for select options—users can now assign and use hotkeys to quickly select items when the dropdown is open
  • Hotkeys are only active when the dropdown menu is open and only for enabled options
  • Includes support for platform-specific modifier key behavior

Review:

  • Commits are atomic
  • Commits have the correct type for the changes made
  • Commits with breaking changes are marked as such

Browsers tested:

(Check any that applies, it's ok to leave boxes unchecked if testing something didn't seem relevant.)

Windows:

  • Chrome
  • Edge
  • Firefox

Linux:

  • Chrome
  • Firefox

macOS:

  • Chrome
  • Firefox
  • Safari

Mobile:

  • Chrome on Android
  • iOS

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 31, 2026

📝 Walkthrough

Walkthrough

The pull request introduces hotkey support to the Select component by extending the Option interface with an optional hotkey property, updating the template to render hotkey UI elements in menu items, implementing global keyboard event handling with hotkey matching logic in the Select component, and providing an example demonstrating the feature.

Changes

Cohort / File(s) Summary
Hotkey Support in Select Component
src/components/select/option.types.ts, src/components/select/select.template.tsx, src/components/select/select.tsx
Extended Option interface with optional hotkey property; updated template to render limel-hotkey components for options with hotkey bindings; implemented global keydown listener with hotkey normalization cache, hotkey matching logic, and selection handling supporting both single and multiple select modes.
Example Component
src/components/select/examples/select-hotkeys.tsx
Added SelectHotkeysExample Stencil component demonstrating hotkey functionality with controlled select element and options containing hotkey bindings.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Document
    participant SelectComponent
    participant OptionMatcher
    participant MenuItems

    User->>Document: Press hotkey
    Document->>SelectComponent: keydown event (menuOpen)
    activate SelectComponent
    SelectComponent->>SelectComponent: Check if reserved hotkey
    SelectComponent->>OptionMatcher: Normalize hotkey & find match
    OptionMatcher->>OptionMatcher: Search enabled options by normalized hotkey
    OptionMatcher-->>SelectComponent: Matching option found
    SelectComponent->>SelectComponent: Update internal state / emit change
    deactivate SelectComponent
    SelectComponent->>MenuItems: Render selected option
    MenuItems-->>User: Visual feedback
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly Related PRs

Suggested Labels

released

Suggested Reviewers

  • adrianschmidt
  • devbymadde
  • LucyChyzhova
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main changes: adding a hotkey prop to select items and improving keyboard navigation through hotkey support.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch split/4-select-hotkeys

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

Documentation has been published to https://lundalogik.github.io/lime-elements/versions/PR-3990/

@adrianschmidt adrianschmidt self-assigned this Mar 31, 2026
@adrianschmidt adrianschmidt mentioned this pull request Mar 31, 2026
13 tasks
@Kiarokh Kiarokh force-pushed the split/3.5-improve-visual-style branch from c53fa25 to 4ae8884 Compare April 1, 2026 09:15
Base automatically changed from split/3.5-improve-visual-style to main April 1, 2026 09:18
@Kiarokh Kiarokh force-pushed the split/4-select-hotkeys branch from abcf148 to 6cdecc7 Compare April 1, 2026 14:33
@Kiarokh Kiarokh marked this pull request as ready for review April 1, 2026 14:33
@Kiarokh Kiarokh requested a review from a team as a code owner April 1, 2026 14:33
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/select/select.tsx`:
- Around line 441-443: The selection path emits change and then closes the menu
without cancelling pending focus tasks; update the block that calls
this.change.emit(matchedOption), this.menuOpen = false, this.setTriggerFocus()
to first call cancelPendingFocus() (the same helper used in closeMenu() and
handleMenuChange()) before flipping menuOpen and setting trigger focus so any
IntersectionObserver/timeouts are cleared.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5c840184-08d6-41e8-ade6-13b34615bdc0

📥 Commits

Reviewing files that changed from the base of the PR and between 603788b and 6cdecc7.

⛔ Files ignored due to path filters (1)
  • etc/lime-elements.api.md is excluded by !etc/lime-elements.api.md
📒 Files selected for processing (4)
  • src/components/select/examples/select-hotkeys.tsx
  • src/components/select/option.types.ts
  • src/components/select/select.template.tsx
  • src/components/select/select.tsx

Comment on lines +441 to +443
this.change.emit(matchedOption);
this.menuOpen = false;
this.setTriggerFocus();
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.

⚠️ Potential issue | 🟡 Minor

Missing cancelPendingFocus() call before closing menu.

When selecting via hotkey in single-select mode, cancelPendingFocus() is not called before setting menuOpen = false. Compare with closeMenu() (line 537-541) and handleMenuChange() (line 397) which both call cancelPendingFocus(). This could leave a stale IntersectionObserver or timeout running.

Proposed fix
         this.change.emit(matchedOption);
         this.menuOpen = false;
+        this.cancelPendingFocus();
         this.setTriggerFocus();
📝 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
this.change.emit(matchedOption);
this.menuOpen = false;
this.setTriggerFocus();
this.change.emit(matchedOption);
this.menuOpen = false;
this.cancelPendingFocus();
this.setTriggerFocus();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/select/select.tsx` around lines 441 - 443, The selection path
emits change and then closes the menu without cancelling pending focus tasks;
update the block that calls this.change.emit(matchedOption), this.menuOpen =
false, this.setTriggerFocus() to first call cancelPendingFocus() (the same
helper used in closeMenu() and handleMenuChange()) before flipping menuOpen and
setting trigger focus so any IntersectionObserver/timeouts are cleared.

@adrianschmidt
Copy link
Copy Markdown
Contributor

Consolidated PR Review -- 6 Dimensions

PR Summary

This PR adds keyboard shortcut (hotkey) support to limel-select options. It introduces an optional hotkey?: string property on the Option interface, wires up document-level keydown handling (active only when the dropdown is open), renders limel-hotkey badges via primaryComponent in list items, and includes a new example component. 5 files changed, +250/-3 lines.


1. Backward Compatibility -- GOOD ✅

The changes are purely additive. The new hotkey property is optional, and no existing behavior is altered for consumers who don't use it.

  • [Low] Document-level capture-phase keydown listener calls stopPropagation/preventDefault on matched hotkeys. This could swallow events from other listeners when the dropdown is open, but only for keys explicitly configured as hotkeys by the consumer. Consistent with limel-menu's existing pattern.
  • [Low] createMenuItems gains an isOpen parameter. This is an internal function with a default value of false, so no external API impact.
  • Positives: The Option interface addition is non-breaking. The API report (etc/lime-elements.api.md) is correctly updated. The keyboard listener is only active while the dropdown is open and properly cleaned up in disconnectedCallback. The pattern closely mirrors the existing limel-menu implementation.

2. Code Quality -- ACCEPTABLE ✅

The implementation follows established patterns from limel-menu closely. Two medium issues related to divergence from that reference implementation.

  • [Medium] Missing isFromTextInput guard. limel-menu (menu.tsx:330) skips hotkey processing when the event originates from a text input, textarea, or contenteditable element. The select's handleDocumentKeyDown (select.tsx:401) does not have this guard. If a select with bare-letter hotkeys (e.g., s, p, d) is open while a text input is focusable in the same context, keystrokes intended for typing would be intercepted.
  • [Medium] Missing listener re-attachment in connectedCallback. limel-menu (menu.tsx:283-290) re-attaches the document keydown listener in connectedCallback if the menu is already open, covering the case where a component is disconnected and reconnected while open. The select does not do this, creating an asymmetry. Since menuOpen is @State() (not @Prop()), this may not be a real bug today, but it diverges from the established pattern.
  • [Low] No unit/e2e tests for hotkey selection behavior. The hotkeys.ts utility has thorough tests, but the select component's new logic (handleDocumentKeyDown, isReservedSelectHotkey, findOptionByHotkey) has no test coverage.
  • [Low] Example uses cmd+d which conflicts with the browser bookmark shortcut. The example doc block warns about this but the example itself violates the advice.
  • Positives: The normalizedHotkeyCache with @Watch('options') invalidation is a nice optimization. Reserved key logic is well-thought-out, allowing modifiers to "unlock" keys like Enter/Escape. The hotkey utility module has excellent test coverage. The example documentation is thorough.

3. Architecture -- GOOD ✅

The feature integrates cleanly with the existing architecture, reusing limel-hotkey for display, primaryComponent for list item rendering, and the shared hotkeys.ts utilities.

  • [Medium] Significant code duplication with limel-menu. The methods handleDocumentKeyDown, isReservedSelectHotkey, findOptionByHotkey, getNormalizedHotkey are nearly identical to their counterparts in menu.tsx. The same listener pattern, cache, and reserved-key logic. Reasonable for now given they operate on different types (Option vs MenuItem), but as hotkeys spread to more components, extracting a shared mixin or utility would reduce maintenance burden.
  • [Low] The isOpen parameter name in createMenuItems doesn't convey its purpose. It's only used to control the limel-hotkey disabled state. A name like enableHotkeys or a comment would clarify intent.
  • Positives: Correct reuse of primaryComponent mechanism and limel-hotkey display component. The shared hotkeys.ts utility is reused without modification. The normalizedHotkeyCache pattern is consistent with the menu's approach.

4. Security -- SAFE ✅

No significant security issues found. The hotkey string flows through normalization/tokenization and is rendered via Stencil's JSX (virtual DOM), not innerHTML.

  • [Low] No validation of hotkey string format. The hotkey property accepts any string. However, the rendering path is safe — limel-hotkey renders tokens inside <kbd><span>{text}</span></kbd> via JSX with textContent semantics. A malicious string would be rendered as literal text, not HTML.
  • Positives: No use of innerHTML or similar. Document listener is properly scoped and cleaned up. event.defaultPrevented check avoids double-handling. Hotkey normalization utilities are pure functions with no side effects.

5. Observability -- SAFE ✅

No significant issues found. This is purely front-end UI component logic with no server-side code, logging, metrics, or network calls.

  • Positives: Clean early returns with clear guard conditions make debugging straightforward. Proper resource cleanup prevents silent leaks.

6. Performance -- GOOD ✅

No blocking performance issues. The implementation is well-considered for typical option counts.

  • [Low] Linear scan in findOptionByHotkey on each keydown. Iterates all non-separator options to find a match. For typical option counts (tens of items), O(n) is perfectly adequate.
  • [Low] createMenuItems re-creates all item objects on every render. Pre-existing pattern; the added cost is one extra primaryComponent object per hotkey-bearing option — negligible.
  • Positives: The normalizedHotkeyCache avoids repeated string parsing during keydown handling. The handleDocumentKeyDown arrow function ensures stable reference for clean removeEventListener pairing. Early bailouts (mobile, defaultPrevented, event.repeat) prevent unnecessary work. No bundle size concern — hotkeys.ts and limel-hotkey are already in the bundle via limel-menu.

Overall Verdicts

Dimension Verdict
Backward Compatibility GOOD
Code Quality ACCEPTABLE
Architecture GOOD
Security SAFE
Observability SAFE
Performance GOOD

Top Recommendations

  1. [Medium from Code Quality] Add the isFromTextInput guard from limel-menu (menu.tsx:330) to handleDocumentKeyDown in select.tsx. Without it, bare-letter hotkeys like s, p, d will be intercepted when the user is typing in a text input that's focusable while the dropdown is open.
  2. [Medium from Code Quality] Consider re-attaching the document keydown listener in connectedCallback if menuOpen is true, matching the pattern in limel-menu for robustness against disconnect/reconnect cycles.
  3. [Medium from Architecture] The hotkey handling logic (handleDocumentKeyDown, reserved-key check, findByHotkey, getNormalizedHotkey, cache) is now duplicated between select.tsx and menu.tsx. Consider extracting a shared utility or mixin in a follow-up to reduce maintenance burden as hotkeys spread to more components.
  4. [Low from Code Quality] Change the example's cmd+d hotkey to something that doesn't conflict with the browser bookmark shortcut, to avoid sending mixed signals alongside the documentation warning.

🤖 Generated with Claude Code

this.setTriggerFocus();
};

private isReservedSelectHotkey(hotkey: string): boolean {
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.

@Kiarokh Small observation on isReservedSelectHotkey vs isReservedMenuHotkey:

In menu, the logic is:

if (hasModifiers) {
    return false; // any modified key is allowed
}
return key === 'arrowup' || ... || key === 'tab' || key === 'enter' || key === 'space' || key === 'escape';

In select, arrow keys and tab are reserved regardless of modifiers:

if (key === 'arrowup' || ... || key === 'arrowright') {
    return true; // always reserved, even with modifiers
}
if (key === 'tab') {
    return true; // always reserved, even with modifiers
}
const hasModifiers = tokens.length > 1;
return !hasModifiers && (key === 'enter' || key === 'space' || key === 'escape');

This means a hotkey like ctrl+arrowdown would work in a menu but be blocked in a select. Since unmodified arrows are used for dropdown navigation in both components equally, it seems like the logic should be the same — i.e., the select version should also do the early if (hasModifiers) return false check, matching the menu.

Was this divergence intentional, or should they be unified?

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.

Kia answered in Slack. The divergence is unintentional, so we should break out a helper and use it in both places.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yes, they both can share the same logic 😊

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants