From 48a27636c76c1a05dc6d878be26cbe028552cbf7 Mon Sep 17 00:00:00 2001 From: Manu MA Date: Tue, 27 Aug 2019 16:29:37 +0200 Subject: [PATCH] fix(all): component reusage (#18963) Use new stencil APIs to allow ionic elements to be reused once removed from the DOM. fixes #18843 fixes #17344 fixes #16453 fixes #15879 fixes #15788 fixes #15484 fixes #17890 fixes #16364 --- core/src/components/app/app.tsx | 37 +++--- core/src/components/backdrop/backdrop.tsx | 6 +- core/src/components/content/content.tsx | 27 ++-- .../infinite-scroll/infinite-scroll.tsx | 10 +- core/src/components/input/input.tsx | 39 +++--- .../components/item-sliding/item-sliding.tsx | 4 +- core/src/components/menu/menu.tsx | 7 +- .../picker-column/picker-column.tsx | 26 ++-- .../components/radio-group/radio-group.tsx | 123 ++++++++++-------- .../radio-group/test/basic/index.html | 42 +++++- core/src/components/radio/radio.tsx | 20 --- core/src/components/range/range.tsx | 6 +- core/src/components/refresher/refresher.tsx | 15 +-- .../reorder-group/reorder-group.tsx | 11 +- .../route-redirect/route-redirect.tsx | 5 +- core/src/components/route/route.tsx | 5 +- .../components/router-outlet/route-outlet.tsx | 13 +- core/src/components/router/test/basic/e2e.ts | 10 ++ .../select-option/select-option.tsx | 30 +---- core/src/components/select/select.tsx | 109 ++++++++-------- .../components/select/test/async/index.html | 1 + core/src/components/slide/slide.tsx | 14 +- core/src/components/slides/slides.tsx | 48 ++++--- .../components/slides/test/basic/index.html | 17 +++ core/src/components/split-pane/split-pane.tsx | 4 +- .../split-pane/test/basic/index.html | 4 - core/src/components/tab/tab.tsx | 1 - core/src/components/tabs/tabs.tsx | 39 ++---- core/src/components/textarea/textarea.tsx | 38 +++--- core/src/components/toggle/toggle.tsx | 12 +- .../virtual-scroll/virtual-scroll.tsx | 10 +- core/src/utils/input-shims/input-shims.ts | 14 +- core/src/utils/watch-options.ts | 32 +++++ 33 files changed, 411 insertions(+), 368 deletions(-) create mode 100644 core/src/components/router/test/basic/e2e.ts create mode 100644 core/src/utils/watch-options.ts diff --git a/core/src/components/app/app.tsx b/core/src/components/app/app.tsx index 34731ff1ce..1c4e57c3a8 100644 --- a/core/src/components/app/app.tsx +++ b/core/src/components/app/app.tsx @@ -1,4 +1,4 @@ -import { Component, ComponentInterface, Element, Host, h } from '@stencil/core'; +import { Build, Component, ComponentInterface, Element, Host, h } from '@stencil/core'; import { config } from '../../global/config'; import { getIonMode } from '../../global/ionic-global'; @@ -14,23 +14,24 @@ export class App implements ComponentInterface { @Element() el!: HTMLElement; componentDidLoad() { - rIC(() => { - const isHybrid = isPlatform(window, 'hybrid'); - if (!config.getBoolean('_testing')) { - import('../../utils/tap-click').then(module => module.startTapClick(config)); - } - if (config.getBoolean('statusTap', isHybrid)) { - import('../../utils/status-tap').then(module => module.startStatusTap()); - } - if (config.getBoolean('inputShims', needInputShims())) { - import('../../utils/input-shims/input-shims').then(module => module.startInputShims(config)); - } - if (config.getBoolean('hardwareBackButton', isHybrid)) { - import('../../utils/hardware-back-button').then(module => module.startHardwareBackButton()); - } - import('../../utils/focus-visible').then(module => module.startFocusVisible()); - - }); + if (Build.isBrowser) { + rIC(() => { + const isHybrid = isPlatform(window, 'hybrid'); + if (!config.getBoolean('_testing')) { + import('../../utils/tap-click').then(module => module.startTapClick(config)); + } + if (config.getBoolean('statusTap', isHybrid)) { + import('../../utils/status-tap').then(module => module.startStatusTap()); + } + if (config.getBoolean('inputShims', needInputShims())) { + import('../../utils/input-shims/input-shims').then(module => module.startInputShims(config)); + } + if (config.getBoolean('hardwareBackButton', isHybrid)) { + import('../../utils/hardware-back-button').then(module => module.startHardwareBackButton()); + } + import('../../utils/focus-visible').then(module => module.startFocusVisible()); + }); + } } render() { diff --git a/core/src/components/backdrop/backdrop.tsx b/core/src/components/backdrop/backdrop.tsx index c9908d7c2b..49643eea61 100644 --- a/core/src/components/backdrop/backdrop.tsx +++ b/core/src/components/backdrop/backdrop.tsx @@ -39,14 +39,14 @@ export class Backdrop implements ComponentInterface { */ @Event() ionBackdropTap!: EventEmitter; - componentDidLoad() { + connectedCallback() { if (this.stopPropagation) { this.blocker.block(); } } - componentDidUnload() { - this.blocker.destroy(); + disconnectedCallback() { + this.blocker.unblock(); } @Listen('touchstart', { passive: false, capture: true }) diff --git a/core/src/components/content/content.tsx b/core/src/components/content/content.tsx index 039d23bcdf..51ccf2f402 100644 --- a/core/src/components/content/content.tsx +++ b/core/src/components/content/content.tsx @@ -24,6 +24,7 @@ export class Content implements ComponentInterface { private cTop = -1; private cBottom = -1; private scrollEl!: HTMLElement; + private mode = getIonMode(this); // Detail is used in a hot loop in the scroll event, by allocating it here // V8 will be able to inline any read/write to it since it's a monomorphic class. @@ -102,21 +103,14 @@ export class Content implements ComponentInterface { */ @Event() ionScrollEnd!: EventEmitter; - componentWillLoad() { - if (this.forceOverscroll === undefined) { - const mode = getIonMode(this); - this.forceOverscroll = mode === 'ios' && isPlatform(window, 'mobile'); - } + disconnectedCallback() { + this.onScrollEnd(); } componentDidLoad() { this.resize(); } - componentDidUnload() { - this.onScrollEnd(); - } - @Listen('click', { capture: true }) onClick(ev: Event) { if (this.isScrolling) { @@ -125,6 +119,13 @@ export class Content implements ComponentInterface { } } + private shouldForceOverscroll() { + const { forceOverscroll, mode } = this; + return forceOverscroll === undefined + ? mode === 'ios' && isPlatform(window, 'mobile') + : forceOverscroll; + } + private resize() { if (this.fullscreen) { readTask(this.readDimensions.bind(this)); @@ -299,9 +300,9 @@ export class Content implements ComponentInterface { } render() { + const { scrollX, scrollY } = this; const mode = getIonMode(this); - const { scrollX, scrollY, forceOverscroll } = this; - + const forceOverscroll = this.shouldForceOverscroll(); const transitionShadow = (mode === 'ios' && config.getBoolean('experimentalTransitionShadow', true)); this.resize(); @@ -312,7 +313,7 @@ export class Content implements ComponentInterface { ...createColorClasses(this.color), [mode]: true, 'content-sizing': hostContext('ion-popover', this.el), - 'overscroll': !!this.forceOverscroll, + 'overscroll': forceOverscroll, }} style={{ '--offset-top': `${this.cTop}px`, @@ -324,7 +325,7 @@ export class Content implements ComponentInterface { 'inner-scroll': true, 'scroll-x': scrollX, 'scroll-y': scrollY, - 'overscroll': (scrollX || scrollY) && !!forceOverscroll + 'overscroll': (scrollX || scrollY) && forceOverscroll }} ref={el => this.scrollEl = el!} onScroll={ev => this.onScroll(ev)} diff --git a/core/src/components/infinite-scroll/infinite-scroll.tsx b/core/src/components/infinite-scroll/infinite-scroll.tsx index b15a422fdc..08e6ecbbb3 100644 --- a/core/src/components/infinite-scroll/infinite-scroll.tsx +++ b/core/src/components/infinite-scroll/infinite-scroll.tsx @@ -76,11 +76,13 @@ export class InfiniteScroll implements ComponentInterface { */ @Event() ionInfinite!: EventEmitter; - async componentDidLoad() { + async connectedCallback() { const contentEl = this.el.closest('ion-content'); - if (contentEl) { - this.scrollEl = await contentEl.getScrollElement(); + if (!contentEl) { + console.error(' must be used inside an '); + return; } + this.scrollEl = await contentEl.getScrollElement(); this.thresholdChanged(); this.disabledChanged(); if (this.position === 'top') { @@ -92,7 +94,7 @@ export class InfiniteScroll implements ComponentInterface { } } - componentDidUnload() { + disconnectedCallback() { this.enableScrollEvents(false); this.scrollEl = undefined; } diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 55eb0f6a13..c498e120ab 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -1,4 +1,4 @@ -import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h } from '@stencil/core'; +import { Build, Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; import { Color, InputChangeEventDetail, StyleEventDetail, TextFieldTypes } from '../../interface'; @@ -66,7 +66,7 @@ export class Input implements ComponentInterface { /** * If `true`, the value will be cleared after focus upon edit. Defaults to `true` when `type` is `"password"`, `false` for all other types. */ - @Prop({ mutable: true }) clearOnEdit?: boolean; + @Prop() clearOnEdit?: boolean; /** * Set the amount of time, in milliseconds, to wait to trigger the `ionChange` event after each keystroke. @@ -218,22 +218,22 @@ export class Input implements ComponentInterface { */ @Event() ionStyle!: EventEmitter; - componentWillLoad() { - // By default, password inputs clear after focus when they have content - if (this.clearOnEdit === undefined && this.type === 'password') { - this.clearOnEdit = true; - } + connectedCallback() { this.emitStyle(); - } - - componentDidLoad() { this.debounceChanged(); - - this.ionInputDidLoad.emit(); + if (Build.isBrowser) { + this.el.dispatchEvent(new CustomEvent('ionInputDidLoad', { + detail: this.el + })); + } } - componentDidUnload() { - this.ionInputDidUnload.emit(); + disconnectedCallback() { + if (Build.isBrowser) { + document.dispatchEvent(new CustomEvent('ionInputDidUnload', { + detail: this.el + })); + } } /** @@ -255,6 +255,13 @@ export class Input implements ComponentInterface { return Promise.resolve(this.nativeInput!); } + private shouldClearOnEdit() { + const { type, clearOnEdit } = this; + return (clearOnEdit === undefined) + ? type === 'password' + : clearOnEdit; + } + private getValue(): string { return this.value || ''; } @@ -295,7 +302,7 @@ export class Input implements ComponentInterface { } private onKeydown = () => { - if (this.clearOnEdit) { + if (this.shouldClearOnEdit()) { // Did the input value change after it was blurred and edited? if (this.didBlurAfterEdit && this.hasValue()) { // Clear the input @@ -327,7 +334,7 @@ export class Input implements ComponentInterface { private focusChanged() { // If clearOnEdit is enabled and the input blurred but has a value, set a flag - if (this.clearOnEdit && !this.hasFocus && this.hasValue()) { + if (!this.hasFocus && this.shouldClearOnEdit() && this.hasValue()) { this.didBlurAfterEdit = true; } } diff --git a/core/src/components/item-sliding/item-sliding.tsx b/core/src/components/item-sliding/item-sliding.tsx index 55c74e2706..29a14e11f3 100644 --- a/core/src/components/item-sliding/item-sliding.tsx +++ b/core/src/components/item-sliding/item-sliding.tsx @@ -64,7 +64,7 @@ export class ItemSliding implements ComponentInterface { */ @Event() ionDrag!: EventEmitter; - async componentDidLoad() { + async connectedCallback() { this.item = this.el.querySelector('ion-item'); await this.updateOptions(); @@ -81,7 +81,7 @@ export class ItemSliding implements ComponentInterface { this.disabledChanged(); } - componentDidUnload() { + disconnectedCallback() { if (this.gesture) { this.gesture.destroy(); this.gesture = undefined; diff --git a/core/src/components/menu/menu.tsx b/core/src/components/menu/menu.tsx index 223ed44a29..8c55a51581 100644 --- a/core/src/components/menu/menu.tsx +++ b/core/src/components/menu/menu.tsx @@ -136,7 +136,7 @@ export class Menu implements ComponentInterface, MenuI { */ @Event() protected ionMenuChange!: EventEmitter; - async componentWillLoad() { + async connectedCallback() { if (this.type === undefined) { this.type = config.get('menuType', this.mode === 'ios' ? 'reveal' : 'overlay'); } @@ -182,11 +182,12 @@ export class Menu implements ComponentInterface, MenuI { this.updateState(); } - componentDidLoad() { + async componentDidLoad() { this.ionMenuChange.emit({ disabled: this.disabled, open: this._isOpen }); + this.updateState(); } - componentDidUnload() { + disconnectedCallback() { this.blocker.destroy(); menuController._unregister(this); if (this.animation) { diff --git a/core/src/components/picker-column/picker-column.tsx b/core/src/components/picker-column/picker-column.tsx index fe5ed6d8f1..ca6735dd5c 100644 --- a/core/src/components/picker-column/picker-column.tsx +++ b/core/src/components/picker-column/picker-column.tsx @@ -47,7 +47,7 @@ export class PickerColumnCmp implements ComponentInterface { this.refresh(); } - componentWillLoad() { + async connectedCallback() { let pickerRotateFactor = 0; let pickerScaleFactor = 0.81; @@ -60,16 +60,6 @@ export class PickerColumnCmp implements ComponentInterface { this.rotateFactor = pickerRotateFactor; this.scaleFactor = pickerScaleFactor; - } - - async componentDidLoad() { - // get the height of one option - const colEl = this.optsEl; - if (colEl) { - this.optHeight = (colEl.firstElementChild ? colEl.firstElementChild.clientHeight : 0); - } - - this.refresh(); this.gesture = (await import('../../utils/gesture')).createGesture({ el: this.el, @@ -81,14 +71,24 @@ export class PickerColumnCmp implements ComponentInterface { onEnd: ev => this.onEnd(ev), }); this.gesture.setDisabled(false); - this.tmrId = setTimeout(() => { this.noAnimate = false; this.refresh(true); }, 250); } - componentDidUnload() { + componentDidLoad() { + const colEl = this.optsEl; + if (colEl) { + // DOM READ + // We perfom a DOM read over a rendered item, this needs to happen after the first render + this.optHeight = (colEl.firstElementChild ? colEl.firstElementChild.clientHeight : 0); + } + + this.refresh(); + } + + disconnectedCallback() { cancelAnimationFrame(this.rafId); clearTimeout(this.tmrId); if (this.gesture) { diff --git a/core/src/components/radio-group/radio-group.tsx b/core/src/components/radio-group/radio-group.tsx index a014813452..9c5febc30a 100644 --- a/core/src/components/radio-group/radio-group.tsx +++ b/core/src/components/radio-group/radio-group.tsx @@ -1,7 +1,8 @@ -import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Listen, Prop, Watch, h } from '@stencil/core'; +import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Prop, Watch, h } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; import { RadioGroupChangeEventDetail } from '../../interface'; +import { findCheckedOption, watchForOptions } from '../../utils/watch-options'; @Component({ tag: 'ion-radio-group' @@ -10,7 +11,7 @@ export class RadioGroup implements ComponentInterface { private inputId = `ion-rg-${radioGroupIds++}`; private labelId = `${this.inputId}-lbl`; - private radios: HTMLIonRadioElement[] = []; + private mutationO?: MutationObserver; @Element() el!: HTMLElement; @@ -40,58 +41,11 @@ export class RadioGroup implements ComponentInterface { */ @Event() ionChange!: EventEmitter; - @Listen('ionRadioDidLoad') - onRadioDidLoad(ev: Event) { - const radio = ev.target as HTMLIonRadioElement; - radio.name = this.name; - - // add radio to internal list - this.radios.push(radio); - - // this radio-group does not have a value - // but this radio is checked, so let's set the - // radio-group's value from the checked radio - if (this.value == null && radio.checked) { - this.value = radio.value; - } else { - this.updateRadios(); - } - } - - @Listen('ionRadioDidUnload') - onRadioDidUnload(ev: Event) { - const index = this.radios.indexOf(ev.target as HTMLIonRadioElement); - if (index > -1) { - this.radios.splice(index, 1); - } - } - - @Listen('ionSelect') - onRadioSelect(ev: Event) { - const selectedRadio = ev.target as HTMLIonRadioElement | null; - if (selectedRadio) { - this.value = selectedRadio.value; - } - } - - @Listen('ionDeselect') - onRadioDeselect(ev: Event) { - if (this.allowEmptySelection) { - const selectedRadio = ev.target as HTMLIonRadioElement | null; - if (selectedRadio) { - selectedRadio.checked = false; - this.value = undefined; - } - } - } - - componentDidLoad() { + async connectedCallback() { // Get the list header if it exists and set the id // this is used to set aria-labelledby - let header = this.el.querySelector('ion-list-header'); - if (!header) { - header = this.el.querySelector('ion-item-divider'); - } + const el = this.el; + const header = el.querySelector('ion-list-header') || el.querySelector('ion-item-divider'); if (header) { const label = header.querySelector('ion-label'); if (label) { @@ -99,13 +53,42 @@ export class RadioGroup implements ComponentInterface { } } + if (this.value === undefined) { + const radio = findCheckedOption(el, 'ion-radio') as HTMLIonRadioElement | undefined; + if (radio !== undefined) { + await radio.componentOnReady(); + if (this.value === undefined) { + this.value = radio.value; + } + } + } + + this.mutationO = watchForOptions(el, 'ion-radio', newOption => { + if (newOption !== undefined) { + newOption.componentOnReady().then(() => { + this.value = newOption.value; + }); + } else { + this.updateRadios(); + } + }); this.updateRadios(); } - private updateRadios() { - const value = this.value; + disconnectedCallback() { + if (this.mutationO) { + this.mutationO.disconnect(); + this.mutationO = undefined; + } + } + + private async updateRadios() { + const { value } = this; + const radios = await this.getRadios(); let hasChecked = false; - for (const radio of this.radios) { + + // Walk the DOM in reverse order, since the last selected one wins! + for (const radio of radios) { if (!hasChecked && radio.value === value) { // correct value for this radio // but this radio isn't checked yet @@ -118,6 +101,34 @@ export class RadioGroup implements ComponentInterface { radio.checked = false; } } + + // Reset value if + if (!hasChecked) { + this.value = undefined; + } + } + + private getRadios() { + return Promise.all( + Array + .from(this.el.querySelectorAll('ion-radio')) + .map(r => r.componentOnReady()) + ); + } + + private onSelect = (ev: Event) => { + const selectedRadio = ev.target as HTMLIonRadioElement | null; + if (selectedRadio) { + this.value = selectedRadio.value; + } + } + + private onDeselect = (ev: Event) => { + const selectedRadio = ev.target as HTMLIonRadioElement | null; + if (selectedRadio) { + selectedRadio.checked = false; + this.value = undefined; + } } render() { @@ -125,6 +136,8 @@ export class RadioGroup implements ComponentInterface { diff --git a/core/src/components/radio-group/test/basic/index.html b/core/src/components/radio-group/test/basic/index.html index 554a51ebec..3c844b24b9 100644 --- a/core/src/components/radio-group/test/basic/index.html +++ b/core/src/components/radio-group/test/basic/index.html @@ -22,7 +22,7 @@ - Luckiest Man On Earth + Luckiest Man On Earth @@ -53,7 +53,10 @@ - Toggle Disabled + Add Select + Add Checked Select + Remove Select + - diff --git a/core/src/components/radio/radio.tsx b/core/src/components/radio/radio.tsx index 9ccdaa17c2..c949412b9d 100644 --- a/core/src/components/radio/radio.tsx +++ b/core/src/components/radio/radio.tsx @@ -49,18 +49,6 @@ export class Radio implements ComponentInterface { */ @Prop({ mutable: true }) value?: any | null; - /** - * Emitted when the radio loads. - * @internal - */ - @Event() ionRadioDidLoad!: EventEmitter; - - /** - * Emitted when the radio unloads. - * @internal - */ - @Event() ionRadioDidUnload!: EventEmitter; - /** * Emitted when the styles change. * @internal @@ -116,14 +104,6 @@ export class Radio implements ComponentInterface { this.emitStyle(); } - componentDidLoad() { - this.ionRadioDidLoad.emit(); - } - - componentDidUnload() { - this.ionRadioDidUnload.emit(); - } - private emitStyle() { this.ionStyle.emit({ 'radio-checked': this.checked, diff --git a/core/src/components/range/range.tsx b/core/src/components/range/range.tsx index 8cf44afdbc..495420f33a 100644 --- a/core/src/components/range/range.tsx +++ b/core/src/components/range/range.tsx @@ -169,13 +169,11 @@ export class Range implements ComponentInterface { */ @Event() ionBlur!: EventEmitter; - componentWillLoad() { + async connectedCallback() { this.updateRatio(); this.debounceChanged(); this.emitStyle(); - } - async componentDidLoad() { this.gesture = (await import('../../utils/gesture')).createGesture({ el: this.rangeSlider!, gestureName: 'range', @@ -188,7 +186,7 @@ export class Range implements ComponentInterface { this.gesture.setDisabled(this.disabled); } - componentDidUnload() { + disconnectedCallback() { if (this.gesture) { this.gesture.destroy(); this.gesture = undefined; diff --git a/core/src/components/refresher/refresher.tsx b/core/src/components/refresher/refresher.tsx index 0701c334f7..c340640dba 100644 --- a/core/src/components/refresher/refresher.tsx +++ b/core/src/components/refresher/refresher.tsx @@ -97,20 +97,19 @@ export class Refresher implements ComponentInterface { */ @Event() ionStart!: EventEmitter; - async componentDidLoad() { + async connectedCallback() { if (this.el.getAttribute('slot') !== 'fixed') { console.error('Make sure you use: '); return; } const contentEl = this.el.closest('ion-content'); - if (contentEl) { - this.scrollEl = await contentEl.getScrollElement(); - } else { - console.error('ion-refresher did not attach, make sure the parent is an ion-content.'); + if (!contentEl) { + console.error(' must be used inside an '); + return; } - + this.scrollEl = await contentEl.getScrollElement(); this.gesture = (await import('../../utils/gesture')).createGesture({ - el: this.el.closest('ion-content') as any, + el: contentEl, gestureName: 'refresher', gesturePriority: 10, direction: 'y', @@ -125,7 +124,7 @@ export class Refresher implements ComponentInterface { this.disabledChanged(); } - componentDidUnload() { + disconnectedCallback() { this.scrollEl = undefined; if (this.gesture) { this.gesture.destroy(); diff --git a/core/src/components/reorder-group/reorder-group.tsx b/core/src/components/reorder-group/reorder-group.tsx index ba5747aed6..cca3ee9356 100644 --- a/core/src/components/reorder-group/reorder-group.tsx +++ b/core/src/components/reorder-group/reorder-group.tsx @@ -52,12 +52,13 @@ export class ReorderGroup implements ComponentInterface { */ @Event() ionItemReorder!: EventEmitter; - async componentDidLoad() { + async connectedCallback() { const contentEl = this.el.closest('ion-content'); - if (contentEl) { - this.scrollEl = await contentEl.getScrollElement(); + if (!contentEl) { + console.error(' must be used inside an '); + return; } - + this.scrollEl = await contentEl.getScrollElement(); this.gesture = (await import('../../utils/gesture')).createGesture({ el: this.el, gestureName: 'reorder', @@ -74,7 +75,7 @@ export class ReorderGroup implements ComponentInterface { this.disabledChanged(); } - componentDidUnload() { + disconnectedCallback() { this.onEnd(); if (this.gesture) { this.gesture.destroy(); diff --git a/core/src/components/route-redirect/route-redirect.tsx b/core/src/components/route-redirect/route-redirect.tsx index 5ba3c1260c..2ca5927c4f 100644 --- a/core/src/components/route-redirect/route-redirect.tsx +++ b/core/src/components/route-redirect/route-redirect.tsx @@ -45,10 +45,7 @@ export class RouteRedirect implements ComponentInterface { this.ionRouteRedirectChanged.emit(); } - componentDidLoad() { - this.ionRouteRedirectChanged.emit(); - } - componentDidUnload() { + connectedCallback() { this.ionRouteRedirectChanged.emit(); } } diff --git a/core/src/components/route/route.tsx b/core/src/components/route/route.tsx index efb9fe5f39..449a6c10f7 100644 --- a/core/src/components/route/route.tsx +++ b/core/src/components/route/route.tsx @@ -58,10 +58,7 @@ export class Route implements ComponentInterface { } } - componentDidLoad() { - this.ionRouteDataChanged.emit(); - } - componentDidUnload() { + connectedCallback() { this.ionRouteDataChanged.emit(); } } diff --git a/core/src/components/router-outlet/route-outlet.tsx b/core/src/components/router-outlet/route-outlet.tsx index e94586ef95..8a6ed638fa 100644 --- a/core/src/components/router-outlet/route-outlet.tsx +++ b/core/src/components/router-outlet/route-outlet.tsx @@ -60,11 +60,7 @@ export class RouterOutlet implements ComponentInterface, NavOutlet { /** @internal */ @Event({ bubbles: false }) ionNavDidChange!: EventEmitter; - componentWillLoad() { - this.ionNavWillLoad.emit(); - } - - async componentDidLoad() { + async connectedCallback() { this.gesture = (await import('../../utils/gesture/swipe-back')).createSwipeBackGesture( this.el, () => !!this.swipeHandler && this.swipeHandler.canStart() && this.animationEnabled, @@ -106,8 +102,11 @@ export class RouterOutlet implements ComponentInterface, NavOutlet { this.swipeHandlerChanged(); } - componentDidUnload() { - this.activeEl = this.activeComponent = undefined; + componentWillLoad() { + this.ionNavWillLoad.emit(); + } + + disconnectedCallback() { if (this.gesture) { this.gesture.destroy(); this.gesture = undefined; diff --git a/core/src/components/router/test/basic/e2e.ts b/core/src/components/router/test/basic/e2e.ts new file mode 100644 index 0000000000..181d0f03a0 --- /dev/null +++ b/core/src/components/router/test/basic/e2e.ts @@ -0,0 +1,10 @@ +import { newE2EPage } from '@stencil/core/testing'; + +test('router: basic', async () => { + const page = await newE2EPage({ + url: '/src/components/router/test/basic?ionic:_testing=true' + }); + + const compare = await page.compareScreenshot(); + expect(compare).toMatchScreenshot(); +}); diff --git a/core/src/components/select-option/select-option.tsx b/core/src/components/select-option/select-option.tsx index 0ef1bc6b78..b09d8101db 100644 --- a/core/src/components/select-option/select-option.tsx +++ b/core/src/components/select-option/select-option.tsx @@ -1,4 +1,4 @@ -import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Prop, h } from '@stencil/core'; +import { Component, ComponentInterface, Element, Host, Prop, h } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; @@ -26,33 +26,7 @@ export class SelectOption implements ComponentInterface { /** * The text value of the option. */ - @Prop({ mutable: true }) value?: any | null; - - /** - * Emitted when the select option loads. - * @internal - */ - @Event() ionSelectOptionDidLoad!: EventEmitter; - - /** - * Emitted when the select option unloads. - * @internal - */ - @Event() ionSelectOptionDidUnload!: EventEmitter; - - componentWillLoad() { - if (this.value === undefined) { - this.value = this.el.textContent || ''; - } - } - - componentDidLoad() { - this.ionSelectOptionDidLoad.emit(); - } - - componentDidUnload() { - this.ionSelectOptionDidUnload.emit(); - } + @Prop() value?: any | null; render() { return ( diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index e9c2047393..29eb9074c9 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -1,10 +1,11 @@ -import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Listen, Method, Prop, State, Watch, h } from '@stencil/core'; +import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; import { ActionSheetButton, ActionSheetOptions, AlertInput, AlertOptions, CssClassMap, OverlaySelect, PopoverOptions, SelectChangeEventDetail, SelectInterface, SelectPopoverOption, StyleEventDetail } from '../../interface'; import { findItemLabel, renderHiddenInput } from '../../utils/helpers'; import { actionSheetController, alertController, popoverController } from '../../utils/overlays'; import { hostContext } from '../../utils/theme'; +import { watchForOptions } from '../../utils/watch-options'; import { SelectCompareFn } from './select-interface'; @@ -21,11 +22,11 @@ import { SelectCompareFn } from './select-interface'; }) export class Select implements ComponentInterface { - private childOpts: HTMLIonSelectOptionElement[] = []; private inputId = `ion-sel-${selectIds++}`; private overlay?: OverlaySelect; private didInit = false; private buttonEl?: HTMLButtonElement; + private mutationO?: MutationObserver; @Element() el!: HTMLIonSelectElement; @@ -117,64 +118,54 @@ export class Select implements ComponentInterface { @Event() ionStyle!: EventEmitter; @Watch('disabled') + @Watch('placeholder') disabledChanged() { this.emitStyle(); } @Watch('value') valueChanged() { + this.updateOptions(); + this.emitStyle(); if (this.didInit) { - this.updateOptions(); this.ionChange.emit({ value: this.value, }); - this.emitStyle(); } } - @Listen('ionSelectOptionDidLoad') - @Listen('ionSelectOptionDidUnload') - async selectOptionChanged() { - await this.loadOptions(); - - if (this.didInit) { - this.updateOptions(); - this.updateOverlayOptions(); - this.emitStyle(); - - /** - * In the event that options - * are not loaded at component load - * this ensures that any value that is - * set is properly rendered once - * options have been loaded - */ - if (this.value !== undefined) { - this.el.forceUpdate(); - } - - } - } - - async componentDidLoad() { - await this.loadOptions(); - + async connectedCallback() { if (this.value === undefined) { if (this.multiple) { // there are no values set at this point // so check to see who should be selected const checked = this.childOpts.filter(o => o.selected); - this.value = checked.map(o => o.value); + this.value = checked.map(o => getOptionValue(o)); } else { const checked = this.childOpts.find(o => o.selected); if (checked) { - this.value = checked.value; + this.value = getOptionValue(checked); } } } this.updateOptions(); + this.updateOverlayOptions(); this.emitStyle(); - this.el.forceUpdate(); + + this.mutationO = watchForOptions(this.el, 'ion-select-option', async () => { + this.updateOptions(); + this.updateOverlayOptions(); + }); + } + + disconnectedCallback() { + if (this.mutationO) { + this.mutationO.disconnect(); + this.mutationO = undefined; + } + } + + componentDidLoad() { this.didInit = true; } @@ -222,22 +213,24 @@ export class Select implements ComponentInterface { } private updateOverlayOptions(): void { - if (!this.overlay) { return; } const overlay = (this.overlay as any); - + if (!overlay) { + return; + } + const childOpts = this.childOpts; switch (this.interface) { case 'action-sheet': - overlay.buttons = this.createActionSheetButtons(this.childOpts); + overlay.buttons = this.createActionSheetButtons(childOpts); break; case 'popover': const popover = overlay.querySelector('ion-select-popover'); if (popover) { - popover.options = this.createPopoverOptions(this.childOpts); + popover.options = this.createPopoverOptions(childOpts); } break; - default: + case 'alert': const inputType = (this.multiple ? 'checkbox' : 'radio'); - overlay.inputs = this.createAlertInputs(this.childOpts, inputType); + overlay.inputs = this.createAlertInputs(childOpts, inputType); break; } } @@ -248,7 +241,7 @@ export class Select implements ComponentInterface { role: (option.selected ? 'selected' : ''), text: option.textContent, handler: () => { - this.value = option.value; + this.value = getOptionValue(option); } } as ActionSheetButton; }); @@ -270,7 +263,7 @@ export class Select implements ComponentInterface { return { type: inputType, label: o.textContent, - value: o.value, + value: getOptionValue(o), checked: o.selected, disabled: o.disabled } as AlertInput; @@ -279,13 +272,14 @@ export class Select implements ComponentInterface { private createPopoverOptions(data: any[]): SelectPopoverOption[] { return data.map(o => { + const value = getOptionValue(o); return { text: o.textContent, - value: o.value, + value, checked: o.selected, disabled: o.disabled, handler: () => { - this.value = o.value; + this.value = value; this.close(); } } as SelectPopoverOption; @@ -374,22 +368,18 @@ export class Select implements ComponentInterface { return this.overlay.dismiss(); } - private async loadOptions() { - this.childOpts = await Promise.all( - Array.from(this.el.querySelectorAll('ion-select-option')).map(o => o.componentOnReady()) - ); - } - private updateOptions() { // iterate all options, updating the selected prop let canSelect = true; - for (const selectOption of this.childOpts) { - const selected = canSelect && isOptionSelected(this.value, selectOption.value, this.compareWith); + const { value, childOpts, compareWith, multiple } = this; + for (const selectOption of childOpts) { + const optValue = getOptionValue(selectOption); + const selected = canSelect && isOptionSelected(value, optValue, compareWith); selectOption.selected = selected; // if current option is selected and select is single-option, we can't select // any option more - if (selected && !this.multiple) { + if (selected && !multiple) { canSelect = false; } } @@ -403,6 +393,10 @@ export class Select implements ComponentInterface { return this.getText() !== ''; } + private get childOpts() { + return Array.from(this.el.querySelectorAll('ion-select-option')); + } + private getText(): string { const selectedText = this.selectedText; if (selectedText != null && selectedText !== '') { @@ -496,6 +490,13 @@ export class Select implements ComponentInterface { } } +const getOptionValue = (el: HTMLIonSelectOptionElement) => { + const value = el.value; + return (value === undefined) + ? el.textContent || '' + : value; +}; + const parseValue = (value: any) => { if (value == null) { return undefined; @@ -543,7 +544,7 @@ const generateText = (opts: HTMLIonSelectOptionElement[], value: any | any[], co const textForValue = (opts: HTMLIonSelectOptionElement[], value: any, compareWith?: string | SelectCompareFn | null): string | null => { const selectOpt = opts.find(opt => { - return compareOptions(opt.value, value, compareWith); + return compareOptions(getOptionValue(opt), value, compareWith); }); return selectOpt ? selectOpt.textContent diff --git a/core/src/components/select/test/async/index.html b/core/src/components/select/test/async/index.html index 9f63ced148..df357e4cf8 100644 --- a/core/src/components/select/test/async/index.html +++ b/core/src/components/select/test/async/index.html @@ -25,6 +25,7 @@ Label with Placeholder +