From 8d698eddfb45aef1ea94a0e3f311f30daeab4eda Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 11 Aug 2026 17:31:36 +0300 Subject: [PATCH 1/6] feat(core): extract shared KbqCheckable primitive (#DS-3498) --- packages/components-dev/checkbox/module.ts | 2 + .../components/checkbox/checkbox-config.ts | 3 +- packages/components/checkbox/checkbox.ts | 147 +++++--------- .../core/common-behaviors/checkable.ts | 190 ++++++++++++++++++ .../components/core/common-behaviors/index.ts | 1 + .../components/toggle/toggle.component.ts | 115 +++++------ .../block-checkbox/block-checkbox-example.css | 66 ++++++ .../block-checkbox/block-checkbox-example.ts | 98 +++++++++ .../components/checkbox/index.ts | 12 +- .../components/checkbox.api.md | 19 +- tools/public_api_guard/components/core.api.md | 62 ++++++ .../public_api_guard/components/toggle.api.md | 16 +- 12 files changed, 555 insertions(+), 176 deletions(-) create mode 100644 packages/components/core/common-behaviors/checkable.ts create mode 100644 packages/docs-examples/components/checkbox/block-checkbox/block-checkbox-example.css create mode 100644 packages/docs-examples/components/checkbox/block-checkbox/block-checkbox-example.ts diff --git a/packages/components-dev/checkbox/module.ts b/packages/components-dev/checkbox/module.ts index e4b72fde1f..77276d7cca 100644 --- a/packages/components-dev/checkbox/module.ts +++ b/packages/components-dev/checkbox/module.ts @@ -18,6 +18,8 @@ import { DevThemeToggle } from '../theme-toggle';
+
+ `, changeDetection: ChangeDetectionStrategy.OnPush }) diff --git a/packages/components/checkbox/checkbox-config.ts b/packages/components/checkbox/checkbox-config.ts index 1c68543bb2..f700095d99 100644 --- a/packages/components/checkbox/checkbox-config.ts +++ b/packages/components/checkbox/checkbox-config.ts @@ -1,4 +1,5 @@ import { InjectionToken } from '@angular/core'; +import { KbqCheckableClickAction } from '@koobiq/components/core'; /** * Checkbox click action when user click on input element. @@ -7,7 +8,7 @@ import { InjectionToken } from '@angular/core'; * check-indeterminate: Toggle checked status, set indeterminate to false. Default behavior. * undefined: Same as `check-indeterminate`. */ -export type KbqCheckboxClickAction = 'noop' | 'check' | 'check-indeterminate' | undefined; +export type KbqCheckboxClickAction = KbqCheckableClickAction; /** * Injection token that can be used to specify the checkbox click behavior. diff --git a/packages/components/checkbox/checkbox.ts b/packages/components/checkbox/checkbox.ts index 99873cf18f..c0b471fc05 100644 --- a/packages/components/checkbox/checkbox.ts +++ b/packages/components/checkbox/checkbox.ts @@ -1,4 +1,4 @@ -import { FocusMonitor, FocusOrigin } from '@angular/cdk/a11y'; +import { FocusOrigin } from '@angular/cdk/a11y'; import { CdkObserveContent } from '@angular/cdk/observers'; import { AfterViewInit, @@ -18,9 +18,16 @@ import { ViewEncapsulation } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { KbqCheckedState, KbqColorDirective } from '@koobiq/components/core'; +import { KbqCheckable, KbqCheckedState, KbqColorDirective, TransitionCheckState } from '@koobiq/components/core'; import { KBQ_CHECKBOX_CLICK_ACTION, KbqCheckboxClickAction } from './checkbox-config'; +/** + * Represents the different states that require custom transitions between them. + * @docs-private + * @deprecated Use `TransitionCheckState` from `@koobiq/components/core` instead. + */ +export { TransitionCheckState }; + // Increasing integer for generating unique ids for checkbox components. let nextUniqueId = 0; @@ -28,6 +35,7 @@ let nextUniqueId = 0; * Provider Expression that allows kbq-checkbox to register as a ControlValueAccessor. * This allows it to support [(ngModel)]. * @docs-private + * @deprecated Unused - the `ControlValueAccessor` is now registered by the `KbqCheckable` host directive. */ export const KBQ_CHECKBOX_CONTROL_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, @@ -35,21 +43,6 @@ export const KBQ_CHECKBOX_CONTROL_VALUE_ACCESSOR: any = { multi: true }; -/** - * Represents the different states that require custom transitions between them. - * @docs-private - */ -export enum TransitionCheckState { - /** The initial state of the component before any user interaction. */ - Init = 'init', - /** The state representing the component when it's becoming checked. */ - Checked = 'checked', - /** The state representing the component when it's becoming unchecked. */ - Unchecked = 'unchecked', - /** The state representing the component when it's becoming indeterminate. */ - Indeterminate = 'indeterminate' -} - /** Change event object emitted by KbqCheckbox. */ export class KbqCheckboxChange { /** The source KbqCheckbox of the event. */ @@ -72,7 +65,6 @@ export class KbqCheckboxChange { ], templateUrl: 'checkbox.html', styleUrls: ['checkbox.scss', 'checkbox-tokens.scss'], - providers: [KBQ_CHECKBOX_CONTROL_VALUE_ACCESSOR], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { @@ -86,11 +78,12 @@ export class KbqCheckboxChange { '[class.kbq-disabled]': 'disabled', '[class.kbq-checkbox_label-before]': 'labelPosition() == "before"' }, + hostDirectives: [KbqCheckable], exportAs: 'kbqCheckbox' }) export class KbqCheckbox extends KbqColorDirective implements ControlValueAccessor, AfterViewInit, OnDestroy { - private changeDetectorRef = inject(ChangeDetectorRef); - private focusMonitor = inject(FocusMonitor); + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly checkable = inject(KbqCheckable, { self: true }); readonly big = input(false); @@ -137,48 +130,36 @@ export class KbqCheckbox extends KbqColorDirective implements ControlValueAccess // Accessor inputs cannot be migrated as they are too complex. @Input() get checked(): boolean { - return this._checked; + return this.checkable.checked(); } set checked(value: boolean) { - if (value !== this.checked) { - this._checked = value; - this.changeDetectorRef.markForCheck(); - } + this.checkable.checked.set(value); } - private _checked: boolean = false; - /** Whether the checkbox is disabled. */ // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input({ transform: booleanAttribute }) get disabled(): boolean { - return this._disabled; + return this.checkable.disabled(); } set disabled(value: boolean) { - if (value !== this.disabled) { - this._disabled = value; - this.changeDetectorRef.markForCheck(); - } + this.checkable.disabled.set(value); } - private _disabled: boolean = false; - // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input({ transform: numberAttribute }) get tabIndex(): number { - return this.disabled ? -1 : this._tabIndex; + return this.checkable.effectiveTabIndex(); } set tabIndex(value: number) { - this._tabIndex = value; + this.checkable.tabIndex.set(value); } - private _tabIndex = 0; - /** * Whether the checkbox is indeterminate. This is also known as "mixed" mode and can be used to * represent a checkbox with three states, e.g. a checkbox that represents a nested list of @@ -189,33 +170,29 @@ export class KbqCheckbox extends KbqColorDirective implements ControlValueAccess // Accessor inputs cannot be migrated as they are too complex. @Input() get indeterminate(): boolean { - return this._indeterminate; + return this.checkable.indeterminate(); } set indeterminate(value: boolean) { - const changed = value !== this._indeterminate; + const changed = value !== this.checkable.indeterminate(); - this._indeterminate = value; + this.checkable.indeterminate.set(value); if (changed) { - if (this._indeterminate) { - this.transitionCheckState(TransitionCheckState.Indeterminate); - } else { - this.transitionCheckState(this.checked ? TransitionCheckState.Checked : TransitionCheckState.Unchecked); - } - - this.indeterminateChange.emit(this._indeterminate); + this.checkable.transitionCheckState( + this.checkable.indeterminate() + ? TransitionCheckState.Indeterminate + : this.checked + ? TransitionCheckState.Checked + : TransitionCheckState.Unchecked + ); + + this.indeterminateChange.emit(value); } } - private _indeterminate: boolean = false; - private uniqueId: string = `kbq-checkbox-${++nextUniqueId}`; - private currentAnimationClass: string = ''; - - private currentCheckState: TransitionCheckState = TransitionCheckState.Init; - constructor() { super(); @@ -229,13 +206,13 @@ export class KbqCheckbox extends KbqColorDirective implements ControlValueAccess onTouched: () => any = () => {}; ngAfterViewInit() { - this.focusMonitor - .monitor(this.inputElement().nativeElement) + this.checkable + .monitorFocus(this.inputElement()) .subscribe((focusOrigin) => this.onInputFocusChange(focusOrigin)); } ngOnDestroy() { - this.focusMonitor.stopMonitoring(this.inputElement().nativeElement); + this.checkable.stopMonitoringFocus(this.inputElement()); } /** Method being called whenever the label text changes. */ @@ -253,12 +230,12 @@ export class KbqCheckbox extends KbqColorDirective implements ControlValueAccess // Implemented as part of ControlValueAccessor. registerOnChange(fn: (value: any) => void) { - this.controlValueAccessorChangeFn = fn; + this.checkable.registerOnChange(fn); } // Implemented as part of ControlValueAccessor. registerOnTouched(fn: any) { - this.onTouched = fn; + this.checkable.registerOnTouched(fn); } // Implemented as part of ControlValueAccessor. @@ -267,12 +244,12 @@ export class KbqCheckbox extends KbqColorDirective implements ControlValueAccess } getAriaChecked(): KbqCheckedState { - return this.checked ? 'true' : this.indeterminate ? 'mixed' : 'false'; + return this.checkable.getAriaChecked(); } /** Toggles the `checked` state of the checkbox. */ toggle(): void { - this.checked = !this.checked; + this.checkable.toggle(); } /** @@ -292,36 +269,36 @@ export class KbqCheckbox extends KbqColorDirective implements ControlValueAccess // Preventing bubbling for the second event will solve that issue. event.stopPropagation(); - // If resetIndeterminate is false, and the current state is indeterminate, do nothing on click - if (!this.disabled && this.clickAction !== 'noop') { + const { shouldToggle, shouldClearIndeterminate } = this.checkable.resolveClick(this.clickAction); + + if (shouldToggle) { // When user manually click on the checkbox, `indeterminate` is set to false. - if (this.indeterminate && this.clickAction !== 'check') { + if (shouldClearIndeterminate) { Promise.resolve().then(() => { - this._indeterminate = false; - this.indeterminateChange.emit(this._indeterminate); + this.checkable.indeterminate.set(false); + this.indeterminateChange.emit(false); }); } this.toggle(); - this.transitionCheckState(this._checked ? TransitionCheckState.Checked : TransitionCheckState.Unchecked); + this.checkable.transitionCheckState( + this.checked ? TransitionCheckState.Checked : TransitionCheckState.Unchecked + ); // Emit our custom change event if the native input emitted one. // It is important to only emit it, if the native input triggered one, because // we don't want to trigger a change event, when the `checked` variable changes for example. this.emitChangeEvent(); - } else if (!this.disabled && this.clickAction === 'noop') { + } else if (!this.disabled) { // Reset native input when clicked with noop. The native checkbox becomes checked after // click, reset it to be align with `checked` value of `kbq-checkbox`. - const inputElement = this.inputElement(); - - inputElement.nativeElement.checked = this.checked; - inputElement.nativeElement.indeterminate = this.indeterminate; + this.checkable.resetNativeInput(this.inputElement().nativeElement); } } /** Focuses the checkbox. */ focus(): void { - this.focusMonitor.focusVia(this.inputElement().nativeElement, 'keyboard'); + this.checkable.focusVia(this.inputElement()); } onInteractionEvent(event: Event) { @@ -330,26 +307,6 @@ export class KbqCheckbox extends KbqColorDirective implements ControlValueAccess // emit its event object to the `change` output. event.stopPropagation(); } - private controlValueAccessorChangeFn: (value: any) => void = () => {}; - - private transitionCheckState(newState: TransitionCheckState) { - const oldState = this.currentCheckState; - const element: HTMLElement = this.elementRef.nativeElement; - - if (oldState === newState) { - return; - } - - if (this.currentAnimationClass.length > 0) { - element.classList.remove(this.currentAnimationClass); - } - - this.currentCheckState = newState; - - if (this.currentAnimationClass.length > 0) { - element.classList.add(this.currentAnimationClass); - } - } private emitChangeEvent() { const event = new KbqCheckboxChange(); @@ -357,14 +314,14 @@ export class KbqCheckbox extends KbqColorDirective implements ControlValueAccess event.source = this; event.checked = this.checked; - this.controlValueAccessorChangeFn(this.checked); + this.checkable.notifyFormValueChange(this.checked); this.change.emit(event); } /** Function is called whenever the focus changes for the input element. */ private onInputFocusChange(focusOrigin: FocusOrigin) { if (focusOrigin) { - this.onTouched(); + this.checkable.onTouched(); } } } diff --git a/packages/components/core/common-behaviors/checkable.ts b/packages/components/core/common-behaviors/checkable.ts new file mode 100644 index 0000000000..be29b24773 --- /dev/null +++ b/packages/components/core/common-behaviors/checkable.ts @@ -0,0 +1,190 @@ +import { FocusMonitor } from '@angular/cdk/a11y'; +import { + computed, + Directive, + ElementRef, + forwardRef, + inject, + InjectionToken, + model, + OnDestroy, + signal +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { KbqCheckedState } from './checkbox'; + +/** + * Provider Expression that allows `KbqCheckable` to register as a `ControlValueAccessor`, so any host + * applying it via `hostDirectives` gets `[(ngModel)]`/`formControl` support without wiring up its own. + * @docs-private + */ +export const KBQ_CHECKABLE_CONTROL_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => KbqCheckable), + multi: true +}; + +/** + * Click action shared by checkable controls (checkbox, toggle, and custom checkbox-like primitives). + * noop: Do not toggle checked or indeterminate. + * check: Only toggle checked status, ignore indeterminate. + * check-indeterminate: Toggle checked status, set indeterminate to false. Default behavior. + * undefined: Same as `check-indeterminate`. + */ +export type KbqCheckableClickAction = 'noop' | 'check' | 'check-indeterminate' | undefined; + +/** Injection token that can be used to specify the default click behavior for checkable controls. */ +export const KBQ_CHECKABLE_CLICK_ACTION = new InjectionToken('kbq-checkable-click-action'); + +/** + * Represents the different states that require custom transitions between them. + * @docs-private + */ +export enum TransitionCheckState { + /** The initial state of the component before any user interaction. */ + Init = 'init', + /** The state representing the component when it's becoming checked. */ + Checked = 'checked', + /** The state representing the component when it's becoming unchecked. */ + Unchecked = 'unchecked', + /** The state representing the component when it's becoming indeterminate. */ + Indeterminate = 'indeterminate' +} + +/** Result of resolving a click on a checkable control's native input. See {@link KbqCheckable.resolveClick}. */ +export interface KbqCheckableClickResult { + /** Whether `checked` should be toggled and a `change` event emitted. */ + readonly shouldToggle: boolean; + /** Whether `indeterminate` should be cleared and an `indeterminateChange` event emitted. */ + readonly shouldClearIndeterminate: boolean; +} + +/** + * Shared behavior for checkbox-like controls: checked/disabled/indeterminate/tabIndex state, the click-to-toggle + * algorithm, ARIA-checked computation, `ControlValueAccessor` change plumbing, and `FocusMonitor` wiring. + * + * Meant to be applied via `hostDirectives` by components that render their own native `` + * and markup (e.g. `KbqCheckbox`, `KbqToggleComponent`, or custom checkbox-like primitives). Hosts stay + * responsible for their own public inputs/outputs and for emitting their own typed `change` event - this + * directive only centralizes the state and decisions behind them. + */ +@Directive({ + selector: '[kbqCheckable]', + providers: [KBQ_CHECKABLE_CONTROL_VALUE_ACCESSOR], + exportAs: 'kbqCheckable' +}) +export class KbqCheckable implements ControlValueAccessor, OnDestroy { + private readonly focusMonitor = inject(FocusMonitor); + + private monitoredElement: ElementRef | null = null; + + private controlValueAccessorChangeFn: (value: any) => void = () => {}; + + /** + * Called when the control is blurred. Needed to properly implement `ControlValueAccessor`. + * @docs-private + */ + onTouched: () => any = () => {}; + + /** @docs-private */ + readonly currentCheckState = signal(TransitionCheckState.Init); + + readonly checked = model(false); + + readonly disabled = model(false); + + readonly indeterminate = model(false); + + /** Raw tab index as set by the caller, ignoring `disabled`. Use `effectiveTabIndex` to read the applied value. */ + readonly tabIndex = model(0); + + /** The tab index actually applied to the native input: forced to `-1` while disabled. */ + readonly effectiveTabIndex = computed(() => (this.disabled() ? -1 : this.tabIndex())); + + ngOnDestroy(): void { + if (this.monitoredElement) { + this.focusMonitor.stopMonitoring(this.monitoredElement.nativeElement); + } + } + + /** Starts tracking the focus origin for `elementRef`, so CDK's `.cdk-*-focused` classes are applied to it. */ + monitorFocus(elementRef: ElementRef, checkChildren = false) { + this.monitoredElement = elementRef; + + return this.focusMonitor.monitor(elementRef.nativeElement, checkChildren); + } + + /** Stops tracking the focus origin for `elementRef`. */ + stopMonitoringFocus(elementRef: ElementRef): void { + this.focusMonitor.stopMonitoring(elementRef.nativeElement); + } + + /** Focuses `elementRef` with a keyboard focus origin. */ + focusVia(elementRef: ElementRef): void { + this.focusMonitor.focusVia(elementRef.nativeElement, 'keyboard'); + } + + /** Returns the ARIA-checked value: `'true'`, `'false'`, or `'mixed'` when indeterminate. */ + getAriaChecked(): KbqCheckedState { + return this.checked() ? 'true' : this.indeterminate() ? 'mixed' : 'false'; + } + + /** Toggles the `checked` state. */ + toggle(): void { + this.checked.update((value) => !value); + } + + /** + * Resolves what a click on the native input should do, based on the current state and `clickAction`. + * Pure - does not mutate state. Callers apply `checked`/`indeterminate` themselves and own emitting + * their own change events, so timing/order stays identical to a hand-written click handler. + */ + resolveClick(clickAction: KbqCheckableClickAction): KbqCheckableClickResult { + if (this.disabled() || clickAction === 'noop') { + return { shouldToggle: false, shouldClearIndeterminate: false }; + } + + return { + shouldToggle: true, + shouldClearIndeterminate: this.indeterminate() && clickAction !== 'check' + }; + } + + /** Resets the native input to match the current `checked`/`indeterminate` state (used for the `noop` click action). */ + resetNativeInput(nativeInput: HTMLInputElement): void { + nativeInput.checked = this.checked(); + nativeInput.indeterminate = this.indeterminate(); + } + + /** Transitions the animation state, ignoring no-op transitions. @docs-private */ + transitionCheckState(newState: TransitionCheckState): void { + if (this.currentCheckState() === newState) return; + + this.currentCheckState.set(newState); + } + + // Implemented as part of ControlValueAccessor. + writeValue(value: any): void { + this.checked.set(!!value); + } + + /** Stores the `ControlValueAccessor` change handler to be notified by `notifyFormValueChange`. */ + registerOnChange(fn: (value: any) => void): void { + this.controlValueAccessorChangeFn = fn; + } + + // Implemented as part of ControlValueAccessor. + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + // Implemented as part of ControlValueAccessor. + setDisabledState(isDisabled: boolean): void { + this.disabled.set(isDisabled); + } + + /** Notifies the registered `ControlValueAccessor` change handler. */ + notifyFormValueChange(value: boolean): void { + this.controlValueAccessorChangeFn(value); + } +} diff --git a/packages/components/core/common-behaviors/index.ts b/packages/components/core/common-behaviors/index.ts index 57c182ee98..46e7052216 100644 --- a/packages/components/core/common-behaviors/index.ts +++ b/packages/components/core/common-behaviors/index.ts @@ -1,5 +1,6 @@ import { InjectionToken } from '@angular/core'; +export * from './checkable'; export * from './checkbox'; export * from './clipboard'; export { CanColor, KbqColorDirective, KbqComponentColors, ThemePalette } from './color'; diff --git a/packages/components/toggle/toggle.component.ts b/packages/components/toggle/toggle.component.ts index 06da8aae18..6e90368d03 100644 --- a/packages/components/toggle/toggle.component.ts +++ b/packages/components/toggle/toggle.component.ts @@ -1,5 +1,4 @@ import { animate, state, style, transition, trigger } from '@angular/animations'; -import { FocusMonitor } from '@angular/cdk/a11y'; import { CdkObserveContent } from '@angular/cdk/observers'; import { AfterViewInit, @@ -8,7 +7,6 @@ import { ChangeDetectorRef, Component, ElementRef, - forwardRef, inject, Input, input, @@ -18,9 +16,18 @@ import { viewChild, ViewEncapsulation } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { KBQ_CHECKBOX_CLICK_ACTION, KbqCheckboxClickAction, TransitionCheckState } from '@koobiq/components/checkbox'; -import { KbqAnimationCurves, KbqAnimationDurations, KbqCheckedState, KbqColorDirective } from '@koobiq/components/core'; +import { ControlValueAccessor } from '@angular/forms'; +import { KBQ_CHECKBOX_CLICK_ACTION } from '@koobiq/components/checkbox'; +import { + KBQ_CHECKABLE_CLICK_ACTION, + KbqAnimationCurves, + KbqAnimationDurations, + KbqCheckable, + KbqCheckableClickAction, + KbqCheckedState, + KbqColorDirective, + TransitionCheckState +} from '@koobiq/components/core'; let nextUniqueId = 0; @@ -31,6 +38,11 @@ export class KbqToggleChange { checked: boolean; } +/** + * Toggle click action when user click on input element. Alias of `KbqCheckableClickAction`. + */ +export type KbqToggleClickAction = KbqCheckableClickAction; + @Component({ selector: 'kbq-toggle', imports: [ @@ -39,11 +51,9 @@ export class KbqToggleChange { templateUrl: './toggle.component.html', styleUrls: ['./toggle.scss', './toggle-tokens.scss'], providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => KbqToggleComponent), - multi: true - } + // Falls back to `KBQ_CHECKBOX_CLICK_ACTION` for backwards compatibility with apps that already + // configure it globally to control click behavior for both checkbox and toggle. + { provide: KBQ_CHECKABLE_CLICK_ACTION, useExisting: KBQ_CHECKBOX_CLICK_ACTION } ], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, @@ -56,6 +66,7 @@ export class KbqToggleChange { '[class.kbq-active]': 'checked', '[class.kbq-indeterminate]': 'indeterminate' }, + hostDirectives: [KbqCheckable], animations: [ trigger('switch', [ state(TransitionCheckState.Init, style({ left: '3px' })), @@ -79,8 +90,8 @@ export class KbqToggleChange { exportAs: 'kbqToggle' }) export class KbqToggleComponent extends KbqColorDirective implements AfterViewInit, ControlValueAccessor, OnDestroy { - private focusMonitor = inject(FocusMonitor); - private changeDetectorRef = inject(ChangeDetectorRef); + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly checkable = inject(KbqCheckable, { self: true }); readonly big = input(false); @@ -107,48 +118,38 @@ export class KbqToggleComponent extends KbqColorDirective implements AfterViewIn // Accessor inputs cannot be migrated as they are too complex. @Input() get disabled() { - return this._disabled; + return this.checkable.disabled(); } set disabled(value: any) { - if (value !== this._disabled) { - this._disabled = value; - this.changeDetectorRef.markForCheck(); - } + this.checkable.disabled.set(value); } - private _disabled: boolean = false; - // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input({ transform: numberAttribute }) get tabIndex(): number { - return this.disabled ? -1 : this._tabIndex; + return this.checkable.effectiveTabIndex(); } set tabIndex(value: number) { - this._tabIndex = value; + this.checkable.tabIndex.set(value); } - private _tabIndex = 0; - get checked() { - return this._checked; + return this.checkable.checked(); } // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input() set checked(value: boolean) { - if (value !== this._checked) { - this._checked = value; + if (value !== this.checkable.checked()) { + this.checkable.checked.set(value); this.setTransitionCheckState(); - this.changeDetectorRef.markForCheck(); } } - private _checked: boolean = false; - /** * Whether the toggle is indeterminate. This is also known as "mixed" mode and can be used to * represent a checkbox with three states, e.g. a checkbox that represents a nested list of @@ -159,21 +160,20 @@ export class KbqToggleComponent extends KbqColorDirective implements AfterViewIn // Accessor inputs cannot be migrated as they are too complex. @Input({ transform: booleanAttribute }) get indeterminate(): boolean { - return this._indeterminate; + return this.checkable.indeterminate(); } set indeterminate(value: boolean) { - const changed = value !== this._indeterminate; + const changed = value !== this.checkable.indeterminate(); - this._indeterminate = value; + this.checkable.indeterminate.set(value); if (changed) { this.setTransitionCheckState(); - this.indeterminateChange.emit(this._indeterminate); + this.indeterminateChange.emit(value); } } - private _indeterminate: boolean = false; /** * Property for manually set loading state. */ @@ -190,7 +190,7 @@ export class KbqToggleComponent extends KbqColorDirective implements AfterViewIn /** Defines the behavior when a user clicks on the toggle. */ // TODO: Skipped for migration because: // Your application code writes to the input. This prevents migration. - @Input() clickAction: KbqCheckboxClickAction = inject(KBQ_CHECKBOX_CLICK_ACTION, { optional: true }) || undefined; + @Input() clickAction: KbqToggleClickAction = inject(KBQ_CHECKABLE_CLICK_ACTION, { optional: true }) || undefined; private uniqueId: string = `kbq-toggle-${++nextUniqueId}`; @@ -201,19 +201,19 @@ export class KbqToggleComponent extends KbqColorDirective implements AfterViewIn } ngAfterViewInit(): void { - this.focusMonitor.monitor(this.elementRef.nativeElement, true); + this.checkable.monitorFocus(this.elementRef, true); } ngOnDestroy() { - this.focusMonitor.stopMonitoring(this.elementRef.nativeElement); + this.checkable.stopMonitoringFocus(this.elementRef); } focus(): void { - this.focusMonitor.focusVia(this.inputElement().nativeElement, 'keyboard'); + this.checkable.focusVia(this.inputElement()); } getAriaChecked(): KbqCheckedState { - return this.checked ? 'true' : this.indeterminate ? 'mixed' : 'false'; + return this.checkable.getAriaChecked(); } onChangeEvent(event: Event) { @@ -235,29 +235,28 @@ export class KbqToggleComponent extends KbqColorDirective implements AfterViewIn // Preventing bubbling for the second event will solve that issue. event.stopPropagation(); - if (!this.disabled && this.clickAction !== 'noop') { + const { shouldToggle, shouldClearIndeterminate } = this.checkable.resolveClick(this.clickAction); + + if (shouldToggle) { // When user manually click on the toggle, `indeterminate` is set to false. - if (this.indeterminate && this.clickAction !== 'check') { + if (shouldClearIndeterminate) { Promise.resolve().then(() => { - this._indeterminate = false; - this.indeterminateChange.emit(this._indeterminate); + this.checkable.indeterminate.set(false); + this.indeterminateChange.emit(false); }); } - this._checked = !this.checked; - this.onTouchedCallback(); - this.transitionCheckState(this._checked ? TransitionCheckState.Checked : TransitionCheckState.Unchecked); + this.checkable.toggle(); + this.checkable.onTouched(); + this.transitionCheckState(this.checked ? TransitionCheckState.Checked : TransitionCheckState.Unchecked); // Emit our custom change event if the native input emitted one. // It is important to only emit it, if the native input triggered one, because // we don't want to trigger a change event, when the `checked` variable changes for example. this.emitChangeEvent(); - } else if (!this.disabled && this.clickAction === 'noop') { + } else if (!this.disabled) { // Reset native input when clicked with noop. The native checkbox becomes checked after // click, reset it to be align with `checked` value of `kbq-toggle`. - const inputElement = this.inputElement(); - - inputElement.nativeElement.checked = this.checked; - inputElement.nativeElement.indeterminate = this.indeterminate; + this.checkable.resetNativeInput(this.inputElement().nativeElement); } } @@ -266,11 +265,11 @@ export class KbqToggleComponent extends KbqColorDirective implements AfterViewIn } registerOnChange(fn: any) { - this.onChangeCallback = fn; + this.checkable.registerOnChange(fn); } registerOnTouched(fn: any) { - this.onTouchedCallback = fn; + this.checkable.registerOnTouched(fn); } setDisabledState(isDisabled: boolean) { @@ -285,14 +284,8 @@ export class KbqToggleComponent extends KbqColorDirective implements AfterViewIn } } - private onTouchedCallback = () => {}; - - private onChangeCallback = (_: any) => {}; - private transitionCheckState(newState: TransitionCheckState) { - const oldState = this.currentCheckState; - - if (oldState === newState) return; + if (this.currentCheckState === newState) return; this.currentCheckState = newState; } @@ -303,7 +296,7 @@ export class KbqToggleComponent extends KbqColorDirective implements AfterViewIn event.source = this; event.checked = this.checked; - this.onChangeCallback(this.checked); + this.checkable.notifyFormValueChange(this.checked); this.change.emit(event); } } diff --git a/packages/docs-examples/components/checkbox/block-checkbox/block-checkbox-example.css b/packages/docs-examples/components/checkbox/block-checkbox/block-checkbox-example.css new file mode 100644 index 0000000000..3809e64267 --- /dev/null +++ b/packages/docs-examples/components/checkbox/block-checkbox/block-checkbox-example.css @@ -0,0 +1,66 @@ +.example-block-checkbox__layout { + position: relative; + display: flex; + flex-direction: column; + gap: var(--kbq-size-m); + box-sizing: border-box; + width: 260px; + padding: var(--kbq-size-l); + border: 1px solid var(--kbq-line-contrast-fade); + border-radius: var(--kbq-size-s); + background: var(--kbq-background-card); + cursor: pointer; + transition: border-color 150ms ease-in-out; +} + +.example-block-checkbox__input { + inset: 0; + position: absolute; + + margin: 0; + opacity: 0; + cursor: inherit; +} + +:host(.example-block-checkbox_checked) .example-block-checkbox__layout { + border-color: var(--kbq-line-theme); +} + +:host(.example-block-checkbox_disabled) .example-block-checkbox__layout { + cursor: not-allowed; + opacity: 0.5; +} + +.example-block-checkbox__thumb { + width: 48px; + height: 48px; + border-radius: var(--kbq-size-xxs); + background: var(--kbq-background-bg-secondary); +} + +.example-block-checkbox__description { + color: var(--kbq-foreground-contrast-secondary); +} + +.example-block-checkbox__indicator { + position: absolute; + top: var(--kbq-size-l); + right: var(--kbq-size-l); + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 20px; + height: 20px; + border: 1px solid var(--kbq-line-contrast-fade); + border-radius: 50%; +} + +:host(.example-block-checkbox_checked) .example-block-checkbox__indicator { + border-color: var(--kbq-line-theme); + background: var(--kbq-line-theme); +} + +:host(.example-block-checkbox_checked) .example-block-checkbox__indicator .kbq-icon { + color: var(--kbq-foreground-white); +} diff --git a/packages/docs-examples/components/checkbox/block-checkbox/block-checkbox-example.ts b/packages/docs-examples/components/checkbox/block-checkbox/block-checkbox-example.ts new file mode 100644 index 0000000000..fba52c6248 --- /dev/null +++ b/packages/docs-examples/components/checkbox/block-checkbox/block-checkbox-example.ts @@ -0,0 +1,98 @@ +import { ChangeDetectionStrategy, Component, inject, model, output } from '@angular/core'; +import { KbqCheckable } from '@koobiq/components/core'; +import { KbqIconModule } from '@koobiq/components/icon'; +import {FormsModule} from "@angular/forms"; + +/** + * EXAMPLE ONLY - not a published Koobiq component. + * + * A checkbox-like "card" control built directly on top of the shared `KbqCheckable` primitive. + * Demonstrates that custom markup/styling can reuse the same click, form, and a11y behavior as + * `kbq-checkbox` and `kbq-toggle` via `hostDirectives`, without reimplementing any of it. + */ +@Component({ + selector: 'block-checkbox-component', + imports: [KbqIconModule], + template: ` + + `, + styleUrl: './block-checkbox-example.css', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'example-block-checkbox', + '[class.example-block-checkbox_checked]': 'checkable.checked()', + '[class.example-block-checkbox_disabled]': 'checkable.disabled()' + }, + hostDirectives: [ + { directive: KbqCheckable, inputs: ['checked', 'disabled'] } + ] +}) +export class BlockCheckboxComponent { + protected readonly checkable = inject(KbqCheckable, { self: true }); + + /** Emitted with the new `checked` value when the card is toggled by the user. */ + readonly checkedChange = output(); + + protected onInputClick(event: Event): void { + // See KbqCheckbox/KbqToggleComponent#onInputClick - stops the label's generated click event + // on the native input from bubbling and firing this handler a second time. + event.stopPropagation(); + + const { shouldToggle } = this.checkable.resolveClick(undefined); + + if (shouldToggle) { + this.checkable.toggle(); + this.checkedChange.emit(this.checkable.checked()); + } + } +} + + +@Component({ + selector: 'block-checkbox-example', + imports: [KbqIconModule, BlockCheckboxComponent, FormsModule], + template: ` + + Title +
Lorem ipsum
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'layout-margin-5xl layout-align-center-center layout-row' + } +}) +export class BlockCheckboxExample { + checked = model(true); +} diff --git a/packages/docs-examples/components/checkbox/index.ts b/packages/docs-examples/components/checkbox/index.ts index 32793fadbc..23890fdcd7 100644 --- a/packages/docs-examples/components/checkbox/index.ts +++ b/packages/docs-examples/components/checkbox/index.ts @@ -1,16 +1,24 @@ import { NgModule } from '@angular/core'; +import { BlockCheckboxExample } from './block-checkbox/block-checkbox-example'; import { CheckboxIndeterminateExample } from './checkbox-indeterminate/checkbox-indeterminate-example'; import { CheckboxMultilineExample } from './checkbox-multiline/checkbox-multiline-example'; import { CheckboxOverviewExample } from './checkbox-overview/checkbox-overview-example'; import { PseudoCheckboxExample } from './pseudo-checkbox/pseudo-checkbox-example'; -export { CheckboxIndeterminateExample, CheckboxMultilineExample, CheckboxOverviewExample, PseudoCheckboxExample }; +export { + BlockCheckboxExample, + CheckboxIndeterminateExample, + CheckboxMultilineExample, + CheckboxOverviewExample, + PseudoCheckboxExample +}; const EXAMPLES = [ CheckboxIndeterminateExample, CheckboxOverviewExample, PseudoCheckboxExample, - CheckboxMultilineExample + CheckboxMultilineExample, + BlockCheckboxExample ]; @NgModule({ diff --git a/tools/public_api_guard/components/checkbox.api.md b/tools/public_api_guard/components/checkbox.api.md index 3af8abd98f..b623eebf46 100644 --- a/tools/public_api_guard/components/checkbox.api.md +++ b/tools/public_api_guard/components/checkbox.api.md @@ -9,16 +9,19 @@ import * as _angular_core from '@angular/core'; import { CheckboxRequiredValidator } from '@angular/forms'; import { ControlValueAccessor } from '@angular/forms'; import { ElementRef } from '@angular/core'; +import * as i1 from '@koobiq/components/core'; import { InjectionToken } from '@angular/core'; +import { KbqCheckableClickAction } from '@koobiq/components/core'; import { KbqCheckedState } from '@koobiq/components/core'; import { KbqColorDirective } from '@koobiq/components/core'; import { OnDestroy } from '@angular/core'; import { Provider } from '@angular/core'; +import { TransitionCheckState } from '@koobiq/components/core'; // @public -export const KBQ_CHECKBOX_CLICK_ACTION: InjectionToken; +export const KBQ_CHECKBOX_CLICK_ACTION: InjectionToken; -// @public +// @public @deprecated export const KBQ_CHECKBOX_CONTROL_VALUE_ACCESSOR: any; // @public (undocumented) @@ -74,7 +77,7 @@ export class KbqCheckbox extends KbqColorDirective implements ControlValueAccess // (undocumented) writeValue(value: any): void; // (undocumented) - static ɵcmp: _angular_core.ɵɵComponentDeclaration; + static ɵcmp: _angular_core.ɵɵComponentDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } @@ -86,7 +89,7 @@ export class KbqCheckboxChange { } // @public -export type KbqCheckboxClickAction = 'noop' | 'check' | 'check-indeterminate' | undefined; +export type KbqCheckboxClickAction = KbqCheckableClickAction; // @public (undocumented) export class KbqCheckboxModule { @@ -106,13 +109,7 @@ export class KbqCheckboxRequiredValidator extends CheckboxRequiredValidator { static ɵfac: _angular_core.ɵɵFactoryDeclaration; } -// @public -export enum TransitionCheckState { - Checked = "checked", - Indeterminate = "indeterminate", - Init = "init", - Unchecked = "unchecked" -} +export { TransitionCheckState } // (No @packageDocumentation comment for this package) diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index bec0b7e218..59ebbddb31 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -8,6 +8,7 @@ import { AbstractControl } from '@angular/forms'; import { AfterContentInit } from '@angular/core'; import { AfterViewChecked } from '@angular/core'; import { AfterViewInit } from '@angular/core'; +import * as _angular_cdk_focus_monitor_d from '@angular/cdk/focus-monitor.d'; import * as _angular_forms from '@angular/forms'; import { AnimationEvent as AnimationEvent_2 } from '@angular/animations'; import { AnimationTriggerMetadata } from '@angular/animations'; @@ -20,6 +21,7 @@ import { ChangeDetectorRef } from '@angular/core'; import { ComponentPortal } from '@angular/cdk/portal'; import { ConnectedOverlayPositionChange } from '@angular/cdk/overlay'; import { ConnectionPositionPair } from '@angular/cdk/overlay'; +import { ControlValueAccessor } from '@angular/forms'; import { DateAdapter as DateAdapter_2 } from '@koobiq/date-adapter'; import { DateFormats } from '@koobiq/date-adapter'; import { DateFormatter as DateFormatter_2 } from '@koobiq/date-formatter'; @@ -53,6 +55,7 @@ import { QueryList } from '@angular/core'; import { Renderer2 } from '@angular/core'; import { RendererFactory2 } from '@angular/core'; import { RepositionScrollStrategy } from '@angular/cdk/overlay'; +import * as rxjs from 'rxjs'; import { ScrollDispatcher } from '@angular/cdk/overlay'; import { ScrollStrategy } from '@angular/cdk/overlay'; import { Signal } from '@angular/core'; @@ -1134,6 +1137,12 @@ export const K = 75; // @public export const KBQ_A11Y_LOCALE_CONFIGURATION: InjectionToken; +// @public +export const KBQ_CHECKABLE_CLICK_ACTION: InjectionToken; + +// @public +export const KBQ_CHECKABLE_CONTROL_VALUE_ACCESSOR: any; + // @public export const KBQ_CONNECTED_OVERLAY_ABOVE_CLASS = "kbq-connected-overlay_above"; @@ -2617,6 +2626,51 @@ export interface KbqBaseFileUploadLocaleConfig { captionTextWithFolder: string; } +// @public +export class KbqCheckable implements ControlValueAccessor, OnDestroy { + // (undocumented) + readonly checked: i0.ModelSignal; + readonly currentCheckState: i0.WritableSignal; + // (undocumented) + readonly disabled: i0.ModelSignal; + readonly effectiveTabIndex: i0.Signal; + focusVia(elementRef: ElementRef): void; + getAriaChecked(): KbqCheckedState; + // (undocumented) + readonly indeterminate: i0.ModelSignal; + monitorFocus(elementRef: ElementRef, checkChildren?: boolean): rxjs.Observable<_angular_cdk_focus_monitor_d.FocusOrigin>; + // (undocumented) + ngOnDestroy(): void; + notifyFormValueChange(value: boolean): void; + onTouched: () => any; + registerOnChange(fn: (value: any) => void): void; + // (undocumented) + registerOnTouched(fn: any): void; + resetNativeInput(nativeInput: HTMLInputElement): void; + resolveClick(clickAction: KbqCheckableClickAction): KbqCheckableClickResult; + // (undocumented) + setDisabledState(isDisabled: boolean): void; + stopMonitoringFocus(elementRef: ElementRef): void; + readonly tabIndex: i0.ModelSignal; + toggle(): void; + transitionCheckState(newState: TransitionCheckState): void; + // (undocumented) + writeValue(value: any): void; + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public +export type KbqCheckableClickAction = 'noop' | 'check' | 'check-indeterminate' | undefined; + +// @public +export interface KbqCheckableClickResult { + readonly shouldClearIndeterminate: boolean; + readonly shouldToggle: boolean; +} + // @public export type KbqCheckedState = 'true' | 'false' | 'mixed'; @@ -5311,6 +5365,14 @@ export const TOP_POSITION_PRIORITY: ConnectionPositionPair[]; // @public (undocumented) export const TOP_RIGHT_POSITION_PRIORITY: ConnectionPositionPair[]; +// @public +export enum TransitionCheckState { + Checked = "checked", + Indeterminate = "indeterminate", + Init = "init", + Unchecked = "unchecked" +} + // @public (undocumented) export const TWO = 50; diff --git a/tools/public_api_guard/components/toggle.api.md b/tools/public_api_guard/components/toggle.api.md index 2e00b9846f..75efc79e32 100644 --- a/tools/public_api_guard/components/toggle.api.md +++ b/tools/public_api_guard/components/toggle.api.md @@ -8,12 +8,13 @@ import { AfterViewInit } from '@angular/core'; import * as _angular_core from '@angular/core'; import { ControlValueAccessor } from '@angular/forms'; import { ElementRef } from '@angular/core'; -import * as i1 from '@angular/cdk/a11y'; -import { KbqCheckboxClickAction } from '@koobiq/components/checkbox'; +import * as i1$1 from '@angular/cdk/a11y'; +import * as i1 from '@koobiq/components/core'; +import { KbqCheckableClickAction } from '@koobiq/components/core'; import { KbqCheckedState } from '@koobiq/components/core'; import { KbqColorDirective } from '@koobiq/components/core'; import { OnDestroy } from '@angular/core'; -import { TransitionCheckState } from '@koobiq/components/checkbox'; +import { TransitionCheckState } from '@koobiq/components/core'; // @public (undocumented) export class KbqToggleChange { @@ -23,6 +24,9 @@ export class KbqToggleChange { source: KbqToggleComponent; } +// @public +export type KbqToggleClickAction = KbqCheckableClickAction; + // @public (undocumented) export class KbqToggleComponent extends KbqColorDirective implements AfterViewInit, ControlValueAccessor, OnDestroy { constructor(); @@ -37,7 +41,7 @@ export class KbqToggleComponent extends KbqColorDirective implements AfterViewIn // (undocumented) get checked(): boolean; set checked(value: boolean); - clickAction: KbqCheckboxClickAction; + clickAction: KbqToggleClickAction; protected currentCheckState: TransitionCheckState; // (undocumented) get disabled(): any; @@ -90,7 +94,7 @@ export class KbqToggleComponent extends KbqColorDirective implements AfterViewIn // (undocumented) writeValue(value: any): void; // (undocumented) - static ɵcmp: _angular_core.ɵɵComponentDeclaration; + static ɵcmp: _angular_core.ɵɵComponentDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } @@ -102,7 +106,7 @@ export class KbqToggleModule { // (undocumented) static ɵinj: _angular_core.ɵɵInjectorDeclaration; // (undocumented) - static ɵmod: _angular_core.ɵɵNgModuleDeclaration; + static ɵmod: _angular_core.ɵɵNgModuleDeclaration; } // (No @packageDocumentation comment for this package) From b5465c80d80606dc8b268e2d5ee1ad96f7631ae7 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 11 Aug 2026 17:47:15 +0300 Subject: [PATCH 2/6] feat(docs): injected example to docs --- packages/components/checkbox/examples.checkbox.en.md | 6 +++--- packages/components/checkbox/examples.checkbox.ru.md | 6 +++--- .../checkbox/block-checkbox/block-checkbox-example.ts | 3 +-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/components/checkbox/examples.checkbox.en.md b/packages/components/checkbox/examples.checkbox.en.md index 2faef3848c..58ab0a405f 100644 --- a/packages/components/checkbox/examples.checkbox.en.md +++ b/packages/components/checkbox/examples.checkbox.en.md @@ -1,5 +1,5 @@ -🚧 **Documentation in progress** 🚧 +### Block checkbox -Unfortunately, the documentation for this section is not ready yet. We are actively working on its creation and plan to add it soon. +A custom checkbox-like "card" control built on the shared `KbqCheckable` primitive (`@koobiq/components/core`), showing how to reuse checkbox's click/form/a11y behavior with your own markup and styling. -If you would like to contribute to the documentation or have any questions, please feel free to [open an issue](https://github.com/koobiq/angular-components/issues) in our GitHub repository. + diff --git a/packages/components/checkbox/examples.checkbox.ru.md b/packages/components/checkbox/examples.checkbox.ru.md index b7a203c6ab..ac02286578 100644 --- a/packages/components/checkbox/examples.checkbox.ru.md +++ b/packages/components/checkbox/examples.checkbox.ru.md @@ -1,5 +1,5 @@ -🚧 **Документация в процессе написания** 🚧 +### Блочный чекбокс -К сожалению, документация для этого раздела еще не готова. Мы активно работаем над ее созданием и планируем добавить в ближайшее время. +Кастомный чекбокс-подобный элемент в виде карточки, построенный на общем примитиве `KbqCheckable` (`@koobiq/components/core`) — демонстрирует переиспользование логики клика, форм и доступности с собственной разметкой и стилями. -Если вы хотите помочь в написании документации или у вас есть вопросы, пожалуйста, [создайте issue](https://github.com/koobiq/angular-components/issues) в нашем репозитории на GitHub. + diff --git a/packages/docs-examples/components/checkbox/block-checkbox/block-checkbox-example.ts b/packages/docs-examples/components/checkbox/block-checkbox/block-checkbox-example.ts index fba52c6248..a0e366f98f 100644 --- a/packages/docs-examples/components/checkbox/block-checkbox/block-checkbox-example.ts +++ b/packages/docs-examples/components/checkbox/block-checkbox/block-checkbox-example.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, inject, model, output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { KbqCheckable } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; -import {FormsModule} from "@angular/forms"; /** * EXAMPLE ONLY - not a published Koobiq component. @@ -78,7 +78,6 @@ export class BlockCheckboxComponent { } } - @Component({ selector: 'block-checkbox-example', imports: [KbqIconModule, BlockCheckboxComponent, FormsModule], From de66e5b756ba7afd810f55ae3243d50e382bb97c Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 11 Aug 2026 18:48:56 +0300 Subject: [PATCH 3/6] fix: cspell --- packages/components/checkbox/examples.checkbox.ru.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/checkbox/examples.checkbox.ru.md b/packages/components/checkbox/examples.checkbox.ru.md index ac02286578..06ee622f86 100644 --- a/packages/components/checkbox/examples.checkbox.ru.md +++ b/packages/components/checkbox/examples.checkbox.ru.md @@ -1,5 +1,5 @@ ### Блочный чекбокс -Кастомный чекбокс-подобный элемент в виде карточки, построенный на общем примитиве `KbqCheckable` (`@koobiq/components/core`) — демонстрирует переиспользование логики клика, форм и доступности с собственной разметкой и стилями. +Кастомный чекбокс-подобный элемент в виде карточки, построенный на общем примитиве `KbqCheckable` (`@koobiq/components/core`) — демонстрирует повторное использование логики клика, форм и доступности с собственной разметкой и стилями. From a2d2cedb38211a4fbb9bb389d9aeb0066274037e Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 11 Aug 2026 19:34:27 +0300 Subject: [PATCH 4/6] fix: build --- .../checkbox/block-checkbox/block-checkbox-example.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/docs-examples/components/checkbox/block-checkbox/block-checkbox-example.ts b/packages/docs-examples/components/checkbox/block-checkbox/block-checkbox-example.ts index a0e366f98f..1237171db0 100644 --- a/packages/docs-examples/components/checkbox/block-checkbox/block-checkbox-example.ts +++ b/packages/docs-examples/components/checkbox/block-checkbox/block-checkbox-example.ts @@ -78,6 +78,9 @@ export class BlockCheckboxComponent { } } +/** + * @title Custom checkbox block + */ @Component({ selector: 'block-checkbox-example', imports: [KbqIconModule, BlockCheckboxComponent, FormsModule], From 154ee9daadf07ddca805a8b1eab4b23ad9697f48 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Wed, 12 Aug 2026 18:43:51 +0300 Subject: [PATCH 5/6] chore: after review --- packages/components/checkbox/checkbox.ts | 5 +- .../core/common-behaviors/checkable.spec.ts | 382 ++++++++++++++++++ .../core/common-behaviors/checkable.ts | 6 +- .../components/toggle/toggle.component.ts | 9 +- tools/public_api_guard/components/core.api.md | 2 +- 5 files changed, 397 insertions(+), 7 deletions(-) create mode 100644 packages/components/core/common-behaviors/checkable.spec.ts diff --git a/packages/components/checkbox/checkbox.ts b/packages/components/checkbox/checkbox.ts index c0b471fc05..8d01f727aa 100644 --- a/packages/components/checkbox/checkbox.ts +++ b/packages/components/checkbox/checkbox.ts @@ -22,7 +22,8 @@ import { KbqCheckable, KbqCheckedState, KbqColorDirective, TransitionCheckState import { KBQ_CHECKBOX_CLICK_ACTION, KbqCheckboxClickAction } from './checkbox-config'; /** - * Represents the different states that require custom transitions between them. + * Re-exported for backwards compatibility - `TransitionCheckState` moved to `@koobiq/components/core` + * so it can be shared with `KbqToggleComponent`. Existing code importing it from here keeps working. * @docs-private * @deprecated Use `TransitionCheckState` from `@koobiq/components/core` instead. */ @@ -309,12 +310,12 @@ export class KbqCheckbox extends KbqColorDirective implements ControlValueAccess } private emitChangeEvent() { + // Note: `toggle()` already notifies the ControlValueAccessor change handler via `KbqCheckable.toggle()`. const event = new KbqCheckboxChange(); event.source = this; event.checked = this.checked; - this.checkable.notifyFormValueChange(this.checked); this.change.emit(event); } diff --git a/packages/components/core/common-behaviors/checkable.spec.ts b/packages/components/core/common-behaviors/checkable.spec.ts new file mode 100644 index 0000000000..f70b8f704f --- /dev/null +++ b/packages/components/core/common-behaviors/checkable.spec.ts @@ -0,0 +1,382 @@ +import { FocusMonitor } from '@angular/cdk/a11y'; +import { AfterViewInit, Component, ElementRef, inject, OnDestroy, Provider, Type, viewChild } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { FormsModule, NgModel } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { KbqCheckable, KbqCheckableClickAction, TransitionCheckState } from './checkable'; + +const createComponent = (component: Type, providers: Provider[] = []): ComponentFixture => { + TestBed.configureTestingModule({ + imports: [component, FormsModule], + providers + }); + + const fixture = TestBed.createComponent(component); + + fixture.autoDetectChanges(); + + return fixture; +}; + +@Component({ + selector: 'test-checkable', + template: ` + + `, + hostDirectives: [ + { directive: KbqCheckable, inputs: ['checked', 'disabled', 'indeterminate', 'tabIndex'] } + ] +}) +class TestCheckable implements AfterViewInit, OnDestroy { + protected readonly checkable = inject(KbqCheckable, { self: true }); + + private readonly input = viewChild.required>('input'); + + clickAction: KbqCheckableClickAction; + + ngAfterViewInit(): void { + this.checkable.monitorFocus(this.input()).subscribe((focusOrigin) => { + if (focusOrigin) { + this.checkable.onTouched(); + } + }); + } + + ngOnDestroy(): void { + this.checkable.stopMonitoringFocus(this.input()); + } + + focus(): void { + this.checkable.focusVia(this.input()); + } + + onInputClick(event: Event): void { + event.stopPropagation(); + + const { shouldToggle, shouldClearIndeterminate } = this.checkable.resolveClick(this.clickAction); + + if (shouldToggle) { + if (shouldClearIndeterminate) { + this.checkable.indeterminate.set(false); + } + + this.checkable.toggle(); + } else { + this.checkable.resetNativeInput(this.input().nativeElement); + } + } +} + +@Component({ + imports: [TestCheckable, FormsModule], + template: ` + + ` +}) +class TestCheckableWithNgModel { + checked = false; +} + +describe(KbqCheckable.name, () => { + let fixture: ComponentFixture; + let hostInstance: TestCheckable; + let checkable: KbqCheckable; + let inputElement: HTMLInputElement; + + beforeEach(() => { + fixture = createComponent(TestCheckable); + hostInstance = fixture.componentInstance; + checkable = fixture.debugElement.injector.get(KbqCheckable); + inputElement = fixture.debugElement.query(By.css('input')).nativeElement; + }); + + describe('state', () => { + it('should default to unchecked, enabled, determinate, tabIndex 0', () => { + expect(checkable.checked()).toBe(false); + expect(checkable.disabled()).toBe(false); + expect(checkable.indeterminate()).toBe(false); + expect(checkable.tabIndex()).toBe(0); + expect(checkable.effectiveTabIndex()).toBe(0); + }); + + it('should force effectiveTabIndex to -1 while disabled, without changing the raw tabIndex', () => { + checkable.tabIndex.set(3); + checkable.disabled.set(true); + fixture.detectChanges(); + + expect(checkable.effectiveTabIndex()).toBe(-1); + expect(checkable.tabIndex()).toBe(3); + + checkable.disabled.set(false); + fixture.detectChanges(); + + expect(checkable.effectiveTabIndex()).toBe(3); + }); + + it('should reflect state changes on the bound native input', () => { + checkable.checked.set(true); + checkable.disabled.set(true); + checkable.indeterminate.set(true); + fixture.detectChanges(); + + expect(inputElement.checked).toBe(true); + expect(inputElement.disabled).toBe(true); + expect(inputElement.indeterminate).toBe(true); + }); + }); + + describe('toggle', () => { + it('should flip checked', () => { + expect(checkable.checked()).toBe(false); + + checkable.toggle(); + expect(checkable.checked()).toBe(true); + + checkable.toggle(); + expect(checkable.checked()).toBe(false); + }); + + it('should notify the registered ControlValueAccessor change handler with the new value', () => { + const onChange = jest.fn(); + + checkable.registerOnChange(onChange); + + checkable.toggle(); + + expect(onChange).toHaveBeenCalledWith(true); + + checkable.toggle(); + + expect(onChange).toHaveBeenCalledWith(false); + expect(onChange).toHaveBeenCalledTimes(2); + }); + }); + + describe('getAriaChecked', () => { + it('should return "false" by default', () => { + expect(checkable.getAriaChecked()).toBe('false'); + }); + + it('should return "true" when checked', () => { + checkable.checked.set(true); + expect(checkable.getAriaChecked()).toBe('true'); + }); + + it('should return "mixed" when indeterminate and not checked', () => { + checkable.indeterminate.set(true); + expect(checkable.getAriaChecked()).toBe('mixed'); + }); + + it('should prefer "true" over "mixed" when both checked and indeterminate', () => { + checkable.checked.set(true); + checkable.indeterminate.set(true); + expect(checkable.getAriaChecked()).toBe('true'); + }); + }); + + describe('resolveClick', () => { + it('should not toggle while disabled, regardless of clickAction', () => { + checkable.disabled.set(true); + + expect(checkable.resolveClick(undefined)).toEqual({ shouldToggle: false, shouldClearIndeterminate: false }); + expect(checkable.resolveClick('check-indeterminate')).toEqual({ + shouldToggle: false, + shouldClearIndeterminate: false + }); + }); + + it('should not toggle when clickAction is "noop"', () => { + expect(checkable.resolveClick('noop')).toEqual({ shouldToggle: false, shouldClearIndeterminate: false }); + }); + + it('should toggle and clear indeterminate by default (undefined/check-indeterminate)', () => { + checkable.indeterminate.set(true); + + expect(checkable.resolveClick(undefined)).toEqual({ shouldToggle: true, shouldClearIndeterminate: true }); + expect(checkable.resolveClick('check-indeterminate')).toEqual({ + shouldToggle: true, + shouldClearIndeterminate: true + }); + }); + + it('should toggle without clearing indeterminate when clickAction is "check"', () => { + checkable.indeterminate.set(true); + + expect(checkable.resolveClick('check')).toEqual({ shouldToggle: true, shouldClearIndeterminate: false }); + }); + + it('should not report shouldClearIndeterminate when not indeterminate', () => { + expect(checkable.resolveClick(undefined)).toEqual({ shouldToggle: true, shouldClearIndeterminate: false }); + }); + }); + + describe('resetNativeInput', () => { + it('should sync the native input to the current checked/indeterminate state', () => { + checkable.checked.set(true); + checkable.indeterminate.set(true); + + inputElement.checked = false; + inputElement.indeterminate = false; + + checkable.resetNativeInput(inputElement); + + expect(inputElement.checked).toBe(true); + expect(inputElement.indeterminate).toBe(true); + }); + }); + + describe('transitionCheckState', () => { + it('should update currentCheckState', () => { + expect(checkable.currentCheckState()).toBe(TransitionCheckState.Init); + + checkable.transitionCheckState(TransitionCheckState.Checked); + + expect(checkable.currentCheckState()).toBe(TransitionCheckState.Checked); + }); + + it('should be a no-op when transitioning to the same state', () => { + checkable.transitionCheckState(TransitionCheckState.Checked); + + const setSpy = jest.spyOn(checkable.currentCheckState, 'set'); + + checkable.transitionCheckState(TransitionCheckState.Checked); + + expect(setSpy).not.toHaveBeenCalled(); + }); + }); + + describe('click handling (via host)', () => { + it('should toggle checked on click', () => { + expect(checkable.checked()).toBe(false); + + inputElement.click(); + fixture.detectChanges(); + + expect(checkable.checked()).toBe(true); + }); + + it('should not toggle when disabled', () => { + checkable.disabled.set(true); + fixture.detectChanges(); + + inputElement.click(); + fixture.detectChanges(); + + expect(checkable.checked()).toBe(false); + }); + + it('should clear indeterminate on click by default', () => { + checkable.indeterminate.set(true); + fixture.detectChanges(); + + inputElement.click(); + fixture.detectChanges(); + + expect(checkable.indeterminate()).toBe(false); + expect(checkable.checked()).toBe(true); + }); + + it('should not toggle on click when clickAction is "noop"', () => { + hostInstance.clickAction = 'noop'; + + inputElement.click(); + fixture.detectChanges(); + + expect(checkable.checked()).toBe(false); + }); + }); + + describe('ControlValueAccessor', () => { + it('writeValue should coerce the value and set checked', () => { + checkable.writeValue(1); + expect(checkable.checked()).toBe(true); + + checkable.writeValue(null); + expect(checkable.checked()).toBe(false); + }); + + it('registerOnChange should wire up notifyFormValueChange', () => { + const onChange = jest.fn(); + + checkable.registerOnChange(onChange); + checkable.notifyFormValueChange(true); + + expect(onChange).toHaveBeenCalledWith(true); + }); + + it('registerOnTouched should wire up onTouched', () => { + const onTouched = jest.fn(); + + checkable.registerOnTouched(onTouched); + checkable.onTouched(); + + expect(onTouched).toHaveBeenCalled(); + }); + + it('setDisabledState should set disabled', () => { + checkable.setDisabledState(true); + expect(checkable.disabled()).toBe(true); + }); + }); + + describe('focus', () => { + it('focusVia should move focus to the given element with a keyboard origin', fakeAsync(() => { + expect(document.activeElement).not.toBe(inputElement); + + hostInstance.focus(); + tick(); + fixture.detectChanges(); + + expect(document.activeElement).toBe(inputElement); + expect(inputElement.classList).toContain('cdk-keyboard-focused'); + })); + + it('stopMonitoringFocus should stop tracking the element', fakeAsync(() => { + const focusMonitor = TestBed.inject(FocusMonitor); + const stopSpy = jest.spyOn(focusMonitor, 'stopMonitoring'); + + checkable.stopMonitoringFocus({ nativeElement: inputElement } as ElementRef); + + expect(stopSpy).toHaveBeenCalledWith(inputElement); + })); + }); +}); + +describe(`${KbqCheckable.name} integration with ngModel`, () => { + let ngModelFixture: ComponentFixture; + + beforeEach(() => { + ngModelFixture = createComponent(TestCheckableWithNgModel); + }); + + it('should support two-way binding through the KbqCheckable ControlValueAccessor', fakeAsync(() => { + const testInput = ngModelFixture.debugElement.query(By.css('input')).nativeElement as HTMLInputElement; + const ngModel = ngModelFixture.debugElement.query(By.directive(NgModel)).injector.get(NgModel); + + tick(); + + expect(ngModelFixture.componentInstance.checked).toBe(false); + + testInput.click(); + ngModelFixture.detectChanges(); + tick(); + + expect(ngModelFixture.componentInstance.checked).toBe(true); + expect(ngModel.touched).toBe(false); + + testInput.dispatchEvent(new FocusEvent('focus')); + tick(); + testInput.dispatchEvent(new FocusEvent('blur')); + tick(); + + expect(ngModel.touched).toBe(true); + })); +}); diff --git a/packages/components/core/common-behaviors/checkable.ts b/packages/components/core/common-behaviors/checkable.ts index be29b24773..b1e62936c9 100644 --- a/packages/components/core/common-behaviors/checkable.ts +++ b/packages/components/core/common-behaviors/checkable.ts @@ -8,6 +8,7 @@ import { InjectionToken, model, OnDestroy, + Provider, signal } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; @@ -18,7 +19,7 @@ import { KbqCheckedState } from './checkbox'; * applying it via `hostDirectives` gets `[(ngModel)]`/`formControl` support without wiring up its own. * @docs-private */ -export const KBQ_CHECKABLE_CONTROL_VALUE_ACCESSOR: any = { +export const KBQ_CHECKABLE_CONTROL_VALUE_ACCESSOR: Provider = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => KbqCheckable), multi: true @@ -129,9 +130,10 @@ export class KbqCheckable implements ControlValueAccessor, OnDestroy { return this.checked() ? 'true' : this.indeterminate() ? 'mixed' : 'false'; } - /** Toggles the `checked` state. */ + /** Toggles the `checked` state and notifies the registered `ControlValueAccessor` change handler. */ toggle(): void { this.checked.update((value) => !value); + this.notifyFormValueChange(this.checked()); } /** diff --git a/packages/components/toggle/toggle.component.ts b/packages/components/toggle/toggle.component.ts index 6e90368d03..1a72343c1e 100644 --- a/packages/components/toggle/toggle.component.ts +++ b/packages/components/toggle/toggle.component.ts @@ -6,6 +6,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + effect, ElementRef, inject, Input, @@ -53,7 +54,7 @@ export type KbqToggleClickAction = KbqCheckableClickAction; providers: [ // Falls back to `KBQ_CHECKBOX_CLICK_ACTION` for backwards compatibility with apps that already // configure it globally to control click behavior for both checkbox and toggle. - { provide: KBQ_CHECKABLE_CLICK_ACTION, useExisting: KBQ_CHECKBOX_CLICK_ACTION } + { provide: KBQ_CHECKABLE_CLICK_ACTION, useFactory: () => inject(KBQ_CHECKBOX_CLICK_ACTION, { optional: true }) } ], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, @@ -198,6 +199,10 @@ export class KbqToggleComponent extends KbqColorDirective implements AfterViewIn super(); this.id = this.uniqueId; + + // `writeValue` (ngModel/formControl) now runs on `KbqCheckable`, bypassing the `checked`/`indeterminate` + // setters below, so this keeps the `[@switch]` animation state in sync for form-driven value changes too. + effect(() => this.setTransitionCheckState()); } ngAfterViewInit(): void { @@ -291,12 +296,12 @@ export class KbqToggleComponent extends KbqColorDirective implements AfterViewIn } private emitChangeEvent() { + // Note: `toggle()` already notifies the ControlValueAccessor change handler via `KbqCheckable.toggle()`. const event = new KbqToggleChange(); event.source = this; event.checked = this.checked; - this.checkable.notifyFormValueChange(this.checked); this.change.emit(event); } } diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 59ebbddb31..0da906a5a4 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -1141,7 +1141,7 @@ export const KBQ_A11Y_LOCALE_CONFIGURATION: InjectionToken; // @public -export const KBQ_CHECKABLE_CONTROL_VALUE_ACCESSOR: any; +export const KBQ_CHECKABLE_CONTROL_VALUE_ACCESSOR: Provider; // @public export const KBQ_CONNECTED_OVERLAY_ABOVE_CLASS = "kbq-connected-overlay_above"; From 61c72b2eda7b7adb2c348c61e8241430c04200f8 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Thu, 13 Aug 2026 10:26:47 +0300 Subject: [PATCH 6/6] chore: moved back focusMonitor and deprecated CVA methods on components --- apps/docs/src/app/structure.ts | 2 +- packages/components/checkbox/checkbox.ts | 37 +++++++++---- .../core/common-behaviors/checkable.spec.ts | 54 ++----------------- .../core/common-behaviors/checkable.ts | 49 ++--------------- .../components/toggle/toggle.component.ts | 28 ++++++++-- .../components/checkbox.api.md | 9 ++-- tools/public_api_guard/components/core.api.md | 9 +--- .../public_api_guard/components/toggle.api.md | 8 +-- 8 files changed, 72 insertions(+), 124 deletions(-) diff --git a/apps/docs/src/app/structure.ts b/apps/docs/src/app/structure.ts index ba358dc32f..d4616e4b08 100644 --- a/apps/docs/src/app/structure.ts +++ b/apps/docs/src/app/structure.ts @@ -417,7 +417,7 @@ const structure: DocsStructure = makeStructure({ svgPreview: 'checkbox', hasApi: true, apiId: 'checkbox', - hasExamples: false + hasExamples: true }, { id: DocsStructureItemId.ClampedList, diff --git a/packages/components/checkbox/checkbox.ts b/packages/components/checkbox/checkbox.ts index 8d01f727aa..5a0db6245a 100644 --- a/packages/components/checkbox/checkbox.ts +++ b/packages/components/checkbox/checkbox.ts @@ -1,4 +1,4 @@ -import { FocusOrigin } from '@angular/cdk/a11y'; +import { FocusMonitor, FocusOrigin } from '@angular/cdk/a11y'; import { CdkObserveContent } from '@angular/cdk/observers'; import { AfterViewInit, @@ -84,6 +84,7 @@ export class KbqCheckboxChange { }) export class KbqCheckbox extends KbqColorDirective implements ControlValueAccessor, AfterViewInit, OnDestroy { private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly focusMonitor = inject(FocusMonitor); private readonly checkable = inject(KbqCheckable, { self: true }); readonly big = input(false); @@ -203,17 +204,19 @@ export class KbqCheckbox extends KbqColorDirective implements ControlValueAccess /** * Called when the checkbox is blurred. Needed to properly implement ControlValueAccessor. * @docs-private + * @deprecated Unused - `ControlValueAccessor` is now implemented by the `KbqCheckable` host directive, + * so this is never called by Angular forms. Will be removed in the next major version. */ onTouched: () => any = () => {}; ngAfterViewInit() { - this.checkable - .monitorFocus(this.inputElement()) + this.focusMonitor + .monitor(this.inputElement().nativeElement) .subscribe((focusOrigin) => this.onInputFocusChange(focusOrigin)); } ngOnDestroy() { - this.checkable.stopMonitoringFocus(this.inputElement()); + this.focusMonitor.stopMonitoring(this.inputElement().nativeElement); } /** Method being called whenever the label text changes. */ @@ -224,22 +227,38 @@ export class KbqCheckbox extends KbqColorDirective implements ControlValueAccess this.changeDetectorRef.markForCheck(); } - // Implemented as part of ControlValueAccessor. + /** + * Implemented as part of ControlValueAccessor. + * @deprecated Unused - `ControlValueAccessor` is now implemented by the `KbqCheckable` host directive, + * so this is never called by Angular forms. Will be removed in the next major version. + */ writeValue(value: any) { this.checked = !!value; } - // Implemented as part of ControlValueAccessor. + /** + * Implemented as part of ControlValueAccessor. + * @deprecated Unused - `ControlValueAccessor` is now implemented by the `KbqCheckable` host directive, + * so this is never called by Angular forms. Will be removed in the next major version. + */ registerOnChange(fn: (value: any) => void) { this.checkable.registerOnChange(fn); } - // Implemented as part of ControlValueAccessor. + /** + * Implemented as part of ControlValueAccessor. + * @deprecated Unused - `ControlValueAccessor` is now implemented by the `KbqCheckable` host directive, + * so this is never called by Angular forms. Will be removed in the next major version. + */ registerOnTouched(fn: any) { this.checkable.registerOnTouched(fn); } - // Implemented as part of ControlValueAccessor. + /** + * Implemented as part of ControlValueAccessor. + * @deprecated Unused - `ControlValueAccessor` is now implemented by the `KbqCheckable` host directive, + * so this is never called by Angular forms. Will be removed in the next major version. + */ setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; } @@ -299,7 +318,7 @@ export class KbqCheckbox extends KbqColorDirective implements ControlValueAccess /** Focuses the checkbox. */ focus(): void { - this.checkable.focusVia(this.inputElement()); + this.focusMonitor.focusVia(this.inputElement().nativeElement, 'keyboard'); } onInteractionEvent(event: Event) { diff --git a/packages/components/core/common-behaviors/checkable.spec.ts b/packages/components/core/common-behaviors/checkable.spec.ts index f70b8f704f..dd49d5062f 100644 --- a/packages/components/core/common-behaviors/checkable.spec.ts +++ b/packages/components/core/common-behaviors/checkable.spec.ts @@ -1,7 +1,6 @@ -import { FocusMonitor } from '@angular/cdk/a11y'; -import { AfterViewInit, Component, ElementRef, inject, OnDestroy, Provider, Type, viewChild } from '@angular/core'; +import { Component, ElementRef, inject, Provider, Type, viewChild } from '@angular/core'; import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { FormsModule, NgModel } from '@angular/forms'; +import { FormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { KbqCheckable, KbqCheckableClickAction, TransitionCheckState } from './checkable'; @@ -35,29 +34,13 @@ const createComponent = (component: Type, providers: Provider[] = []): Com { directive: KbqCheckable, inputs: ['checked', 'disabled', 'indeterminate', 'tabIndex'] } ] }) -class TestCheckable implements AfterViewInit, OnDestroy { +class TestCheckable { protected readonly checkable = inject(KbqCheckable, { self: true }); private readonly input = viewChild.required>('input'); clickAction: KbqCheckableClickAction; - ngAfterViewInit(): void { - this.checkable.monitorFocus(this.input()).subscribe((focusOrigin) => { - if (focusOrigin) { - this.checkable.onTouched(); - } - }); - } - - ngOnDestroy(): void { - this.checkable.stopMonitoringFocus(this.input()); - } - - focus(): void { - this.checkable.focusVia(this.input()); - } - onInputClick(event: Event): void { event.stopPropagation(); @@ -326,28 +309,6 @@ describe(KbqCheckable.name, () => { expect(checkable.disabled()).toBe(true); }); }); - - describe('focus', () => { - it('focusVia should move focus to the given element with a keyboard origin', fakeAsync(() => { - expect(document.activeElement).not.toBe(inputElement); - - hostInstance.focus(); - tick(); - fixture.detectChanges(); - - expect(document.activeElement).toBe(inputElement); - expect(inputElement.classList).toContain('cdk-keyboard-focused'); - })); - - it('stopMonitoringFocus should stop tracking the element', fakeAsync(() => { - const focusMonitor = TestBed.inject(FocusMonitor); - const stopSpy = jest.spyOn(focusMonitor, 'stopMonitoring'); - - checkable.stopMonitoringFocus({ nativeElement: inputElement } as ElementRef); - - expect(stopSpy).toHaveBeenCalledWith(inputElement); - })); - }); }); describe(`${KbqCheckable.name} integration with ngModel`, () => { @@ -359,7 +320,6 @@ describe(`${KbqCheckable.name} integration with ngModel`, () => { it('should support two-way binding through the KbqCheckable ControlValueAccessor', fakeAsync(() => { const testInput = ngModelFixture.debugElement.query(By.css('input')).nativeElement as HTMLInputElement; - const ngModel = ngModelFixture.debugElement.query(By.directive(NgModel)).injector.get(NgModel); tick(); @@ -370,13 +330,5 @@ describe(`${KbqCheckable.name} integration with ngModel`, () => { tick(); expect(ngModelFixture.componentInstance.checked).toBe(true); - expect(ngModel.touched).toBe(false); - - testInput.dispatchEvent(new FocusEvent('focus')); - tick(); - testInput.dispatchEvent(new FocusEvent('blur')); - tick(); - - expect(ngModel.touched).toBe(true); })); }); diff --git a/packages/components/core/common-behaviors/checkable.ts b/packages/components/core/common-behaviors/checkable.ts index b1e62936c9..56d5192a6d 100644 --- a/packages/components/core/common-behaviors/checkable.ts +++ b/packages/components/core/common-behaviors/checkable.ts @@ -1,16 +1,4 @@ -import { FocusMonitor } from '@angular/cdk/a11y'; -import { - computed, - Directive, - ElementRef, - forwardRef, - inject, - InjectionToken, - model, - OnDestroy, - Provider, - signal -} from '@angular/core'; +import { computed, Directive, forwardRef, InjectionToken, model, Provider, signal } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { KbqCheckedState } from './checkbox'; @@ -62,23 +50,19 @@ export interface KbqCheckableClickResult { /** * Shared behavior for checkbox-like controls: checked/disabled/indeterminate/tabIndex state, the click-to-toggle - * algorithm, ARIA-checked computation, `ControlValueAccessor` change plumbing, and `FocusMonitor` wiring. + * algorithm, ARIA-checked computation, and `ControlValueAccessor` change plumbing. * * Meant to be applied via `hostDirectives` by components that render their own native `` * and markup (e.g. `KbqCheckbox`, `KbqToggleComponent`, or custom checkbox-like primitives). Hosts stay - * responsible for their own public inputs/outputs and for emitting their own typed `change` event - this - * directive only centralizes the state and decisions behind them. + * responsible for their own public inputs/outputs, `FocusMonitor` wiring, and emitting their own typed + * `change` event - this directive only centralizes the state and decisions behind them. */ @Directive({ selector: '[kbqCheckable]', providers: [KBQ_CHECKABLE_CONTROL_VALUE_ACCESSOR], exportAs: 'kbqCheckable' }) -export class KbqCheckable implements ControlValueAccessor, OnDestroy { - private readonly focusMonitor = inject(FocusMonitor); - - private monitoredElement: ElementRef | null = null; - +export class KbqCheckable implements ControlValueAccessor { private controlValueAccessorChangeFn: (value: any) => void = () => {}; /** @@ -102,29 +86,6 @@ export class KbqCheckable implements ControlValueAccessor, OnDestroy { /** The tab index actually applied to the native input: forced to `-1` while disabled. */ readonly effectiveTabIndex = computed(() => (this.disabled() ? -1 : this.tabIndex())); - ngOnDestroy(): void { - if (this.monitoredElement) { - this.focusMonitor.stopMonitoring(this.monitoredElement.nativeElement); - } - } - - /** Starts tracking the focus origin for `elementRef`, so CDK's `.cdk-*-focused` classes are applied to it. */ - monitorFocus(elementRef: ElementRef, checkChildren = false) { - this.monitoredElement = elementRef; - - return this.focusMonitor.monitor(elementRef.nativeElement, checkChildren); - } - - /** Stops tracking the focus origin for `elementRef`. */ - stopMonitoringFocus(elementRef: ElementRef): void { - this.focusMonitor.stopMonitoring(elementRef.nativeElement); - } - - /** Focuses `elementRef` with a keyboard focus origin. */ - focusVia(elementRef: ElementRef): void { - this.focusMonitor.focusVia(elementRef.nativeElement, 'keyboard'); - } - /** Returns the ARIA-checked value: `'true'`, `'false'`, or `'mixed'` when indeterminate. */ getAriaChecked(): KbqCheckedState { return this.checked() ? 'true' : this.indeterminate() ? 'mixed' : 'false'; diff --git a/packages/components/toggle/toggle.component.ts b/packages/components/toggle/toggle.component.ts index 1a72343c1e..cda057f19b 100644 --- a/packages/components/toggle/toggle.component.ts +++ b/packages/components/toggle/toggle.component.ts @@ -1,4 +1,5 @@ import { animate, state, style, transition, trigger } from '@angular/animations'; +import { FocusMonitor } from '@angular/cdk/a11y'; import { CdkObserveContent } from '@angular/cdk/observers'; import { AfterViewInit, @@ -92,6 +93,7 @@ export type KbqToggleClickAction = KbqCheckableClickAction; }) export class KbqToggleComponent extends KbqColorDirective implements AfterViewInit, ControlValueAccessor, OnDestroy { private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly focusMonitor = inject(FocusMonitor); private readonly checkable = inject(KbqCheckable, { self: true }); readonly big = input(false); @@ -206,15 +208,15 @@ export class KbqToggleComponent extends KbqColorDirective implements AfterViewIn } ngAfterViewInit(): void { - this.checkable.monitorFocus(this.elementRef, true); + this.focusMonitor.monitor(this.elementRef.nativeElement, true); } ngOnDestroy() { - this.checkable.stopMonitoringFocus(this.elementRef); + this.focusMonitor.stopMonitoring(this.elementRef.nativeElement); } focus(): void { - this.checkable.focusVia(this.inputElement()); + this.focusMonitor.focusVia(this.inputElement().nativeElement, 'keyboard'); } getAriaChecked(): KbqCheckedState { @@ -265,18 +267,38 @@ export class KbqToggleComponent extends KbqColorDirective implements AfterViewIn } } + /** + * Implemented as part of ControlValueAccessor. + * @deprecated Unused - `ControlValueAccessor` is now implemented by the `KbqCheckable` host directive, + * so this is never called by Angular forms. Will be removed in the next major version. + */ writeValue(value: any) { this.checked = !!value; } + /** + * Implemented as part of ControlValueAccessor. + * @deprecated Unused - `ControlValueAccessor` is now implemented by the `KbqCheckable` host directive, + * so this is never called by Angular forms. Will be removed in the next major version. + */ registerOnChange(fn: any) { this.checkable.registerOnChange(fn); } + /** + * Implemented as part of ControlValueAccessor. + * @deprecated Unused - `ControlValueAccessor` is now implemented by the `KbqCheckable` host directive, + * so this is never called by Angular forms. Will be removed in the next major version. + */ registerOnTouched(fn: any) { this.checkable.registerOnTouched(fn); } + /** + * Implemented as part of ControlValueAccessor. + * @deprecated Unused - `ControlValueAccessor` is now implemented by the `KbqCheckable` host directive, + * so this is never called by Angular forms. Will be removed in the next major version. + */ setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; } diff --git a/tools/public_api_guard/components/checkbox.api.md b/tools/public_api_guard/components/checkbox.api.md index b623eebf46..c0cf6ae09f 100644 --- a/tools/public_api_guard/components/checkbox.api.md +++ b/tools/public_api_guard/components/checkbox.api.md @@ -61,20 +61,21 @@ export class KbqCheckbox extends KbqColorDirective implements ControlValueAccess // (undocumented) onInteractionEvent(event: Event): void; onLabelTextChange(): void; + // @deprecated onTouched: () => any; - // (undocumented) + // @deprecated registerOnChange(fn: (value: any) => void): void; - // (undocumented) + // @deprecated registerOnTouched(fn: any): void; readonly required: _angular_core.InputSignalWithTransform; - // (undocumented) + // @deprecated setDisabledState(isDisabled: boolean): void; // (undocumented) get tabIndex(): number; set tabIndex(value: number); toggle(): void; readonly value: _angular_core.InputSignal; - // (undocumented) + // @deprecated writeValue(value: any): void; // (undocumented) static ɵcmp: _angular_core.ɵɵComponentDeclaration; diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 0da906a5a4..02c72a953d 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -8,7 +8,6 @@ import { AbstractControl } from '@angular/forms'; import { AfterContentInit } from '@angular/core'; import { AfterViewChecked } from '@angular/core'; import { AfterViewInit } from '@angular/core'; -import * as _angular_cdk_focus_monitor_d from '@angular/cdk/focus-monitor.d'; import * as _angular_forms from '@angular/forms'; import { AnimationEvent as AnimationEvent_2 } from '@angular/animations'; import { AnimationTriggerMetadata } from '@angular/animations'; @@ -55,7 +54,6 @@ import { QueryList } from '@angular/core'; import { Renderer2 } from '@angular/core'; import { RendererFactory2 } from '@angular/core'; import { RepositionScrollStrategy } from '@angular/cdk/overlay'; -import * as rxjs from 'rxjs'; import { ScrollDispatcher } from '@angular/cdk/overlay'; import { ScrollStrategy } from '@angular/cdk/overlay'; import { Signal } from '@angular/core'; @@ -2627,20 +2625,16 @@ export interface KbqBaseFileUploadLocaleConfig { } // @public -export class KbqCheckable implements ControlValueAccessor, OnDestroy { +export class KbqCheckable implements ControlValueAccessor { // (undocumented) readonly checked: i0.ModelSignal; readonly currentCheckState: i0.WritableSignal; // (undocumented) readonly disabled: i0.ModelSignal; readonly effectiveTabIndex: i0.Signal; - focusVia(elementRef: ElementRef): void; getAriaChecked(): KbqCheckedState; // (undocumented) readonly indeterminate: i0.ModelSignal; - monitorFocus(elementRef: ElementRef, checkChildren?: boolean): rxjs.Observable<_angular_cdk_focus_monitor_d.FocusOrigin>; - // (undocumented) - ngOnDestroy(): void; notifyFormValueChange(value: boolean): void; onTouched: () => any; registerOnChange(fn: (value: any) => void): void; @@ -2650,7 +2644,6 @@ export class KbqCheckable implements ControlValueAccessor, OnDestroy { resolveClick(clickAction: KbqCheckableClickAction): KbqCheckableClickResult; // (undocumented) setDisabledState(isDisabled: boolean): void; - stopMonitoringFocus(elementRef: ElementRef): void; readonly tabIndex: i0.ModelSignal; toggle(): void; transitionCheckState(newState: TransitionCheckState): void; diff --git a/tools/public_api_guard/components/toggle.api.md b/tools/public_api_guard/components/toggle.api.md index 75efc79e32..4484f25a2a 100644 --- a/tools/public_api_guard/components/toggle.api.md +++ b/tools/public_api_guard/components/toggle.api.md @@ -80,18 +80,18 @@ export class KbqToggleComponent extends KbqColorDirective implements AfterViewIn onInputClick(event: MouseEvent): void; // (undocumented) onLabelTextChange(): void; - // (undocumented) + // @deprecated registerOnChange(fn: any): void; - // (undocumented) + // @deprecated registerOnTouched(fn: any): void; - // (undocumented) + // @deprecated setDisabledState(isDisabled: boolean): void; // (undocumented) get tabIndex(): number; set tabIndex(value: number); // (undocumented) readonly value: _angular_core.InputSignal; - // (undocumented) + // @deprecated writeValue(value: any): void; // (undocumented) static ɵcmp: _angular_core.ɵɵComponentDeclaration;