From 28d62037844253dfe95e3ed02f4af380c108b8f5 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 29 Nov 2017 14:36:29 +0100 Subject: [PATCH] MOBILE-2253 core: Implement components and directives needed by login --- src/components/components.module.ts | 19 +- src/components/input-errors/input-errors.html | 5 + src/components/input-errors/input-errors.scss | 11 ++ src/components/input-errors/input-errors.ts | 96 ++++++++++ .../mark-required/mark-required.html | 2 + .../mark-required/mark-required.scss | 9 + src/components/mark-required/mark-required.ts | 61 ++++++ .../show-password/show-password.html | 4 + .../show-password/show-password.scss | 24 +++ src/components/show-password/show-password.ts | 106 +++++++++++ src/directives/auto-focus.ts | 12 +- src/directives/directives.module.ts | 3 + src/directives/keep-keyboard.ts | 180 ++++++++++++++++++ 13 files changed, 526 insertions(+), 6 deletions(-) create mode 100644 src/components/input-errors/input-errors.html create mode 100644 src/components/input-errors/input-errors.scss create mode 100644 src/components/input-errors/input-errors.ts create mode 100644 src/components/mark-required/mark-required.html create mode 100644 src/components/mark-required/mark-required.scss create mode 100644 src/components/mark-required/mark-required.ts create mode 100644 src/components/show-password/show-password.html create mode 100644 src/components/show-password/show-password.scss create mode 100644 src/components/show-password/show-password.ts create mode 100644 src/directives/keep-keyboard.ts diff --git a/src/components/components.module.ts b/src/components/components.module.ts index 8407a35f7..781ea29ba 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -14,17 +14,30 @@ import { NgModule } from '@angular/core'; import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreDirectivesModule } from '../directives/directives.module'; import { CoreLoadingComponent } from './loading/loading'; +import { CoreMarkRequiredComponent } from './mark-required/mark-required'; +import { CoreInputErrorsComponent } from './input-errors/input-errors'; +import { CoreShowPasswordComponent } from './show-password/show-password'; @NgModule({ declarations: [ - CoreLoadingComponent + CoreLoadingComponent, + CoreMarkRequiredComponent, + CoreInputErrorsComponent, + CoreShowPasswordComponent ], imports: [ - IonicModule + IonicModule, + TranslateModule.forChild(), + CoreDirectivesModule ], exports: [ - CoreLoadingComponent + CoreLoadingComponent, + CoreMarkRequiredComponent, + CoreInputErrorsComponent, + CoreShowPasswordComponent ] }) export class CoreComponentsModule {} diff --git a/src/components/input-errors/input-errors.html b/src/components/input-errors/input-errors.html new file mode 100644 index 000000000..f914829fb --- /dev/null +++ b/src/components/input-errors/input-errors.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/components/input-errors/input-errors.scss b/src/components/input-errors/input-errors.scss new file mode 100644 index 000000000..c432be9ab --- /dev/null +++ b/src/components/input-errors/input-errors.scss @@ -0,0 +1,11 @@ +core-input-errors { + width: 100%; + + .core-input-error-container { + .core-input-error { + padding: 4px; + color: red; + font-size: 12px; + } + } +} diff --git a/src/components/input-errors/input-errors.ts b/src/components/input-errors/input-errors.ts new file mode 100644 index 000000000..1e40cbee0 --- /dev/null +++ b/src/components/input-errors/input-errors.ts @@ -0,0 +1,96 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * Component to show errors if an input isn't valid. + * + * @description + * The purpose of this component is to make easier and consistent the validation of forms. + * + * It should be applied next to the input element (ion-input, ion-select, ...). In case of ion-checkbox, it should be in another + * item, placing it in the same item as the checkbox will cause problems. + * + * Please notice that the inputs need to have a FormControl to make it work. That FormControl needs to be passed to this component. + * + * If this component is placed in the same ion-item as a ion-label or ion-input, then it should have the attribute "item-content", + * otherwise Ionic will remove it. + * + * Example usage: + * + * + * {{ 'mm.login.username' | translate }} + * + * + * + */ +@Component({ + selector: 'core-input-errors', + templateUrl: 'input-errors.html' +}) +export class CoreInputErrorsComponent implements OnInit { + @Input('control') formControl: FormControl; + @Input() errorMessages?: any; + errorKeys: any[]; + + constructor(private translate: TranslateService) {} + + /** + * Component is being initialized. + */ + ngOnInit() { + this.initErrorMessages(); + + this.errorKeys = Object.keys(this.errorMessages); + } + + /** + * Initialize some common errors if they aren't set. + */ + protected initErrorMessages() { + this.errorMessages = this.errorMessages || {}; + + this.errorMessages.required = this.errorMessages.required || this.translate.instant('mm.core.required'); + this.errorMessages.email = this.errorMessages.email || this.translate.instant('mm.login.invalidemail'); + this.errorMessages.date = this.errorMessages.date || this.translate.instant('mm.login.invaliddate'); + this.errorMessages.datetime = this.errorMessages.datetime || this.translate.instant('mm.login.invaliddate'); + this.errorMessages.datetimelocal = this.errorMessages.datetimelocal || this.translate.instant('mm.login.invaliddate'); + this.errorMessages.time = this.errorMessages.time || this.translate.instant('mm.login.invalidtime'); + this.errorMessages.url = this.errorMessages.url || this.translate.instant('mm.login.invalidurl'); + + // @todo: Check how to handle min/max errors once we have a test case to use. Also, review previous errors. + // ['min', 'max'].forEach((type) => { + // // Initialize min/max errors if needed. + // if (!this.errorMessages[type]) { + // if (input && typeof input[type] != 'undefined' && input[type] !== '') { + // var value = input[type]; + // if (input.type == 'date' || input.type == 'datetime' || input.type == 'datetime-local') { + // var date = moment(value); + // if (date.isValid()) { + // value = moment(value).format($translate.instant('mm.core.dfdaymonthyear')); + // } + // } + + // scope.errorMessages[type] = $translate.instant('mm.login.invalidvalue' + type, {$a: value}); + // } else { + // scope.errorMessages[type] = $translate.instant('mm.login.profileinvaliddata'); + // } + // } + // }); + } + +} diff --git a/src/components/mark-required/mark-required.html b/src/components/mark-required/mark-required.html new file mode 100644 index 000000000..b50d08d78 --- /dev/null +++ b/src/components/mark-required/mark-required.html @@ -0,0 +1,2 @@ + + diff --git a/src/components/mark-required/mark-required.scss b/src/components/mark-required/mark-required.scss new file mode 100644 index 000000000..5bff71c3f --- /dev/null +++ b/src/components/mark-required/mark-required.scss @@ -0,0 +1,9 @@ +*[core-mark-required] { + .core-input-required-asterisk, .icon.core-input-required-asterisk { + color: red !important; + font-size: 8px; + padding-left: 4px; + line-height: 100%; + vertical-align: top; + } +} diff --git a/src/components/mark-required/mark-required.ts b/src/components/mark-required/mark-required.ts new file mode 100644 index 000000000..f41614110 --- /dev/null +++ b/src/components/mark-required/mark-required.ts @@ -0,0 +1,61 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, AfterViewInit, ElementRef } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreTextUtilsProvider } from '../../providers/utils/text'; + +/** + * Directive to add a red asterisk for required input fields. + * + * @description + * For forms with required and not required fields, it is recommended to use this directive to mark the required ones. + * + * This directive should be applied in the label. Example: + * + * {{ 'mm.login.username' | translate }} + */ +@Component({ + selector: '[core-mark-required]', + templateUrl: 'mark-required.html' +}) +export class CoreMarkRequiredComponent implements AfterViewInit { + @Input('core-mark-required') coreMarkRequired: boolean|string = true; + protected element: HTMLElement; + requiredLabel: string; + + constructor(element: ElementRef, private translate: TranslateService, private textUtils: CoreTextUtilsProvider) { + this.element = element.nativeElement; + this.requiredLabel = this.translate.instant('mm.core.required'); + } + + /** + * Called after the view is initialized. + */ + ngAfterViewInit() : void { + if (this.coreMarkRequired) { + // Add the "required" to the aria-label. + const ariaLabel = this.element.getAttribute('aria-label') || this.textUtils.cleanTags(this.element.innerHTML, true); + if (ariaLabel) { + this.element.setAttribute('aria-label', ariaLabel + ' ' + this.requiredLabel); + } + } else { + // Remove the "required" from the aria-label. + const ariaLabel = this.element.getAttribute('aria-label'); + if (ariaLabel) { + this.element.setAttribute('aria-label', ariaLabel.replace(' ' + this.requiredLabel, '')); + } + } + } +} diff --git a/src/components/show-password/show-password.html b/src/components/show-password/show-password.html new file mode 100644 index 000000000..440aa125f --- /dev/null +++ b/src/components/show-password/show-password.html @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/show-password/show-password.scss b/src/components/show-password/show-password.scss new file mode 100644 index 000000000..1beb78e94 --- /dev/null +++ b/src/components/show-password/show-password.scss @@ -0,0 +1,24 @@ +core-show-password { + padding: 0px; + width: 100%; + position: relative; + + ion-input { + padding-right: 47 + $content-padding; + } + + .button[icon-only] { + padding: 0 ($content-padding / 2); + position: absolute; + top: $content-padding / 2; + right: 0; + margin-top: 0; + margin-bottom: 0; + } +} + +.item-label-stacked core-show-password { + .button[icon-only] { + top: 0; + } +} diff --git a/src/components/show-password/show-password.ts b/src/components/show-password/show-password.ts new file mode 100644 index 000000000..0df6189fc --- /dev/null +++ b/src/components/show-password/show-password.ts @@ -0,0 +1,106 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, AfterViewInit, Input, ElementRef } from '@angular/core'; + +/** + * Component to allow showing and hiding a password. The affected input MUST have a name to identify it. + * + * @description + * This directive needs to surround the input with the password. + * + * You need to supply the name of the input. + * + * Example: + * + * + * + * + */ +@Component({ + selector: 'core-show-password', + templateUrl: 'show-password.html' +}) +export class CoreShowPasswordComponent implements OnInit, AfterViewInit { + @Input() name: string; // Name of the input affected. + @Input() initialShown?: boolean|string; // Whether the password should be shown at start. + + shown: boolean; // Whether the password is shown. + label: string; // Label for the button to show/hide. + iconName: string; // Name of the icon of the button to show/hide. + selector: string = ''; // Selector to identify the input. + + protected input: HTMLInputElement; // Input affected. + protected element: HTMLElement; // Current element. + + constructor(element: ElementRef) { + this.element = element.nativeElement; + } + + /** + * Component being initialized. + */ + ngOnInit() { + this.shown = this.initialShown && this.initialShown !== 'false'; + this.selector = 'input[name="' + this.name + '"]'; + this.setData(); + } + + /** + * View has been initialized. + */ + ngAfterViewInit() { + this.searchInput(); + } + + /** + * Search the input to show/hide. + */ + protected searchInput() { + // Search the input. + this.input = this.element.querySelector(this.selector); + + if (this.input) { + // Input found. Set the right type. + this.input.type = this.shown ? 'text' : 'password'; + + // By default, don't autocapitalize and autocorrect. + if (!this.input.getAttribute('autocorrect')) { + this.input.setAttribute('autocorrect', 'off'); + } + if (!this.input.getAttribute('autocapitalize')) { + this.input.setAttribute('autocapitalize', 'none'); + } + } + } + + /** + * Set label, icon name and input type. + */ + protected setData() { + this.label = this.shown ? 'mm.core.hide' : 'mm.core.show'; + this.iconName = this.shown ? 'eye-off' : 'eye'; + if (this.input) { + this.input.type = this.shown ? 'text' : 'password'; + } + } + + /** + * Toggle show/hide password. + */ + toggle() : void { + this.shown = !this.shown; + this.setData(); + } +} diff --git a/src/directives/auto-focus.ts b/src/directives/auto-focus.ts index 13ad67418..cc3745d6f 100644 --- a/src/directives/auto-focus.ts +++ b/src/directives/auto-focus.ts @@ -24,7 +24,7 @@ import { CoreDomUtilsProvider } from '../providers/utils/dom'; selector: '[core-auto-focus]' }) export class CoreAutoFocusDirective implements AfterViewInit { - @Input('core-auto-focus') coreAutoFocus: boolean = true; + @Input('core-auto-focus') coreAutoFocus: boolean|string = true; protected element: HTMLElement; @@ -36,8 +36,14 @@ export class CoreAutoFocusDirective implements AfterViewInit { * Function after the view is initialized. */ ngAfterViewInit() { - this.coreAutoFocus = typeof this.coreAutoFocus != 'boolean' ? true : this.coreAutoFocus; - if (this.coreAutoFocus) { + let autoFocus; + if (typeof this.coreAutoFocus == 'string') { + autoFocus = this.coreAutoFocus && this.coreAutoFocus !== 'false'; + } else { + autoFocus = !!this.coreAutoFocus; + } + + if (autoFocus) { // If it's a ion-input or ion-textarea, search the right input to use. let element = this.element; if (this.element.tagName == 'ION-INPUT') { diff --git a/src/directives/directives.module.ts b/src/directives/directives.module.ts index 5f10d4699..105f6565b 100644 --- a/src/directives/directives.module.ts +++ b/src/directives/directives.module.ts @@ -17,12 +17,14 @@ import { CoreAutoFocusDirective } from './auto-focus'; import { CoreExternalContentDirective } from './external-content'; import { CoreFormatTextDirective } from './format-text'; import { CoreLinkDirective } from './link'; +import { CoreKeepKeyboardDirective } from './keep-keyboard'; @NgModule({ declarations: [ CoreAutoFocusDirective, CoreExternalContentDirective, CoreFormatTextDirective, + CoreKeepKeyboardDirective, CoreLinkDirective ], imports: [], @@ -30,6 +32,7 @@ import { CoreLinkDirective } from './link'; CoreAutoFocusDirective, CoreExternalContentDirective, CoreFormatTextDirective, + CoreKeepKeyboardDirective, CoreLinkDirective ] }) diff --git a/src/directives/keep-keyboard.ts b/src/directives/keep-keyboard.ts new file mode 100644 index 000000000..e464ed15e --- /dev/null +++ b/src/directives/keep-keyboard.ts @@ -0,0 +1,180 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Directive, AfterViewInit, Input, ElementRef, OnDestroy } from '@angular/core'; +import { CoreDomUtilsProvider } from '../providers/utils/dom'; + +/** + * Directive to keep the keyboard open when clicking a certain element (usually a button). + * + * @description + * + * This directive needs to be applied to an input or textarea. The value of the directive needs to be a selector + * to identify the element to listen for clicks (usually a button). + * + * When that element is clicked, the input that has this directive will keep the focus if it has it already and the keyboard + * won't be closed. + * + * Example usage: + * + * + * + * + * Alternatively, this directive can be applied to the button. The value of the directive needs to be a selector to identify + * the input element. In this case, you need to set [inButton]="true". + * + * Example usage: + * + * + * + */ +@Directive({ + selector: '[core-keep-keyboard]' +}) +export class CoreKeepKeyboardDirective implements AfterViewInit, OnDestroy { + @Input('core-keep-keyboard') selector: string; // Selector to identify the button or input. + @Input() inButton?: boolean|string; // Whether this directive is applied to the button (true) or to the input (false). + + protected element: HTMLElement; // Current element. + protected button: HTMLElement; // Button element. + protected input: HTMLElement; // Input element. + protected lastFocusOut = 0; // Last time the input was focused out. + protected clickListener : any; // Listener for clicks in the button. + protected focusOutListener : any; // Listener for focusout in the input. + protected focusAgainListener : any; // Another listener for focusout, with the purpose to focus again. + protected stopFocusAgainTimeout: any; // Timeout to stop focus again listener. + + constructor(element: ElementRef, private domUtils: CoreDomUtilsProvider) { + this.element = element.nativeElement; + } + + /** + * View has been initialized. + */ + ngAfterViewInit() { + // Use a setTimeout because, if this directive is applied to a button, then the ion-input that it affects + // maybe it hasn't been treated yet. + setTimeout(() => { + let inButton = this.inButton && this.inButton !== 'false', + candidateEls, + selectedEl; + + if (typeof this.selector != 'string' || !this.selector) { + // Not a valid selector, stop. + return; + } + + // Get the selected element. Get the last one found. + candidateEls = document.querySelectorAll(this.selector); + selectedEl = candidateEls[candidateEls.length - 1]; + if (!selectedEl) { + // Element not found. + return; + } + + if (inButton) { + // The directive is applied to the button. + this.button = this.element; + this.input = selectedEl; + } else { + // The directive is applied to the input. + this.button = selectedEl; + + if (this.element.tagName == 'ION-INPUT') { + // Search the inner input. + this.input = this.element.querySelector('input'); + } else if (this.element.tagName == 'ION-TEXTAREA') { + // Search the inner textarea. + this.input = this.element.querySelector('textarea'); + } else { + this.input = this.element; + } + + if (!this.input) { + // Input not found, stop. + return; + } + } + + // Listen for focusout event. This is to be able to check if previous focus was on this element. + this.focusOutListener = this.focusOut.bind(this); + this.input.addEventListener('focusout', this.focusOutListener); + + // Listen for clicks in the button. + this.clickListener = this.buttonClicked.bind(this); + this.button.addEventListener('click', this.clickListener); + }); + } + + /** + * Component destroyed. + */ + ngOnDestroy() { + if (this.button && this.clickListener) { + this.button.removeEventListener('click', this.clickListener); + } + if (this.input && this.focusOutListener) { + this.input.removeEventListener('focusout', this.focusOutListener); + } + } + + /** + * The button we're interested in was clicked. + */ + protected buttonClicked() : void { + if (document.activeElement == this.input) { + // Directive's element is focused at the time the button is clicked. Listen for focusout to focus it again. + this.focusAgainListener = this.focusElementAgain.bind(this); + this.input.addEventListener('focusout', this.focusAgainListener); + + // Focus it after a timeout just in case the focusout event isn't triggered. + // @todo: This doesn't seem to be needed in iOS. We should test it in Android. + // setTimeout(() => { + // if (this.focusAgainListener) { + // this.focusElementAgain(); + // } + // }, 1000); + } else if (document.activeElement == this.button && Date.now() - this.lastFocusOut < 200) { + // Last focused element was the directive's element, focus it again. + setTimeout(this.focusElementAgain.bind(this), 0); + } + } + + /** + * If keyboard is open, focus the input again and stop listening focusout to focus again if needed. + */ + protected focusElementAgain() : void { + this.domUtils.focusElement(this.input); + + if (this.focusAgainListener) { + // Sometimes we can receive more than 1 focus out event. If we spend 1 second without receiving any, + // stop listening for them. + let listener = this.focusAgainListener; // Store it in a local variable, in case it changes. + clearTimeout(this.stopFocusAgainTimeout); + this.stopFocusAgainTimeout = setTimeout(() => { + this.input.removeEventListener('focusout', listener); + if (listener == this.focusAgainListener) { + delete this.focusAgainListener; + } + }, 1000); + } + } + + /** + * Input was focused out, save the time it was done. + */ + protected focusOut() : void { + this.lastFocusOut = Date.now(); + } +}