mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-15 17:42:15 +08:00
fix(angular): apply validation classes properly
* fix(angular): add validation classes to ion-item * fix(inputs): focus handling fixes #17171 fixes #16052 fixes #15572 fixes #16452 fixes #17063
This commit is contained in:
@ -43,26 +43,43 @@ export class ValueAccessor implements ControlValueAccessor {
|
||||
|
||||
export function setIonicClasses(element: ElementRef) {
|
||||
requestAnimationFrame(() => {
|
||||
const classList = (element.nativeElement as HTMLElement).classList;
|
||||
const input = element.nativeElement as HTMLElement;
|
||||
const classes = getClasses(input);
|
||||
setClasses(input, classes);
|
||||
|
||||
classList.remove(
|
||||
'ion-valid',
|
||||
'ion-invalid',
|
||||
'ion-touched',
|
||||
'ion-untouched',
|
||||
'ion-dirty',
|
||||
'ion-pristine'
|
||||
);
|
||||
|
||||
for (let i = 0; i < classList.length; i++) {
|
||||
const item = classList.item(i);
|
||||
if (item !== null && startsWith(item, 'ng-')) {
|
||||
classList.add(`ion-${item.substr(3)}`);
|
||||
}
|
||||
const item = input.closest('ion-item');
|
||||
if (item) {
|
||||
setClasses(item, classes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getClasses(element: HTMLElement) {
|
||||
const classList = element.classList;
|
||||
const classes = [];
|
||||
for (let i = 0; i < classList.length; i++) {
|
||||
const item = classList.item(i);
|
||||
if (item !== null && startsWith(item, 'ng-')) {
|
||||
classes.push(`ion-${item.substr(3)}`);
|
||||
}
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
function setClasses(element: HTMLElement, classes: string[]) {
|
||||
const classList = element.classList;
|
||||
|
||||
classList.remove(
|
||||
'ion-valid',
|
||||
'ion-invalid',
|
||||
'ion-touched',
|
||||
'ion-untouched',
|
||||
'ion-dirty',
|
||||
'ion-pristine'
|
||||
);
|
||||
classList.add(...classes);
|
||||
}
|
||||
|
||||
function startsWith(input: string, search: string): boolean {
|
||||
return input.substr(0, search.length) === search;
|
||||
}
|
||||
|
@ -17,10 +17,12 @@ import { NestedOutletPage2Component } from './nested-outlet-page2/nested-outlet-
|
||||
import { ViewChildComponent } from './view-child/view-child.component';
|
||||
import { ProvidersComponent } from './providers/providers.component';
|
||||
import { SlidesComponent } from './slides/slides.component';
|
||||
import { FormComponent } from './form/form.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', component: HomePageComponent },
|
||||
{ path: 'inputs', component: InputsComponent },
|
||||
{ path: 'form', component: FormComponent },
|
||||
{ path: 'modals', component: ModalComponent },
|
||||
{ path: 'view-child', component: ViewChildComponent },
|
||||
{ path: 'providers', component: ProvidersComponent },
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { AppComponent } from './app.component';
|
||||
@ -25,6 +26,7 @@ import { NavComponent } from './nav/nav.component';
|
||||
import { ViewChildComponent } from './view-child/view-child.component';
|
||||
import { ProvidersComponent } from './providers/providers.component';
|
||||
import { SlidesComponent } from './slides/slides.component';
|
||||
import { FormComponent } from './form/form.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
@ -48,12 +50,14 @@ import { SlidesComponent } from './slides/slides.component';
|
||||
NavComponent,
|
||||
ViewChildComponent,
|
||||
ProvidersComponent,
|
||||
SlidesComponent
|
||||
SlidesComponent,
|
||||
FormComponent
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
AppRoutingModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
IonicModule.forRoot(),
|
||||
],
|
||||
entryComponents: [
|
||||
|
76
angular/test/test-app/src/app/form/form.component.html
Normal file
76
angular/test/test-app/src/app/form/form.component.html
Normal file
@ -0,0 +1,76 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>
|
||||
Forms test
|
||||
</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<form [formGroup]="profileForm">
|
||||
<ion-list>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>DateTime</ion-label>
|
||||
<ion-datetime formControlName="datetime" min="1994-03-14" max="2017-12-09" display-format="MM/DD/YYYY"></ion-datetime>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>Select</ion-label>
|
||||
<ion-select formControlName="select">
|
||||
<ion-select-option value="">No Game Console</ion-select-option>
|
||||
<ion-select-option value="nes">NES</ion-select-option>
|
||||
<ion-select-option value="n64" selected>Nintendo64</ion-select-option>
|
||||
<ion-select-option value="ps">PlayStation</ion-select-option>
|
||||
<ion-select-option value="genesis">Sega Genesis</ion-select-option>
|
||||
<ion-select-option value="saturn">Sega Saturn</ion-select-option>
|
||||
<ion-select-option value="snes">SNES</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>Toggle</ion-label>
|
||||
<ion-toggle formControlName="toggle" slot="end"></ion-toggle>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>Input (required)</ion-label>
|
||||
<ion-input formControlName="input"></ion-input>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>Input</ion-label>
|
||||
<ion-input formControlName="input2"></ion-input>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>Checkbox</ion-label>
|
||||
<ion-checkbox formControlName="checkbox" slot="start"></ion-checkbox>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>Range</ion-label>
|
||||
<ion-range formControlName="range"></ion-range>
|
||||
</ion-item>
|
||||
|
||||
</ion-list>
|
||||
<p>
|
||||
Form Status: <span id="status">{{ profileForm.status }}</span>
|
||||
</p>
|
||||
<p>
|
||||
Form Status: <span id="data">{{ profileForm.value | json }}</span>
|
||||
</p>
|
||||
<ion-button type="submit" [disabled]="!profileForm.valid">Submit</ion-button>
|
||||
|
||||
</form>
|
||||
<ion-list>
|
||||
<ion-item>
|
||||
<ion-label>Outside form</ion-label>
|
||||
<ion-toggle [formControl]="outsideToggle"></ion-toggle>
|
||||
<ion-note slot="end">{{outsideToggle.value}}</ion-note>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
<p>
|
||||
<ion-button (click)="setValues()" id="set-button">Set values</ion-button>
|
||||
</p>
|
||||
</ion-content>
|
37
angular/test/test-app/src/app/form/form.component.ts
Normal file
37
angular/test/test-app/src/app/form/form.component.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { FormGroup, FormBuilder, Validators, FormControl } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-form',
|
||||
templateUrl: './form.component.html',
|
||||
})
|
||||
export class FormComponent {
|
||||
|
||||
profileForm: FormGroup;
|
||||
outsideToggle = new FormControl(true);
|
||||
|
||||
constructor(fb: FormBuilder) {
|
||||
this.profileForm = fb.group({
|
||||
datetime: ['2010-08-20', Validators.required],
|
||||
select: [undefined, Validators.required],
|
||||
toggle: [false],
|
||||
input: ['', Validators.required],
|
||||
input2: ['Default Value'],
|
||||
checkbox: [false],
|
||||
range: [20, Validators.min(10)],
|
||||
}, {updateOn: 'blur'});
|
||||
}
|
||||
|
||||
setValues() {
|
||||
this.profileForm.patchValue({
|
||||
datetime: '2010-08-20',
|
||||
setValue: 'nes',
|
||||
toggle: true,
|
||||
input: 'Some value',
|
||||
input2: 'Another values',
|
||||
checkbox: true,
|
||||
range: 50
|
||||
});
|
||||
}
|
||||
|
||||
}
|
@ -12,6 +12,11 @@
|
||||
Inputs test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/form">
|
||||
<ion-label>
|
||||
Form test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/modals">
|
||||
<ion-label>
|
||||
Modals test
|
||||
|
@ -15,6 +15,7 @@ import { createColorClasses, hostContext } from '../../utils/theme';
|
||||
export class Checkbox implements ComponentInterface {
|
||||
|
||||
private inputId = `ion-cb-${checkboxIds++}`;
|
||||
private buttonEl?: HTMLElement;
|
||||
|
||||
@Element() el!: HTMLElement;
|
||||
|
||||
@ -98,9 +99,16 @@ export class Checkbox implements ComponentInterface {
|
||||
|
||||
@Listen('click')
|
||||
onClick() {
|
||||
this.setFocus();
|
||||
this.checked = !this.checked;
|
||||
}
|
||||
|
||||
private setFocus() {
|
||||
if (this.buttonEl) {
|
||||
this.buttonEl.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private onFocus = () => {
|
||||
this.ionFocus.emit();
|
||||
}
|
||||
@ -146,6 +154,7 @@ export class Checkbox implements ComponentInterface {
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
disabled={this.disabled}
|
||||
ref={el => this.buttonEl = el}
|
||||
>
|
||||
</button>
|
||||
];
|
||||
|
@ -246,6 +246,7 @@ export class Datetime implements ComponentInterface {
|
||||
|
||||
@Listen('click')
|
||||
onClick() {
|
||||
this.setFocus();
|
||||
this.open();
|
||||
}
|
||||
|
||||
|
@ -8,13 +8,13 @@
|
||||
$input-md-font-size: inherit !default;
|
||||
|
||||
/// @prop - Margin top of the input
|
||||
$input-md-padding-top: $item-md-padding-top !default;
|
||||
$input-md-padding-top: 10px !default;
|
||||
|
||||
/// @prop - Margin end of the input
|
||||
$input-md-padding-end: 0 !default;
|
||||
|
||||
/// @prop - Margin bottom of the input
|
||||
$input-md-padding-bottom: $item-md-padding-bottom !default;
|
||||
$input-md-padding-bottom: 10px !default;
|
||||
|
||||
/// @prop - Margin start of the input
|
||||
$input-md-padding-start: ($item-md-padding-start / 2) !default;
|
||||
|
@ -284,9 +284,8 @@ button, a {
|
||||
// Item Input Focused
|
||||
// --------------------------------------------------
|
||||
|
||||
:host(.item-interactive.item-has-focus) {
|
||||
--highlight-background: var(--highlight-color-focused);
|
||||
|
||||
:host(.item-interactive.item-has-focus),
|
||||
:host(.item-interactive.ion-touched.ion-invalid) {
|
||||
// If the item has a full border and highlight is enabled, show the full item highlight
|
||||
--full-highlight-height: #{calc(var(--highlight-height) * var(--show-full-highlight))};
|
||||
|
||||
@ -294,6 +293,12 @@ button, a {
|
||||
--inset-highlight-height: #{calc(var(--highlight-height) * var(--show-inset-highlight))};
|
||||
}
|
||||
|
||||
// Item Input Focus
|
||||
// --------------------------------------------------
|
||||
|
||||
:host(.item-interactive.item-has-focus) {
|
||||
--highlight-background: var(--highlight-color-focused);
|
||||
}
|
||||
|
||||
// Item Input Valid
|
||||
// --------------------------------------------------
|
||||
@ -302,7 +307,6 @@ button, a {
|
||||
--highlight-background: var(--highlight-color-valid);
|
||||
}
|
||||
|
||||
|
||||
// Item Input Invalid
|
||||
// --------------------------------------------------
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component, ComponentInterface, Element, Event, EventEmitter, Prop, QueueApi, State, Watch } from '@stencil/core';
|
||||
import { Component, ComponentInterface, Element, Event, EventEmitter, Listen, Prop, QueueApi, State, Watch } from '@stencil/core';
|
||||
|
||||
import { Color, Gesture, GestureDetail, KnobName, Mode, RangeChangeEventDetail, RangeValue, StyleEventDetail } from '../../interface';
|
||||
import { clamp, debounceEvent } from '../../utils/helpers';
|
||||
@ -145,6 +145,24 @@ export class Range implements ComponentInterface {
|
||||
*/
|
||||
@Event() ionBlur!: EventEmitter<void>;
|
||||
|
||||
@Listen('focusout')
|
||||
onBlur() {
|
||||
if (this.hasFocus) {
|
||||
this.hasFocus = false;
|
||||
this.ionBlur.emit();
|
||||
this.emitStyle();
|
||||
}
|
||||
}
|
||||
|
||||
@Listen('focusin')
|
||||
onFocus() {
|
||||
if (!this.hasFocus) {
|
||||
this.hasFocus = true;
|
||||
this.ionFocus.emit();
|
||||
this.emitStyle();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillLoad() {
|
||||
this.updateRatio();
|
||||
this.debounceChanged();
|
||||
@ -165,7 +183,7 @@ export class Range implements ComponentInterface {
|
||||
this.gesture.setDisabled(this.disabled);
|
||||
}
|
||||
|
||||
private handleKeyboard = (knob: string, isIncrease: boolean) => {
|
||||
private handleKeyboard = (knob: KnobName, isIncrease: boolean) => {
|
||||
let step = this.step;
|
||||
step = step > 0 ? step : 1;
|
||||
step = step / (this.max - this.min);
|
||||
@ -200,29 +218,12 @@ export class Range implements ComponentInterface {
|
||||
|
||||
private emitStyle() {
|
||||
this.ionStyle.emit({
|
||||
'interactive': true,
|
||||
'interactive-disabled': this.disabled
|
||||
});
|
||||
}
|
||||
|
||||
private fireBlur() {
|
||||
if (this.hasFocus) {
|
||||
this.hasFocus = false;
|
||||
this.ionBlur.emit();
|
||||
this.emitStyle();
|
||||
}
|
||||
}
|
||||
|
||||
private fireFocus() {
|
||||
if (!this.hasFocus) {
|
||||
this.hasFocus = true;
|
||||
this.ionFocus.emit();
|
||||
this.emitStyle();
|
||||
}
|
||||
}
|
||||
|
||||
private onStart(detail: GestureDetail) {
|
||||
this.fireFocus();
|
||||
|
||||
const rect = this.rect = this.rangeSlider!.getBoundingClientRect() as any;
|
||||
const currentX = detail.currentX;
|
||||
|
||||
@ -234,6 +235,8 @@ export class Range implements ComponentInterface {
|
||||
? 'A'
|
||||
: 'B';
|
||||
|
||||
this.setFocus(this.pressedKnob);
|
||||
|
||||
// update the active knob's position
|
||||
this.update(currentX);
|
||||
}
|
||||
@ -245,7 +248,6 @@ export class Range implements ComponentInterface {
|
||||
private onEnd(detail: GestureDetail) {
|
||||
this.update(detail.currentX);
|
||||
this.pressedKnob = undefined;
|
||||
this.fireBlur();
|
||||
}
|
||||
|
||||
private update(currentX: number) {
|
||||
@ -255,8 +257,11 @@ export class Range implements ComponentInterface {
|
||||
let ratio = clamp(0, (currentX - rect.left) / rect.width, 1);
|
||||
if (this.snaps) {
|
||||
// snaps the ratio to the current value
|
||||
const value = ratioToValue(ratio, this.min, this.max, this.step);
|
||||
ratio = valueToRatio(value, this.min, this.max);
|
||||
ratio = valueToRatio(
|
||||
ratioToValue(ratio, this.min, this.max, this.step),
|
||||
this.min,
|
||||
this.max
|
||||
);
|
||||
}
|
||||
|
||||
// update which knob is pressed
|
||||
@ -317,6 +322,15 @@ export class Range implements ComponentInterface {
|
||||
this.noUpdate = false;
|
||||
}
|
||||
|
||||
private setFocus(knob: KnobName) {
|
||||
if (this.el.shadowRoot) {
|
||||
const knobEl = this.el.shadowRoot.querySelector(knob === 'A' ? '.range-knob-a' : '.range-knob-b') as HTMLElement | undefined;
|
||||
if (knobEl) {
|
||||
knobEl.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hostData() {
|
||||
return {
|
||||
class: {
|
||||
@ -401,7 +415,7 @@ export class Range implements ComponentInterface {
|
||||
}
|
||||
|
||||
interface RangeKnob {
|
||||
knob: string;
|
||||
knob: KnobName;
|
||||
value: number;
|
||||
ratio: number;
|
||||
min: number;
|
||||
@ -410,7 +424,7 @@ interface RangeKnob {
|
||||
pressed: boolean;
|
||||
pin: boolean;
|
||||
|
||||
handleKeyboard: (name: string, isIncrease: boolean) => void;
|
||||
handleKeyboard: (name: KnobName, isIncrease: boolean) => void;
|
||||
}
|
||||
|
||||
function renderKnob({ knob, value, ratio, min, max, disabled, pressed, pin, handleKeyboard }: RangeKnob) {
|
||||
@ -431,6 +445,8 @@ function renderKnob({ knob, value, ratio, min, max, disabled, pressed, pin, hand
|
||||
}}
|
||||
class={{
|
||||
'range-knob-handle': true,
|
||||
'range-knob-a': knob === 'A',
|
||||
'range-knob-b': knob === 'B',
|
||||
'range-knob-pressed': pressed,
|
||||
'range-knob-min': value === min,
|
||||
'range-knob-max': value === max
|
||||
|
@ -140,6 +140,7 @@ export class Select implements ComponentInterface {
|
||||
|
||||
@Listen('click')
|
||||
onClick(ev: UIEvent) {
|
||||
this.setFocus();
|
||||
this.open(ev);
|
||||
}
|
||||
|
||||
|
@ -16,8 +16,9 @@ import { createColorClasses, hostContext } from '../../utils/theme';
|
||||
export class Toggle implements ComponentInterface {
|
||||
|
||||
private inputId = `ion-tg-${toggleIds++}`;
|
||||
private pivotX = 0;
|
||||
private gesture?: Gesture;
|
||||
private buttonEl?: HTMLElement;
|
||||
private lastDrag = 0;
|
||||
|
||||
@Element() el!: HTMLElement;
|
||||
|
||||
@ -108,8 +109,9 @@ export class Toggle implements ComponentInterface {
|
||||
queue: this.queue,
|
||||
gestureName: 'toggle',
|
||||
gesturePriority: 100,
|
||||
threshold: 0,
|
||||
onStart: ev => this.onStart(ev),
|
||||
threshold: 5,
|
||||
passive: false,
|
||||
onStart: () => this.onStart(),
|
||||
onMove: ev => this.onMove(ev),
|
||||
onEnd: ev => this.onEnd(ev),
|
||||
});
|
||||
@ -118,7 +120,9 @@ export class Toggle implements ComponentInterface {
|
||||
|
||||
@Listen('click')
|
||||
onClick() {
|
||||
this.checked = !this.checked;
|
||||
if (this.lastDrag + 300 < Date.now()) {
|
||||
this.checked = !this.checked;
|
||||
}
|
||||
}
|
||||
|
||||
private emitStyle() {
|
||||
@ -127,38 +131,37 @@ export class Toggle implements ComponentInterface {
|
||||
});
|
||||
}
|
||||
|
||||
private onStart(detail: GestureDetail) {
|
||||
this.pivotX = detail.currentX;
|
||||
private onStart() {
|
||||
this.activated = true;
|
||||
|
||||
// touch-action does not work in iOS
|
||||
detail.event.preventDefault();
|
||||
return true;
|
||||
this.setFocus();
|
||||
}
|
||||
|
||||
private onMove(detail: GestureDetail) {
|
||||
const currentX = detail.currentX;
|
||||
if (shouldToggle(this.checked, currentX - this.pivotX, -15)) {
|
||||
if (shouldToggle(this.checked, detail.deltaX, -10)) {
|
||||
this.checked = !this.checked;
|
||||
this.pivotX = currentX;
|
||||
hapticSelection();
|
||||
}
|
||||
}
|
||||
|
||||
private onEnd(detail: GestureDetail) {
|
||||
const delta = detail.currentX - this.pivotX;
|
||||
if (shouldToggle(this.checked, delta, 4)) {
|
||||
this.checked = !this.checked;
|
||||
hapticSelection();
|
||||
}
|
||||
|
||||
private onEnd(ev: GestureDetail) {
|
||||
this.activated = false;
|
||||
this.lastDrag = Date.now();
|
||||
ev.event.preventDefault();
|
||||
ev.event.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
private getValue() {
|
||||
return this.value || '';
|
||||
}
|
||||
|
||||
private setFocus() {
|
||||
if (this.buttonEl) {
|
||||
this.buttonEl.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private onFocus = () => {
|
||||
this.ionFocus.emit();
|
||||
}
|
||||
@ -205,6 +208,7 @@ export class Toggle implements ComponentInterface {
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
disabled={this.disabled}
|
||||
ref={el => this.buttonEl = el}
|
||||
>
|
||||
</button>
|
||||
];
|
||||
|
@ -5,7 +5,6 @@ import { now, pointerCoord } from './helpers';
|
||||
export function startTapClick(doc: Document, config: Config) {
|
||||
let lastTouch = -MOUSE_WAIT * 10;
|
||||
let lastActivated = 0;
|
||||
let cancelled = false;
|
||||
let scrollingEl: HTMLElement | undefined;
|
||||
|
||||
let activatableEle: HTMLElement | undefined;
|
||||
@ -51,11 +50,9 @@ export function startTapClick(doc: Document, config: Config) {
|
||||
removeActivated(false);
|
||||
activatableEle = undefined;
|
||||
}
|
||||
cancelled = true;
|
||||
}
|
||||
|
||||
function pointerDown(ev: any) {
|
||||
cancelled = false;
|
||||
if (activatableEle || isScrolling()) {
|
||||
return;
|
||||
}
|
||||
@ -64,13 +61,7 @@ export function startTapClick(doc: Document, config: Config) {
|
||||
}
|
||||
|
||||
function pointerUp(ev: UIEvent) {
|
||||
if (isScrolling()) {
|
||||
return;
|
||||
}
|
||||
setActivatedElement(undefined, ev);
|
||||
if (cancelled && ev.cancelable) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function setActivatedElement(el: HTMLElement | undefined, ev: UIEvent) {
|
||||
|
Reference in New Issue
Block a user