Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat (v18): Modernize togglebutton and expand test #16597

Open
wants to merge 10 commits into
base: v18
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 84 additions & 11 deletions src/app/components/togglebutton/togglebutton.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ComponentRef } from '@angular/core';
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ToggleButton } from './togglebutton';
Expand All @@ -6,39 +7,111 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
describe('ToggleButton', () => {
let toggleButton: ToggleButton;
let fixture: ComponentFixture<ToggleButton>;
let toggleButtonRef: ComponentRef<ToggleButton>;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [NoopAnimationsModule],
declarations: [ToggleButton],
imports: [NoopAnimationsModule, ToggleButton],
});

fixture = TestBed.createComponent(ToggleButton);
toggleButton = fixture.componentInstance;
toggleButtonRef = fixture.componentRef;
fixture.detectChanges();
});

it('should display the onLabel and offLabel', () => {
toggleButton.onLabel = 'YES';
toggleButton.offLabel = 'NO';
fixture.detectChanges();

const clickEl = fixture.nativeElement.querySelector('.p-togglebutton');
clickEl.click();
fixture.detectChanges();

const labelEl = fixture.debugElement.query(By.css('.p-button-label'));
expect(labelEl.nativeElement.textContent).toBe('YES');
const labelEl = fixture.debugElement.query(By.css('.p-togglebutton-label'));
expect(labelEl.nativeElement.textContent.trim()).toBe('Yes');

clickEl.click();
fixture.detectChanges();

expect(labelEl.nativeElement.textContent).toBe('NO');
expect(labelEl.nativeElement.textContent.trim()).toBe('No');
});

it('Should display as checked when value is true by default', () => {
toggleButton.checked = true;
toggleButton.checked.set(true);
fixture.detectChanges();

expect(toggleButton.active()).toBe(true);
});

it('should initialize with default values', () => {
expect(toggleButton.onLabel()).toBe('Yes');
expect(toggleButton.offLabel()).toBe('No');
expect(toggleButton.checked()).toBeFalse();
expect(toggleButton.iconPos()).toBe('left');
});

it('should toggle the checked state on click', () => {
const button = fixture.nativeElement.querySelector('button');
button.click();

fixture.detectChanges();
expect(toggleButton.checked()).toBeTrue();

button.click();
fixture.detectChanges();
expect(toggleButton.checked()).toBeFalse();
});

it('should not toggle when disabled', () => {
toggleButton.disabled.set(true);
fixture.detectChanges();

const button = fixture.nativeElement.querySelector('button');
button.click();

expect(toggleButton.checked()).toBeFalse(); // Should not change
});

it('should display custom labels and icons', () => {
toggleButtonRef.setInput('onLabel', 'Active');
toggleButtonRef.setInput('offLabel', 'Inactive');
toggleButtonRef.setInput('onIcon', 'pi pi-check');
toggleButtonRef.setInput('offIcon', 'pi pi-times');

expect(toggleButton.checked).toBe(true);
fixture.detectChanges();

const label = fixture.nativeElement.querySelector('.p-togglebutton-icon');
expect(label.classList.contains('pi-times')).toBeTrue(); // Initially off icon

toggleButton.toggle(new MouseEvent('click'));
fixture.detectChanges();

expect(label.classList.contains('pi-check')).toBeTrue(); // After toggle, should show on icon
});

it('should toggle when Enter or Space is pressed', () => {
const button = fixture.nativeElement.querySelector('button');

const keydownEvent = new KeyboardEvent('keydown', { code: 'Enter' });
button.dispatchEvent(keydownEvent);

expect(toggleButton.checked()).toBeTrue();
});

it('should have correct ARIA attributes', () => {
toggleButtonRef.setInput('ariaLabel', 'Toggle Button');
fixture.detectChanges();

const button = fixture.nativeElement.querySelector('button');
expect(button.getAttribute('aria-pressed')).toBe('false');
expect(button.getAttribute('aria-label')).toBe('Toggle Button');
});

