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 ?

423 Applications

155 Applications (most internals)

16 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"

The way

However it's an ongoing process

An internal negociation

@mixin main() {
  .mat-mdc-form-field {
    // medium is the default size
    .mdc-text-field {
      overflow: visible;
      padding: 0;
      margin-bottom: var(--spacing-0-5);
      display: flex;
      align-items: center;
      position: relative;

      &--filled {
        .mdc-floating-label--float-above {
          display: flex;
          align-items: center;
          transform: translateY(-100%) scale(1);

          mat-label {
            display: flex;
            align-items: center;

            .mat-icon {
              margin-left: var(--spacing-0-5);
            }
          }
        }
      }

      &--disabled {
        .mat-mdc-form-field-flex {
          border: none !important;
        }
      }

      &:not(.mdc-text-field--no-label) {
        padding: var(--form-field-padding-top) 0 0 0;
      }

      .mat-mdc-form-field-flex {
        display: flex;
        align-items: center;
        border: var(--form-field-border);
        padding: var(--spacing-1) var(--spacing-2);
        border-radius: var(--form-field-border-radius);
        height: var(--height-5);
        min-height: var(--height-5);

        transition: var(--animation-duration-common) border;

        .mdc-text-field__input[type='search']::-webkit-search-decoration,
        .mdc-text-field__input[type='search']::-webkit-search-cancel-button,
        .mdc-text-field__input[type='search']::-webkit-search-results-button,
        .mdc-text-field__input[type='search']::-webkit-search-results-decoration {
          display: none;
        }

        .mat-mdc-form-field-icon-prefix {
          padding-right: var(--spacing-1);

          .mat-icon {
            padding: var(--spacing-none);
          }
        }

        .mat-mdc-form-field-icon-suffix {
          display: flex;
          padding-left: var(--spacing-1);

          .mat-icon {
            padding: var(--spacing-none);
          }
          .cds-icon-color-picker {
            border-radius: var(--radius-sm);
            border: 1px solid var(--color-border-neutral-field-default);
          }
          .cds-password-visibility-icon,
          .cds-search-remove-icon {
            cursor: pointer;
          }
        }

        .mat-mdc-form-field-infix {
          position: initial;
          display: flex;
          padding: var(--spacing-none);
          min-height: auto;

          .mat-mdc-floating-label {
            top: var(--spacing-2);
            left: var(--spacing-none);
          }
        }

        .mdc-notched-outline__notch,
        .mdc-notched-outline__leading,
        .mdc-notched-outline__trailing {
          border: none;
        }
      }

      .mdc-line-ripple {
        &:before,
        &:after {
          border: none;
        }
      }

      &.mat-mdc-text-field-wrapper {
        flex: none;
      }
    }

    &[cds-label-hidden='true'] {
      .mat-mdc-form-field-required-marker {
        display: none;
      }

      .mdc-text-field {
        padding-top: 0;
      }
    }

    .mat-mdc-form-field-subscript-wrapper {
      // permit the browser to calculate to align with other components
      height: var(--height-2);
    }

    &.no-hint,
    &[cds-subscript-sizing='dynamic'] {
      .mat-mdc-form-field-subscript-wrapper {
        height: auto;
        &::before {
          content: unset;
        }
      }

      .mdc-text-field {
        margin-bottom: 0 !important;
      }
    }

    .mat-mdc-form-field-hint-wrapper,
    .mat-mdc-form-field-error-wrapper {
      padding: var(--spacing-none);

      .mat-mdc-form-field-hint,
      .mat-mdc-form-field-error {
        display: flex;
      }
    }

    &[cds-field-type='textarea'] {
      .mat-mdc-text-field-wrapper {
        .mat-mdc-form-field-flex {
          height: auto !important;
        }
      }

      textarea {
        &.cdk-textarea-autosize {
          // In case cdkAutosizeMinRows is not defined
          min-height: 17px;
        }

        &[cdkautosizeminrows='1'] {
          min-height: 17px !important;
        }
      }
    }

    &[cds-field-type='select'] {
      .mat-mdc-text-field-wrapper {
        .mat-mdc-form-field-flex {
          cursor: pointer;

          mat-label {
            cursor: pointer;
          }
        }
      }
    }
  }
}

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 cds-" + _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