diff --git a/projects/igniteui-angular/button-group/src/button-group/button-group.component.spec.ts b/projects/igniteui-angular/button-group/src/button-group/button-group.component.spec.ts index dd51eec749e..c03185e0a95 100644 --- a/projects/igniteui-angular/button-group/src/button-group/button-group.component.spec.ts +++ b/projects/igniteui-angular/button-group/src/button-group/button-group.component.spec.ts @@ -106,7 +106,7 @@ describe('IgxButtonGroup', () => { expect(btnGroupInstance.selected.emit).not.toHaveBeenCalled(); - btnGroupInstance.buttons[1].select(); + btnGroupInstance.buttons[1].selected = true; fixture.detectChanges(); expect(btnGroupInstance.selected.emit).not.toHaveBeenCalled(); @@ -126,8 +126,8 @@ describe('IgxButtonGroup', () => { fixture.detectChanges(); const btnGroupInstance = fixture.componentInstance.buttonGroup; - btnGroupInstance.buttons[0].select(); - btnGroupInstance.buttons[1].select(); + btnGroupInstance.buttons[0].selected = true; + btnGroupInstance.buttons[1].selected = true; spyOn(btnGroupInstance.deselected, 'emit'); btnGroupInstance.ngAfterViewInit(); @@ -135,7 +135,7 @@ describe('IgxButtonGroup', () => { expect(btnGroupInstance.deselected.emit).not.toHaveBeenCalled(); - btnGroupInstance.buttons[1].deselect(); + btnGroupInstance.buttons[1].selected = false; fixture.detectChanges(); expect(btnGroupInstance.deselected.emit).not.toHaveBeenCalled(); diff --git a/projects/igniteui-angular/button-group/src/button-group/button-group.component.ts b/projects/igniteui-angular/button-group/src/button-group/button-group.component.ts index f11590b12c7..2940856a85d 100644 --- a/projects/igniteui-angular/button-group/src/button-group/button-group.component.ts +++ b/projects/igniteui-angular/button-group/src/button-group/button-group.component.ts @@ -330,8 +330,8 @@ export class IgxButtonGroupComponent implements AfterViewInit, OnDestroy { return; } - const button = this.buttons[index]; - button.select(); + this.buttons[index].selected = true; + this.updateSelected(index); } /** @@ -398,8 +398,8 @@ export class IgxButtonGroupComponent implements AfterViewInit, OnDestroy { return; } - const button = this.buttons[index]; - button.deselect(); + this.buttons[index].selected = false; + this.updateDeselected(index); } /** diff --git a/projects/igniteui-angular/directives/src/directives/button/button-base.ts b/projects/igniteui-angular/directives/src/directives/button/button-base.ts index f07aa008621..e729b867de4 100644 --- a/projects/igniteui-angular/directives/src/directives/button/button-base.ts +++ b/projects/igniteui-angular/directives/src/directives/button/button-base.ts @@ -1,18 +1,6 @@ -import { - Directive, - ElementRef, - EventEmitter, - HostBinding, - HostListener, - Input, - Output, - booleanAttribute, - inject, - AfterViewInit, - OnDestroy -} from '@angular/core'; -import { PlatformUtil } from 'igniteui-angular/core'; -import { animationFrameScheduler, Subscription } from 'rxjs'; +import { Directive, ElementRef, Input, booleanAttribute, inject, AfterViewInit, signal, EventEmitter, Output} from '@angular/core'; +import { IgxFocusRingDirective } from '../focus-ring/focus-ring.directive'; + export const IgxBaseButtonType = { Flat: 'flat', @@ -21,126 +9,53 @@ export const IgxBaseButtonType = { } as const; -@Directive() -export abstract class IgxButtonBaseDirective implements AfterViewInit, OnDestroy { - private _platformUtil = inject(PlatformUtil); - public element = inject(ElementRef); - private _viewInit = false; - private _animationScheduler: Subscription; +@Directive({ + host: { + 'role': 'button', + '[attr.disabled]': '_disabled() || null', + '[class.igx-button--focused]': '_hasKeyboardFocus()', + '[class.igx-button--disabled]': '_disabled()', + '[style.--_init-transition]': '_hasRendered() ? null : "0s"', + '[style.transition]': '_hasRendered() ? "var(--_button-transition)" : "none"', + '(click)': 'buttonClick.emit($event)', + }, + hostDirectives: [IgxFocusRingDirective] +}) +export abstract class IgxButtonBaseDirective implements AfterViewInit { + protected readonly _element = inject>(ElementRef); + protected readonly _hasKeyboardFocus = inject(IgxFocusRingDirective).hasKeyboardFocus; - /** - * Emitted when the button is clicked. - */ - @Output() - public buttonClick = new EventEmitter(); + protected readonly _hasRendered = signal(false); + protected readonly _disabled = signal(false); /** - * Sets/gets the `role` attribute. + * Gets or sets whether the button is disabled. * * @example - * ```typescript - * this.button.role = 'navbutton'; - * let buttonRole = this.button.role; - * ``` - */ - @HostBinding('attr.role') - public role = 'button'; - - /** - * @hidden - * @internal - */ - @HostListener('click', ['$event']) - public onClick(ev: MouseEvent) { - this.buttonClick.emit(ev); - this.focused = false; - } - - /** - * @hidden - * @internal - */ - @HostListener('blur') - protected onBlur() { - this.focused = false; - } - - /** - * Sets/gets whether the button component is on focus. - * Default value is `false`. - * ```typescript - * this.button.focus = true; - * ``` - * ```typescript - * let isFocused = this.button.focused; + * ```html + * * ``` */ - @HostBinding('class.igx-button--focused') - protected focused = false; - - /** - * Enables/disables the button. - * - * @example - * ```html - * - * ``` - */ @Input({ transform: booleanAttribute }) - @HostBinding('class.igx-button--disabled') - public disabled = false; - - /** - * @hidden - * @internal - */ - @HostBinding('attr.disabled') - public get disabledAttribute() { - return this.disabled || null; - } - - protected constructor() { - // In browser, set via native API for immediate effect (no-op on server). - // In SSR there is no paint, so there’s no visual rendering or transitions to suppress. - // Fix style flickering https://github.com/IgniteUI/igniteui-angular/issues/14759 - if (this._platformUtil.isBrowser) { - this.element.nativeElement.style.setProperty('--_init-transition', '0s'); - this.element.nativeElement.style.setProperty('transition', 'none'); - } + public set disabled(value: boolean) { + this._disabled.set(value); } - public ngAfterViewInit(): void { - if (this._platformUtil.isBrowser && !this._viewInit) { - this._viewInit = true; - - this._animationScheduler = animationFrameScheduler.schedule(() => { - this.element.nativeElement.style.removeProperty('--_init-transition'); - this.element.nativeElement.style.setProperty('transition', 'var(--_button-transition)'); - }); - } + public get disabled(): boolean { + return this._disabled(); } - public ngOnDestroy(): void { - if (this._animationScheduler) { - this._animationScheduler.unsubscribe(); - } - } + /** Emitted when the button is clicked. */ + @Output() + public readonly buttonClick = new EventEmitter(); - /** - * @hidden - * @internal - */ - @HostListener('keyup', ['$event']) - protected updateOnKeyUp(event: KeyboardEvent) { - if (event.key === "Tab") { - this.focused = true; - } + /** Returns the underlying DOM element. */ + public get nativeElement(): HTMLButtonElement { + return this._element.nativeElement as HTMLButtonElement; } - /** - * Returns the underlying DOM element. - */ - public get nativeElement() { - return this.element.nativeElement; + public ngAfterViewInit(): void { + // FUOC workaround - ensures that the transition is only applied after the initial render + this._hasRendered.set(true); } } diff --git a/projects/igniteui-angular/directives/src/directives/button/button.directive.ts b/projects/igniteui-angular/directives/src/directives/button/button.directive.ts index 15714511be0..390591d75a7 100644 --- a/projects/igniteui-angular/directives/src/directives/button/button.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/button/button.directive.ts @@ -1,25 +1,10 @@ -import { - Directive, - EventEmitter, - HostBinding, - HostListener, - Input, - Output, - Renderer2, - booleanAttribute, - inject -} from '@angular/core'; +import { Directive, EventEmitter, Input, Output, booleanAttribute, signal } from '@angular/core'; import { IBaseEventArgs } from 'igniteui-angular/core'; import { IgxBaseButtonType, IgxButtonBaseDirective } from './button-base'; -const IgxButtonType = { - ...IgxBaseButtonType, - FAB: 'fab' -} as const; +const IgxButtonType = { ...IgxBaseButtonType, FAB: 'fab' } as const; -/** - * Determines the Button type. - */ +/** The type of the button. */ export type IgxButtonType = typeof IgxButtonType[keyof typeof IgxButtonType]; /** @@ -43,62 +28,27 @@ export type IgxButtonType = typeof IgxButtonType[keyof typeof IgxButtonType]; */ @Directive({ selector: '[igxButton]', - standalone: true + standalone: true, + host: { + 'class': 'igx-button', + '[class.igx-button--flat]': '_type() === "flat"', + '[class.igx-button--contained]': '_type() === "contained"', + '[class.igx-button--outlined]': '_type() === "outlined"', + '[class.igx-button--fab]': '_type() === "fab"', + '[attr.aria-label]': '_label() || null', + '[attr.data-selected]': '_selected() ? "true" : "false"', + '(click)': 'buttonSelected.emit({ button: this })', + } }) export class IgxButtonDirective extends IgxButtonBaseDirective { - private _renderer = inject(Renderer2); - - private static ngAcceptInputType_type: IgxButtonType | ''; + protected readonly _type = signal(IgxButtonType.Flat); + protected readonly _label = signal(''); + protected readonly _selected = signal(false); - /** - * Called when the button is selected. - */ + /** Emitted when the button is selected. */ @Output() - public buttonSelected = new EventEmitter(); + public readonly buttonSelected = new EventEmitter(); - /** - * @hidden - * @internal - */ - @HostBinding('class.igx-button') - public _cssClass = 'igx-button'; - - /** - * @hidden - * @internal - */ - private _type: IgxButtonType; - - /** - * @hidden - * @internal - */ - private _color: string; - - /** - * @hidden - * @internal - */ - private _label: string; - - /** - * @hidden - * @internal - */ - private _backgroundColor: string; - - /** - * @hidden - * @internal - */ - private _selected = false; - - @HostListener('click') - protected emitSelected() { - this.buttonSelected.emit({ - button: this - }); - } /** * Gets or sets whether the button is selected. @@ -111,19 +61,13 @@ export class IgxButtonDirective extends IgxButtonBaseDirective { */ @Input({ transform: booleanAttribute }) public set selected(value: boolean) { - if (this._selected !== value) { - this._selected = value; - this._renderer.setAttribute(this.nativeElement, 'data-selected', value.toString()); - } + this._selected.set(value); } public get selected(): boolean { - return this._selected; + return this._selected(); } - constructor() { - super(); - } /** * Sets the type of the button. @@ -133,12 +77,9 @@ export class IgxButtonDirective extends IgxButtonBaseDirective { * * ``` */ - @Input('igxButton') + @Input({ alias: 'igxButton', transform: (value: string) => value.trim() || IgxButtonType.Flat }) public set type(type: IgxButtonType) { - const t = type ? type : IgxButtonType.Flat; - if (this._type !== t) { - this._type = t; - } + this._type.set(type); } /** @@ -149,63 +90,9 @@ export class IgxButtonDirective extends IgxButtonBaseDirective { * * ``` */ - @Input('igxLabel') + @Input({ alias: 'igxLabel' }) public set label(value: string) { - this._label = value || this._label; - this._renderer.setAttribute(this.nativeElement, 'aria-label', this._label); - } - - /** - * @hidden - * @internal - */ - @HostBinding('class.igx-button--flat') - public get flat(): boolean { - return this._type === IgxButtonType.Flat; - } - - /** - * @hidden - * @internal - */ - @HostBinding('class.igx-button--contained') - public get contained(): boolean { - return this._type === IgxButtonType.Contained; - } - - /** - * @hidden - * @internal - */ - @HostBinding('class.igx-button--outlined') - public get outlined(): boolean { - return this._type === IgxButtonType.Outlined; - } - - /** - * @hidden - * @internal - */ - @HostBinding('class.igx-button--fab') - public get fab(): boolean { - return this._type === IgxButtonType.FAB; - } - - /** - * @hidden - * @internal - */ - public select() { - this.selected = true; - } - - /** - * @hidden - * @internal - */ - public deselect() { - this.selected = false; - this.focused = false; + this._label.set(value || ''); } } diff --git a/projects/igniteui-angular/directives/src/directives/button/icon-button.directive.ts b/projects/igniteui-angular/directives/src/directives/button/icon-button.directive.ts index 7cad2ca3ae8..f83b42d28c4 100644 --- a/projects/igniteui-angular/directives/src/directives/button/icon-button.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/button/icon-button.directive.ts @@ -1,4 +1,4 @@ -import {Directive, HostBinding, Input} from '@angular/core'; +import {Directive, Input, signal} from '@angular/core'; import { IgxBaseButtonType, IgxButtonBaseDirective } from './button-base'; /** @@ -18,27 +18,16 @@ export type IgxIconButtonType = typeof IgxBaseButtonType[keyof typeof IgxBaseBut */ @Directive({ selector: '[igxIconButton]', - standalone: true + standalone: true, + host: { + 'class': 'igx-icon-button', + '[class.igx-icon-button--flat]': '_type() === "flat"', + '[class.igx-icon-button--contained]': '_type() === "contained"', + '[class.igx-icon-button--outlined]': '_type() === "outlined"', + } }) export class IgxIconButtonDirective extends IgxButtonBaseDirective { - private static ngAcceptInputType_type: IgxIconButtonType | ''; - - constructor() { - super(); - } - - /** - * @hidden - * @internal - */ - @HostBinding('class.igx-icon-button') - protected _cssClass = 'igx-icon-button'; - - /** - * @hidden - * @internal - */ - private _type: IgxIconButtonType; + protected readonly _type = signal(IgxBaseButtonType.Contained); /** * Sets the type of the icon button. @@ -48,38 +37,8 @@ export class IgxIconButtonDirective extends IgxButtonBaseDirective { * * ``` */ - @Input('igxIconButton') + @Input({ alias: 'igxIconButton', transform: (value: string) => value.trim() || IgxBaseButtonType.Contained }) public set type(type: IgxIconButtonType) { - const t = type ? type : IgxBaseButtonType.Contained; - if (this._type !== t) { - this._type = t; - } - } - - /** - * @hidden - * @internal - */ - @HostBinding('class.igx-icon-button--flat') - public get flat(): boolean { - return this._type === IgxBaseButtonType.Flat; - } - - /** - * @hidden - * @internal - */ - @HostBinding('class.igx-icon-button--contained') - public get contained(): boolean { - return this._type === IgxBaseButtonType.Contained; - } - - /** - * @hidden - * @internal - */ - @HostBinding('class.igx-icon-button--outlined') - public get outlined(): boolean { - return this._type === IgxBaseButtonType.Outlined; + this._type.set(type); } } diff --git a/projects/igniteui-angular/directives/src/directives/focus-ring/focus-ring.directive.ts b/projects/igniteui-angular/directives/src/directives/focus-ring/focus-ring.directive.ts new file mode 100644 index 00000000000..0a868969f58 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/focus-ring/focus-ring.directive.ts @@ -0,0 +1,27 @@ +import { Directive, ElementRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { fromEvent, merge } from 'rxjs'; + +/** + * The Focus Ring directive provides a way to style an element when it has keyboard focus. + * It listens for keyboard and pointer events to determine when to apply the focus ring styles. + * @hidden @internal + */ +@Directive({ selector: '[igxFocusRing]', standalone: true }) +export class IgxFocusRingDirective { + private readonly _element = inject>(ElementRef); + private readonly _hasKeyboardFocus = signal(false); + + /** Indicates whether the element has keyboard focus. */ + public readonly hasKeyboardFocus = this._hasKeyboardFocus.asReadonly(); + + constructor() { + merge( + fromEvent(this._element.nativeElement, 'keyup'), + fromEvent(this._element.nativeElement, 'focusout'), + fromEvent(this._element.nativeElement, 'pointerdown') + ).pipe(takeUntilDestroyed()).subscribe(({ type }) => { + this._hasKeyboardFocus.set(type === 'keyup'); + }); + } +}