it('should write value externally', () => {
toggleButton.writeValue(true);
fixture.detectChanges();
expect(toggleButton.checked()).toBeTrue();

toggleButton.writeValue(false);
fixture.detectChanges();
expect(toggleButton.checked()).toBeFalse();
});
});
128 changes: 59 additions & 69 deletions src/app/components/togglebutton/togglebutton.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { CommonModule } from '@angular/common';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
ContentChild,
EventEmitter,
contentChild,
OutputEmitterRef,
forwardRef,
HostBinding,
computed,
inject,
Input,
input,
model,
signal,
NgModule,
numberAttribute,
Output,
output,
TemplateRef,
WritableSignal,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Ripple } from 'primeng/ripple';
Expand All @@ -35,48 +38,51 @@ export const TOGGLEBUTTON_VALUE_ACCESSOR: any = {
@Component({
selector: 'p-toggleButton, p-togglebutton',
standalone: true,
imports: [Ripple, AutoFocus, CommonModule, SharedModule],
imports: [Ripple, AutoFocus, NgClass, NgTemplateOutlet, SharedModule],
template: `
<button
pRipple
type="button"
[ngClass]="cx('root')"
[class]="styleClass"
[tabindex]="tabindex"
[disabled]="disabled"
[class]="styleClass()"
[tabindex]="tabindex()"
[disabled]="disabled()"
(click)="toggle($event)"
[attr.aria-labelledby]="ariaLabelledBy"
[attr.aria-pressed]="checked"
[attr.data-p-checked]="active"
[attr.data-p-disabled]="disabled"
(keydown)="onKeyDown($event)"
[attr.aria-label]="ariaLabel()"
[attr.aria-labelledby]="ariaLabelledBy()"
[attr.aria-pressed]="checked()"
[attr.data-p-checked]="active()"
[attr.data-p-disabled]="disabled()"
>
<span [ngClass]="cx('content')">
<ng-container *ngTemplateOutlet="contentTemplate; context: { $implicit: checked }"></ng-container>
@if (!contentTemplate) {
@if (!iconTemplate) {
@if (onIcon || offIcon) {
<ng-container *ngTemplateOutlet="contentTemplate(); context: { $implicit: checked() }"></ng-container>
@if (!contentTemplate()) {
@if (!iconTemplate()) {
@if (onIcon() || offIcon()) {
<span
[class]="checked ? this.onIcon : this.offIcon"
[class]="checked() ? onIcon() : offIcon()"
[ngClass]="{
'p-togglebutton-icon': true,
'p-togglebutton-icon-left': iconPos === 'left',
'p-togglebutton-icon-right': iconPos === 'right',
'p-togglebutton-icon-left': iconPos() === 'left',
'p-togglebutton-icon-right': iconPos() === 'right',
}"
[attr.data-pc-section]="'icon'"
></span>
}
} @else {
<ng-container *ngTemplateOutlet="iconTemplate; context: { $implicit: checked }"></ng-container>
<ng-container *ngTemplateOutlet="iconTemplate(); context: { $implicit: checked() }"></ng-container>
}
@if (onLabel || offLabel) {
<span [ngClass]="cx('label')" [attr.data-pc-section]="'label'">{{
checked ? (hasOnLabel ? onLabel : '') : hasOffLabel ? offLabel : ''
}}</span>
@if (onLabel() || offLabel()) {
<span [ngClass]="cx('label')" [attr.data-pc-section]="'label'">
{{ checked() ? (hasOnLabel() ? onLabel() : '') : hasOffLabel() ? offLabel() : '' }}
</span>
}
}
</span>
</button>
`,
host: { '[class]': 'styleClass()' },
providers: [TOGGLEBUTTON_VALUE_ACCESSOR, ToggleButtonStyle],
changeDetection: ChangeDetectionStrategy.OnPush,
})
Expand All @@ -85,60 +91,57 @@ export class ToggleButton extends BaseComponent implements ControlValueAccessor
* Label for the on state.
* @group Props
*/
@Input() onLabel: string = 'Yes';
onLabel = input<string>('Yes');
/**
* Label for the off state.
* @group Props
*/
@Input() offLabel: string = 'No';
offLabel = input<string>('No');
/**
* Icon for the on state.
* @group Props
*/
@Input() onIcon: string | undefined;
onIcon = input<string>();
/**
* Icon for the off state.
* @group Props
*/
@Input() offIcon: string | undefined;
offIcon = input<string>();
/**
* Defines a string that labels the input for accessibility.
* @group Props
*/
@Input() ariaLabel: string | undefined;
ariaLabel = input<string>();
/**
* Establishes relationships between the component and label(s) where its value should be one or more element IDs.
* @group Props
*/
@Input() ariaLabelledBy: string | undefined;
ariaLabelledBy = input<string>();
/**
* When present, it specifies that the element should be disabled.
* @group Props
*/
@Input({ transform: booleanAttribute }) disabled: boolean | undefined;
disabled = model<boolean>();
/**
* Inline style of the element.
* @group Props
*/
@Input() style: any;
style = input<{ [klass: string]: any } | null>();
/**
* Style class of the element.
* @group Props
*/
@Input() styleClass: string | undefined;
@HostBinding('class') get hostClass() {
return this.styleClass || '';
}
styleClass = input<string>();
/**
* Identifier of the focus input to match a label defined for the component.
* @group Props
*/
@Input() inputId: string | undefined;
inputId = input<string>();
/**
* Index of the element in tabbing order.
* @group Props
*/
@Input({ transform: numberAttribute }) tabindex: number | undefined = 0;
tabindex = input<number, any>(0, { transform: numberAttribute });
/**
* Defines the size of the component.
* @group Props
Expand All @@ -148,35 +151,35 @@ export class ToggleButton extends BaseComponent implements ControlValueAccessor
* Position of the icon.
* @group Props
*/
@Input() iconPos: 'left' | 'right' = 'left';
iconPos = input<'left' | 'right'>('left');
/**
* When present, it specifies that the component should automatically get focus on load.
* @group Props
*/
@Input({ transform: booleanAttribute }) autofocus: boolean | undefined;
autofocus = input<boolean, any>(undefined, { transform: booleanAttribute });
/**
* Whether selection can not be cleared.
* @group Props
*/
@Input() allowEmpty: boolean | undefined;
allowEmpty = input<boolean, any>(undefined, { transform: booleanAttribute });
/**
* Callback to invoke on value change.
* @param {ToggleButtonChangeEvent} event - Custom change event.
* @group Emits
*/
@Output() onChange: EventEmitter<ToggleButtonChangeEvent> = new EventEmitter<ToggleButtonChangeEvent>();
onChange: OutputEmitterRef<ToggleButtonChangeEvent> = output<ToggleButtonChangeEvent>();
/**
* Custom icon template.
* @group Templates
*/
@ContentChild('icon') iconTemplate: Nullable<TemplateRef<any>>;
iconTemplate = contentChild<Nullable<TemplateRef<any>>>('icon');
/**
* Custom content template.
* @group Templates
*/
@ContentChild('content') contentTemplate: Nullable<TemplateRef<any>>;
contentTemplate = contentChild<Nullable<TemplateRef<any>>>('content');

checked: boolean = false;
checked: WritableSignal<boolean> = signal<boolean>(false);

onModelChange: Function = () => {};

Expand All @@ -185,16 +188,11 @@ export class ToggleButton extends BaseComponent implements ControlValueAccessor
_componentStyle = inject(ToggleButtonStyle);

toggle(event: Event) {
if (!this.disabled && !(this.allowEmpty === false && this.checked)) {
this.checked = !this.checked;
this.onModelChange(this.checked);
if (!this.disabled() && !(this.allowEmpty() === false && this.checked())) {
this.checked.set(!this.checked());
this.onModelChange(this.checked());
this.onModelTouched();
this.onChange.emit({
originalEvent: event,
checked: this.checked,
});

this.cd.markForCheck();
this.onChange.emit({ originalEvent: event, checked: this.checked() });
}
}

Expand All @@ -216,8 +214,7 @@ export class ToggleButton extends BaseComponent implements ControlValueAccessor
}

writeValue(value: any): void {
this.checked = value;
this.cd.markForCheck();
this.checked.set(value);
}

registerOnChange(fn: Function): void {
Expand All @@ -229,21 +226,14 @@ export class ToggleButton extends BaseComponent implements ControlValueAccessor
}

setDisabledState(val: boolean): void {
this.disabled = val;
this.cd.markForCheck();
this.disabled.set(val);
}

get hasOnLabel(): boolean {
return (this.onLabel && this.onLabel.length > 0) as boolean;
}
hasOnLabel = computed<boolean>(() => this.onLabel() && this.onLabel().length > 0);

get hasOffLabel(): boolean {
return (this.onLabel && this.onLabel.length > 0) as boolean;
}
hasOffLabel = computed<boolean>(() => this.offLabel() && this.offLabel().length > 0);

get active() {
return this.checked === true;
}
active = computed<boolean>(() => this.checked() === true);
}

@NgModule({
Expand Down
Loading