
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