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-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..5a0db6245a 100644 --- a/packages/components/checkbox/checkbox.ts +++ b/packages/components/checkbox/checkbox.ts @@ -18,9 +18,17 @@ 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'; +/** + * 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. + */ +export { TransitionCheckState }; + // Increasing integer for generating unique ids for checkbox components. let nextUniqueId = 0; @@ -28,6 +36,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 +44,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 +66,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 +79,13 @@ 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 focusMonitor = inject(FocusMonitor); + private readonly checkable = inject(KbqCheckable, { self: true }); readonly big = input(false); @@ -137,48 +132,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 +172,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(); @@ -225,6 +204,8 @@ 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 = () => {}; @@ -246,33 +227,49 @@ 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.controlValueAccessorChangeFn = fn; + 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.onTouched = fn; + 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; } 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,30 +289,30 @@ 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); } } @@ -330,41 +327,21 @@ 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() { + // Note: `toggle()` already notifies the ControlValueAccessor change handler via `KbqCheckable.toggle()`. const event = new KbqCheckboxChange(); event.source = this; event.checked = this.checked; - this.controlValueAccessorChangeFn(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/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..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`) — демонстрирует повторное использование логики клика, форм и доступности с собственной разметкой и стилями. -Если вы хотите помочь в написании документации или у вас есть вопросы, пожалуйста, [создайте issue](https://github.com/koobiq/angular-components/issues) в нашем репозитории на GitHub. + 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..dd49d5062f --- /dev/null +++ b/packages/components/core/common-behaviors/checkable.spec.ts @@ -0,0 +1,334 @@ +import { Component, ElementRef, inject, Provider, Type, viewChild } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { FormsModule } 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 { + protected readonly checkable = inject(KbqCheckable, { self: true }); + + private readonly input = viewChild.required>('input'); + + clickAction: KbqCheckableClickAction; + + 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(`${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; + + tick(); + + expect(ngModelFixture.componentInstance.checked).toBe(false); + + testInput.click(); + ngModelFixture.detectChanges(); + tick(); + + expect(ngModelFixture.componentInstance.checked).toBe(true); + })); +}); diff --git a/packages/components/core/common-behaviors/checkable.ts b/packages/components/core/common-behaviors/checkable.ts new file mode 100644 index 0000000000..56d5192a6d --- /dev/null +++ b/packages/components/core/common-behaviors/checkable.ts @@ -0,0 +1,153 @@ +import { computed, Directive, forwardRef, InjectionToken, model, Provider, 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: Provider = { + 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, 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, `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 { + 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())); + + /** 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 and notifies the registered `ControlValueAccessor` change handler. */ + toggle(): void { + this.checked.update((value) => !value); + this.notifyFormValueChange(this.checked()); + } + + /** + * 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..cda057f19b 100644 --- a/packages/components/toggle/toggle.component.ts +++ b/packages/components/toggle/toggle.component.ts @@ -7,8 +7,8 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + effect, ElementRef, - forwardRef, inject, Input, input, @@ -18,9 +18,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 +40,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 +53,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, useFactory: () => inject(KBQ_CHECKBOX_CLICK_ACTION, { optional: true }) } ], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, @@ -56,6 +68,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 +92,9 @@ 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 focusMonitor = inject(FocusMonitor); + private readonly checkable = inject(KbqCheckable, { self: true }); readonly big = input(false); @@ -107,48 +121,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 +163,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 +193,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}`; @@ -198,6 +201,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 { @@ -213,7 +220,7 @@ export class KbqToggleComponent extends KbqColorDirective implements AfterViewIn } getAriaChecked(): KbqCheckedState { - return this.checked ? 'true' : this.indeterminate ? 'mixed' : 'false'; + return this.checkable.getAriaChecked(); } onChangeEvent(event: Event) { @@ -235,44 +242,63 @@ 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); } } + /** + * 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.onChangeCallback = fn; + 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.onTouchedCallback = fn; + 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; } @@ -285,25 +311,19 @@ 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; } 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.onChangeCallback(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..1237171db0 --- /dev/null +++ b/packages/docs-examples/components/checkbox/block-checkbox/block-checkbox-example.ts @@ -0,0 +1,100 @@ +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'; + +/** + * 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()); + } + } +} + +/** + * @title Custom checkbox block + */ +@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..c0cf6ae09f 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) @@ -58,23 +61,24 @@ 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; + static ɵcmp: _angular_core.ɵɵComponentDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } @@ -86,7 +90,7 @@ export class KbqCheckboxChange { } // @public -export type KbqCheckboxClickAction = 'noop' | 'check' | 'check-indeterminate' | undefined; +export type KbqCheckboxClickAction = KbqCheckableClickAction; // @public (undocumented) export class KbqCheckboxModule { @@ -106,13 +110,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..02c72a953d 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -20,6 +20,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'; @@ -1134,6 +1135,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: Provider; + // @public export const KBQ_CONNECTED_OVERLAY_ABOVE_CLASS = "kbq-connected-overlay_above"; @@ -2617,6 +2624,46 @@ export interface KbqBaseFileUploadLocaleConfig { captionTextWithFolder: string; } +// @public +export class KbqCheckable implements ControlValueAccessor { + // (undocumented) + readonly checked: i0.ModelSignal; + readonly currentCheckState: i0.WritableSignal; + // (undocumented) + readonly disabled: i0.ModelSignal; + readonly effectiveTabIndex: i0.Signal; + getAriaChecked(): KbqCheckedState; + // (undocumented) + readonly indeterminate: i0.ModelSignal; + 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; + 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 +5358,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..4484f25a2a 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; @@ -76,21 +80,21 @@ 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; + 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)