Thoughtful Component
Design

Staff Software Engineer @Ascenda

🎤 Ascenda

📍 Singapore @ 06 Oct 2022

About me

Hi, My name's Trung 😊

  • Staff Software Engineer @Ascenda
  • Author of Angular Spotify, Jira Clone, Tetris
  • Founder @Angular Singapore
  • Community Lead @Angular Vietnam
  • Founded in 2021
  • Advocate and grow the Angular developer community in Singapore
  • Monthly meetup on the first Tuesday  
  • FREE one-on-one support 👉 BOOK NOW!
  • Biggest Angular group in APAC
  • Advocate and grow the Angular developer community in Vietnam
  • 16k 25k members
  • Founded in 2017 by
  • 100 Days Of Angular series

Agenda

  • Angular component

  • Thoughtful component design

  • Q&A

Angular Component

Components are the most basic UI building block of an Angular app. An Angular app contains a tree of Angular components.

Angular Component

  • An HTML template that declares what renders on the page
  • A TypeScript class that defines behavior
  • A CSS selector that defines how the component is used in a template
  • Optionally, CSS styles applied to the template

Example

@Component({
  selector: 'fancy-button',
  template: `
    <button [ngClass]="classes">{{ text }}</button>
  `
})
export class FancyButtonComponent implements OnInit {
  @Input() text: string;
  @Input() theme: string;

  get classes() {
    return {
      'btn': true,
      'btn-primary': this.theme === 'primary',
      'btn-secondary': this.theme === 'secondary'
    };
  }
}
<fancy-button [text]="Read more" theme="primary">
</fancy-button>

Augmenting native elements

Augmenting native elements

Native HTML elements capture a number of standard interaction patterns that are important to accessibility. When authoring Angular components, you should re-use these native elements directly when possible, rather than re-implementing well-supported behaviors.

Creating a custom button

Creating a custom button

@Component({
  selector: 'shared-button',
  template: `
  <button
    class="button focus-link"
    [attr.aria-label]="ariaLabel"
    [attr.disabled]="isDisabled ? true : null"
    [attr.type]="buttonType"
  >
    <img *ngIf="iconPath" alt="" class="btn-icon" [src]="iconPath" />
    <span class="button-text">
      <ng-container *ngIf="!!buttonText"> {{ buttonText }} </ng-container>
      <ng-container *ngTemplateOutlet="buttonContent"> </ng-container>
    </span>
  </button>`
})
export class ButtonComponent implements OnInit {
  @HostBinding('class.is-disabled') get isButtonDisabled(): boolean {
    return this.isDisabled;
  }

  @Input() buttonClass: string;
  @Input() buttonContent: TemplateRef<any>;
  @Input() buttonText: string;
  @Input() buttonType: 'button' | 'link' = 'button';
  @Input() iconPath: string;
  @Input() isDisabled: boolean;
  @Input() ariaLabel: string;
}

Creating a custom button #1

<shared-button
  [buttonClass]="'item-button-wrapper login-button-wrapper'"
  [buttonText]="'Login'"
>
</shared-button>

Creating a custom button #1

Creating a custom button #2

<shared-button
  [buttonClass]="'item-button-wrapper login-button-wrapper'"
  [buttonContent]="loginButtonTmpl"
>
  <ng-template #loginButtonTmpl>
    Login <shield-icon class="btn-icon"></shield-icon>
  </ng-template>
</shared-button>

Creating a custom button #2

Creating a custom button #3

<shared-button
  [buttonContent]="readmoreTmpl"
  [buttonTheme]="'primary'"
  [isTargetBlank]="true"
  redirectURL="https://trungk18.com/"
>
  <ng-template #readmoreTmpl> Readmore ↗ </ng-template>
</shared-button>

Creating a custom button #3

Creating a custom button

Problems 

⚠️ This approach won't scale very well 

Problems 

Problems 

