DEV Community

Cover image for Creating a Command Palette Component in Angular (Part 1)
Heghine
Heghine

Posted on

Creating a Command Palette Component in Angular (Part 1)

Dynamic Palette Generation

Sources:

GitHub: https://github.com/ZeroaNinea/Command-Palette-Angular
GitHub Pages: https://zeroaninea.github.io/Command-Palette-Angular/


This is the first part of a series where I build a reusable and customizable command palette in Angular.

Since the component needs to support different themes, I started by creating a dynamic color palette generator. It takes a set of base colors and produces consistent palettes for primary, secondary, tertiary, neutral, and error roles.

Palette Generation

I've created a palette generation utility in the project's src/app/shared/utils/palette.util.ts directory. It uses the chroma-js library, and exports a single function createPalette that takes one parameter the base color of a string type, then it returns an object of Palette type.

The function generates a scale of 10 colors (from 50 to 900), similar to common design systems. These are then mapped to semantic roles like background, surface, text, and accent.

Here is the example of the code:

import chroma from 'chroma-js';
import { Palette } from '../../../types/palette.alias';

export function createPalette(base: string): Palette {
  const scale = chroma
    .scale([chroma(base).brighten(3), base, chroma(base).darken(3)])
    .mode('lab')
    .colors(10);

  return {
    50: scale[0],
    100: scale[1],
    200: scale[2],
    300: scale[3],
    400: scale[4],
    500: scale[5],
    600: scale[6],
    700: scale[7],
    800: scale[8],
    900: scale[9],

    // Semantic roles.
    bg: scale[0],
    surface: scale[8],
    surfaceAlt: scale[7],
    border: scale[6],
    text: chroma.contrast(scale[0], '#fff') > 4.5 ? '#fff' : '#000',
    textMuted: scale[3],
    accent: scale[4],
    accentHover: scale[3],
    accentActive: scale[2],

    // State colors.
    hover: chroma(base).brighten(0.5).hex(),
    active: chroma(base).darken(0.5).hex(),
    focus: chroma(base).saturate(1).hex(),

    // Base
    base: base,
  };
}
Enter fullscreen mode Exit fullscreen mode

Updating the Palettes Reactively

And then in the, in the root of the project, in the app.ts file. I passed six base colors to the createPalette function as default values. I used signals and the computed function to reactively update the palettes later, when the user inputs new colors.

Here's how palettes are computed reactively using Angular signals:

import { Component, computed, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';

import { createPalette } from './shared/utils/palette.util';

import { Palette } from '../types/palette.alias';

import { CommandPalette } from './command-palette/command-palette';
import { ColorInput } from './color-input/color-input';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet, CommandPalette, ColorInput],
  templateUrl: './app.html',
  styleUrl: './app.scss',
})
export class App {
  protected readonly title = signal('Command-Palette-Angular');

  primary = signal('#4FC3F7');
  secondary = signal('#2196F3');
  tertiary = signal('#086CBC');
  neutral = signal('#929CA6');
  neutralVariant = signal('#6E8E9D');
  error = signal('#E01B24');

  primaryPalette = computed<Palette>(() => createPalette(this.primary()));
  secondaryPalette = computed<Palette>(() => createPalette(this.secondary()));
  tertiaryPalette = computed<Palette>(() => createPalette(this.tertiary()));
  neutralPalette = computed<Palette>(() => createPalette(this.neutral()));
  neutralVariantPalette = computed<Palette>(() => createPalette(this.neutralVariant()));
  errorPalette = computed<Palette>(() => createPalette(this.error()));
}
Enter fullscreen mode Exit fullscreen mode

After that I added the ColorInput component that reactively updates the chosen colors and emits them back to the root of the project.

The ColorInput component contains four elements positioned absolutely, label, .text-overlay, .ripple and .color-picker-overlay:

  • label: it's the label of the input element it raises up when user hovers or focuses on the input.
  • .text-overlay: overlays the text of the input when it is lowered, and disappears on focus or hover, when the label raises up.
  • .ripple: provides a ripple effect.
  • .color-picker-overlay: overlays the color picker input, but it has a pointer-events: none, it's only used for changing the color of the input on hover.

