Implement a custom Design System on top of Angular Material 🏡

Sylvain DEDIEU

Senior Software Development Engineer - #ui-foundations

Agenda

1

2

4

What is and why a DS ?

How to implement a DS ?

Tips & Tricks

3

Our trade-offs

         s.dedieu@criteo.com

Sylvain DEDIEU

Senior Software Development Engineer - #ui-foundations

         @sdedieu.bsky.social

         s.dedieu@criteo.com

Sylvain Dedieu

Get the presentation slides

🤳

What is & why a DS ?

What is a Design System ?

At its core, a design system is a set of building blocks and standards that help keep the look and feel of products and experiences consistent. Think of it as a blueprint, offering a unified language and structured framework that guides teams through the complex process of creating digital products

What is a Design System ?

What is a Design System ?

Why a Design System ?

Demo time !

Why a Design System ?

Context: Our trade-offs

How "deep" should we build it ?

343 Applications

74 Applications (most internals)

31 Applications (internals)

Cdk

Material

How "deep" should we build it ?

The customization

Material

The (bad) customization

button,
a {
  &.mat-button, &.mat-flat-button, &.mat-icon-button, &.mat-stroked-button {
    line-height: 40px;
    border-radius: 24px;
    &.small {
      line-height: 32px;
    }

    &.large {
      line-height: 48px;
    }
  }
  &.mat-button:hover .mat-button-focus-overlay, &.mat-stroked-button:hover .mat-button-focus-overlay,
  &.mat-icon-button:hover .mat-button-focus-overlay {
    opacity: 0.12;
  }
  &.mat-button, &.mat-raised-button, &.mat-flat-button, &.mat-stroked-button {
    .mat-button-wrapper {
      display: flex;
      flex-direction: row;
      align-items: center;
      justify-content: center;
    }
  }
}

// removing the pure circle on icon button ripple
.mat-icon-button .mat-button-ripple-round {
  border-radius: initial;
}

The issue

Theming

Customization

Customization

Theming

We suffer the Angular Material Components HTML changes

v15

v14

<button mat-button 
  color="primary">
  Primary
</button>
<button mat-button 
  color="primary">
  Primary
</button>

v14

<span class="mat-button-wrapper"><ng-content></ng-content></span>
<span matRipple class="mat-button-ripple"
      [class.mat-button-ripple-round]="isRoundButton || isIconButton"
      [matRippleDisabled]="_isRippleDisabled()"
      [matRippleCentered]="isIconButton"
      [matRippleTrigger]="_getHostElement()"></span>
<span class="mat-button-focus-overlay"></span>
<span
    class="mat-mdc-button-persistent-ripple"
    [class.mdc-button__ripple]="!_isFab"
    [class.mdc-fab__ripple]="_isFab"></span>

<ng-content select=".material-icons:not([iconPositionEnd]), 
  mat-icon:not([iconPositionEnd]), [matButtonIcon]:not([iconPositionEnd])">
</ng-content>

<span class="mdc-button__label"><ng-content></ng-content></span>

<ng-content select=".material-icons[iconPositionEnd], 
  mat-icon[iconPositionEnd], [matButtonIcon][iconPositionEnd]">
</ng-content>

<!--
  The indicator can't be directly on the button, because MDC uses ::before for high contrast
  indication and it can't be on the ripple, because it has a border radius and overflow: hidden.
-->
<span class="mat-mdc-focus-indicator"></span>

<span matRipple class="mat-mdc-button-ripple"
     [matRippleDisabled]="_isRippleDisabled()"
     [matRippleTrigger]="_elementRef.nativeElement"></span>

<span class="mat-mdc-button-touch-target"></span>

v15

Gains

  • Cost saving
    • API Implementation
    • API Documentation
  • Accessibility "ensured"

A bright future

However it's an ongoing process

Using Angular power for a better DX

export const CDS_BUTTON_HOST = {
  '[attr.cds-size]': '_size || null',
  '[attr.cds-type]': '_type || null',
  '[class]': '_color ? "cds-button mat-" + _color : "cds-button"',
};

@Directive({
  selector: `button[mat-button], button[mat-raised-button],
  button[mat-flat-button], button[mat-stroked-button], 
  button[mat-icon-button], a[mat-button], a[mat-raised-button],
  a[mat-flat-button], a[mat-stroked-button], a[mat-icon-button]`,
  host: CDS_BUTTON_HOST,
})
export class CdsButtonDirective {
  private _size: CdsSize = this._defaultOptions.size ?? 'medium';
  private _type!: CdsType = this._defaultOptions.type;
  private _color!: CdsButtonColor=  this._defaultOptions.color;

