diff --git a/projects/ng-tw/src/modules/option/option.component.html b/projects/ng-tw/src/modules/option/option.component.html index 504a8b6..21f5976 100644 --- a/projects/ng-tw/src/modules/option/option.component.html +++ b/projects/ng-tw/src/modules/option/option.component.html @@ -1,30 +1,23 @@ -
-
- -
+
+ +
- + + -
\ No newline at end of file + + + \ No newline at end of file diff --git a/projects/ng-tw/src/modules/option/option.component.ts b/projects/ng-tw/src/modules/option/option.component.ts index dff8824..6abee6f 100644 --- a/projects/ng-tw/src/modules/option/option.component.ts +++ b/projects/ng-tw/src/modules/option/option.component.ts @@ -1,6 +1,9 @@ import { ENTER, hasModifierKey, SPACE } from '@angular/cdk/keycodes'; import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; import * as _ from 'lodash'; +import { difference } from 'lodash'; +import { TwSelectConfig } from '../select/select-config.interface'; +import { TwSelectConfigService } from '../select/select-config.service'; /** * Option IDs need to be unique across components, so this counter exists outside of @@ -25,26 +28,35 @@ export class OptionSelectionChange { templateUrl: './option.component.html', styleUrls: ['./option.component.css'], host: { - '(click)': 'select(true)', '[attr.id]': 'id', + '[attr.role]': 'option', + '[class]': 'getClasses()', + '(click)': 'select(true)', }, }) export class OptionComponent implements OnInit { + public selected: boolean = false; + public active: boolean = false; + + private _config: TwSelectConfig['option'] = this.selectConfig.config.option; + + @Input() public class: string = ''; + @Input() public ignore: string = ''; + @Input() public activeClass: string = this._config.activeClass; + @Input() public selectedClass: string = this._config.selectedClass; + @Input() public indicatorClass: string = this._config.indicatorClass; @Input() public value: any; @Input() public disabled: boolean = false; @Input() public id: string = `tw-option-${_uniqueIdCounter++}`; @Input() public useSelectedIndicator: boolean = true; - @Input() public selectedIndicatorSide: 'left' | 'right' = 'right'; + @Input() public indicator: 'left' | 'right' | null = 'right'; @Output() readonly onSelectionChange = new EventEmitter>(); @ViewChild('content') public contentElement!: ElementRef; - public selected: boolean = false; - public active: boolean = false; - - constructor(private readonly element: ElementRef) {} + constructor(private readonly element: ElementRef, private readonly selectConfig: TwSelectConfigService) {} ngOnInit(): void {} @@ -124,4 +136,92 @@ export class OptionComponent implements OnInit { this.scrollIntoView(); }); } + + getClasses() { + // + // Hold classes + let classes: string[] = []; + + // + // Set global config and classes + const config: any = this._config; + const globalClasses: string[] = config.class ? config.class.split(' ').filter((item: string) => item) : []; + + // + // Get @input classes if available + const inputClasses: string[] = this.class?.split(' ').filter((item: string) => item) || []; + const inputIgnoreClasses: string[] = this?.ignore ? this.ignore.split(' ').filter((item: string) => item) : []; + + // + // Add global classes + classes = [...globalClasses]; + + // + // Filter global classes using global and @input ignore + classes = difference(classes, inputClasses, inputIgnoreClasses); + + // + // Get active and selected classes + const activeClasses: string[] = this.activeClass ? this.activeClass.split(' ').filter((item: string) => item) : []; + const selectedClasses: string[] = this.selectedClass ? this.selectedClass.split(' ').filter((item: string) => item) : []; + + // + // Apply selected/active + if (this.active === true) { + classes = [...classes, ...activeClasses]; + } + + if (this.selected === true) { + classes = [...classes, ...selectedClasses]; + } + + // + // Indicator classes + if (this.indicator === 'left') { + classes = [...classes, ...['pl-9', 'pr-3']]; + } else if (this.indicator === 'right') { + classes = [...classes, ...['pl-3', 'pr-9']]; + } + + return classes?.length ? classes.join(' ') : ''; + } + + getIndicatorClasses() { + // + // Validate indicator + if (!this.indicator) return ''; + + // + // Hold classes + let classes: string[] = []; + + // + // Set global config and classes + const config: any = this._config; + const globalClasses: string[] = config.indicatorClass ? config.indicatorClass.split(' ').filter((item: string) => item) : []; + const globalMandatoryClasses: string[] = config.indicatorMandatoryClass + ? config.indicatorMandatoryClass.split(' ').filter((item: string) => item) + : []; + const inputClasses: string[] = this.indicatorClass ? this.indicatorClass.split(' ').filter((item: string) => item) : []; + + // + // Add global classes + classes = [...globalClasses, ...globalMandatoryClasses, ...inputClasses]; + + // + // Left/right + if (this.indicator === 'left') { + classes = [...classes, ...['left-0']]; + } else if (this.indicator === 'right') { + classes = [...classes, ...['right-0']]; + } + + // + // Selected + if (this.selected === true) { + classes = [...classes, ...['flex']]; + } + + return classes?.length ? classes.join(' ') : ''; + } } diff --git a/projects/ng-tw/src/modules/select/select-config.interface.ts b/projects/ng-tw/src/modules/select/select-config.interface.ts new file mode 100644 index 0000000..42f0e29 --- /dev/null +++ b/projects/ng-tw/src/modules/select/select-config.interface.ts @@ -0,0 +1,21 @@ +export interface TwSelectConfig { + select: { + host: { + class: string; + ignore: string; + }; + panel: { + class: string; + mandatoryClass: string; + ignore: string; + }; + }; + option: { + class: string; + ignore: string; + indicatorClass: string; + indicatorMandatoryClass: string; + activeClass: string; + selectedClass: string; + }; +} diff --git a/projects/ng-tw/src/modules/select/select-config.service.ts b/projects/ng-tw/src/modules/select/select-config.service.ts new file mode 100644 index 0000000..282702f --- /dev/null +++ b/projects/ng-tw/src/modules/select/select-config.service.ts @@ -0,0 +1,54 @@ +import { Inject, Injectable, InjectionToken, Optional } from '@angular/core'; +import { isEmpty, merge } from 'lodash'; +import { TwSelectConfig } from './select-config.interface'; + +/** + * This is not a real service, but it looks like it from the outside. + * It's just an InjectionTToken used to import the config object, provided from the outside + */ +export const TwSelectSetup = new InjectionToken('TwSelectConfig'); + +@Injectable({ + providedIn: 'root', +}) +export class TwSelectConfigService { + public config: TwSelectConfig = { + select: { + host: { + class: 'bg-white relative border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm', + ignore: '', + }, + panel: { + class: 'bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5', + mandatoryClass: 'absolute z-10 mt-1 w-full overflow-auto focus:outline-none sm:text-sm tw-option-panel-scroll', + ignore: '', + }, + }, + option: { + class: 'block py-2 hover:bg-gray-50 cursor-default select-none relative', + ignore: '', + indicatorClass: 'text-primary-600 pr-4', + indicatorMandatoryClass: 'absolute inset-y-0 items-center', + activeClass: 'font-bold', + selectedClass: 'bg-gray-100', + }, + }; + + constructor(@Optional() @Inject(TwSelectSetup) public options: TwSelectConfig) { + // + // Validate + if (isEmpty(options)) return; + + // + // Merge panel config + if (options.select) { + this.config.select = merge(this.config.select, options.select); + } + + // + // Merge items config + if (options.option) { + this.config.option = merge(this.config.option, options.option); + } + } +} diff --git a/projects/ng-tw/src/modules/select/select.component.html b/projects/ng-tw/src/modules/select/select.component.html index efa6b6c..354f308 100644 --- a/projects/ng-tw/src/modules/select/select.component.html +++ b/projects/ng-tw/src/modules/select/select.component.html @@ -1,61 +1,50 @@ - + + + +
diff --git a/projects/ng-tw/src/modules/select/select.component.html.bkp b/projects/ng-tw/src/modules/select/select.component.html.bkp new file mode 100644 index 0000000..b2cae67 --- /dev/null +++ b/projects/ng-tw/src/modules/select/select.component.html.bkp @@ -0,0 +1,64 @@ + + + +
+ +
+
\ No newline at end of file diff --git a/projects/ng-tw/src/modules/select/select.component.ts b/projects/ng-tw/src/modules/select/select.component.ts index 71959c2..74d6b1c 100644 --- a/projects/ng-tw/src/modules/select/select.component.ts +++ b/projects/ng-tw/src/modules/select/select.component.ts @@ -6,7 +6,6 @@ import { ContentChildren, ElementRef, forwardRef, - HostListener, Input, NgZone, OnInit, @@ -16,8 +15,11 @@ import { import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { defer, merge, Observable, startWith, switchMap, take } from 'rxjs'; import { OptionComponent, OptionSelectionChange } from '../option/option.component'; -import { A, DOWN_ARROW, ENTER, hasModifierKey, LEFT_ARROW, RIGHT_ARROW, SPACE, UP_ARROW } from '@angular/cdk/keycodes'; +import { A, DOWN_ARROW, ENTER, hasModifierKey, SPACE, UP_ARROW } from '@angular/cdk/keycodes'; import { ActiveDescendantKeyManager, LiveAnnouncer } from '@angular/cdk/a11y'; +import { difference } from 'lodash'; +import { TwSelectConfig } from './select-config.interface'; +import { TwSelectConfigService } from './select-config.service'; /** * IDs need to be unique across components, so this counter exists outside of @@ -31,17 +33,23 @@ let _uniqueIdCounter = 0; styleUrls: ['./select.component.css'], changeDetection: ChangeDetectionStrategy.OnPush, host: { - role: 'combobox', - 'aria-autocomplete': 'none', + '[attr.tabindex]': '0', + '[attr.role]': 'combobox', + '[attr.aria-autocomplete]': 'none', // TODO: the value for aria-haspopup should be `listbox`, but currently it's difficult // to sync into Google, because of an outdated automated a11y check which flags it as an invalid // value. At some point we should try to switch it back to being `listbox`. - 'aria-haspopup': 'true', + // '[attr.aria-haspopup]': 'true', + '[class]': 'getClasses()', '[attr.id]': 'id', '[attr.aria-controls]': 'isOpen ? id + "-panel" : null', '[attr.aria-expanded]': 'isOpen', '[attr.aria-disabled]': 'disabled.toString()', + '[attr.aria-haspopup]': 'listbox', + '[attr.aria-labelledby]': 'listbox-label', '(keydown)': 'handleKeydown($event)', + '(click)': 'openPanel()', + cdkOverlayOrigin: '', }, providers: [ { @@ -52,6 +60,10 @@ let _uniqueIdCounter = 0; ], }) export class SelectComponent implements ControlValueAccessor, OnInit, AfterContentInit { + @Input() public class: string = ''; + @Input() public ignore: string = ''; + @Input() public panelClass: string = ''; + @Input() public panelIgnoreClass: string = ''; @Input() public placeholder: string = 'Select an option'; @Input() public disabled: boolean = false; @Input() public id: string = `tw-select-${_uniqueIdCounter++}`; @@ -78,6 +90,7 @@ export class SelectComponent implements ControlValueAccessor, OnInit, AfterConte public overlayWidth!: string; private _keyManager!: ActiveDescendantKeyManager; + private _config: TwSelectConfig['select'] = this.selectConfig.config.select; /** Combined stream of all of the child options' change events. */ readonly optionSelectionChanges: Observable = defer(() => { @@ -96,7 +109,13 @@ export class SelectComponent implements ControlValueAccessor, OnInit, AfterConte ); }) as Observable; - constructor(public cdr: ChangeDetectorRef, private readonly zone: NgZone, private readonly liveAnnouncer: LiveAnnouncer) {} + constructor( + public cdr: ChangeDetectorRef, + public elementRef: ElementRef, + private readonly zone: NgZone, + private readonly liveAnnouncer: LiveAnnouncer, + private readonly selectConfig: TwSelectConfigService + ) {} ngOnInit(): void {} @@ -313,4 +332,63 @@ export class SelectComponent implements ControlValueAccessor, OnInit, AfterConte if (correspondingOption) manager.setActiveItem(correspondingOption); } } + + getClasses() { + // + // Hold classes + let classes: string[] = []; + + // + // Set global config and classes + const config: any = this._config; + const globalClasses: string[] = config.host.class ? config.host.class.split(' ').filter((item: string) => item) : []; + + // + // Get @input classes if available + const inputClasses: string[] = this.class?.split(' ').filter((item: string) => item) || []; + const inputIgnoreClasses: string[] = this?.ignore ? this.ignore.split(' ').filter((item: string) => item) : []; + + // + // Add global classes + classes = [...globalClasses]; + + // + // Filter global classes using global and @input ignore + classes = difference(classes, inputClasses, inputIgnoreClasses); + + return classes?.length ? classes.join(' ') : ''; + } + + getPanelClass() { + // + // Hold classes + let classes: string[] = []; + + // + // Set global config and classes + const config: any = this._config; + const globalClasses: string[] = config.panel.class ? config.panel.class.split(' ').filter((item: string) => item) : []; + const globalMandatoryClasses: string[] = config.panel.mandatoryClass + ? config.panel.mandatoryClass.split(' ').filter((item: string) => item) + : []; + + // + // Get @input classes if available + const inputClasses: string[] = this.panelClass?.split(' ').filter((item: string) => item) || []; + const inputIgnoreClasses: string[] = this?.panelIgnoreClass ? this.panelIgnoreClass.split(' ').filter((item: string) => item) : []; + + // + // Add global classes + classes = [...globalClasses]; + + // + // Filter global classes using global and @input ignore + classes = difference(classes, inputClasses, inputIgnoreClasses); + + // + // Add mandatory classes + classes = [...classes, ...globalMandatoryClasses]; + + return classes?.length ? classes.join(' ') : ''; + } } diff --git a/projects/ng-tw/src/modules/select/select.component.ts.bkp b/projects/ng-tw/src/modules/select/select.component.ts.bkp new file mode 100644 index 0000000..134266e --- /dev/null +++ b/projects/ng-tw/src/modules/select/select.component.ts.bkp @@ -0,0 +1,374 @@ +import { + AfterContentInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ContentChildren, + ElementRef, + forwardRef, + HostBinding, + HostListener, + Input, + NgZone, + OnInit, + QueryList, + SimpleChanges, + ViewChild, +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { defer, merge, Observable, startWith, switchMap, take } from 'rxjs'; +import { OptionComponent, OptionSelectionChange } from '../option/option.component'; +import { A, DOWN_ARROW, ENTER, hasModifierKey, LEFT_ARROW, RIGHT_ARROW, SPACE, UP_ARROW } from '@angular/cdk/keycodes'; +import { ActiveDescendantKeyManager, LiveAnnouncer } from '@angular/cdk/a11y'; +import { difference } from 'lodash'; + +/** + * IDs need to be unique across components, so this counter exists outside of + * the component definition. + */ +let _uniqueIdCounter = 0; + +@Component({ + selector: 'tw-select', + templateUrl: './select.component.html', + styleUrls: ['./select.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + role: 'combobox', + 'aria-autocomplete': 'none', + // TODO: the value for aria-haspopup should be `listbox`, but currently it's difficult + // to sync into Google, because of an outdated automated a11y check which flags it as an invalid + // value. At some point we should try to switch it back to being `listbox`. + 'aria-haspopup': 'true', + '[attr.id]': 'id', + '[attr.aria-controls]': 'isOpen ? id + "-panel" : null', + '[attr.aria-expanded]': 'isOpen', + '[attr.aria-disabled]': 'disabled.toString()', + '(keydown)': 'handleKeydown($event)', + }, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SelectComponent), + multi: true, + }, + ], +}) +export class SelectComponent implements ControlValueAccessor, OnInit, AfterContentInit { + @Input() public class: string = + 'bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm'; + @Input() public panelClass: string = 'bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5'; + @Input() public ignore: string = ''; + @Input() public placeholder: string = 'Select an option'; + @Input() public disabled: boolean = false; + @Input() public id: string = `tw-select-${_uniqueIdCounter++}`; + @Input() public compareWith: (o1: any, o2: any) => boolean = (o1: any, o2: any) => o1 === o2; + @Input() + get value(): any { + return this.innerValue; + } + set value(newValue: any) { + this.selectOption(newValue, null, false); + } + + @ViewChild('trigger', { static: true }) public trigger!: ElementRef; + @ContentChildren(OptionComponent, { descendants: true }) public options!: QueryList; + + @HostBinding('class') get classes(): string { + console.log('this.class', this.class); + // + // Set button class + this.setButtonClass(); + // return null cause the host element must be empty of classes + return ''; + } + + public onChange = (value: any) => {}; + public onTouched = () => {}; + + public innerValue: any = null; + public htmlValue: any = null; + + public wasTouched: boolean = false; + public isOpen: boolean = false; + public overlayWidth!: string; + + public _buttonClass: string = ''; + public _panelClass: string = ''; + public _panelMandatoryClass: string = 'absolute z-10 mt-1 w-full overflow-auto focus:outline-none sm:text-sm tw-option-panel-scroll'; + + private _keyManager!: ActiveDescendantKeyManager; + + /** Combined stream of all of the child options' change events. */ + readonly optionSelectionChanges: Observable = defer(() => { + const options = this.options; + + if (options) { + return options.changes.pipe( + startWith(options), + switchMap(() => merge(...options.map((option) => option.onSelectionChange))) + ); + } + + return this.zone.onStable.pipe( + take(1), + switchMap(() => this.optionSelectionChanges) + ); + }) as Observable; + + constructor(public cdr: ChangeDetectorRef, private readonly zone: NgZone, private readonly liveAnnouncer: LiveAnnouncer) { + // + // Set panel class + this.setPanelClass(); + } + + ngOnInit(): void {} + + ngAfterContentInit(): void { + this._initKeyManager(); + + this.optionSelectionChanges.subscribe((event) => { + this.onSelect(event.source, event.isUserInput, event.innerHTML); + this.cdr.markForCheck(); + }); + + this.options.changes.pipe(startWith(null)).subscribe(() => { + // Defer setting the value in order to avoid the "Expression + // has changed after it was checked" errors from Angular. + Promise.resolve().then(() => { + this.selectOption(this.innerValue, null, false, true); + }); + }); + } + + ngOnChanges(changes: SimpleChanges) { + console.log('changes', changes); + // + // Change for panelClass + if (changes['panelClass']?.previousValue !== changes['panelClass']?.currentValue) { + this.setPanelClass(); + } + } + + writeValue(value: any) { + this.selectOption(value, null, false); + } + + registerOnChange(onChange: any) { + this.onChange = onChange; + } + + registerOnTouched(onTouched: any) { + this.onTouched = onTouched; + } + + markAsTouched() { + if (!this.wasTouched) { + this.onTouched(); + this.wasTouched = true; + } + } + + setDisabledState(isDisabled: boolean) { + // + // Set disabled + this.disabled = isDisabled; + + // + // Set aria-disabled + this.trigger.nativeElement.setAttribute('aria-disabled', isDisabled.toString()); + } + + openPanel() { + // + // Validate disabled + if (this.disabled === true) return; + + // + // open panel + this.isOpen = true; + + // + // Scroll if we have an active item + if (this._keyManager.activeItem) this._keyManager.activeItem.setActiveStylesWithDelay(); + } + + closePanel() { + // + // Update manager active item + if (this.innerValue) this._updateKeyManagerActiveItem(this.innerValue); + + // close + this.isOpen = false; + } + + backdropClick() { + this.closePanel(); + } + + selectOption(newValue: any, innerHTML: string | null, touched: boolean, forceUpdate = false) { + // + // Do nothing if selected is the same as the current value + if (this.compareWith(this.innerValue, newValue) && forceUpdate === false) return; + + // + // Set new value + this.innerValue = newValue; + + // + // On change event + this.onChange(newValue); + // mark as touched if this was made by a user interaction + if (touched === true) this.markAsTouched(); + + // + // Skip if we don't have options + if (!this.options) return; + + // + // Update content + this.updateContent(innerHTML, newValue); + + // + // Update manager active item + this._updateKeyManagerActiveItem(newValue); + } + + onSelect(source: OptionComponent, isUserInput: boolean, innerHTML: string) { + // + // Validate value is different + if (this.innerValue === source.value) return this.closePanel(); + + // + // Loop options and deselect all except the selected one + this.options.forEach((option) => { + if (option.id !== source.id) { + option.deselect(); + } + }); + + // + // Select option + this.selectOption(source.value, innerHTML, true); + + // + // Close + this.closePanel(); + } + + updateContent(newInnerHTML: string | null, value: any) { + // + // Set innerHTML + let innerHTML: string = ''; + + // + // Get option inner html if not provided + // @TODO: find out why this.htmlValue changes from null to '' when we first click on the select trigger, this find runs for each select list at start and we don't need that + // possibly we would check and get inside this code only if newInnerHTML is null + if (!newInnerHTML) { + const option = this.options.find((option) => this.compareWith(option.value, value)); + innerHTML = option?.contentElement?.nativeElement?.innerHTML || ''; + } + // + // innerHTML provided + else { + innerHTML = newInnerHTML; + } + + this.htmlValue = value ? innerHTML : ''; + } + + handleKeydown(event: KeyboardEvent) { + if (!this.disabled) { + this.isOpen === true ? this._handleOpenKeydown(event) : this._handleClosedKeydown(event); + } + } + + private _handleClosedKeydown(event: KeyboardEvent): void { + const manager = this._keyManager; + + manager.onKeydown(event); + } + + /** Handles keyboard events when the selected is open. */ + private _handleOpenKeydown(event: KeyboardEvent): void { + const manager = this._keyManager; + const keyCode = event.keyCode; + const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW; + const isTyping = manager.isTyping(); + + if (isArrowKey && event.altKey) { + // Close the select on ALT + arrow key to match the native