Announcing @ngneat/cmdk — Fast, composable, unstyled command menu for Angular.

Announcing @ngneat/cmdk — Fast, composable, unstyled command menu for Angular.

A few weeks ago I and Netanel Basal came up with an idea to create cmdk like a library for the Angular community.

And I am really glad that we ended up creating it, announcing...

🎉 @ngneat/cmdk — Fast, composable, unstyled command menu for Angular.

Getting Started

You can install it through Angular CLI:

ng add @ngneat/cmdk

or with npm:

# First, install dependencies
npm install @ngneat/overview @ngneat/until-destroy @angular/cdk

npm install @ngneat/cmdk

When you install using npm or yarn, you will also need to import CmdkModule in your app.module.

import { CmdkModule } from '@ngneat/hot-toast';

@NgModule({
  imports: [CmdkModule.forRoot()],
})
class AppModule {}

Features

  • 🎨 Un-styled, so that you can provide your styles easily

  • 🥙 Provides wrapper, so that you can pass your template, component or static HTML

  • 🔍 Default filtering present

  • 🖼️ Drop in stylesheet themes provided

  • ♿ Accessible

  • ⌨️ Keyboard interaction

Here are some basic examples:

<cmdk-command>
  <input cmdkInput />
  <cmdk-list>
    <div *cmdkEmpty>No results found.</div>
    <cmdk-group label="Letters">
      <button cmdkItem>a</button>
      <button cmdkItem>b</button>
      <cmdk-separator></cmdk-separator>
      <button cmdkItem>c</button>
    </cmdk-group>
  </cmdk-list>

  <button cmdkItem>Apple</button>
</cmdk-command>

Nested items

Often selecting one item should navigate deeper, with a more refined set of items. For example, selecting "Change theme…" should show new items "Dark theme" and "Light theme". We call these sets of items "pages", and they can be implemented with a simple state:

<cmdk-command (keydown)="onKeyDown($event)">
  <input cmdkInput (input)="setSearch($event)" />
  <ng-container *ngIf="!page">
    <button cmdkItem (selected)="setPages('projects')">Search projects...</button>
    <button cmdkItem (selected)="setPages('teams')">Join a team...</button>
  </ng-container>
  <ng-container *ngIf="page === 'projects'">
    <button cmdkItem>Project A</button>
    <button cmdkItem>Project B</button>
  </ng-container>
  <ng-container *ngIf="page === 'teams'">
    <button cmdkItem>Team 1</button>
    <button cmdkItem>Team 2</button>
  </ng-container>
</cmdk-command>
pages: Array<string> = [];
search = '';

get page() {
  return this.pages[this.pages.length - 1];
}

onKeyDown(e: KeyboardEvent) {
  // Escape goes to previous page
  // Backspace goes to previous page when search is empty
  if (e.key === 'Escape' || (e.key === 'Backspace' && !this.search)) {
    e.preventDefault();
    this.pages = this.pages.slice(0, -1);
  }
}

setSearch(ev: Event) {
  this.search = (ev.target as HTMLInputElement)?.value;
}

setPages(page: string) {
  this.pages.push(page);
}

Asynchronous results

Render the items as they become available. Filtering and sorting will happen automatically.

<cmdk-command [loading]="loading">
  <input cmdkInput />
  <div *cmdkLoader>Fetching words...</div>
  <button cmdkItem *ngFor="let item of items" [value]="item">
    {{item}}
  </button>
</cmdk-command>
loading = false;

getItems() {
  this.loading = true;
  setTimeout(() => {
    this.items = ['A', 'B', 'C', 'D'];
    this.loading = false;
  }, 3000);
}

Use inside Popover

We recommend using the Angular CDK Overlay. @ngneat/cdk relies on the Angular CDK, so this will reduce your bundle size a bit due to shared dependencies.

First, configure the trigger component:

<button (click)="isDialogOpen = !isDialogOpen" cdkOverlayOrigin #trigger="cdkOverlayOrigin" [attr.aria-expanded]="isDialogOpen">
  Actions
      <kbd></kbd>
      <kbd>K</kbd>
</button>
<ng-template
  cdkConnectedOverlay
  [cdkConnectedOverlayOrigin]="trigger"
  [cdkConnectedOverlayOpen]="isDialogOpen"
>
  <app-sub-command-dialog [value]="value"></app-sub-command-dialog>
</ng-template>
isDialogOpen = false;

listener(e: KeyboardEvent) {
  if (e.key === 'k' && (e.metaKey || e.altKey)) {
    e.preventDefault();
    if (this.isDialogOpen) {
      this.isDialogOpen = false;
    } else {
      this.isDialogOpen = true;
    }
  }
}

ngOnInit() {
  document.addEventListener('keydown', (ev) => this.listener(ev));
}

ngOnDestroy() {
  document.removeEventListener('keydown', (ev) => this.listener(ev));
}

Then, render the cmdk-command inside CDK Overlay content:

<div class="cmdk-submenu">
  <cmdk-command>
    <cmdk-list>
      <cmdk-group [label]="value">
        <button cmdkItem *ngFor="let item of items" [value]="item.label">
          {{ item.label }}
        </button>
      </cmdk-group>
    </cmdk-list>
    <input cmdkInput #input placeholder="Search for actions..." />
  </cmdk-command>
</div>
readonly items: Array<{ label: string }> = [
  {
    label: 'Open Application',
  },
  {
    label: 'Show in Finder',
  },
  {
    label: 'Show Info in Finder',
  },
  {
    label: 'Add to Favorites',
  },
];

ngAfterViewInit() {
  this.input.nativeElement.focus();
}

Drop in stylesheets

You can use the provided global stylesheets to drop in as a starting point for styling.

First, include the SCSS stylesheet in your application's style file:

// Global is needed for any theme
@use "../node_modules/@ngneat/cdk/styles/scss/globals";

// Then add theme
@use "../node_modules/@ngneat/cdk/styles/scss/raycast";
// @use "../node_modules/@ngneat/cdk/styles/scss/vercel";
// @use "../node_modules/@ngneat/cdk/styles/scss/framer";
// @use "../node_modules/@ngneat/cdk/styles/scss/linear";

or, use pre-built CSS file in angular.json

// ...
"styles": [
  "...",
  "node_modules/@ngneat/cdk/styles/globals.css"
  "node_modules/@ngneat/cdk/styles/raycast.css"
],
// ...

And then wrap your cmdk-command in respective theme class:

<div class="raycast">
  <cmdk-command>
    <!-- ... -->
  </cmdk-command>
</div>

And the output will be like the below:

Check out theme code examples at cmdk/src/app/themes.

Live Demo

A live demo is available at below link:

Time to give a ⭐

If you liked our library, don't forget to give a ⭐ at the below link:


Do check out the library and let us know your feedback. You can reach out to me at @shhdharmen.

Happy Coding

🌲 🌞 😊

Did you find this article valuable?

Support Dharmen Shah by becoming a sponsor. Any amount is appreciated!