Problems 

  @Input() ariaHidden: boolean;
  @Input() ariaPlaceholder: string;
  @Input() ariaPressed: string;
  @Input() ariaReadonly: string;
  @Input() ariaRequired: string;
  @Input() ariaSelected: string;
  @Input() ariaSort: string;
  @Input() ariaValueText: string;
  @Input() ariaControls: string;
  @Input() ariaDescribedBy: string;
  @Input() ariaDescription: string;
  @Input() ariaDetails: string;
  @Input() ariaErrorMessage: string;
  @Input() ariaFlowTo: string;
  @Input() ariaLabelledBy: string;
  // and another 20 more Inputs 😏

A better alternative

Create a component that uses an attribute selector that extends the native <button> element. 

A better alternative

A better much better alternative

@Component({
  selector: 'button[shared-button], a[shared-button]',
  template: ` <ng-content></ng-content> `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrls: ['./button-v2.component.scss'],
  encapsulation: ViewEncapsulation.None,
})
export class ButtonV2Component {
  @HostBinding('class') get rdButtonClass(): string {
    const classes = ['button', `btn-${this.buttonTheme}`];
    return classes.filter(Boolean).join(' ');
  }

  @Input() buttonTheme: ButtonTheme = 'secondary';
}

A better much better alternative #1

<button 
  shared-button 
  class="item-button-wrapper login-button-wrapper"
>
  Login
</button>
<shared-button
  [buttonClass]="'item-button-wrapper login-button-wrapper'"
  [buttonText]="'Login'"
>
</shared-button>

🔁

A better much better alternative #1

A better much better alternative #2

<button 
  shared-button 
  class="item-button-wrapper login-button-wrapper"
>
  Login
  <shield-icon class="btn-icon"></shield-icon>
</button>
<shared-button
  [buttonClass]="'item-button-wrapper login-button-wrapper'"
  [buttonContent]="loginButtonTmpl"
>
  <ng-template #loginButtonTmpl>
    Login <shield-icon class="btn-icon"></shield-icon>
  </ng-template>
</shared-button>

🔁

A better much better alternative #2

A better much better alternative

<a
  shared-button
  [buttonTheme]="'primary'"
  target="_blank"
  href="https://trungk18.com/"
>
  Readmore ↗
</a>
<shared-button
  [buttonContent]="readmoreTmpl"
  [buttonTheme]="'primary'"
  [isTargetBlank]="true"
  redirectURL="https://trungk18.com/"
>
  <ng-template #readmoreTmpl> Readmore ↗ </ng-template>
</shared-button>

🔁

Takeaway

Utilize attribute selector for component to extend the native element 💡

TL;DR: Components doesn't have to be <some-component> lah 😆

Pros

1. Familiar APIs!

Pros

2. Accessibility win!

1. Familiar APIs!

Pros

2. Accessibility win!

1. Familiar APIs!

3. Simpler implementation!

Material Button