It also has two inputs .color-text and .color-picker.

  • The .color-text is needed to input colors as a text.
  • And the .color-picker uses the default browser color picker to input colors with a visual interface.

They are both working and there is no big difference which of them to use. They are both emitting the chosen color back to the App component.

<div class="input-container">
  <label>{{ label }}</label>
  <div class="text-overlay"></div>

  <div class="input">
    <input
      type="text"
      [value]="value"
      (input)="onInput($any($event.target).value)"
      class="color-text"
    />

    <div class="color-picker-wrapper" (click)="createRipple($event)">
      <input
        type="color"
        [value]="value"
        (input)="onInput($any($event.target).value)"
        class="color-picker"
      />
      <span class="ripple"></span>
      <div
        [style.background]="
          'color-mix(in oklab, var(--cp-base) 0%, ' + getColorPickerOverlayColor() + ' 20%)'
        "
        class="color-picker-overlay"
      ></div>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

The most interesting part is that the .color-picker-overlay's color is calculated dynamically based on contrast. If the base color is dark, a lighter overlay is used, and vice versa. This ensures the hover effect remains visible regardless of the selected color.. It takes the base color, that is actually the value of the input element, and checks how light or dark it is. If it's light then it will make the overlay darker, and if it's dark the overlay will become lighter.

The .ts file of the ColorInput component looks like this:

import { Component, EventEmitter, Input, Output } from '@angular/core';

import chroma from 'chroma-js';

@Component({
  selector: 'app-color-input',
  imports: [],
  templateUrl: './color-input.html',
  styleUrl: './color-input.scss',
})
export class ColorInput {
  @Input() label = '';
  @Input() value = '#4FC3F7';
  @Output() valueChange = new EventEmitter<string>();

  getColorPickerOverlayColor() {
    return chroma.contrast(this.value, '#fff') > 4.5 ? '#fff' : '#000';
  }

  onInput(value: string) {
    this.valueChange.emit(value);
  }

  createRipple(event: MouseEvent) {
    const wrapper = event.currentTarget as HTMLElement;
    const ripple = wrapper.querySelector('.ripple') as HTMLElement;

    const rect = wrapper.getBoundingClientRect();

    const size = Math.max(rect.width, rect.height);
    const x = event.clientX - rect.left - size / 2;
    const y = event.clientY - rect.top - size / 2;

    ripple.style.width = ripple.style.height = `${size}px`;
    ripple.style.left = `${x}px`;
    ripple.style.top = `${y}px`;

    ripple.classList.remove('active');
    void ripple.offsetWidth;
    ripple.classList.add('active');
  }
}
Enter fullscreen mode Exit fullscreen mode

And finally, I call the ColorInput component in the App component’s template six times, and pass them the CSS variables, the base color and accept the emitted new color.

        <app-color-input
          label="Primary"
          [value]="primary()"
          (valueChange)="primary.set($event)"
          [style.--cp-bg]="neutralPalette().bg"
          [style.--cp-text]="primaryPalette().text"
          [style.--cp-accent]="primaryPalette().accent"
          [style.--cp-border]="primaryPalette().border"
          [style.--cp-surface]="primaryPalette().surface"
          [style.--cp-base]="primaryPalette().base"
          [style.--cp-input-border]="primaryPalette()['300']"
          [style.--cp-input-label]="primaryPalette()['900']"
          [style.--cp-label-muted]="neutralPalette()['600']"
          [style.--cp-input-text]="neutralVariantPalette()['900']"
          [style.--cp-input-ripple]="primaryPalette()['100']"
        ></app-color-input>
Enter fullscreen mode Exit fullscreen mode

Afterword

In the next part, I'll build the actual command palette (similar to VS Code's Ctrl + Shift + P), using this theming system. The goal is to make it fully customizable and reusable. It will allow users to create their own custom commands. They should name them and pass functions that they want to execute when the command it entered.

That component will be completely customizable. That's why I needed to create a color theme setup component first.

Top comments (0)