From 1d45be8b918d0e6d5ed28e3be32e0419fb5b3e65 Mon Sep 17 00:00:00 2001 From: Lukas Spirig Date: Tue, 24 Sep 2019 17:42:28 +0200 Subject: [PATCH] feat: provide sbb-radio-group for radio-button type components --- .vscode/settings.json | 3 +- .../tooltip-showcase.component.html | 2 +- .../ps-chrome-patch.directive.ts | 41 +- .../accordion-showcase.component.html | 2 +- .../ghettobox-showcase.component.html | 4 +- ...lightbox-showcase-content-2.component.html | 4 +- .../loading-showcase.component.html | 4 +- .../notification-showcase.component.html | 6 +- ...radio-button-panel-showcase.component.html | 17 +- .../radio-button-showcase.component.html | 12 +- .../search-showcase.component.html | 2 +- .../toggle-showcase.component.html | 46 +- .../toggle-showcase.component.ts | 11 - .../tooltip-showcase.component.html | 2 +- .../src/app/public/public/public.component.ts | 2 +- .../angular-core/radio-button/index.ts | 1 + .../angular-core/radio-button/package.json | 19 + .../radio-button/src/public_api.ts | 3 + .../radio-button/src/radio-button.module.ts | 9 + .../radio-button/src/radio-button.ts | 375 +++++++ .../src/radio-group.directive.spec.ts | 927 ++++++++++++++++++ .../radio-button/src/radio-group.directive.ts | 245 +++++ .../radio-button-panel/radio-button-panel.md | 59 +- .../src/radio-button-panel.module.ts | 6 +- .../radio-button-panel.component.html | 2 +- .../radio-button-panel.component.spec.ts | 2 +- .../radio-button-panel.component.ts | 14 +- .../radio-button/radio-button.md | 31 +- .../radio-button/src/public_api.ts | 1 + .../radio-button/src/radio-button.module.ts | 5 +- .../radio-button/radio-button.component.ts | 295 +----- .../radio-button/radio-button.interface.ts | 9 - .../toggle-option/toggle-option.component.ts | 64 +- .../angular-public/toggle/src/toggle.base.ts | 2 + .../toggle/src/toggle.module.ts | 11 +- .../toggle/src/toggle/toggle.component.ts | 139 +-- .../sbb-esta/angular-public/toggle/toggle.md | 123 +-- schematics/public2business/index.js | 2 +- schematics/public2business/index.ts | 2 +- tslint.json | 11 - 40 files changed, 1892 insertions(+), 623 deletions(-) create mode 100644 projects/sbb-esta/angular-core/radio-button/index.ts create mode 100644 projects/sbb-esta/angular-core/radio-button/package.json create mode 100644 projects/sbb-esta/angular-core/radio-button/src/public_api.ts create mode 100644 projects/sbb-esta/angular-core/radio-button/src/radio-button.module.ts create mode 100644 projects/sbb-esta/angular-core/radio-button/src/radio-button.ts create mode 100644 projects/sbb-esta/angular-core/radio-button/src/radio-group.directive.spec.ts create mode 100644 projects/sbb-esta/angular-core/radio-button/src/radio-group.directive.ts delete mode 100644 projects/sbb-esta/angular-public/radio-button/src/radio-button/radio-button.interface.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index d7207a4659..668cffb1a8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "typescript.tsdk": "node_modules\\typescript\\lib" + "typescript.tsdk": "node_modules\\typescript\\lib", + "editor.defaultFormatter": "esbenp.prettier-vscode" } \ No newline at end of file diff --git a/projects/angular-showcase/src/app/business/business-examples/tooltip-showcase/tooltip-showcase.component.html b/projects/angular-showcase/src/app/business/business-examples/tooltip-showcase/tooltip-showcase.component.html index 36470755ee..00eb880eb5 100644 --- a/projects/angular-showcase/src/app/business/business-examples/tooltip-showcase/tooltip-showcase.component.html +++ b/projects/angular-showcase/src/app/business/business-examples/tooltip-showcase/tooltip-showcase.component.html @@ -55,7 +55,7 @@

Tooltip with Custom Icon

- +

Dies ist ein Tooltip mit einem angepassten Icon.