@Component({
  selector: `
    button[mat-button], 
    button[mat-raised-button], 
    button[mat-flat-button],
    button[mat-stroked-button]
  `,
  templateUrl: 'button.html',
  styleUrls: ['button.css', 'button-high-contrast.css'],
  inputs: MAT_BUTTON_INPUTS,
  host: MAT_BUTTON_HOST,
  exportAs: 'matButton',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatButton extends MatButtonBase {

NgZorro Button

@Component({
  selector: 'button[nz-button], a[nz-button]',
  exportAs: 'nzButton',
  preserveWhitespaces: false,
  encapsulation: ViewEncapsulation.None,
  template: `
    <span nz-icon nzType="loading" *ngIf="nzLoading"></span>
    <ng-content></ng-content>
  `,
  host: {
    class: 'ant-btn',
    '[class.ant-btn-primary]': `nzType === 'primary'`,
    '[class.ant-btn-dashed]': `nzType === 'dashed'`,
    '[class.ant-btn-link]': `nzType === 'link'`,
  }
})
export class NzButtonComponent implements OnDestroy, OnChanges, AfterViewInit, AfterContentInit, OnInit {

Nexus TC Button 😏

export type ButtonType =
  | 'solid-primary'
  | 'outline-secondary'
  | 'outline-primary';

@Component({
  selector: 'button[nexus-tc-button], a[nexus-tc-button]',
  styleUrls: ['./button.component.scss'],
  template: ` <ng-content></ng-content> `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
})
export class NexusTCButtonComponent {
  @HostBinding('class') get rdButtonClass(): string {
    const classes = ['btn', `${variantClasses[this.type]}`];
    return classes.filter(Boolean).join(' ');
  }

  @Input() type: ButtonType = 'outline-secondary';
}

What else?

@Component({
  selector: 'mat-table, table[mat-table]',
  exportAs: 'matTable',
  template: CDK_TABLE_TEMPLATE,
  styleUrls: ['table.css'],
  host: {
    'class': 'mat-table',
    '[class.mat-table-fixed-layout]': 'fixedLayout',
  },

What else?

MatTabNav ↗️

@Component({
  selector: '[mat-tab-nav-bar]',
  exportAs: 'matTabNavBar, matTabNav',
  inputs: ['color'],
  templateUrl: 'tab-nav-bar.html',
  styleUrls: ['tab-nav-bar.css'],
  host: {
    'class': 'mat-tab-nav-bar mat-tab-header',
    '[class.mat-tab-header-pagination-controls-enabled]': '_showPaginationControls',
    '[class.mat-tab-header-rtl]': "_getLayoutDirection() == 'rtl'",
    '[class.mat-primary]': 'color !== "warn" && color !== "accent"',
    '[class.mat-accent]': 'color === "accent"',
    '[class.mat-warn]': 'color === "warn"',
  },
  encapsulation: ViewEncapsulation.None,

Does this mean we should never replace native components with custom components?

No, Of course not.
 

The key takeaway from this is that, whenever you’re creating a new component, you should ask yourself:

can I augment an existing one instead?

Configure for specific use case

Configure for specific use case

<button>
  <span class="button-text">
    <ng-container *ngIf="!!buttonText">
      {{ buttonText }} 
    </ng-container>
    <ng-container *ngTemplateOutlet="buttonContent">
    </ng-container>
  </span>
</button>
<shared-button
  [buttonClass]="'item-button-wrapper login-button-wrapper'"
  [buttonContent]="loginButtonTmpl"
  [buttonText]="'Hihi'"
>
  <ng-template #loginButtonTmpl>
    Login <shield-icon class="btn-icon"></shield-icon>
  </ng-template>
</shared-button>

Configure for specific use case

Configure for specific use case

<shared-button
  [buttonText]="'Hihi'"
>
</shared-button>
<shared-button
  [buttonContent]="loginButtonTmpl"
>
  <ng-template #loginButtonTmpl>
    Login <shield-icon class="btn-icon"></shield-icon>
  </ng-template>
</shared-button>

We only want either buttonText, or buttonContent. 
NOT both of them

How?

@Component({
  selector: `
    shared-button[buttonText]:not([buttonContent]),
    shared-button[buttonContent]:not([buttonText])`,
  templateUrl: './button.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ButtonComponent implements OnInit {
  @Input() buttonClass!: string;
  @Input() buttonContent!: TemplateRef<any>;

How?

How?

Real example

@Component({
  selector: `
    ngx-lil-gui:not([config]):not([object]),
    ngx-lil-gui[config]:not([object]),
    ngx-lil-gui[object]:not([config])
  `,
  template: ` <ng-content></ng-content> `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
})

Takeaway

Combine multiple selectors to have very flexible components 💡

Summary

sometimes works better than

<button shared-button>
</button>
<shared-button>
</shared-button>

Demo

Thank you!

Thoughtful Angular Component Design

By Trung Vo

Thoughtful Angular Component Design

Deck for Ascenda Tech All Hands, 06.10.2022

  • 439