15

I'm new to Angular and Angular Material, now I'm working as a support in some project. There's a grid with filters and one checkbox, which checks if user in grid is active, inactive, or not chosen. It would be simpler with only two options (active, inactive) but well, I have to make 3 states for it:

  1. 1st click - Checked for active
  2. 2nd click - Unchecked for inactive
  3. 3rd click - Indeterminate for not chosen

Here is checkbox example from official Angular Material documentation: https://stackblitz.com/angular/rxdmnbxmkgk?file=app%2Fcheckbox-configurable-example.html

How to make it in the most simply way?

smnbbrv
  • 23,502
  • 9
  • 78
  • 109
TomasThall
  • 245
  • 1
  • 2
  • 12
  • 1
    at worst, double checkbox, one for choosen/not choosen, and one for active/inactive. Three states checkbox is probably doable but will violate what user expect from classic checkboxes, as such it might impact user experience. – Walfrat Mar 15 '18 at 09:49

7 Answers7

29

@angular/material >= 9

Here is a ready-to-use component:

import { Component, forwardRef, Input } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { MatCheckboxDefaultOptions, MAT_CHECKBOX_DEFAULT_OPTIONS } from '@angular/material/checkbox';

@Component({
  selector: 'app-tri-state-checkbox',
  templateUrl: './tri-state-checkbox.component.html',
  styleUrls: ['./tri-state-checkbox.component.scss'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => TriStateCheckboxComponent),
      multi: true,
    },
    { provide: MAT_CHECKBOX_DEFAULT_OPTIONS, useValue: { clickAction: 'noop' } as MatCheckboxDefaultOptions },
  ],
})
export class TriStateCheckboxComponent implements ControlValueAccessor {

  @Input() tape = [null, true, false];

  value: any;

  disabled: boolean;

  private onChange: (val: boolean) => void;
  private onTouched: () => void;

  writeValue(value: any) {
    this.value = value || this.tape[0];
  }

  setDisabledState(disabled: boolean) {
    this.disabled = disabled;
  }

  next() {
    this.onChange(this.value = this.tape[(this.tape.indexOf(this.value) + 1) % this.tape.length]);
    this.onTouched();
  }

  registerOnChange(fn: any) {
    this.onChange = fn;
  }

  registerOnTouched(fn: any) {
    this.onTouched = fn;
  }

}

and template:

<mat-checkbox [ngModel]="value" (click)="next()" [disabled]="disabled" [indeterminate]="value === false" [color]="value === false ? 'warn' : 'accent'">
  <ng-content></ng-content>
</mat-checkbox>

Usage:

<app-tri-state-checkbox [(ngModel)]="done">is done</app-tri-state-checkbox>
<app-tri-state-checkbox formControlName="done">is done</app-tri-state-checkbox>

You can also override default tape to e.g. enum values:

<app-tri-state-checkbox [tape]="customTape" [(ngModel)]="done">is done</app-tri-state-checkbox>

where customTape replaces default values [null, true, false]

customTape = [Status.open, Status.complete, Status.cancelled];

@angular/material <= 8

Just change

{ provide: MAT_CHECKBOX_DEFAULT_OPTIONS, useValue: { clickAction: 'noop' } as MatCheckboxDefaultOptions },

to the deprecated since version 9 MAT_CHECKBOX_CLICK_ACTION

{ provide: MAT_CHECKBOX_CLICK_ACTION, useValue: 'noop' },
smnbbrv
  • 23,502
  • 9
  • 78
  • 109
  • 2
    In version 9 of the Angular Material: BREAKING CHANGES: MAT_CHECKBOX_CLICK_ACTION is deprecated, use MAT_CHECKBOX_DEFAULT_OPTIONS. See: https://github.com/angular/components/releases/tag/9.0.0-next.2 – MhagnumDw Nov 08 '19 at 11:43
  • 5
    for anyone previously using 'noop' with material 8 and moves to material 9 using this as an example, an important difference between the two is the default options have color set to 'accent'. if you now specify your own default options instead of just clickAction, it will wipe out the accent color. i spent hours trying to figure out why a checkbox was not working. turns it out it was, it just wasn't showing the accent color. if you want to preserve the original behavior, use { provide: MAT_CHECKBOX_DEFAULT_OPTIONS, useValue: { clickAction: 'noop', color: 'accent' } } – Ryan O Apr 21 '20 at 20:00
  • @RyanO Thanks for your great comment. That saved me quite some debugging time! – Mark Langer Apr 30 '20 at 14:34
  • 3
    I noticed some flickering in the animation when transitioning from indeterminate -> checked. Assigning the value to the mat-checkbox's checked property instead of the ngModel binding seems to solve the problem. Template: – Mark Langer Apr 30 '20 at 16:17
  • 1
    Thanks @RyanO for saving my time – Darpan Dec 11 '20 at 18:43
  • Thanks for this weel made component ! I just noticed the "disabled" code looks weird. Shouldn't it be an `@Input()`, without any need of the `setDisabledState` method, or do I miss some magic ? An othor minor point is the content of the `next()` method where I assigned `this.value` before the `this.onChange` call for readability, but that's personal :) – Random Apr 12 '21 at 15:13
  • 1
    @Random this is a proper implementation of ControlValueAccessor. `[disabled]` as input might also work for template-driven forms, but it will not work with reactive forms – smnbbrv Apr 19 '21 at 09:53
  • In the template, I used `[indeterminate]="value === null"`. It seems to me like that would be a common change for others as well (vs `[indeterminate]="value === false"`) – Penny May 12 '21 at 05:40
3

I wanted a solution for this without requiring another component and in a reactive form and this does seem to do the trick, although there is a duplicated animation in one transition:

component.ts

  onChangeCheckbox(checkbox: MatCheckbox): void {
    if (checkbox.indeterminate){
      checkbox.indeterminate = false;
      checkbox.checked = true;
    } 
    // by the time you click it is changed
    else if (!checkbox.indeterminate && !checkbox.checked ){
      checkbox.checked = false;
    }else if (!checkbox.indeterminate && checkbox.checked ){
      checkbox.indeterminate = true;
    }
  }

component.html

<mat-checkbox
                        #checkboxName
                        formControlName="checkboxName"
                        indeterminate="true"
                        (change)="onChangeCheckbox(checkboxName)"
                        id="edit:checkboxName"> 
                        Checkbox Name
                    </mat-checkbox>
nck
  • 1,673
  • 16
  • 40
  • Idea with passing MatCheckbox to the function is what I needed. In my case I do not duplicate anything. Thanks! :) – Kamil Dec 02 '21 at 10:58
2

I tried to reply this Answer but I don't have enough reputation. First thank you for the answer and deep explanation. I was having trouble with the edition of the checkboxes and I figured out that if the value is false this line

this.value = value || this.tape[0];

doesn't work and the color is not updated either. I changed it for this one

this.value = value !== null ? value : this.tape[0];

I hope this comment help others.

0

One way to do this is to set MAT_CHECKBOX_CLICK_ACTION to 'noop' and then you'll have to set the checked values with (click). Don't forget to bind both [ngModel] and [indeterminate].

providers: [
    {provide: MAT_CHECKBOX_CLICK_ACTION, useValue: 'noop'}
]

Have a look at this: https://github.com/angular/material2/blob/master/src/lib/checkbox/checkbox.md

rcunha
  • 16
  • 1
0

If you need a working example you can also clone the material2 project here, and then:

cd material2
npm i
npm run demo-app

Open the demo app and navigate to the checkbox component.

Liviu Ilea
  • 1,494
  • 12
  • 13
0

Thanks for this solution! Using Angular 9.1.7 here.

I needed a custom set of values. This didn't work out of the box. Thanks to this (post) for enlightening me on the solution to create 2-way binding

I changed the parts as follows:

Template:

<mat-checkbox (click)="next()"
              [(ngModel)]="guiValue"
              [disabled]="disabled"
              [indeterminate]="isIndeterminate"
              [color]="isIndeterminate ? 'warn' : 'accent'"
><ng-content></ng-content>
</mat-checkbox>

Typescript:

import {Component, EventEmitter, forwardRef, Input, Output} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { MatCheckboxDefaultOptions, MAT_CHECKBOX_DEFAULT_OPTIONS } from '@angular/material/checkbox';

@Component({
  selector: 'app-tri-state-checkbox',
  templateUrl: './tri-state-checkbox.component.html',
  styleUrls: ['./tri-state-checkbox.component.css'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => TriStateCheckboxComponent),
      multi: true,
    },
    { provide: MAT_CHECKBOX_DEFAULT_OPTIONS, useValue: { clickAction: 'noop' } as MatCheckboxDefaultOptions },
  ],
})
export class TriStateCheckboxComponent implements ControlValueAccessor {
  internalValue: any;
  guiValue: any;
  disabled: boolean;
  isIndeterminate: boolean;

  @Input() states = [null, true, false];
  @Output() triStateValueChange = new EventEmitter<any>();

  @Input()
  get triStateValue(): any {
    return this.internalValue;
  }

  set triStateValue(v) {
    this.internalValue = v;
    this.writeValue();
  }


  onChange = (x: any) => {console.log(`onChange:${x}`); };
  onTouched = () => {};


  writeValue() {
    if      (this.internalValue === this.states[0]) { this.guiValue = true; }  // undetermined
    else if (this.internalValue === this.states[1]) { this.guiValue = true; }  // true
    else if (this.internalValue === this.states[2]) { this.guiValue = false; } // false
    else { console.error (`Wrong value for tri state checkbox : ${this.internalValue}`); }

    this.isIndeterminate = ( this.internalValue === this.states[0] );
  }

  setDisabledState(disabled: boolean) {
    this.disabled = disabled;
  }

  next() {
    this.determineNextValue();
    this.onTouched();
    this.onChange(this.guiValue);
  }
  determineNextValue(){
    if      (this.internalValue === this.states[0]) {this.internalValue = this.states[1]; }
    else if (this.internalValue === this.states[1]) {this.internalValue = this.states[2]; }
    else if (this.internalValue === this.states[2]) {this.internalValue = this.states[0]; }

    this.triStateValueChange.emit(this.internalValue);
  }

  registerOnChange(fn: any) {
    this.onChange = fn;
  }

  registerOnTouched(fn: any) {
    this.onTouched = fn;
  }

}

And the usage:

Template:

<app-tri-state-checkbox [states]="locTriState" [(triStateValue)]="targetVariable" ">Labeltext</app-tri-state-checkbox>

And the setup of the source (TS) using the component: (the variable targetVariable is used as a source/target using 2-way binding.

export enum TriStateValues {
  on,
  off,
  dontcare
};

let targetVariable = TriStateValues.on;
const locTriState = [ TriStateValues.dontcare, TriStateValues.on ,  TriStateValues.off];
Pianoman
  • 327
  • 2
  • 10
0

@angular/material >= 9

In my scenario , it works. color: 'primary' must be provide.

 providers: [{ provide: MAT_CHECKBOX_DEFAULT_OPTIONS, useValue: { clickAction: 'noop', color: 'primary' } as MatCheckboxDefaultOptions }]
Zahidur Rahman
  • 1,688
  • 2
  • 20
  • 30