diff --git a/packages/components-dev/button/module.ts b/packages/components-dev/button/module.ts index 06c5c402f6..6723757fe7 100644 --- a/packages/components-dev/button/module.ts +++ b/packages/components-dev/button/module.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/ import { KbqButtonModule, KbqButtonStyles } from '@koobiq/components/button'; import { KbqComponentColors } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; +import { KbqTitleModule } from '@koobiq/components/title'; import { ButtonExamplesModule } from 'packages/docs-examples/components/button'; @Component({ @@ -26,7 +27,7 @@ export class DevDocsExamples {} @Component({ selector: 'dev-app', - imports: [KbqButtonModule, KbqIconModule, DevDocsExamples], + imports: [KbqButtonModule, KbqIconModule, DevDocsExamples, KbqTitleModule], templateUrl: 'template.html', styleUrls: ['styles.scss'], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/packages/components-dev/button/template.html b/packages/components-dev/button/template.html index 389d078e31..5e52c4c5cd 100644 --- a/packages/components-dev/button/template.html +++ b/packages/components-dev/button/template.html @@ -2,6 +2,59 @@
+
+

Explicit icon slots (source order is reversed on purpose)

+ + + +   + +   + +   + + +
+ +
+ +
+

Text ellipsis (width-constrained)

+ + + +   + + +   + + +
+ +
+
+ ` +}) +class KbqButtonThreeIconsCaseTestApp {} + @Component({ selector: 'kbq-button-text-icon-case-test-app', imports: [KbqButtonModule, KbqIconModule], @@ -615,6 +905,20 @@ class ButtonDropdownTrigger { backdropClass: string; } +@Component({ + imports: [KbqButtonModule, KbqDropdownModule], + template: ` + Toggle dropdown + + + + ` +}) +class DisabledButtonDropdownTrigger { + readonly trigger = viewChild.required('triggerEl', { read: ElementRef }); + readonly dropdownTrigger = viewChild.required(KbqDropdownTrigger); +} + @Component({ imports: [KbqButtonModule], template: ` @@ -651,3 +955,62 @@ class DynamicChildrenTestComponent { color: KbqComponentColors | string = KbqComponentColors.Theme; showExtra = false; } + +@Component({ + selector: 'kbq-button-left-icon-slot-reorder-test-app', + imports: [KbqButtonModule, KbqIconModule], + template: ` + + ` +}) +class KbqButtonLeftIconSlotReorderTestApp {} + +@Component({ + selector: 'kbq-button-right-icon-slot-reorder-test-app', + imports: [KbqButtonModule, KbqIconModule], + template: ` + + ` +}) +class KbqButtonRightIconSlotReorderTestApp {} + +@Component({ + selector: 'kbq-button-left-right-icon-slot-test-app', + imports: [KbqButtonModule, KbqIconModule], + template: ` + + ` +}) +class KbqButtonLeftRightIconSlotTestApp {} + +@Component({ + selector: 'kbq-button-two-icons-slot-test-app', + imports: [KbqButtonModule, KbqIconModule], + template: ` + + ` +}) +class KbqButtonTwoIconsSlotTestApp {} + +@Component({ + selector: 'styler-only-test-app', + // KbqButton is intentionally NOT imported, so the host has no .kbq-button-wrapper + imports: [KbqButtonCssStyler], + template: ` +
+ ` +}) +class StylerOnlyTestApp {} diff --git a/packages/components/button/button.component.ts b/packages/components/button/button.component.ts index 162a2930eb..b7a44fbd9a 100644 --- a/packages/components/button/button.component.ts +++ b/packages/components/button/button.component.ts @@ -14,23 +14,28 @@ import { forwardRef, inject, Input, + isDevMode, numberAttribute, OnDestroy, Renderer2, signal, - SkipSelf, + untracked, ViewChild, ViewEncapsulation } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; import { + DOWN_ARROW, + ENTER, getNodesWithoutComments, KBQ_TITLE_TEXT_REF, KbqColorDirective, KbqComponentColors, KbqTitleTextRef, + LEFT_ARROW, leftIconClassName, - rightIconClassName + RIGHT_ARROW, + rightIconClassName, + SPACE } from '@koobiq/components/core'; import { KbqIcon } from '@koobiq/components/icon'; @@ -43,6 +48,19 @@ export enum KbqButtonStyles { export const buttonLeftIconClassName = 'kbq-button-icon_left'; export const buttonRightIconClassName = 'kbq-button-icon_right'; +/** A button containing more icons than this keeps regular (non icon-button) styling. */ +const maxIconsForIconButton = 2; + +/** + * Applies the `kbq-button`/`kbq-button-icon` host class and the left/right icon modifier classes. + * + * A button is treated as an icon button when its projected content consists only of `KbqIcon`s + * and there are at most 2 of them. When icons are mixed with other content, only the outermost + * icons receive the left/right classes. + * + * Must be used together with `KbqButton` (both match `[kbq-button]`): icon detection relies on + * the `.kbq-button-wrapper` element rendered by the component's template. + */ @Directive({ selector: '[kbq-button]', host: { @@ -55,14 +73,31 @@ export class KbqButtonCssStyler implements AfterContentInit { nativeElement: HTMLElement; - isIconButton: boolean = false; + /** Whether the button contains only icons (at most 2). */ + get isIconButton(): boolean { + return this._isIconButton(); + } + + private readonly _isIconButton = signal(false); + + private leftIcon: HTMLElement | null = null; + private rightIcon: HTMLElement | null = null; constructor( elementRef: ElementRef, - private renderer: Renderer2, - @SkipSelf() private cdr: ChangeDetectorRef + private renderer: Renderer2 ) { this.nativeElement = elementRef.nativeElement; + + // The contentChildren query tracks only KbqIcon instances, while icon placement also + // depends on sibling text nodes that are invisible to the query — those are covered by + // the MutationObserver in the component template. This effect covers icon creation and + // removal (e.g. via @if) while the observer is disabled for icon-less buttons. + effect(() => { + this.icons(); + + untracked(() => this.updateClassModifierForIcons()); + }); } ngAfterContentInit() { @@ -70,47 +105,78 @@ export class KbqButtonCssStyler implements AfterContentInit { } updateClassModifierForIcons() { - this.renderer.removeClass(this.nativeElement, buttonLeftIconClassName); - this.renderer.removeClass(this.nativeElement, buttonRightIconClassName); - this.icons() - .map((item) => item.getHostElement()) - .forEach((iconHostElement) => { - this.renderer.removeClass(iconHostElement, leftIconClassName); - this.renderer.removeClass(iconHostElement, rightIconClassName); - }); - - const twoIcons = 2; - const filteredNodesWithoutComments = getNodesWithoutComments( - this.nativeElement.querySelector('.kbq-button-wrapper')!.childNodes as NodeList - ); + const wrapper = this.nativeElement.querySelector('.kbq-button-wrapper'); + + if (!wrapper) { + if (isDevMode()) { + // eslint-disable-next-line no-console + console.warn('KbqButtonCssStyler should be imported together with KbqButton.'); + } + + return; + } const icons = this.icons(); - const currentIsIconButtonValue = - !!icons.length && icons.length === filteredNodesWithoutComments.length && icons.length <= twoIcons; + const textElement = wrapper.querySelector('.kbq-button-text'); + + // Build an ordered list of "effective" content nodes: the left-slot content, then the + // default-slot content flattened out of `.kbq-button-text`, then the right-slot content. + // Flattening the text span keeps legacy ` Text` markup (projected into the + // default slot) working: those icons live inside `.kbq-button-text`, but for placement + // they must be treated as direct siblings of the text, exactly as before the text span + // existed. With no marker slots this list equals the old wrapper children. + const effectiveNodes: Node[] = []; + + for (const node of getNodesWithoutComments(wrapper.childNodes)) { + if (node === textElement) { + effectiveNodes.push(...getNodesWithoutComments((node as HTMLElement).childNodes)); + } else { + effectiveNodes.push(node); + } + } + + this._isIconButton.set( + !!icons.length && icons.length === effectiveNodes.length && icons.length <= maxIconsForIconButton + ); + + let leftIcon: HTMLElement | null = null; + let rightIcon: HTMLElement | null = null; + + if (icons.length && effectiveNodes.length > 1) { + for (const icon of icons) { + const iconHostElement = icon.getHostElement(); + const iconIndex = effectiveNodes.indexOf(iconHostElement); - if (currentIsIconButtonValue !== this.isIconButton) { - this.isIconButton = currentIsIconButtonValue; - this.cdr.detectChanges(); + if (iconIndex === 0) leftIcon = iconHostElement; + + if (iconIndex === effectiveNodes.length - 1) rightIcon = iconHostElement; + } } - const iconsValue = this.icons(); + this.updateIconClass(this.leftIcon, leftIcon, leftIconClassName, buttonLeftIconClassName); + this.updateIconClass(this.rightIcon, rightIcon, rightIconClassName, buttonRightIconClassName); - if (iconsValue.length && filteredNodesWithoutComments.length > 1) { - iconsValue - .map((item) => item.getHostElement()) - .forEach((iconHostElement) => { - const iconIndex = filteredNodesWithoutComments.findIndex((node) => node === iconHostElement); + this.leftIcon = leftIcon; + this.rightIcon = rightIcon; + } - if (iconIndex === 0) { - this.renderer.addClass(iconHostElement, leftIconClassName); - this.renderer.addClass(this.nativeElement, buttonLeftIconClassName); - } + private updateIconClass( + previous: HTMLElement | null, + current: HTMLElement | null, + iconClassName: string, + buttonClassName: string + ) { + if (previous === current) return; - if (iconIndex === filteredNodesWithoutComments.length - 1) { - this.renderer.addClass(iconHostElement, rightIconClassName); - this.renderer.addClass(this.nativeElement, buttonRightIconClassName); - } - }); + if (previous) { + this.renderer.removeClass(previous, iconClassName); + } + + if (current) { + this.renderer.addClass(current, iconClassName); + this.renderer.addClass(this.nativeElement, buttonClassName); + } else { + this.renderer.removeClass(this.nativeElement, buttonClassName); } } } @@ -129,10 +195,11 @@ export class KbqButtonCssStyler implements AfterContentInit { encapsulation: ViewEncapsulation.None, host: { '[attr.disabled]': 'disabled || null', + '[attr.aria-disabled]': 'disabled || null', '[class.kbq-disabled]': 'disabled', '[attr.tabIndex]': 'tabIndex', '[class]': 'kbqStyle', - '(focus)': 'onFocus($event)', + '(focus)': 'onFocus()', '(blur)': 'onBlur()' } }) @@ -141,7 +208,10 @@ export class KbqButton extends KbqColorDirective implements OnDestroy, AfterView hasFocus: boolean = false; - @ViewChild('kbqTitleText', { static: false }) textElement: ElementRef; + @ViewChild('kbqTitleText') textElement: ElementRef; + + /** The flex row that lays out the icons and text, used as the overflow width constraint. */ + @ViewChild('parentTextElement') parentTextElement: ElementRef; // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @@ -164,16 +234,13 @@ export class KbqButton extends KbqColorDirective implements OnDestroy, AfterView // Accessor inputs cannot be migrated as they are too complex. @Input({ transform: booleanAttribute }) get disabled(): boolean { - return this._disabled; + return this.disabledSignal(); } set disabled(value: boolean) { this.disabledSignal.set(value); } - // @todo 20 In the next major release this line will be deleted. - private _disabled: boolean; - /** @docs-private */ readonly disabledSignal = signal(false); @@ -192,17 +259,21 @@ export class KbqButton extends KbqColorDirective implements OnDestroy, AfterView constructor( private focusMonitor: FocusMonitor, - private styler: KbqButtonCssStyler + protected styler: KbqButtonCssStyler ) { super(); this.color = KbqComponentColors.ContrastFade; this.setDefaultColor(KbqComponentColors.ContrastFade); - // @todo 20 In the next major release this line will be deleted. - toObservable(this.disabledSignal).subscribe((value) => (this._disabled = value)); - - effect(() => (this.disabledSignal() ? this.stopFocusMonitor() : this.runFocusMonitor())); + // Native capture-phase listeners instead of host listeners: Angular coalesces listeners + // for the same event on the same element, so stopImmediatePropagation from a host listener + // would not stop consumer-bound handlers. Matters for hosts only — + // a disabled native
+
+
Slots (order-independent)
+ +
`, styleUrls: ['button-content-example.css'], diff --git a/tools/public_api_guard/components/button.api.md b/tools/public_api_guard/components/button.api.md index 0cd7df8d00..b34e8219b8 100644 --- a/tools/public_api_guard/components/button.api.md +++ b/tools/public_api_guard/components/button.api.md @@ -6,7 +6,6 @@ import { AfterContentInit } from '@angular/core'; import { AfterViewInit } from '@angular/core'; -import { ChangeDetectorRef } from '@angular/core'; import { ElementRef } from '@angular/core'; import { FocusMonitor } from '@angular/cdk/a11y'; import * as i0 from '@angular/core'; @@ -41,7 +40,7 @@ export class KbqButton extends KbqColorDirective implements OnDestroy, AfterView // (undocumented) getHostElement(): HTMLElement; // (undocumented) - haltDisabledEvents(event: Event): void; + haltDisabledEvents: (event: Event) => void; // (undocumented) hasFocus: boolean; // (undocumented) @@ -58,27 +57,29 @@ export class KbqButton extends KbqColorDirective implements OnDestroy, AfterView // (undocumented) onBlur(): void; // (undocumented) - onFocus($event: any): void; + onFocus(): void; + parentTextElement: ElementRef; // (undocumented) projectContentChanged(): void; // (undocumented) + protected styler: KbqButtonCssStyler; + // (undocumented) get tabIndex(): number; set tabIndex(value: number); // (undocumented) - textElement: ElementRef; + textElement: ElementRef; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } -// @public (undocumented) +// @public export class KbqButtonCssStyler implements AfterContentInit { - constructor(elementRef: ElementRef, renderer: Renderer2, cdr: ChangeDetectorRef); + constructor(elementRef: ElementRef, renderer: Renderer2); // (undocumented) readonly icons: i0.Signal; - // (undocumented) - isIconButton: boolean; + get isIconButton(): boolean; // (undocumented) nativeElement: HTMLElement; // (undocumented) @@ -88,7 +89,7 @@ export class KbqButtonCssStyler implements AfterContentInit { // (undocumented) static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public @@ -128,6 +129,14 @@ export class KbqButtonGroupRoot extends KbqColorDirective { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export class KbqButtonLeftIcon { + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + // @public (undocumented) export class KbqButtonModule { // (undocumented) @@ -135,7 +144,15 @@ export class KbqButtonModule { // (undocumented) static ɵinj: i0.ɵɵInjectorDeclaration; // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; +} + +// @public +export class KbqButtonRightIcon { + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public (undocumented)