  @Input() color: CdsButtonColor;
  @Input('cds-size') size: CdsSize;
  @Input('cds-type') type: CdsType;
  
  constructor(
    @Optional()
    @Inject(CDS_BUTTON_DEFAULT_OPTIONS)
    private _defaultOptions?: CdsButtonDefaultOptions
  ) {}
}

Using Angular power for a better DX

import { Directive } from '@angular/core';

@Directive({
  selector: `table[mat-table], mat-table`,
  providers: [{
      provide: CDS_BUTTON_DEFAULT_OPTIONS,
      useValue: { size: 'small' },
    }],
})
export class CdsTableDirective {}

Demo time !

Writing a full native library

Cdk

Material

Writing a full native library

@customElement(elementName)
export class Button extends LitElement {

  static override styles = css`
      :host {
        display: block;
        height: fit-content;
      }

      a, button {
        --_color: var(--color, #212121);
        --_background-color: var(--background-color, #1DE9B6);
        --_border-color: var(--border-color, #1DE9B6);
     }`;

  render() {
      const { ariaLabel, ariaHasPopup, ariaExpanded } = this as ARIAMixin;
      return html`
        <button part="button" 
          type=${<'button' | 'submit' | 'reset' | 'menu'>this.elementType} 
          ?disabled="${this.disabled}" 
          ?ripple=${this.ripple} 
          ?color=${this.color} 
          ?size=${this.size}
          fill=${this.fill}
          aria-label="${ariaLabel || nothing}"
          aria-haspopup="${ariaHasPopup || nothing}"
          aria-expanded="${ariaExpanded || nothing}">
          <slot></slot>
        </button>
      `;
    }
}

🤔

Add Wrappers

import { Button as _CoreButton, Props } from '@chameleon/core';
import { ProxyCmp } from '../../utils/utils';

const BUTTON_INPUTS = [ 'color', 'disabled', 'download', ...];

export const BUTTON_PROPS: Props = _CoreButton.props;

@ProxyCmp({
  inputs: BUTTON_INPUTS,
})
@Component({
  selector: `cha-button`,
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: ` <ng-content></ng-content> `,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  inputs: BUTTON_INPUTS,
})
export class Button {
  protected el: HTMLElement;

  constructor(
    private readonly _cdr: ChangeDetectorRef,
    private readonly _ref: ElementRef,
    protected zone: NgZone
  ) {
    this._cdr.detach();
    this.el = this._ref.nativeElement;
  }
}

Add Wrappers

export function ProxyCmp(opts: { defineCustomElementFn?: () => void; inputs?: any; methods?: any }) {
  const decorator = function (cls: any) {
    const { defineCustomElementFn, inputs, methods } = opts;

    if (defineCustomElementFn !== undefined) {
      defineCustomElementFn();
    }

    if (inputs) {
      proxyInputs(cls, inputs);
    }
    if (methods) {
      proxyMethods(cls, methods);
    }
    return cls;
  };
  return decorator;
}
export const proxyOutputs = (instance: any, el: any, events: string[]) => {
  events.forEach((eventName) => (instance[eventName] = fromEvent(el, eventName)));
};

export const defineCustomElement = (tagName: string, customElement: any) => {
  if (customElement !== undefined 
      && typeof customElements !== 'undefined' 
      && !customElements.get(tagName)) {
    customElements.define(tagName, customElement);
  }
};

Add Wrappers

export const proxyMethods = (Cmp: any, methods: string[]) => {
  const Prototype = Cmp.prototype;
  methods.forEach((methodName) => {
    Prototype[methodName] = function () {
      const args = arguments;
      return this.zone.runOutsideAngular(() => this.el[methodName].apply(this.el, args));
    };
  });
};
export const proxyInputs = (Cmp: any, inputs: string[]) => {
  const Prototype = Cmp.prototype;
  inputs.forEach((item) => {
    Object.defineProperty(Prototype, item, {
      get() {
        return this.el[item];
      },
      set(val: any) {
        this.zone.runOutsideAngular(() => (this.el[item] = val));
      },
      /**
       * In the event that proxyInputs is called
       * multiple times re-defining these inputs
       * will cause an error to be thrown. As a result
       * we set configurable: true to indicate these
       * properties can be changed.
       */
      configurable: true,
    });
  });
};

Demo time !

Thank You !

Get the demo code

👩‍💻🧑‍💻

Opened positions

Implement a custom Design System on top of Angular Material.

By Dedieu Sylvain

Implement a custom Design System on top of Angular Material.

  • 4