diff --git a/projects/angular-showcase/src/app/features/public-component-viewer/ps-chrome-patch.directive.ts b/projects/angular-showcase/src/app/features/public-component-viewer/ps-chrome-patch.directive.ts index b78079de0a..5ae40fd694 100644 --- a/projects/angular-showcase/src/app/features/public-component-viewer/ps-chrome-patch.directive.ts +++ b/projects/angular-showcase/src/app/features/public-component-viewer/ps-chrome-patch.directive.ts @@ -1,20 +1,37 @@ -import { Directive, ElementRef, Self } from '@angular/core'; +import { AfterViewInit, Directive, ElementRef, OnDestroy, Self } from '@angular/core'; import { PerfectScrollbarComponent } from 'ngx-perfect-scrollbar'; +import { interval, merge, Subject } from 'rxjs'; +import { filter, takeUntil } from 'rxjs/operators'; @Directive({ // tslint:disable-next-line: directive-selector selector: 'perfect-scrollbar' }) -export class PsChromePatchDirective { - constructor(elementRef: ElementRef, @Self() perfectScrollbar: PerfectScrollbarComponent) { - const element: HTMLElement = elementRef.nativeElement; - perfectScrollbar.psXReachEnd.subscribe(() => { - if ( - perfectScrollbar.directiveRef.elementRef.nativeElement.getBoundingClientRect().top < 0 && - element.scrollTo - ) { - element.scrollTo(0, 0); - } - }); +export class PsChromePatchDirective implements OnDestroy, AfterViewInit { + private _destroy = new Subject(); + + constructor( + private _elementRef: ElementRef, + @Self() private _perfectScrollbar: PerfectScrollbarComponent + ) {} + + ngAfterViewInit(): void { + const element: HTMLElement = this._elementRef.nativeElement; + if (!element.scrollTo) { + return; + } + + const directiveElement: HTMLElement = this._perfectScrollbar.directiveRef.elementRef + .nativeElement; + merge(interval(100), this._perfectScrollbar.psXReachEnd) + .pipe( + takeUntil(this._destroy), + filter(() => directiveElement.getBoundingClientRect().top < 0) + ) + .subscribe(() => element.scrollTo(0, 0)); + } + + ngOnDestroy(): void { + this._destroy.next(); } } diff --git a/projects/angular-showcase/src/app/public/public-examples/accordion-showcase/accordion-showcase.component.html b/projects/angular-showcase/src/app/public/public-examples/accordion-showcase/accordion-showcase.component.html index c9f7e6e87c..4d605bdb54 100644 --- a/projects/angular-showcase/src/app/public/public-examples/accordion-showcase/accordion-showcase.component.html +++ b/projects/angular-showcase/src/app/public/public-examples/accordion-showcase/accordion-showcase.component.html @@ -228,7 +228,7 @@

Panel with custom html

" > Test button - +
diff --git a/projects/angular-showcase/src/app/public/public-examples/ghettobox-showcase/ghettobox-showcase.component.html b/projects/angular-showcase/src/app/public/public-examples/ghettobox-showcase/ghettobox-showcase.component.html index 943803a11e..3a69973688 100644 --- a/projects/angular-showcase/src/app/public/public-examples/ghettobox-showcase/ghettobox-showcase.component.html +++ b/projects/angular-showcase/src/app/public/public-examples/ghettobox-showcase/ghettobox-showcase.component.html @@ -25,7 +25,7 @@

Ghettobox Link with custom icon

[queryParams]="{ debug: false }" fragment="test" > - + This is a simple link text with custom icon @@ -47,7 +47,7 @@

Ghettobox container with initial entry

[queryParams]="{ debug: true }" fragment="education" > - + This is an initial ghettobox into a container. Die Strecke zwischen Stadelhofen und Zürich Hauptbahnhof ist vorübergehend wegen einer technischen Störung auf 5km. diff --git a/projects/angular-showcase/src/app/public/public-examples/lightbox-showcase/lightbox-showcase-content-2.component.html b/projects/angular-showcase/src/app/public/public-examples/lightbox-showcase/lightbox-showcase-content-2.component.html index 8b0e2c1bb7..822110c428 100644 --- a/projects/angular-showcase/src/app/public/public-examples/lightbox-showcase/lightbox-showcase-content-2.component.html +++ b/projects/angular-showcase/src/app/public/public-examples/lightbox-showcase/lightbox-showcase-content-2.component.html @@ -120,11 +120,11 @@

Architecture overview

diff --git a/projects/angular-showcase/src/app/public/public-examples/loading-showcase/loading-showcase.component.html b/projects/angular-showcase/src/app/public/public-examples/loading-showcase/loading-showcase.component.html index 2a616bf67b..c0d2fc6ab2 100644 --- a/projects/angular-showcase/src/app/public/public-examples/loading-showcase/loading-showcase.component.html +++ b/projects/angular-showcase/src/app/public/public-examples/loading-showcase/loading-showcase.component.html @@ -30,14 +30,14 @@

Spinner overlay example

>

diff --git a/projects/angular-showcase/src/app/public/public-examples/notification-showcase/notification-showcase.component.html b/projects/angular-showcase/src/app/public/public-examples/notification-showcase/notification-showcase.component.html index 08effe0f60..ac5faeab23 100644 --- a/projects/angular-showcase/src/app/public/public-examples/notification-showcase/notification-showcase.component.html +++ b/projects/angular-showcase/src/app/public/public-examples/notification-showcase/notification-showcase.component.html @@ -20,7 +20,7 @@

Properties

Notification with custom icon

- +
@@ -33,7 +33,7 @@

Error notification with jump marks and custom icon

- + @@ -46,7 +46,7 @@

Success notification with jump marks and custom icon

- + diff --git a/projects/angular-showcase/src/app/public/public-examples/radio-button-panel-showcase/radio-button-panel-showcase.component.html b/projects/angular-showcase/src/app/public/public-examples/radio-button-panel-showcase/radio-button-panel-showcase.component.html index 9382cfe5cf..f86a08a1a9 100644 --- a/projects/angular-showcase/src/app/public/public-examples/radio-button-panel-showcase/radio-button-panel-showcase.component.html +++ b/projects/angular-showcase/src/app/public/public-examples/radio-button-panel-showcase/radio-button-panel-showcase.component.html @@ -50,16 +50,13 @@

Properties

Option selections

- -
- -
-
+ + +

Model

diff --git a/projects/angular-showcase/src/app/public/public-examples/radio-button-showcase/radio-button-showcase.component.html b/projects/angular-showcase/src/app/public/public-examples/radio-button-showcase/radio-button-showcase.component.html index 86ae1d2f74..93b1047ca5 100644 --- a/projects/angular-showcase/src/app/public/public-examples/radio-button-showcase/radio-button-showcase.component.html +++ b/projects/angular-showcase/src/app/public/public-examples/radio-button-showcase/radio-button-showcase.component.html @@ -30,13 +30,11 @@

Properties

Radio buttons

- -
- {{ - option.name - }} -
-
+ + {{ + option.name + }} +

Model

diff --git a/projects/angular-showcase/src/app/public/public-examples/search-showcase/search-showcase.component.html b/projects/angular-showcase/src/app/public/public-examples/search-showcase/search-showcase.component.html index 52d7fb5cd4..ae9a5c1122 100644 --- a/projects/angular-showcase/src/app/public/public-examples/search-showcase/search-showcase.component.html +++ b/projects/angular-showcase/src/app/public/public-examples/search-showcase/search-showcase.component.html @@ -73,7 +73,7 @@

[formControl]="myControlStatic" [sbbAutocomplete]="auto3" > - + diff --git a/projects/angular-showcase/src/app/public/public-examples/toggle-showcase/toggle-showcase.component.html b/projects/angular-showcase/src/app/public/public-examples/toggle-showcase/toggle-showcase.component.html index 7414fb3dd5..bd304d2896 100644 --- a/projects/angular-showcase/src/app/public/public-examples/toggle-showcase/toggle-showcase.component.html +++ b/projects/angular-showcase/src/app/public/public-examples/toggle-showcase/toggle-showcase.component.html @@ -6,20 +6,12 @@

- - - - - - - - - + + + + + + Select date @@ -43,24 +35,18 @@

Toggle buttons used as Template driven Form

- - - - - - - - - Select date + + + + Select date - + -

+ + + +

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. @@ -81,7 +67,7 @@

Model

Toggle buttons simple use case no Forms attached

- + = of([ - { - label: 'Einfache Fahrt', - value: 'Option_1' - }, - { - label: 'Hin- und Rückfahrt', - value: 'Option_2' - } - ]); - toggleValues: any; form = new FormGroup({ diff --git a/projects/angular-showcase/src/app/public/public-examples/tooltip-showcase/tooltip-showcase.component.html b/projects/angular-showcase/src/app/public/public-examples/tooltip-showcase/tooltip-showcase.component.html index e788627a0b..5776cdb31f 100644 --- a/projects/angular-showcase/src/app/public/public-examples/tooltip-showcase/tooltip-showcase.component.html +++ b/projects/angular-showcase/src/app/public/public-examples/tooltip-showcase/tooltip-showcase.component.html @@ -55,7 +55,7 @@

Tooltip with Custom Icon

- +

Dies ist ein Tooltip mit einem angepassten Icon.

diff --git a/projects/angular-showcase/src/app/public/public/public.component.ts b/projects/angular-showcase/src/app/public/public/public.component.ts index 0bf3c00d70..c3d774b4f1 100644 --- a/projects/angular-showcase/src/app/public/public/public.component.ts +++ b/projects/angular-showcase/src/app/public/public/public.component.ts @@ -94,7 +94,7 @@ export class PublicComponent implements ExampleProvider { 'file-selector': { 'file-selector-showcase': new ComponentPortal(FileSelectorShowcaseComponent) }, - 'radio-button': { 'radioButton-showcase': new ComponentPortal(RadioButtonShowcaseComponent) }, + 'radio-button': { 'radio-button-showcase': new ComponentPortal(RadioButtonShowcaseComponent) }, 'radio-button-panel': { 'radio-button-panel-showcase': new ComponentPortal(RadioButtonPanelShowcaseComponent) }, diff --git a/projects/sbb-esta/angular-core/radio-button/index.ts b/projects/sbb-esta/angular-core/radio-button/index.ts new file mode 100644 index 0000000000..decc72d85b --- /dev/null +++ b/projects/sbb-esta/angular-core/radio-button/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/sbb-esta/angular-core/radio-button/package.json b/projects/sbb-esta/angular-core/radio-button/package.json new file mode 100644 index 0000000000..260e1cf29a --- /dev/null +++ b/projects/sbb-esta/angular-core/radio-button/package.json @@ -0,0 +1,19 @@ +{ + "ngPackage": { + "lib": { + "umdModuleIds": { + "@sbb-esta/angular-core": "sbbAngularCore", + "@sbb-esta/angular-core/base": "sbbAngularCoreBase", + "@sbb-esta/angular-core/breakpoints": "sbbAngularCoreBreakpoints", + "@sbb-esta/angular-core/common-behaviors": "sbbAngularCoreCommonBehaviors", + "@sbb-esta/angular-core/datetime": "sbbAngularCoreDatetime", + "@sbb-esta/angular-core/error": "sbbAngularCoreError", + "@sbb-esta/angular-core/forms": "sbbAngularCoreForms", + "@sbb-esta/angular-core/icon-directive": "sbbAngularCoreIconDirective", + "@sbb-esta/angular-core/models": "sbbAngularCoreModels", + "@sbb-esta/angular-icons": "sbbAngularIcons", + "ngx-perfect-scrollbar": "ngxPerfectScrollbar" + } + } + } +} \ No newline at end of file diff --git a/projects/sbb-esta/angular-core/radio-button/src/public_api.ts b/projects/sbb-esta/angular-core/radio-button/src/public_api.ts new file mode 100644 index 0000000000..c2d4affb88 --- /dev/null +++ b/projects/sbb-esta/angular-core/radio-button/src/public_api.ts @@ -0,0 +1,3 @@ +export { RadioButtonModule as ɵRadioButtonModule } from './radio-button.module'; +export * from './radio-button'; +export * from './radio-group.directive'; diff --git a/projects/sbb-esta/angular-core/radio-button/src/radio-button.module.ts b/projects/sbb-esta/angular-core/radio-button/src/radio-button.module.ts new file mode 100644 index 0000000000..d2ee32bb9f --- /dev/null +++ b/projects/sbb-esta/angular-core/radio-button/src/radio-button.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from '@angular/core'; + +import { RadioGroupDirective } from './radio-group.directive'; + +@NgModule({ + declarations: [RadioGroupDirective], + exports: [RadioGroupDirective] +}) +export class RadioButtonModule {} diff --git a/projects/sbb-esta/angular-core/radio-button/src/radio-button.ts b/projects/sbb-esta/angular-core/radio-button/src/radio-button.ts new file mode 100644 index 0000000000..207682b047 --- /dev/null +++ b/projects/sbb-esta/angular-core/radio-button/src/radio-button.ts @@ -0,0 +1,375 @@ +import { FocusMonitor } from '@angular/cdk/a11y'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { UniqueSelectionDispatcher } from '@angular/cdk/collections'; +import { + AfterViewInit, + ChangeDetectorRef, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + OnDestroy, + OnInit, + Optional, + Output, + ViewChild +} from '@angular/core'; +import { ControlValueAccessor, NgControl } from '@angular/forms'; +import { HasTabIndexCtor, mixinTabIndex } from '@sbb-esta/angular-core/common-behaviors'; + +import { RadioGroupDirective } from './radio-group.directive'; + +/** Change event object emitted by RadioButtonComponent. */ +export class RadioChange { + constructor( + /** The RadioButtonComponent that emits the change event. */ + public source: RadioButton, + /** The value of the RadioButtonComponent. */ + public value: any + ) {} +} + +class RadioButtonBase { + // Since the disabled property is manually defined for the MatRadioButton and isn't set up in + // the mixin base class. To be able to use the tabindex mixin, a disabled property must be + // defined to properly work. + disabled: boolean; +} + +// tslint:disable-next-line: naming-convention +const _RadioButtonMixinBase: HasTabIndexCtor & typeof RadioButtonBase = mixinTabIndex( + RadioButtonBase +); + +let nextUniqueId = 0; + +export class RadioButton extends _RadioButtonMixinBase + implements ControlValueAccessor, OnInit, AfterViewInit, OnDestroy { + private _uniqueId = `sbb-radio-button-${++nextUniqueId}`; + + /** The id of this component. */ + // tslint:disable-next-line: no-input-rename + @Input() @HostBinding('attr.id') id: string = this._uniqueId; + /** Radio input identifier. */ + @Input() inputId = `${this.id}-input`; + /** Analog to HTML 'name' attribute used to group radios for unique selection. */ + @Input() name: string; + /** Used to set the 'aria-label' attribute on the underlying input element. */ + @Input('aria-label') ariaLabel: string; + /** @docs-private */ + @HostBinding('attr.aria-label') _ariaLabelAttr = null; + /** The 'aria-labelledby' attribute takes precedence as the element's text alternative. */ + @Input('aria-labelledby') ariaLabelledby: string; + /** @docs-private */ + @HostBinding('attr.aria-labelledby') _arialabelledbyAttr = null; + /** The 'aria-describedby' attribute is read after the element's label and field type. */ + @Input('aria-describedby') ariaDescribedby: string; + /** @docs-private */ + @HostBinding('attr.aria-describedby') _ariadescribedbyAttr = null; + /** @docs-private */ + @HostBinding('class.sbb-radio-button') _cssClass = true; + + /** + * Needs to be -1 so the `focus` event still fires. + * @docs-private + */ + @HostBinding('attr.tabindex') _tabIndexAttr = -1; + + /** Whether this radio button is checked. */ + @Input() + get checked(): boolean { + return this._checked; + } + set checked(value: boolean) { + const newCheckedState = coerceBooleanProperty(value); + if (this._checked !== newCheckedState) { + this._checked = newCheckedState; + if (newCheckedState && this.radioGroup && this.radioGroup.value !== this.value) { + this.radioGroup.selected = this; + } else if (!newCheckedState && this.radioGroup && this.radioGroup.value === this.value) { + // When unchecking the selected radio button, update the selected radio + // property on the group. + this.radioGroup.selected = null; + } + + if (newCheckedState) { + // Notify all radio buttons with the same name to un-check. + this._radioDispatcher.notify(this.id, this.name); + // TODO: Remove this after dropping support for form + // control on radio button (instead of radio group). + this.onChange(this.value); + } + this._changeDetector.markForCheck(); + } + } + + /** The value of this radio button. */ + @Input() + get value(): any { + return this._value; + } + set value(value: any) { + if (this._value !== value) { + this._value = value; + if (this.radioGroup !== null) { + if (!this.checked) { + // Update checked when the value changed to match the radio group's value + this.checked = this.radioGroup.value === value; + } + if (this.checked) { + this.radioGroup.selected = this; + } + } + } + } + + /** Whether the radio button is disabled. */ + @Input() + get disabled(): boolean { + return this._disabled || (this.radioGroup !== null && this.radioGroup.disabled); + } + set disabled(value: boolean) { + const newDisabledState = coerceBooleanProperty(value); + if (this._disabled !== newDisabledState) { + this._disabled = newDisabledState; + this._changeDetector.markForCheck(); + } + } + + /** Whether the radio button is required. */ + @Input() + get required(): boolean { + return this._required || (this.radioGroup && this.radioGroup.required); + } + set required(value: boolean) { + this._required = coerceBooleanProperty(value); + } + + /** + * Indicates radio button name in formControl. + * @deprecated + */ + @Input() formControlName: string; + /** + * @docs-private + * @deprecated + */ + _control: NgControl; + + /** + * Event emitted when the checked state of this radio button changes. + * Change events are only emitted when the value changes due to user interaction with + * the radio button (the same behavior as ``). + */ + @Output() readonly change: EventEmitter = new EventEmitter(); + + /** The native `` element */ + @ViewChild('input', { static: false }) _inputElement: ElementRef; + + private _disabled = false; + private _required = false; + private _checked = false; + private _value: any = null; + + /** Unregister function for _radioDispatcher */ + private _removeUniqueSelectionListener: () => void = () => {}; + + /** + * Class property that represents a change on the radio button + * @docs-private + * @deprecated + */ + onChange = (_: any) => {}; + /** + * Class property that represents a touch on the radio button + * @docs-private + * @deprecated + */ + onTouched = () => {}; + + constructor( + readonly radioGroup: RadioGroupDirective, + protected readonly _changeDetector: ChangeDetectorRef, + private _elementRef: ElementRef, + private _focusMonitor: FocusMonitor, + private _radioDispatcher: UniqueSelectionDispatcher + ) { + super(); + this._removeUniqueSelectionListener = _radioDispatcher.listen((id: string, name: string) => { + if (id !== this.id && name === this.name) { + this.checked = false; + } + }); + } + + /** Focuses the radio button. */ + focus(options?: FocusOptions): void { + this._focusMonitor.focusVia(this._inputElement, 'keyboard', options); + } + + /** + * Note: under normal conditions focus shouldn't land on this element, however it may be + * programmatically set, for example inside of a focus trap, in this case we want to forward + * the focus to the native element. + * @docs-private + */ + @HostListener('focus') + _forwardFocus() { + this._inputElement.nativeElement.focus(); + } + + /** + * Marks the radio button as needing checking for change detection. + * This method is exposed because the parent radio group will directly + * update bound properties of the radio button. + * @docs-private + */ + _markForCheck() { + // When group value changes, the button will not be notified. Use `markForCheck` to explicit + // update radio button's status + this._changeDetector.markForCheck(); + } + + ngOnInit(): void { + if (this.radioGroup && this.formControlName) { + throw new Error('The form control should be applied to the sbb-radio-group'); + } else if (this.radioGroup) { + // If the radio is inside a radio group, determine if it should be checked + this.checked = this.radioGroup.value === this._value; + // Copy name from parent radio group + this.name = this.radioGroup.name; + } else { + this._checkName(); + } + } + + ngAfterViewInit() { + this._focusMonitor.monitor(this._elementRef, true).subscribe(focusOrigin => { + if (!focusOrigin) { + this.onTouched(); + } + if (!focusOrigin && this.radioGroup) { + this.radioGroup._touch(); + } + }); + } + + ngOnDestroy(): void { + this._focusMonitor.stopMonitoring(this._elementRef); + this._removeUniqueSelectionListener(); + } + + /** + * @docs-private + * @deprecated + */ + writeValue(value: any): void { + this.checked = this.value === value; + } + + /** + * Registers the on change callback + * @docs-private + * @deprecated + */ + registerOnChange(fn: any): void { + this.onChange = fn; + } + + /** + * Registers the on touched callback + * @docs-private + * @deprecated + */ + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + /** + * Manage the event click on the radio button + * @deprecated Use .checked + */ + click($event: Event) { + this.checked = true; + } + + /** + * Sets the radio button status to disabled + * @docs-private + * @deprecated + */ + setDisabledState(disabled: boolean) { + this.disabled = disabled; + this._changeDetector.markForCheck(); + } + + /** + * Unchecks the radio button + * @deprecated Use .checked + */ + uncheck() { + this.checked = false; + } + + /** @docs-private */ + _onInputClick(event: Event) { + // We have to stop propagation for click events on the visual hidden input element. + // By default, when a user clicks on a label element, a generated click event will be + // dispatched on the associated input element. Since we are using a label element as our + // root container, the click event on the `radio-button` will be executed twice. + // The real click event will bubble up, and the generated click event also tries to bubble up. + // This will lead to multiple click events. + // Preventing bubbling for the second event will solve that issue. + event.stopPropagation(); + } + + /** + * Triggered when the radio button received a click or the input recognized any change. + * Clicking on a label element, will trigger a change event on the associated input. + * @docs-private + */ + _onInputChange(event: Event) { + // We always have to stop propagation on the change event. + // Otherwise the change event, from the input element, will bubble up and + // emit its event object to the `change` output. + event.stopPropagation(); + + const groupValueChanged = this.radioGroup && this.value !== this.radioGroup.value; + this.checked = true; + this._emitChangeEvent(); + + if (this.radioGroup) { + this.radioGroup._controlValueAccessorChangeFn(this.value); + if (groupValueChanged) { + this.radioGroup._emitChangeEvent(); + } + } + } + + /** + * Verify that radio button name matches with radio button form control name + */ + private _checkName(): void { + if (this.name && this.formControlName && this.name !== this.formControlName) { + this._throwNameError(); + } else if (!this.name && this.formControlName) { + this.name = this.formControlName; + } + } + + /** + * Throws an exception if the radio button name doesn't match with the radio button form control name + */ + private _throwNameError(): void { + throw new Error(` + If you define both a name and a formControlName attribute on your radio button, their values + must match. Ex: + `); + } + + /** Dispatch change event with current value. */ + private _emitChangeEvent(): void { + this.change.emit(new RadioChange(this, this.value)); + } +} diff --git a/projects/sbb-esta/angular-core/radio-button/src/radio-group.directive.spec.ts b/projects/sbb-esta/angular-core/radio-button/src/radio-group.directive.spec.ts new file mode 100644 index 0000000000..7dcff28b3f --- /dev/null +++ b/projects/sbb-esta/angular-core/radio-button/src/radio-group.directive.spec.ts @@ -0,0 +1,927 @@ +import { FocusMonitor } from '@angular/cdk/a11y'; +import { UniqueSelectionDispatcher } from '@angular/cdk/collections'; +import { + ChangeDetectorRef, + Component, + DebugElement, + ElementRef, + Optional, + ViewChild +} from '@angular/core'; +import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { FormControl, FormsModule, NgModel, ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { dispatchFakeEvent } from '@sbb-esta/angular-core/testing'; + +import { RadioButton, RadioChange, RadioGroupDirective, ɵRadioButtonModule } from './public_api'; + +// tslint:disable: no-non-null-assertion + +@Component({ + selector: 'sbb-radio-button', + template: ` + + `, + inputs: ['tabIndex'], + providers: [{ provide: RadioButton, useExisting: RadioButtonComponent }] +}) +class RadioButtonComponent extends RadioButton { + constructor( + @Optional() radioGroup: RadioGroupDirective, + changeDetector: ChangeDetectorRef, + elementRef: ElementRef, + focusMonitor: FocusMonitor, + radioDispatcher: UniqueSelectionDispatcher + ) { + super(radioGroup, changeDetector, elementRef, focusMonitor, radioDispatcher); + } +} + +@Component({ + template: ` + + + Charmander + + + Squirtle + + + Bulbasaur + + + ` +}) +class RadiosInsideRadioGroupComponent { + isFirstDisabled = false; + isGroupDisabled = false; + isGroupRequired = false; + groupValue: string | null = null; +} + +@Component({ + template: ` + Spring + Summer + Autumn + + Spring + Summer + Autumn + + Baby Banana + A smaller banana + + + Raspberry + No name + ` +}) +class StandaloneRadioButtonsComponent { + ariaLabel = 'Banana'; + ariaLabelledby = 'xyz'; + ariaDescribedby = 'abc'; +} + +@Component({ + template: ` + + + {{ option.label }} + + + ` +}) +class RadioGroupWithNgModelComponent { + modelValue: string; + groupName = 'radio-group'; + options = [ + { label: 'Vanilla', value: 'vanilla' }, + { label: 'Chocolate', value: 'chocolate' }, + { label: 'Strawberry', value: 'strawberry' } + ]; + lastEvent: RadioChange; +} + +@Component({ + template: ` + One + ` +}) +class DisableableRadioButtonComponent { + @ViewChild(RadioButton, { static: false }) radioButton: RadioButton; + + set disabled(value: boolean) { + this.radioButton.disabled = value; + } +} + +@Component({ + template: ` + + One + + ` +}) +class RadioGroupWithFormControlComponent { + formControl = new FormControl(); +} + +@Component({ + template: ` + + ` +}) +class FocusableRadioButtonComponent { + tabIndex: number; +} + +@Component({ + template: ` + + + {{ option.label }} + + + ` +}) +class InterleavedRadioGroupComponent { + modelValue = 'strawberry'; + options = [ + { label: 'Vanilla', value: 'vanilla' }, + { label: 'Chocolate', value: 'chocolate' }, + { label: 'Strawberry', value: 'strawberry' } + ]; +} + +@Component({ + // tslint:disable-next-line: component-selector + selector: 'transcluding-wrapper', + template: ` +
+ ` +}) +class TranscludingWrapperComponent {} + +@Component({ + template: ` + + ` +}) +class RadioButtonWithPredefinedTabindexComponent {} + +@Component({ + template: ` + + ` +}) +class RadioButtonWithPredefinedAriaAttributesComponent {} + +describe('MatRadio', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ɵRadioButtonModule, FormsModule, ReactiveFormsModule], + declarations: [ + RadioButtonComponent, + DisableableRadioButtonComponent, + FocusableRadioButtonComponent, + RadiosInsideRadioGroupComponent, + RadioGroupWithNgModelComponent, + RadioGroupWithFormControlComponent, + StandaloneRadioButtonsComponent, + InterleavedRadioGroupComponent, + TranscludingWrapperComponent, + RadioButtonWithPredefinedTabindexComponent, + RadioButtonWithPredefinedAriaAttributesComponent + ] + }); + + TestBed.compileComponents(); + })); + + describe('inside of a group', () => { + let fixture: ComponentFixture; + let groupDebugElement: DebugElement; + let radioDebugElements: DebugElement[]; + let radioNativeElements: HTMLElement[]; + let radioLabelElements: HTMLLabelElement[]; + let radioInputElements: HTMLInputElement[]; + let groupInstance: RadioGroupDirective; + let radioInstances: RadioButton[]; + let testComponent: RadiosInsideRadioGroupComponent; + + beforeEach(async(() => { + fixture = TestBed.createComponent(RadiosInsideRadioGroupComponent); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + + groupDebugElement = fixture.debugElement.query(By.directive(RadioGroupDirective))!; + groupInstance = groupDebugElement.injector.get(RadioGroupDirective); + + radioDebugElements = fixture.debugElement.queryAll(By.directive(RadioButton)); + radioNativeElements = radioDebugElements.map(debugEl => debugEl.nativeElement); + radioInstances = radioDebugElements.map(debugEl => debugEl.componentInstance); + + radioLabelElements = radioDebugElements.map( + debugEl => debugEl.query(By.css('label'))!.nativeElement + ); + radioInputElements = radioDebugElements.map( + debugEl => debugEl.query(By.css('input'))!.nativeElement + ); + })); + + it('should set individual radio names based on the group name', () => { + expect(groupInstance.name).toBeTruthy(); + for (const radio of radioInstances) { + expect(radio.name).toBe(groupInstance.name); + } + }); + + it('should coerce the disabled binding on the radio group', () => { + (groupInstance as any).disabled = ''; + fixture.detectChanges(); + + radioLabelElements[0].click(); + fixture.detectChanges(); + + expect(radioInstances[0].checked).toBe(false); + expect(groupInstance.disabled).toBe(true); + }); + + it('should disable click interaction when the group is disabled', () => { + testComponent.isGroupDisabled = true; + fixture.detectChanges(); + + radioLabelElements[0].click(); + fixture.detectChanges(); + + expect(radioInstances[0].checked).toBe(false); + }); + + it('should disable each individual radio when the group is disabled', () => { + testComponent.isGroupDisabled = true; + fixture.detectChanges(); + + for (const radio of radioInstances) { + expect(radio.disabled).toBe(true); + } + }); + + it('should set required to each radio button when the group is required', () => { + testComponent.isGroupRequired = true; + fixture.detectChanges(); + + for (const radio of radioInstances) { + expect(radio.required).toBe(true); + } + }); + + it('should update the group value when one of the radios changes', () => { + expect(groupInstance.value).toBeFalsy(); + + radioInstances[0].checked = true; + fixture.detectChanges(); + + expect(groupInstance.value).toBe('fire'); + expect(groupInstance.selected).toBe(radioInstances[0]); + }); + + it('should update the group and radios when one of the radios is clicked', () => { + expect(groupInstance.value).toBeFalsy(); + + radioLabelElements[0].click(); + fixture.detectChanges(); + + expect(groupInstance.value).toBe('fire'); + expect(groupInstance.selected).toBe(radioInstances[0]); + expect(radioInstances[0].checked).toBe(true); + expect(radioInstances[1].checked).toBe(false); + + radioLabelElements[1].click(); + fixture.detectChanges(); + + expect(groupInstance.value).toBe('water'); + expect(groupInstance.selected).toBe(radioInstances[1]); + expect(radioInstances[0].checked).toBe(false); + expect(radioInstances[1].checked).toBe(true); + }); + + it('should check a radio upon interaction with the underlying native radio button', () => { + radioInputElements[0].click(); + fixture.detectChanges(); + + expect(radioInstances[0].checked).toBe(true); + expect(groupInstance.value).toBe('fire'); + expect(groupInstance.selected).toBe(radioInstances[0]); + }); + + it('should emit a change event from radio buttons', () => { + expect(radioInstances[0].checked).toBe(false); + + const spies = radioInstances.map((radio, index) => + jasmine.createSpy(`onChangeSpy ${index} for ${radio.name}`) + ); + + spies.forEach((spy, index) => radioInstances[index].change.subscribe(spy)); + + radioLabelElements[0].click(); + fixture.detectChanges(); + + expect(spies[0]).toHaveBeenCalled(); + + radioLabelElements[1].click(); + fixture.detectChanges(); + + // To match the native radio button behavior, the change event shouldn't + // be triggered when the radio got unselected. + expect(spies[0]).toHaveBeenCalledTimes(1); + expect(spies[1]).toHaveBeenCalledTimes(1); + }); + + it(`should not emit a change event from the radio group when change group value + programmatically`, () => { + expect(groupInstance.value).toBeFalsy(); + + const changeSpy = jasmine.createSpy('radio-group change listener'); + groupInstance.change.subscribe(changeSpy); + + radioLabelElements[0].click(); + fixture.detectChanges(); + + expect(changeSpy).toHaveBeenCalledTimes(1); + + groupInstance.value = 'water'; + fixture.detectChanges(); + + expect(changeSpy).toHaveBeenCalledTimes(1); + }); + + it('should update the group and radios when updating the group value', () => { + expect(groupInstance.value).toBeFalsy(); + + testComponent.groupValue = 'fire'; + fixture.detectChanges(); + + expect(groupInstance.value).toBe('fire'); + expect(groupInstance.selected).toBe(radioInstances[0]); + expect(radioInstances[0].checked).toBe(true); + expect(radioInstances[1].checked).toBe(false); + + testComponent.groupValue = 'water'; + fixture.detectChanges(); + + expect(groupInstance.value).toBe('water'); + expect(groupInstance.selected).toBe(radioInstances[1]); + expect(radioInstances[0].checked).toBe(false); + expect(radioInstances[1].checked).toBe(true); + }); + + it('should deselect all of the radios when the group value is cleared', () => { + radioInstances[0].checked = true; + + expect(groupInstance.value).toBeTruthy(); + + groupInstance.value = null; + + expect(radioInstances.every(radio => !radio.checked)).toBe(true); + }); + + it(`should update the group's selected radio to null when unchecking that radio + programmatically`, () => { + const changeSpy = jasmine.createSpy('radio-group change listener'); + groupInstance.change.subscribe(changeSpy); + radioInstances[0].checked = true; + + fixture.detectChanges(); + + expect(changeSpy).not.toHaveBeenCalled(); + expect(groupInstance.value).toBeTruthy(); + + radioInstances[0].checked = false; + + fixture.detectChanges(); + + expect(changeSpy).not.toHaveBeenCalled(); + expect(groupInstance.value).toBeFalsy(); + expect(radioInstances.every(radio => !radio.checked)).toBe(true); + expect(groupInstance.selected).toBeNull(); + }); + + it('should not fire a change event from the group when a radio checked state changes', () => { + const changeSpy = jasmine.createSpy('radio-group change listener'); + groupInstance.change.subscribe(changeSpy); + radioInstances[0].checked = true; + + fixture.detectChanges(); + + expect(changeSpy).not.toHaveBeenCalled(); + expect(groupInstance.value).toBeTruthy(); + expect(groupInstance.value).toBe('fire'); + + radioInstances[1].checked = true; + + fixture.detectChanges(); + + expect(groupInstance.value).toBe('water'); + expect(changeSpy).not.toHaveBeenCalled(); + }); + + it(`should update checked status if changed value to radio group's value`, () => { + const changeSpy = jasmine.createSpy('radio-group change listener'); + groupInstance.change.subscribe(changeSpy); + groupInstance.value = 'apple'; + + expect(changeSpy).not.toHaveBeenCalled(); + expect(groupInstance.value).toBe('apple'); + expect(groupInstance.selected).toBeFalsy('expect group selected to be null'); + expect(radioInstances[0].checked).toBeFalsy('should not select the first button'); + expect(radioInstances[1].checked).toBeFalsy('should not select the second button'); + expect(radioInstances[2].checked).toBeFalsy('should not select the third button'); + + radioInstances[0].value = 'apple'; + + fixture.detectChanges(); + + expect(groupInstance.selected).toBe( + radioInstances[0], + 'expect group selected to be first button' + ); + expect(radioInstances[0].checked).toBeTruthy('expect group select the first button'); + expect(radioInstances[1].checked).toBeFalsy('should not select the second button'); + expect(radioInstances[2].checked).toBeFalsy('should not select the third button'); + }); + }); + + describe('group with ngModel', () => { + let fixture: ComponentFixture; + let groupDebugElement: DebugElement; + let radioDebugElements: DebugElement[]; + let innerRadios: DebugElement[]; + let radioLabelElements: HTMLLabelElement[]; + let groupInstance: RadioGroupDirective; + let radioInstances: RadioButton[]; + let testComponent: RadioGroupWithNgModelComponent; + let groupNgModel: NgModel; + + beforeEach(() => { + fixture = TestBed.createComponent(RadioGroupWithNgModelComponent); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + + groupDebugElement = fixture.debugElement.query(By.directive(RadioGroupDirective))!; + groupInstance = groupDebugElement.injector.get(RadioGroupDirective); + groupNgModel = groupDebugElement.injector.get(NgModel); + + radioDebugElements = fixture.debugElement.queryAll(By.directive(RadioButton)); + radioInstances = radioDebugElements.map(debugEl => debugEl.componentInstance); + innerRadios = fixture.debugElement.queryAll(By.css('input[type="radio"]')); + + radioLabelElements = radioDebugElements.map( + debugEl => debugEl.query(By.css('label'))!.nativeElement + ); + }); + + it('should set individual radio names based on the group name', () => { + expect(groupInstance.name).toBeTruthy(); + for (const radio of radioInstances) { + expect(radio.name).toBe(groupInstance.name); + } + + groupInstance.name = 'new name'; + + for (const radio of radioInstances) { + expect(radio.name).toBe(groupInstance.name); + } + }); + + it('should update the name of radio DOM elements if the name of the group changes', () => { + const nodes: HTMLInputElement[] = innerRadios.map(radio => radio.nativeElement); + + expect(nodes.every(radio => radio.getAttribute('name') === groupInstance.name)).toBe( + true, + 'Expected all radios to have the initial name.' + ); + + fixture.componentInstance.groupName = 'changed-name'; + fixture.detectChanges(); + + expect(groupInstance.name).toBe('changed-name'); + expect(nodes.every(radio => radio.getAttribute('name') === groupInstance.name)).toBe( + true, + 'Expected all radios to have the new name.' + ); + }); + + it('should check the corresponding radio button on group value change', () => { + expect(groupInstance.value).toBeFalsy(); + for (const radio of radioInstances) { + expect(radio.checked).toBeFalsy(); + } + + groupInstance.value = 'vanilla'; + for (const radio of radioInstances) { + expect(radio.checked).toBe(groupInstance.value === radio.value); + } + expect(groupInstance.selected!.value).toBe(groupInstance.value); + }); + + it('should have the correct control state initially and after interaction', () => { + // The control should start off valid, pristine, and untouched. + expect(groupNgModel.valid).toBe(true); + expect(groupNgModel.pristine).toBe(true); + expect(groupNgModel.touched).toBe(false); + + // After changing the value programmatically, the control should stay pristine + // but remain untouched. + radioInstances[1].checked = true; + fixture.detectChanges(); + + expect(groupNgModel.valid).toBe(true); + expect(groupNgModel.pristine).toBe(true); + expect(groupNgModel.touched).toBe(false); + + // After a user interaction occurs (such as a click), the control should become dirty and + // now also be touched. + radioLabelElements[2].click(); + fixture.detectChanges(); + + expect(groupNgModel.valid).toBe(true); + expect(groupNgModel.pristine).toBe(false); + expect(groupNgModel.touched).toBe(false); + + // Blur the input element in order to verify that the ng-touched state has been set to true. + // The touched state should be only set to true after the form control has been blurred. + dispatchFakeEvent(innerRadios[2].nativeElement, 'blur'); + + expect(groupNgModel.valid).toBe(true); + expect(groupNgModel.pristine).toBe(false); + expect(groupNgModel.touched).toBe(true); + }); + + it('should write to the radio button based on ngModel', fakeAsync(() => { + testComponent.modelValue = 'chocolate'; + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(innerRadios[1].nativeElement.checked).toBe(true); + expect(radioInstances[1].checked).toBe(true); + })); + + it('should update the ngModel value when selecting a radio button', () => { + dispatchFakeEvent(innerRadios[1].nativeElement, 'change'); + fixture.detectChanges(); + expect(testComponent.modelValue).toBe('chocolate'); + }); + + it('should update the model before firing change event', () => { + expect(testComponent.modelValue).toBeUndefined(); + expect(testComponent.lastEvent).toBeUndefined(); + + dispatchFakeEvent(innerRadios[1].nativeElement, 'change'); + fixture.detectChanges(); + expect(testComponent.lastEvent.value).toBe('chocolate'); + + dispatchFakeEvent(innerRadios[0].nativeElement, 'change'); + fixture.detectChanges(); + expect(testComponent.lastEvent.value).toBe('vanilla'); + }); + }); + + describe('group with FormControl', () => { + let fixture: ComponentFixture; + let groupDebugElement: DebugElement; + let groupInstance: RadioGroupDirective; + let testComponent: RadioGroupWithFormControlComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(RadioGroupWithFormControlComponent); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + groupDebugElement = fixture.debugElement.query(By.directive(RadioGroupDirective))!; + groupInstance = groupDebugElement.injector.get(RadioGroupDirective); + }); + + it('should toggle the disabled state', () => { + expect(groupInstance.disabled).toBeFalsy(); + + testComponent.formControl.disable(); + fixture.detectChanges(); + + expect(groupInstance.disabled).toBeTruthy(); + + testComponent.formControl.enable(); + fixture.detectChanges(); + + expect(groupInstance.disabled).toBeFalsy(); + }); + }); + + describe('disableable', () => { + let fixture: ComponentFixture; + let radioInstance: RadioButton; + let radioNativeElement: HTMLInputElement; + let testComponent: DisableableRadioButtonComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(DisableableRadioButtonComponent); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + const radioDebugElement = fixture.debugElement.query(By.directive(RadioButton))!; + radioInstance = radioDebugElement.injector.get(RadioButton); + radioNativeElement = radioDebugElement.nativeElement.querySelector('input'); + }); + + it('should toggle the disabled state', () => { + expect(radioInstance.disabled).toBeFalsy(); + expect(radioNativeElement.disabled).toBeFalsy(); + + testComponent.disabled = true; + fixture.detectChanges(); + expect(radioInstance.disabled).toBeTruthy(); + expect(radioNativeElement.disabled).toBeTruthy(); + + testComponent.disabled = false; + fixture.detectChanges(); + expect(radioInstance.disabled).toBeFalsy(); + expect(radioNativeElement.disabled).toBeFalsy(); + }); + }); + + describe('as standalone', () => { + let fixture: ComponentFixture; + let radioDebugElements: DebugElement[]; + let seasonRadioInstances: RadioButton[]; + let weatherRadioInstances: RadioButton[]; + let fruitRadioInstances: RadioButton[]; + let fruitRadioNativeInputs: HTMLElement[]; + let testComponent: StandaloneRadioButtonsComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(StandaloneRadioButtonsComponent); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + + radioDebugElements = fixture.debugElement.queryAll(By.directive(RadioButton)); + seasonRadioInstances = radioDebugElements + .filter(debugEl => debugEl.componentInstance.name === 'season') + .map(debugEl => debugEl.componentInstance); + weatherRadioInstances = radioDebugElements + .filter(debugEl => debugEl.componentInstance.name === 'weather') + .map(debugEl => debugEl.componentInstance); + fruitRadioInstances = radioDebugElements + .filter(debugEl => debugEl.componentInstance.name === 'fruit') + .map(debugEl => debugEl.componentInstance); + + const fruitRadioNativeElements = radioDebugElements + .filter(debugEl => debugEl.componentInstance.name === 'fruit') + .map(debugEl => debugEl.nativeElement); + + fruitRadioNativeInputs = []; + for (const element of fruitRadioNativeElements) { + fruitRadioNativeInputs.push(element.querySelector('input')); + } + }); + + it('should uniquely select radios by a name', () => { + seasonRadioInstances[0].checked = true; + weatherRadioInstances[1].checked = true; + + fixture.detectChanges(); + expect(seasonRadioInstances[0].checked).toBe(true); + expect(seasonRadioInstances[1].checked).toBe(false); + expect(seasonRadioInstances[2].checked).toBe(false); + expect(weatherRadioInstances[0].checked).toBe(false); + expect(weatherRadioInstances[1].checked).toBe(true); + expect(weatherRadioInstances[2].checked).toBe(false); + + seasonRadioInstances[1].checked = true; + fixture.detectChanges(); + expect(seasonRadioInstances[0].checked).toBe(false); + expect(seasonRadioInstances[1].checked).toBe(true); + expect(seasonRadioInstances[2].checked).toBe(false); + expect(weatherRadioInstances[0].checked).toBe(false); + expect(weatherRadioInstances[1].checked).toBe(true); + expect(weatherRadioInstances[2].checked).toBe(false); + + weatherRadioInstances[2].checked = true; + expect(seasonRadioInstances[0].checked).toBe(false); + expect(seasonRadioInstances[1].checked).toBe(true); + expect(seasonRadioInstances[2].checked).toBe(false); + expect(weatherRadioInstances[0].checked).toBe(false); + expect(weatherRadioInstances[1].checked).toBe(false); + expect(weatherRadioInstances[2].checked).toBe(true); + }); + + it('should add required attribute to the underlying input element if defined', () => { + const radioInstance = seasonRadioInstances[0]; + radioInstance.required = true; + fixture.detectChanges(); + + expect(radioInstance.required).toBe(true); + }); + + it('should add value attribute to the underlying input element', () => { + expect(fruitRadioNativeInputs[0].getAttribute('value')).toBe('banana'); + expect(fruitRadioNativeInputs[1].getAttribute('value')).toBe('raspberry'); + }); + + it('should add aria-label attribute to the underlying input element if defined', () => { + expect(fruitRadioNativeInputs[0].getAttribute('aria-label')).toBe('Banana'); + }); + + it('should not add aria-label attribute if not defined', () => { + expect(fruitRadioNativeInputs[1].hasAttribute('aria-label')).toBeFalsy(); + }); + + it('should change aria-label attribute if property is changed at runtime', () => { + expect(fruitRadioNativeInputs[0].getAttribute('aria-label')).toBe('Banana'); + + testComponent.ariaLabel = 'Pineapple'; + fixture.detectChanges(); + + expect(fruitRadioNativeInputs[0].getAttribute('aria-label')).toBe('Pineapple'); + }); + + it('should add aria-labelledby attribute to the underlying input element if defined', () => { + expect(fruitRadioNativeInputs[0].getAttribute('aria-labelledby')).toBe('xyz'); + }); + + it('should not add aria-labelledby attribute if not defined', () => { + expect(fruitRadioNativeInputs[1].hasAttribute('aria-labelledby')).toBeFalsy(); + }); + + it('should change aria-labelledby attribute if property is changed at runtime', () => { + expect(fruitRadioNativeInputs[0].getAttribute('aria-labelledby')).toBe('xyz'); + + testComponent.ariaLabelledby = 'uvw'; + fixture.detectChanges(); + + expect(fruitRadioNativeInputs[0].getAttribute('aria-labelledby')).toBe('uvw'); + }); + + it('should add aria-describedby attribute to the underlying input element if defined', () => { + expect(fruitRadioNativeInputs[0].getAttribute('aria-describedby')).toBe('abc'); + }); + + it('should not add aria-describedby attribute if not defined', () => { + expect(fruitRadioNativeInputs[1].hasAttribute('aria-describedby')).toBeFalsy(); + }); + + it('should change aria-describedby attribute if property is changed at runtime', () => { + expect(fruitRadioNativeInputs[0].getAttribute('aria-describedby')).toBe('abc'); + + testComponent.ariaDescribedby = 'uvw'; + fixture.detectChanges(); + + expect(fruitRadioNativeInputs[0].getAttribute('aria-describedby')).toBe('uvw'); + }); + + it('should focus on underlying input element when focus() is called', () => { + for (let i = 0; i < fruitRadioInstances.length; i++) { + expect(document.activeElement).not.toBe(fruitRadioNativeInputs[i]); + fruitRadioInstances[i].focus(); + fixture.detectChanges(); + + expect(document.activeElement).toBe(fruitRadioNativeInputs[i]); + } + }); + + it('should not add the "name" attribute if it is not passed in', () => { + const radio = fixture.debugElement.nativeElement.querySelector('#nameless input'); + expect(radio.hasAttribute('name')).toBe(false); + }); + }); + + describe('with tabindex', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(FocusableRadioButtonComponent); + fixture.detectChanges(); + }); + + it('should forward focus to native input', () => { + const radioButtonEl = fixture.debugElement.query(By.css('.sbb-radio-button'))!.nativeElement; + const inputEl = fixture.debugElement.query(By.css('input'))!.nativeElement; + + radioButtonEl.focus(); + // Focus events don't always fire in tests, so we need to fake it. + dispatchFakeEvent(radioButtonEl, 'focus'); + fixture.detectChanges(); + + expect(document.activeElement).toBe(inputEl); + }); + + it('should allow specifying an explicit tabindex for a single radio-button', () => { + const radioButtonInput = fixture.debugElement.query(By.css('.sbb-radio-button input'))! + .nativeElement as HTMLInputElement; + + expect(radioButtonInput.tabIndex).toBe( + 0, + 'Expected the tabindex to be set to "0" by default.' + ); + + fixture.componentInstance.tabIndex = 4; + fixture.detectChanges(); + + expect(radioButtonInput.tabIndex).toBe(4, 'Expected the tabindex to be set to "4".'); + }); + + it('should remove the tabindex from the host element', () => { + const predefinedFixture = TestBed.createComponent(RadioButtonWithPredefinedTabindexComponent); + predefinedFixture.detectChanges(); + + const radioButtonEl = predefinedFixture.debugElement.query(By.css('.sbb-radio-button'))! + .nativeElement; + + expect(radioButtonEl.getAttribute('tabindex')).toBe('-1'); + }); + + it('should remove the aria attributes from the host element', () => { + const predefinedFixture = TestBed.createComponent( + RadioButtonWithPredefinedAriaAttributesComponent + ); + predefinedFixture.detectChanges(); + + const radioButtonEl = predefinedFixture.debugElement.query(By.css('.sbb-radio-button'))! + .nativeElement; + + expect(radioButtonEl.hasAttribute('aria-label')).toBe(false); + expect(radioButtonEl.hasAttribute('aria-describedby')).toBe(false); + expect(radioButtonEl.hasAttribute('aria-labelledby')).toBe(false); + }); + }); + + describe('group interspersed with other tags', () => { + let fixture: ComponentFixture; + let groupDebugElement: DebugElement; + let groupInstance: RadioGroupDirective; + let radioDebugElements: DebugElement[]; + let radioInstances: RadioButton[]; + + beforeEach(async(() => { + fixture = TestBed.createComponent(InterleavedRadioGroupComponent); + fixture.detectChanges(); + + groupDebugElement = fixture.debugElement.query(By.directive(RadioGroupDirective))!; + groupInstance = groupDebugElement.injector.get(RadioGroupDirective); + radioDebugElements = fixture.debugElement.queryAll(By.directive(RadioButton)); + radioInstances = radioDebugElements.map(debugEl => debugEl.componentInstance); + })); + + it('should initialize selection of radios based on model value', () => { + expect(groupInstance.selected).toBe(radioInstances[2]); + }); + }); +}); diff --git a/projects/sbb-esta/angular-core/radio-button/src/radio-group.directive.ts b/projects/sbb-esta/angular-core/radio-button/src/radio-group.directive.ts new file mode 100644 index 0000000000..ef8c3c5138 --- /dev/null +++ b/projects/sbb-esta/angular-core/radio-button/src/radio-group.directive.ts @@ -0,0 +1,245 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { + AfterContentInit, + ChangeDetectorRef, + ContentChildren, + Directive, + EventEmitter, + forwardRef, + HostBinding, + Input, + Output, + QueryList +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { RadioButton, RadioChange } from './radio-button'; + +let nextUniqueId = 0; + +@Directive({ + // tslint:disable-next-line: directive-selector + selector: 'sbb-radio-group', + exportAs: 'sbbRadioButtonGroup', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RadioGroupDirective), + multi: true + } + ] +}) +export class RadioGroupDirective implements AfterContentInit, ControlValueAccessor { + /** + * Role of sbb-toggle. + */ + @HostBinding('attr.role') role = 'radiogroup'; + + /** Name of the radio button group. All radio buttons inside this group will use this name. */ + @Input() + get name(): string { + return this._name; + } + set name(value: string) { + this._name = value; + this._updateRadioButtonNames(); + } + + /** + * Value for the radio-group. Should equal the value of the selected radio button if there is + * a corresponding radio button with a matching value. If there is not such a corresponding + * radio button, this value persists to be applied in case a new radio button is added with a + * matching value. + */ + @Input() + get value(): any { + return this._value; + } + set value(newValue: any) { + if (this._value !== newValue) { + // Set this before proceeding to ensure no circular loop occurs with selection. + this._value = newValue; + + this._updateSelectedRadioFromValue(); + this._checkSelectedRadioButton(); + } + } + + /** + * The currently selected radio button. If set to a new radio button, the radio group value + * will be updated to match the new selected button. + */ + @Input() + get selected() { + return this._selected; + } + set selected(selected: RadioButton | null) { + this._selected = selected; + this.value = selected ? selected.value : null; + this._checkSelectedRadioButton(); + } + + /** Whether the radio group is disabled */ + @Input() + get disabled(): boolean { + return this._disabled; + } + set disabled(value) { + this._disabled = coerceBooleanProperty(value); + this._markRadiosForCheck(); + } + + /** Whether the radio group is required */ + @Input() + get required(): boolean { + return this._required; + } + set required(value: boolean) { + this._required = coerceBooleanProperty(value); + this._markRadiosForCheck(); + } + + /** + * Event emitted when the group value changes. + * Change events are only emitted when the value changes due to user interaction with + * a radio button (the same behavior as ``). + */ + @Output() readonly change: EventEmitter = new EventEmitter(); + + /** Child radio buttons. */ + @ContentChildren(forwardRef(() => RadioButton), { descendants: true }) + _radios: QueryList; + + /** Selected value for the radio group. */ + private _value: any = null; + + /** The HTML name attribute applied to radio buttons in this group. */ + private _name = `sbb-radio-group-${nextUniqueId++}`; + + /** The currently selected radio button. Should match value. */ + private _selected: RadioButton | null = null; + + /** Whether the `value` has been set to its initial value. */ + private _isInitialized = false; + + /** Whether the radio group is disabled. */ + private _disabled = false; + + /** Whether the radio group is required. */ + private _required = false; + + /** + * The method to be called in order to update ngModel + * @docs-private + */ + _controlValueAccessorChangeFn: (value: any) => void = () => {}; + + /** + * onTouch function registered via registerOnTouch (ControlValueAccessor). + * @docs-private + */ + onTouched: () => any = () => {}; + + constructor(private _changeDetector: ChangeDetectorRef) {} + + _checkSelectedRadioButton() { + if (this._selected && !this._selected.checked) { + this._selected.checked = true; + } + } + + /** + * Initialize properties once content children are available. + * This allows us to propagate relevant attributes to associated buttons. + */ + ngAfterContentInit() { + // Mark this component as initialized in AfterContentInit because the initial value can + // possibly be set by NgModel on RadioGroup, and it is possible that the OnInit of the + // NgModel occurs *after* the OnInit of the RadioGroup. + this._isInitialized = true; + } + + /** + * Mark this group as being "touched" (for ngModel). Meant to be called by the contained + * radio buttons upon their blur. + */ + _touch() { + if (this.onTouched) { + this.onTouched(); + } + } + + /** Dispatch change event with current selection and group value. */ + _emitChangeEvent(): void { + if (this._isInitialized) { + // tslint:disable-next-line: no-non-null-assertion + this.change.emit(new RadioChange(this._selected!, this._value)); + } + } + + _markRadiosForCheck() { + if (this._radios) { + this._radios.forEach(radio => radio._markForCheck()); + } + } + + /** + * Sets the model value. Implemented as part of ControlValueAccessor. + */ + writeValue(value: any) { + this.value = value; + this._changeDetector.markForCheck(); + } + + /** + * Registers a callback to be triggered when the model value changes. + * Implemented as part of ControlValueAccessor. + * @param fn Callback to be registered. + */ + registerOnChange(fn: (value: any) => void) { + this._controlValueAccessorChangeFn = fn; + } + + /** + * Registers a callback to be triggered when the control is touched. + * Implemented as part of ControlValueAccessor. + * @param fn Callback to be registered. + */ + registerOnTouched(fn: any) { + this.onTouched = fn; + } + + /** + * Sets the disabled state of the control. Implemented as a part of ControlValueAccessor. + * @param isDisabled Whether the control should be disabled. + */ + setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + this._changeDetector.markForCheck(); + } + + private _updateRadioButtonNames(): void { + if (this._radios) { + this._radios.forEach(radio => { + radio.name = this.name; + radio._markForCheck(); + }); + } + } + + /** Updates the `selected` radio button from the internal _value state. */ + private _updateSelectedRadioFromValue(): void { + // If the value already matches the selected radio, do nothing. + const isAlreadySelected = this._selected !== null && this._selected.value === this._value; + + if (this._radios && !isAlreadySelected) { + this._selected = null; + this._radios.forEach(radio => { + radio.checked = this.value === radio.value; + if (radio.checked) { + this._selected = radio; + } + }); + } + } +} diff --git a/projects/sbb-esta/angular-public/radio-button-panel/radio-button-panel.md b/projects/sbb-esta/angular-public/radio-button-panel/radio-button-panel.md index d9cd055afa..7489ecc6dc 100644 --- a/projects/sbb-esta/angular-public/radio-button-panel/radio-button-panel.md +++ b/projects/sbb-esta/angular-public/radio-button-panel/radio-button-panel.md @@ -1,23 +1,18 @@ -The radio button panels are essentially large checkboxes, with more content options. +The radio button panels are essentially large radio buttons, with more content options. ### Simple radio button panel ```html -

Basic example

-
- -
+ ``` ### Radio button panel with a subtitle ```html -

Radio button panel with subtitle

Radio button panel with subtitle and an icon

- + ``` + +### Radio groups + +Radio-button panels should typically be placed inside of an `` unless the DOM structure +would make that impossible (e.g., radio-buttons inside of table cells). The radio-group has a +`value` property that reflects the currently selected radio-button panel inside of the group. + +Individual radio-button panel inside of a radio-group will inherit the `name` of the group. + +### Use with `@angular/forms` + +`` is compatible with `@angular/forms` and supports both `FormsModule` +and `ReactiveFormsModule`. + +#### Template-driven forms + +```html + + Bananas + Apple + Orange + +``` + +#### Reactive Forms + +```html + + Bananas + Apple + Orange + +``` + +### Accessibility + +The `` uses an internal `` to provide an accessible experience. +This internal radio button receives focus and is automatically labelled by the text content of the +`` element. diff --git a/projects/sbb-esta/angular-public/radio-button-panel/src/radio-button-panel.module.ts b/projects/sbb-esta/angular-public/radio-button-panel/src/radio-button-panel.module.ts index 77bf65d1a6..92a1bf6503 100644 --- a/projects/sbb-esta/angular-public/radio-button-panel/src/radio-button-panel.module.ts +++ b/projects/sbb-esta/angular-public/radio-button-panel/src/radio-button-panel.module.ts @@ -1,12 +1,12 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; -import { RadioButtonModule } from '@sbb-esta/angular-public/radio-button'; +import { ɵRadioButtonModule } from '@sbb-esta/angular-core/radio-button'; import { RadioButtonPanelComponent } from './radio-button-panel/radio-button-panel.component'; @NgModule({ - imports: [CommonModule, RadioButtonModule], + imports: [CommonModule, ɵRadioButtonModule], declarations: [RadioButtonPanelComponent], - exports: [RadioButtonPanelComponent] + exports: [RadioButtonPanelComponent, ɵRadioButtonModule] }) export class RadioButtonPanelModule {} diff --git a/projects/sbb-esta/angular-public/radio-button-panel/src/radio-button-panel/radio-button-panel.component.html b/projects/sbb-esta/angular-public/radio-button-panel/src/radio-button-panel/radio-button-panel.component.html index d368a68b5b..c810cf593d 100644 --- a/projects/sbb-esta/angular-public/radio-button-panel/src/radio-button-panel/radio-button-panel.component.html +++ b/projects/sbb-esta/angular-public/radio-button-panel/src/radio-button-panel/radio-button-panel.component.html @@ -40,7 +40,7 @@
- +
diff --git a/projects/sbb-esta/angular-public/radio-button-panel/src/radio-button-panel/radio-button-panel.component.spec.ts b/projects/sbb-esta/angular-public/radio-button-panel/src/radio-button-panel/radio-button-panel.component.spec.ts index 8e1b146c71..fa298544f2 100644 --- a/projects/sbb-esta/angular-public/radio-button-panel/src/radio-button-panel/radio-button-panel.component.spec.ts +++ b/projects/sbb-esta/angular-public/radio-button-panel/src/radio-button-panel/radio-button-panel.component.spec.ts @@ -58,7 +58,7 @@ describe('RadioButtonPanelComponent', () => { }); it('should have a generated id if not provided', () => { - expect(component.inputId).toMatch(/sbb-radio-button-panel-\d+/); + expect(component.inputId).toMatch(/sbb-radio-button-\d+-input/); }); }); diff --git a/projects/sbb-esta/angular-public/radio-button-panel/src/radio-button-panel/radio-button-panel.component.ts b/projects/sbb-esta/angular-public/radio-button-panel/src/radio-button-panel/radio-button-panel.component.ts index f36e422af4..f5c916159b 100644 --- a/projects/sbb-esta/angular-public/radio-button-panel/src/radio-button-panel/radio-button-panel.component.ts +++ b/projects/sbb-esta/angular-public/radio-button-panel/src/radio-button-panel/radio-button-panel.component.ts @@ -7,23 +7,26 @@ import { ElementRef, forwardRef, Input, + Optional, ViewEncapsulation } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { RadioButton, RadioGroupDirective } from '@sbb-esta/angular-core/radio-button'; import { RadioButtonComponent } from '@sbb-esta/angular-public/radio-button'; -let counter = 0; - +// TODO: Inherit directly from RadioButton @Component({ selector: 'sbb-radio-button-panel', templateUrl: './radio-button-panel.component.html', styleUrls: ['./radio-button-panel.component.scss'], + inputs: ['tabIndex'], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RadioButtonPanelComponent), multi: true - } + }, + { provide: RadioButton, useExisting: RadioButtonPanelComponent } ], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None @@ -45,13 +48,12 @@ export class RadioButtonPanelComponent extends RadioButtonComponent { } constructor( + @Optional() radioGroup: RadioGroupDirective, changeDetector: ChangeDetectorRef, elementRef: ElementRef, focusMonitor: FocusMonitor, radioDispatcher: UniqueSelectionDispatcher ) { - super(changeDetector, elementRef, focusMonitor, radioDispatcher); - this.id = `sbb-radio-button-panel-${counter++}`; - this.inputId = `${this.id}-input`; + super(radioGroup, changeDetector, elementRef, focusMonitor, radioDispatcher); } } diff --git a/projects/sbb-esta/angular-public/radio-button/radio-button.md b/projects/sbb-esta/angular-public/radio-button/radio-button.md index 9fef90b40e..014893646e 100644 --- a/projects/sbb-esta/angular-public/radio-button/radio-button.md +++ b/projects/sbb-esta/angular-public/radio-button/radio-button.md @@ -1,7 +1,6 @@ -You can use the radioButton component as seen below +`` provides the same functionality as a native `` ```html -

Single Radio Button

` element. +### Radio groups + +Radio-buttons should typically be placed inside of an `` unless the DOM structure +would make that impossible (e.g., radio-buttons inside of table cells). The radio-group has a +`value` property that reflects the currently selected radio-button inside of the group. + +Individual radio-buttons inside of a radio-group will inherit the `name` of the group. + ### Use with `@angular/forms` `` is compatible with `@angular/forms` and supports both `FormsModule` and `ReactiveFormsModule`. +#### Template-driven forms + +```html + + Bananas + Apple + Orange + +``` + +#### Reactive Forms + +```html + + Bananas + Apple + Orange + +``` + ### Accessibility The `` uses an internal `` to provide an accessible experience. diff --git a/projects/sbb-esta/angular-public/radio-button/src/public_api.ts b/projects/sbb-esta/angular-public/radio-button/src/public_api.ts index b376c18ea0..cef3736e00 100644 --- a/projects/sbb-esta/angular-public/radio-button/src/public_api.ts +++ b/projects/sbb-esta/angular-public/radio-button/src/public_api.ts @@ -2,3 +2,4 @@ export * from './radio-button.module'; export * from './radio-button/radio-button.component'; export * from './radio-button/radio-button-registry.service'; export * from './radio-button/radio-button.model'; +export { RadioChange, RadioGroupDirective } from '@sbb-esta/angular-core/radio-button'; diff --git a/projects/sbb-esta/angular-public/radio-button/src/radio-button.module.ts b/projects/sbb-esta/angular-public/radio-button/src/radio-button.module.ts index c9161fac02..074bc92a8e 100644 --- a/projects/sbb-esta/angular-public/radio-button/src/radio-button.module.ts +++ b/projects/sbb-esta/angular-public/radio-button/src/radio-button.module.ts @@ -1,11 +1,12 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; +import { ɵRadioButtonModule } from '@sbb-esta/angular-core/radio-button'; import { RadioButtonComponent } from './radio-button/radio-button.component'; @NgModule({ - imports: [CommonModule], - exports: [RadioButtonComponent], + imports: [CommonModule, ɵRadioButtonModule], + exports: [RadioButtonComponent, ɵRadioButtonModule], declarations: [RadioButtonComponent] }) export class RadioButtonModule {} diff --git a/projects/sbb-esta/angular-public/radio-button/src/radio-button/radio-button.component.ts b/projects/sbb-esta/angular-public/radio-button/src/radio-button/radio-button.component.ts index b7593b98cb..0c558d2097 100644 --- a/projects/sbb-esta/angular-public/radio-button/src/radio-button/radio-button.component.ts +++ b/projects/sbb-esta/angular-public/radio-button/src/radio-button/radio-button.component.ts @@ -1,308 +1,39 @@ import { FocusMonitor } from '@angular/cdk/a11y'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { UniqueSelectionDispatcher } from '@angular/cdk/collections'; import { - AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, - EventEmitter, forwardRef, - HostBinding, - Input, - OnDestroy, - OnInit, - Output, - ViewChild + Optional } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl } from '@angular/forms'; -import { HasTabIndexCtor, mixinTabIndex } from '@sbb-esta/angular-core/common-behaviors'; - -import { RadioButtonInterface } from './radio-button.interface'; - -/** Change event object emitted by RadioButtonComponent. */ -export class RadioChange { - constructor( - /** The RadioButtonComponent that emits the change event. */ - public source: RadioButtonComponent, - /** The value of the RadioButtonComponent. */ - public value: any - ) {} -} - -class RadioButtonBase { - // Since the disabled property is manually defined for the MatRadioButton and isn't set up in - // the mixin base class. To be able to use the tabindex mixin, a disabled property must be - // defined to properly work. - disabled: boolean; -} - -// tslint:disable-next-line: naming-convention -const _RadioButtonMixinBase: HasTabIndexCtor & typeof RadioButtonBase = mixinTabIndex( - RadioButtonBase -); - -let counter = 0; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { RadioButton, RadioGroupDirective } from '@sbb-esta/angular-core/radio-button'; @Component({ selector: 'sbb-radio-button', templateUrl: './radio-button.component.html', styleUrls: ['./radio-button.component.scss'], + inputs: ['tabIndex'], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RadioButtonComponent), multi: true - } + }, + { provide: RadioButton, useExisting: RadioButtonComponent } ], changeDetection: ChangeDetectionStrategy.OnPush }) -export class RadioButtonComponent extends _RadioButtonMixinBase - implements ControlValueAccessor, OnInit, AfterViewInit, OnDestroy, RadioButtonInterface { - @Input() @HostBinding() id: string; - /** Radio input identifier. */ - @Input() inputId: string; - /** Whether the radio button is disabled. */ - @Input() - get disabled(): boolean { - return this._disabled; - } - set disabled(value: boolean) { - const newDisabledState = coerceBooleanProperty(value); - if (this._disabled !== newDisabledState) { - this._disabled = newDisabledState; - this._changeDetector.markForCheck(); - } - } - - /** Whether the radio button is required. */ - @Input() - get required(): boolean { - return this._required; - } - set required(value: boolean) { - this._required = coerceBooleanProperty(value); - } - - /** - * The checked state of the radio button - */ - @Input() - get checked(): boolean { - return this._checked; - } - set checked(value: boolean) { - const newCheckedState = coerceBooleanProperty(value); - - if (this._checked !== newCheckedState) { - this._checked = newCheckedState; - if (newCheckedState) { - // Notify all radio buttons with the same name to un-check. - this._radioDispatcher.notify(this.id, this.name); - this.onChange(this.value); - } - - this._changeDetector.markForCheck(); - } - } - - /** The value of this radio button. */ - @Input() - get value(): any { - return this._value; - } - set value(value: any) { - if (this._value !== value) { - this._value = value; - } - } - - /** Indicates radio button name in formControl. */ - @Input() formControlName: string; - /** Analog to HTML 'name' attribute used to group radios for unique selection. */ - @Input() name: string; - /** @docs-private */ - // tslint:disable-next-line:no-input-rename - @Input('aria-label') ariaLabel: string; - /** @docs-private */ - // tslint:disable-next-line:no-input-rename - @Input('aria-labelledby') ariaLabelledby: string; - /** @docs-private */ - // tslint:disable-next-line:no-input-rename - @Input('aria-describedby') ariaDescribedby: string; - /** - * Needs to be -1 so the `focus` event still fires. - * @docs-private - */ - @HostBinding('attr.tabindex') tabIndexAttr = -1; - /** - * @docs-private - * @deprecated - */ - _control: NgControl; - - /** - * Event emitted when the checked state of this radio button changes. - * Change events are only emitted when the value changes due to user interaction with - * the radio button (the same behavior as ``). - */ - @Output() readonly change: EventEmitter = new EventEmitter(); - - /** The native `` element */ - @ViewChild('input', { static: false }) _inputElement: ElementRef; - - private _disabled = false; - private _required = false; - private _checked = false; - private _value: any = null; - - /** Unregister function for _radioDispatcher */ - private _removeUniqueSelectionListener: () => void = () => {}; - - /** - * Class property that represents a change on the radio button - */ - onChange = (_: any) => {}; - /** - * Class property that represents a touch on the radio button - */ - onTouched = () => {}; - +export class RadioButtonComponent extends RadioButton { constructor( - protected readonly _changeDetector: ChangeDetectorRef, - private _elementRef: ElementRef, - private _focusMonitor: FocusMonitor, - private _radioDispatcher: UniqueSelectionDispatcher + @Optional() radioGroup: RadioGroupDirective, + changeDetector: ChangeDetectorRef, + elementRef: ElementRef, + focusMonitor: FocusMonitor, + radioDispatcher: UniqueSelectionDispatcher ) { - super(); - this.id = `sbb-radio-button-${counter++}`; - this.inputId = `${this.id}-input`; - - this._removeUniqueSelectionListener = _radioDispatcher.listen((id: string, name: string) => { - if (id !== this.id && name === this.name) { - this.checked = false; - } - }); - } - - /** Focuses the radio button. */ - focus(options?: FocusOptions): void { - this._focusMonitor.focusVia(this._inputElement, 'keyboard', options); - } - - ngOnInit(): void { - this._checkName(); - } - - ngAfterViewInit() { - this._focusMonitor.monitor(this._elementRef, true).subscribe(focusOrigin => { - if (!focusOrigin) { - this.onTouched(); - } - }); - } - - ngOnDestroy(): void { - this._focusMonitor.stopMonitoring(this._elementRef); - this._removeUniqueSelectionListener(); - } - - writeValue(value: any): void { - this.checked = this.value === value; - } - - /** - * Registers the on change callback - */ - registerOnChange(fn: any): void { - this.onChange = fn; - } - - /** - * Registers the on touched callback - */ - registerOnTouched(fn: any): void { - this.onTouched = fn; - } - - /** - * Manage the event click on the radio button - * @deprecated Use .checked - */ - click($event: Event) { - this.onChange(this.value); - this.onTouched(); - this.writeValue(this.value); - this.checked = true; - } - - /** - * Sets the radio button status to disabled - */ - setDisabledState(disabled: boolean) { - this.disabled = disabled; - this._changeDetector.markForCheck(); - } - - /** - * Unchecks the radio button - * @deprecated Use .checked - */ - uncheck() { - this.checked = false; - } - - /** @docs-private */ - _onInputClick(event: Event) { - // We have to stop propagation for click events on the visual hidden input element. - // By default, when a user clicks on a label element, a generated click event will be - // dispatched on the associated input element. Since we are using a label element as our - // root container, the click event on the `radio-button` will be executed twice. - // The real click event will bubble up, and the generated click event also tries to bubble up. - // This will lead to multiple click events. - // Preventing bubbling for the second event will solve that issue. - event.stopPropagation(); - } - - /** - * Triggered when the radio button received a click or the input recognized any change. - * Clicking on a label element, will trigger a change event on the associated input. - * @docs-private - */ - _onInputChange(event: Event) { - // We always have to stop propagation on the change event. - // Otherwise the change event, from the input element, will bubble up and - // emit its event object to the `change` output. - event.stopPropagation(); - - this.checked = true; - this._emitChangeEvent(); - } - - /** - * Verify that radio button name matches with radio button form control name - */ - private _checkName(): void { - if (this.name && this.formControlName && this.name !== this.formControlName) { - this._throwNameError(); - } else if (!this.name && this.formControlName) { - this.name = this.formControlName; - } - } - - /** - * Throws an exception if the radio button name doesn't match with the radio button form control name - */ - private _throwNameError(): void { - throw new Error(` - If you define both a name and a formControlName attribute on your radio button, their values - must match. Ex: - `); - } - - /** Dispatch change event with current value. */ - private _emitChangeEvent(): void { - this.change.emit(new RadioChange(this, this.value)); + super(radioGroup, changeDetector, elementRef, focusMonitor, radioDispatcher); } } diff --git a/projects/sbb-esta/angular-public/radio-button/src/radio-button/radio-button.interface.ts b/projects/sbb-esta/angular-public/radio-button/src/radio-button/radio-button.interface.ts deleted file mode 100644 index 2a70519a49..0000000000 --- a/projects/sbb-esta/angular-public/radio-button/src/radio-button/radio-button.interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NgControl } from '@angular/forms'; - -export interface RadioButtonInterface { - checked: boolean; - name: string; - - /** @docs-private */ - _control: NgControl; -} diff --git a/projects/sbb-esta/angular-public/toggle/src/toggle-option/toggle-option.component.ts b/projects/sbb-esta/angular-public/toggle/src/toggle-option/toggle-option.component.ts index 6d3b8cb3ff..60c0ef6e2e 100644 --- a/projects/sbb-esta/angular-public/toggle/src/toggle-option/toggle-option.component.ts +++ b/projects/sbb-esta/angular-public/toggle/src/toggle-option/toggle-option.component.ts @@ -12,41 +12,41 @@ import { HostBinding, Inject, Input, + Optional, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { IconDirective } from '@sbb-esta/angular-core/icon-directive'; +import { RadioButton, RadioGroupDirective } from '@sbb-esta/angular-core/radio-button'; import { RadioButtonComponent } from '@sbb-esta/angular-public/radio-button'; import { Subject } from 'rxjs'; -import { SBB_TOGGLE_COMPONENT, ToggleBase } from '../toggle.base'; - -let counter = 0; +import { ToggleBase } from '../toggle.base'; +// TODO: Inherit directly from RadioButton @Component({ selector: 'sbb-toggle-option', templateUrl: './toggle-option.component.html', styleUrls: ['./toggle-option.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, + inputs: ['tabIndex'], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ToggleOptionComponent), multi: true - } - ] + }, + { provide: RadioButton, useExisting: ToggleOptionComponent } + ], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None }) export class ToggleOptionComponent extends RadioButtonComponent implements ToggleBase, AfterViewInit { - /** Identifier of sbb-toggle label. */ - readonly labelId: string; - /** Identifier of sbb-toggle content. */ - readonly contentId: string; /** @docs-private */ @HostBinding('class.sbb-toggle-option') toggleOptionClass = true; + /** Label of a sbb-toggle-option. */ @Input() label: string; /** Information text in a sbb-toggle-option. */ @@ -58,31 +58,14 @@ export class ToggleOptionComponent extends RadioButtonComponent return this.checked; } - /** - * Name of a toggle parent of options. - * Can only be set on sbb-toggle. - */ - @Input() - get name() { - return `${this._parent.inputId}-option`; - } - set name(value) { - throw new Error(`You're trying to assign the name "${value}" directly on sbb-toggle-option. - Please bind it to its parent component.`); + /** Identifier of sbb-toggle label. */ + get labelId() { + return `${this.inputId}-label`; } - /** - * @docs-private - * @deprecated - * It should not be used here but it should be set on sbb-toggle. - */ - @Input() - get formControlName() { - return null; - } - set formControlName(value) { - throw new Error(`You're trying to assign the formControlName "${value}" directly on sbb-toggle-option. - Please bind it to its parent component.`); + /** Identifier of sbb-toggle content. */ + get contentId() { + return `${this.inputId}-content`; } /** @@ -132,22 +115,20 @@ export class ToggleOptionComponent extends RadioButtonComponent private _document: Document; constructor( - @Inject(SBB_TOGGLE_COMPONENT) private _parent: ToggleBase, + @Optional() radioGroup: RadioGroupDirective, changeDetector: ChangeDetectorRef, elementRef: ElementRef, focusMonitor: FocusMonitor, radioDispatcher: UniqueSelectionDispatcher, @Inject(DOCUMENT) document: any ) { - super(changeDetector, elementRef, focusMonitor, radioDispatcher); + super(radioGroup, changeDetector, elementRef, focusMonitor, radioDispatcher); this._document = document; - this.id = `sbb-toggle-option-${counter++}`; - this.inputId = `${this.id}-input`; - this.labelId = `${this.inputId}-label`; - this.contentId = `${this.inputId}-content`; + this.change.subscribe((e: any) => this.valueChange$.next(e)); } ngAfterViewInit() { + super.ngAfterViewInit(); const nodeList = this.contentContainer.nativeElement.childNodes; for (let k = 0; k < nodeList.length; k++) { const node = nodeList.item(k); @@ -168,9 +149,6 @@ export class ToggleOptionComponent extends RadioButtonComponent * @deprecated Use .checked instead. */ setToggleChecked(checked: boolean) { - this.onChange(checked); - this.onTouched(); - this.writeValue(checked); this.checked = checked; } } diff --git a/projects/sbb-esta/angular-public/toggle/src/toggle.base.ts b/projects/sbb-esta/angular-public/toggle/src/toggle.base.ts index e29d68fd93..2c22e1d7d7 100644 --- a/projects/sbb-esta/angular-public/toggle/src/toggle.base.ts +++ b/projects/sbb-esta/angular-public/toggle/src/toggle.base.ts @@ -3,6 +3,7 @@ import { InjectionToken } from '@angular/core'; /** * Describes a parent component that manages a list of toggle options. * Contains properties that the options can inherit. + * @deprecated * @docs-private */ export interface ToggleBase { @@ -13,5 +14,6 @@ export interface ToggleBase { /** * Injection token used to provide the parent component to toggle options. + * @deprecated */ export const SBB_TOGGLE_COMPONENT = new InjectionToken('SBB_TOGGLE_COMPONENT'); diff --git a/projects/sbb-esta/angular-public/toggle/src/toggle.module.ts b/projects/sbb-esta/angular-public/toggle/src/toggle.module.ts index 8bfdb41b00..98f2b5226a 100644 --- a/projects/sbb-esta/angular-public/toggle/src/toggle.module.ts +++ b/projects/sbb-esta/angular-public/toggle/src/toggle.module.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { IconDirectiveModule } from '@sbb-esta/angular-core/icon-directive'; +import { ɵRadioButtonModule } from '@sbb-esta/angular-core/radio-button'; import { ToggleOptionIconDirective } from './toggle-option/toggle-option-icon.directive'; import { ToggleOptionComponent } from './toggle-option/toggle-option.component'; @@ -8,7 +9,13 @@ import { ToggleComponent } from './toggle/toggle.component'; @NgModule({ declarations: [ToggleComponent, ToggleOptionComponent, ToggleOptionIconDirective], - imports: [CommonModule, IconDirectiveModule], - exports: [ToggleComponent, ToggleOptionComponent, ToggleOptionIconDirective, IconDirectiveModule] + imports: [CommonModule, IconDirectiveModule, ɵRadioButtonModule], + exports: [ + ToggleComponent, + ToggleOptionComponent, + ToggleOptionIconDirective, + IconDirectiveModule, + ɵRadioButtonModule + ] }) export class ToggleModule {} diff --git a/projects/sbb-esta/angular-public/toggle/src/toggle/toggle.component.ts b/projects/sbb-esta/angular-public/toggle/src/toggle/toggle.component.ts index d25e359c9a..85ebb6e3a8 100644 --- a/projects/sbb-esta/angular-public/toggle/src/toggle/toggle.component.ts +++ b/projects/sbb-esta/angular-public/toggle/src/toggle/toggle.component.ts @@ -1,6 +1,7 @@ import { AfterContentInit, ChangeDetectionStrategy, + ChangeDetectorRef, Component, ContentChildren, EventEmitter, @@ -15,15 +16,13 @@ import { ViewEncapsulation } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { RadioButton } from '@sbb-esta/angular-public/radio-button'; -import { merge, Observable, Subscription } from 'rxjs'; +import { RadioChange, RadioGroupDirective } from '@sbb-esta/angular-core/radio-button'; import { first } from 'rxjs/operators'; import { ToggleOptionComponent } from '../toggle-option/toggle-option.component'; -import { SBB_TOGGLE_COMPONENT, ToggleBase } from '../toggle.base'; - -let counter = 0; +import { SBB_TOGGLE_COMPONENT } from '../toggle.base'; +// TODO: Change this to a directive @Component({ selector: 'sbb-toggle', templateUrl: './toggle.component.html', @@ -37,145 +36,81 @@ let counter = 0; { provide: SBB_TOGGLE_COMPONENT, useExisting: ToggleComponent + }, + { + provide: RadioGroupDirective, + useExisting: ToggleComponent } ], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None }) -export class ToggleComponent extends RadioButton - implements ToggleBase, ControlValueAccessor, OnInit, OnDestroy, AfterContentInit { - /** - * Radio button panel identifier - */ - @Input() - @HostBinding('id') - inputId = `sbb-toggle-${counter++}`; +export class ToggleComponent extends RadioGroupDirective + implements ControlValueAccessor, OnInit, OnDestroy, AfterContentInit { + /** @docs-private */ + @HostBinding('class.sbb-toggle') + toggleClass = true; /** * Indicates radio button name in formControl + * @deprecated */ @Input() formControlName: string; - /** - * Attribute name of sbb-toggle. - */ - @Input() - name: string; - - /** - * Css class on sbb-toggle. - */ - @HostBinding('class.sbb-toggle') - toggleClass = true; - - /** - * Role of sbb-toggle. - */ - @HostBinding('attr.role') - role = 'radiogroup'; - /** * Event generated on a change of sbb-toggle. + * @deprecated Use change event. */ @Output() - toggleChange = new EventEmitter(); + toggleChange: EventEmitter = this.change; /** * Reference to sbb-toggle-options. + * @deprecated */ @ContentChildren(forwardRef(() => ToggleOptionComponent)) toggleOptions: QueryList; - private _toggleValueChangesSubscription = Subscription.EMPTY; - private _toggleValueChanges$: Observable; - /** - * Class property that represents a change on the radio button + * @deprecated + * @docs-private */ - onChange = (_: any) => {}; - - /** - * Class property that represents a touch on the radio button - */ - onTouched = () => {}; - - constructor(private _zone: NgZone) { - super(); + get onChange() { + return this._controlValueAccessorChangeFn; } - ngOnInit() { - this._checkName(); + constructor(private _zone: NgZone, changeDetectorRef: ChangeDetectorRef) { + super(changeDetectorRef); } + // TODO: Remove + ngOnInit() {} + ngAfterContentInit() { + super.ngAfterContentInit(); this._zone.onStable.pipe(first()).subscribe(() => this._zone.run(() => { this._checkNumOfOptions(); - // Before assigning the first tab to defaultOption, I check whether a different value has been specified - const defaultOption = - this.toggleOptions.toArray().find(toggle => toggle.value === this.value) || - this.toggleOptions.toArray()[0]; - defaultOption.setToggleChecked(true); + if (this._radios.toArray().every(r => this.value !== r.value)) { + this.value = this._radios.first.value; + } }) ); - - this._toggleValueChanges$ = merge(...this.toggleOptions.map(toggle => toggle.valueChange$)); - this._toggleValueChangesSubscription = this._toggleValueChanges$.subscribe(value => { - this.onChange(value); - this.onTouched(); - this.writeValue(value); - this.toggleChange.emit(value); - }); - } - - ngOnDestroy() { - this._toggleValueChangesSubscription.unsubscribe(); - } - - writeValue(value: any): void { - this.value = value; - } - - registerOnChange(fn: any): void { - this.onChange = fn; } - registerOnTouched(fn: any): void { - this.onTouched = fn; - } + // TODO: Remove + ngOnDestroy() {} /** @deprecated Use .checked instead */ uncheck() {} - private _checkName(): void { - if (this.name && this.formControlName && this.name !== this.formControlName) { - this._throwNameError(); - } else if (!this.name && this.formControlName) { - this.name = this.formControlName; - } - } - - /** - * Throws an exception if the Toggle name doesn't match with the Toggle form control name - */ - private _throwNameError(): void { - throw new Error(` - If you define both a name and a formControlName attribute on your Toggle, their values - must match. Ex: - `); - } - private _checkNumOfOptions(): void { - if (this.toggleOptions.length !== 2) { - this._throwNotTwoOptionsError(); + if (this._radios.length !== 2) { + throw new Error( + `You must set two sbb-toggle-option into the sbb-toggle component. ` + + `Currently there are ${this._radios.length} options.` + ); } } - - private _throwNotTwoOptionsError(): void { - throw new Error( - `You must set two sbb-toggle-option into the sbb-toggle component. ` + - `You set ${this.toggleOptions.length} options.` - ); - } } diff --git a/projects/sbb-esta/angular-public/toggle/toggle.md b/projects/sbb-esta/angular-public/toggle/toggle.md index 3051474fc3..7eefa9532e 100644 --- a/projects/sbb-esta/angular-public/toggle/toggle.md +++ b/projects/sbb-esta/angular-public/toggle/toggle.md @@ -1,89 +1,94 @@ -You can use the toggle component as seen below +The `` component offers the user a choice of exactly two options. ```html - + ``` -### What does the module do? +### Info text -It offers the user a choice of exactly two options. +A `` can have an optional info text, which will be shown below the label. -### Characteristics - -The toggle button has two states: - -- First (first option chosen) -- Second (second option selected) +```html + + + + +``` -By default, the first option is always preselected. +### Icon -First simple example +A `` can have an optional icon, which will be shown on the left side of +the label. ```html - - - + + + + + + + ``` -Toggle button is shown in three modes: +### Option content -- with icon +A `` can have optional content, which will be shown below the option +if the option is currently selected. This can be any kind of html content. ```html -

Toggle buttons used as Reactive forms

- - - - - - - - - + + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut + labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco + laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in + voluptate velit esse cillum dolore eu fugiat nulla pariatur. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut + labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco + laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in + voluptate velit esse cillum dolore eu fugiat nulla pariatur. +

+
+ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut + labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco + laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in + voluptate velit esse cillum dolore eu fugiat nulla pariatur. + +
``` -- with info text +### Use with `@angular/forms` + +`` is compatible with `@angular/forms` and supports both `FormsModule` +and `ReactiveFormsModule`. + +#### Template-driven forms ```html -

Toggle buttons used without forms

- - - - - + + + ``` -- with additional input context +#### Reactive Forms ```html -

Toggle buttons simple example with info content

- - - - - - - - - - Select date - - - - -

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut - labore et dolore magna aliqua. -

-
+ + + ``` + +### Accessibility + +The `` uses an internal `` to provide an accessible experience. +This internal radio button receives focus and is automatically labelled by the panel of the +`` element. diff --git a/schematics/public2business/index.js b/schematics/public2business/index.js index c4c8614e67..d63309adf8 100644 --- a/schematics/public2business/index.js +++ b/schematics/public2business/index.js @@ -79,7 +79,7 @@ function adaptFile(entry) { return `${autogenerated}$sbbBusiness: true;\n\n${content}`; } else if (entry.path.endsWith('.ts')) { - return `${autogenerated}${content}`.replace(/@sbb-esta\/angular-public/g, '@sbb-esta/angular-business'); + return `${autogenerated}/* tslint:disable */\n${content}`.replace(/@sbb-esta\/angular-public/g, '@sbb-esta/angular-business'); } else { return `${autogenerated}${content}`; diff --git a/schematics/public2business/index.ts b/schematics/public2business/index.ts index e8bda24bfd..7b1081bb5f 100644 --- a/schematics/public2business/index.ts +++ b/schematics/public2business/index.ts @@ -51,7 +51,7 @@ function adaptFile(entry: Readonly) { } else if (entry.path.endsWith('.scss')) { return `${autogenerated}$sbbBusiness: true;\n\n${content}`; } else if (entry.path.endsWith('.ts')) { - return `${autogenerated}${content}`.replace( + return `${autogenerated}/* tslint:disable */\n${content}`.replace( /@sbb-esta\/angular-public/g, '@sbb-esta/angular-business' ); diff --git a/tslint.json b/tslint.json index be5df74fbb..c077b8225d 100644 --- a/tslint.json +++ b/tslint.json @@ -2,7 +2,6 @@ "extends": ["tslint-plugin-prettier", "tslint-config-prettier"], "rulesDirectory": ["node_modules/codelyzer", "node_modules/tslint-consistent-codestyle/rules"], "rules": { - "arrow-parens": [true, "ban-single-arg-parens"], "arrow-return-shorthand": true, "callable-types": true, "class-name": true, @@ -11,11 +10,8 @@ "deprecation": false, "forin": true, "import-blacklist": [true, "rxjs/Rx"], - "import-spacing": true, - "indent": [true, "spaces"], "interface-over-type-literal": true, "label-position": true, - "linebreak-style": [true, "LF"], "member-access": false, "member-ordering": [ true, @@ -121,7 +117,6 @@ "no-arg": true, "no-bitwise": true, "no-collapsible-if": true, - "no-consecutive-blank-lines": true, "no-console": [true, "debug", "info", "time", "timeEnd", "trace"], "no-construct": true, "no-debugger": true, @@ -137,14 +132,12 @@ "no-string-literal": false, "no-string-throw": true, "no-switch-case-fall-through": true, - "no-trailing-whitespace": true, "no-unnecessary-initializer": true, "no-unused-expression": true, "no-use-before-declare": true, "no-var-before-return": true, "no-var-keyword": true, "object-literal-sort-keys": false, - "one-line": [true, "check-open-brace", "check-catch", "check-else", "check-whitespace"], "ordered-imports": [ true, { @@ -154,9 +147,7 @@ "parameter-properties": [true, "leading"], "prefer-const": true, "prettier": true, - "quotemark": [true, "single"], "radix": true, - "semicolon": [true, "always", "ignore-bound-class-methods"], "triple-equals": [true, "allow-null-check"], "typedef-whitespace": [ false, @@ -178,9 +169,7 @@ "check-separator", "check-type" ], - "import-destructuring-spacing": true, "no-output-on-prefix": true, - "no-inputs-metadata-property": true, "no-outputs-metadata-property": true, "no-host-metadata-property": true, "no-input-rename": true,